1import numpy as np2import pytest34from market_agents.exchange.accounts import Account5from market_agents.exchange.engine import AssetSpec, Exchange, ExchangeConfig6from market_agents.exchange.orders import Action, CancelIntent, OrderIntent, OrderStatus, OrderType, Side789def act(*intents):10 return Action(orders=list(intents))111213def test_fill_moves_cash_and_shares_with_fees(exchange):14 ex = exchange15 ex.submit(0, act(OrderIntent(0, Side.SELL, OrderType.LIMIT, 10, 20000)), 0.0)16 ex.submit(1, act(OrderIntent(0, Side.BUY, OrderType.LIMIT, 10, 20000)), 0.0)17 ex.process_until(1.0)18 a0, a1 = ex.ledger[0], ex.ledger[1]19 notional = 20000 * 1020 fee = round(notional * 2 / 10000)21 assert a0.cash == 1_000_000 + notional and a0.positions[0] == 90 # maker: no fee22 assert a1.cash == 1_000_000 - notional - fee and a1.positions[0] == 110 # taker pays 2 bp23 assert ex.ledger.exchange_fees == fee24 assert a1.reserved_cash == 0 and a0.reserved_qty[0] == 025 ex.ledger.check_conservation()26 assert len(ex.trades) == 1 and ex.trades[0].aggressor is Side.BUY272829def test_latency_orders_arrivals(exchange):30 ex = exchange31 # agent 3 (latency 0.4) submits first but agent 0 (latency 0.1) arrives first32 ex.submit(3, act(OrderIntent(0, Side.BUY, OrderType.LIMIT, 1, 19000)), 0.0)33 ex.submit(0, act(OrderIntent(0, Side.BUY, OrderType.LIMIT, 1, 19000)), 0.0)34 ex.process_until(1.0)35 q = ex.books[0].bids[-19000]36 assert [o.agent_id for o in q] == [0, 3]373839def test_rejections(exchange):40 ex = exchange41 ex.submit(0, act(OrderIntent(0, Side.SELL, OrderType.LIMIT, 1000, 20000)), 0.0) # only 100 held -> clipped to 10042 ex.submit(1, act(OrderIntent(0, Side.BUY, OrderType.LIMIT, 1, 5000)), 0.0) # outside price band43 ex.submit(2, act(OrderIntent(0, Side.BUY, OrderType.LIMIT, 10_000, 20000)), 0.0) # insufficient cash44 ex.submit(3, act(OrderIntent(0, Side.BUY, OrderType.LIMIT, 0, 20000)), 0.0) # bad qty45 ex.process_until(1.0)46 reasons = [e.payload["reason"] for e in ex.events if e.kind == "reject"]47 assert reasons == ["price_band", "insufficient_cash", "bad_qty"]48 assert ex.orders[1].qty == 100 and ex.orders[1].status is OrderStatus.OPEN49 ex.ledger.check_conservation()505152def test_cancel_releases_reservation(exchange):53 ex = exchange54 ids = ex.submit(1, act(OrderIntent(0, Side.BUY, OrderType.LIMIT, 10, 19000)), 0.0)55 ex.process_until(1.0)56 assert ex.ledger[1].reserved_cash == 19000 * 10 + round(19000 * 10 * 2 / 10000) + 10 # notional + worst-case fee + 1c/lot57 ex.submit(1, Action(cancels=[CancelIntent(ids[0])]), 1.0)58 ex.process_until(2.0)59 assert ex.ledger[1].reserved_cash == 0 and ex.orders[ids[0]].status is OrderStatus.CANCELLED60 ex.ledger.check_conservation()616263def test_market_buy_capped_by_cash():64 ex = Exchange([AssetSpec(asset=0, name="A0")], ExchangeConfig(taker_fee_bps=0.0), np.random.default_rng(0), {0: 100})65 ex.register(Account(agent_id=0, cash=10_000, positions={0: 0}), 0.1)66 ex.register(Account(agent_id=1, cash=0, positions={0: 1000}), 0.1)67 ex.submit(1, act(OrderIntent(0, Side.SELL, OrderType.LIMIT, 1000, 100)), 0.0)68 ex.submit(0, act(OrderIntent(0, Side.BUY, OrderType.MARKET, 500, None)), 0.5)69 ex.process_until(2.0)70 assert ex.ledger[0].positions[0] == 100 and ex.ledger[0].cash == 071 ex.ledger.check_conservation()727374def test_dividend_and_conservation(exchange):75 ex = exchange76 ex.pay_dividends(0, 50)77 assert all(a.cash == 1_000_000 + 5000 for a in ex.ledger.accounts.values())78 ex.ledger.check_conservation()798081def test_halt_rejects_new_orders():82 ex = Exchange([AssetSpec(asset=0, name="A0")], ExchangeConfig(halt_threshold=0.05, halt_window=2, halt_duration=5),83 np.random.default_rng(0), {0: 10000})84 ex.register(Account(agent_id=0, cash=10_000_000, positions={0: 1000}), 0.1)85 ex.register(Account(agent_id=1, cash=10_000_000, positions={0: 1000}), 0.1)86 for t in range(3):87 ex.end_of_tick(t)88 ex.submit(0, act(OrderIntent(0, Side.SELL, OrderType.LIMIT, 10, 9000)), 3.0)89 ex.submit(1, act(OrderIntent(0, Side.BUY, OrderType.MARKET, 10, None)), 3.0)90 ex.process_until(4.0)91 assert ex.state[0].n_halts == 1 and ex.is_halted(0)92 ex.submit(1, act(OrderIntent(0, Side.BUY, OrderType.LIMIT, 1, 9000)), 4.0)93 ex.process_until(5.0)94 assert [e.payload["reason"] for e in ex.events if e.kind == "reject"] == ["halted"]959697def test_margin_call_generates_liquidation():98 ex = Exchange([AssetSpec(asset=0, name="A0")], ExchangeConfig(margin_check=True), np.random.default_rng(0), {0: 10000})99 ex.register(Account(agent_id=0, cash=-900_000, positions={0: 100}, leverage_cap=5.0, maintenance_margin=0.25), 0.1)100 ex.register(Account(agent_id=1, cash=5_000_000, positions={0: 100}), 0.1)101 # equity = -900,000 + 100*10000 = 100,000 ; gross = 1,000,000 ; ratio 0.10 < 0.25 -> margin call102 actions = ex.margin_check()103 assert len(actions) == 1 and actions[0][0] == 0 and actions[0][1].orders[0].tag == "liquidation"104 assert any(e.kind == "margin_call" for e in ex.events)105