""" Exhaustive CWM tests — every exchange mechanic, every edge case. Test categories: 1. Tick/lot rounding 2. Price-time priority + sequential level consumption 3. Partial fills across multiple levels 4. Post-only rejection (buy crosses ask, sell crosses bid) 5. CROSS_SPREAD immediate fill 6. Cancel order 7. Cancel-replace 8. Fee application (maker vs taker) 9. Position update (open, add, reduce, close) 10. Mark-to-market 11. Realized PnL on sell 12. Available balance deduction 13. Path-state update (entry, MAE, MFE, recovery) 14. Counterparty fills consuming book levels 15. Empty book handling 16. Determinism (same input = same output) 17. Input immutability 18. Timestamp advancement 19. Edge cases (zero qty, zero price, negative equity) """ import math import pytest from malkhut.state import ( AccountState, ExecutionIntent, FulfilmentPolicyParams, IntentKind, MarketWorldState, Mode, OpenOrderState, OrderBookState, PositionState, PriceLevel, Side, TradePathState, VenueRules, ) from malkhut.cwm.core import ( MinimalCryptoLOBCWM, materialize_price_from_action, _round_tick, _round_lot, _clip_lots, _fill_from_levels, ) from malkhut.actions import ActionKind, CounterpartyAction, AgentRole, FulfilmentAction, OrderType # ── Helpers ────────────────────────────────────────────────────────────────── def _venue(**kw): d = dict(exchange="bingx", symbol="BTCUSDT", tick_size=0.1, lot_size=0.001, min_qty=0.001, min_notional=5.0, maker_fee_bps=-0.2, taker_fee_bps=0.5, post_only_supported=True, reduce_only_supported=True, max_orders_per_second=100, max_cancels_per_minute=120) d.update(kw) return VenueRules(**d) def _book(bid=50000.0, ask=50001.0, bid_qty=1.0, ask_qty=1.0, ts=1_000_000_000, **kw): bids = kw.get("bids", ((bid, bid_qty),)) asks = kw.get("asks", ((ask, ask_qty),)) return OrderBookState( ts_ns=ts, symbol="BTCUSDT", bids=tuple(PriceLevel(p, q) for p, q in bids), asks=tuple(PriceLevel(p, q) for p, q in asks), ) def _account(equity=10000.0, **kw): return AccountState( ts_ns=kw.get("ts", 1_000_000_000), equity=equity, wallet_balance=kw.get("wallet", equity), available_balance=kw.get("available", equity), margin_used=kw.get("margin", 0.0), total_notional=kw.get("notional", 0.0), positions=kw.get("positions", {}), ) def _state(bid=50000.0, ask=50001.0, equity=10000.0, **kw): return MarketWorldState( ts_ns=kw.get("ts", 1_000_000_000), mode=Mode.REPLAY_NO_IMPACT, venue=kw.get("venue", _venue()), book=_book(bid, ask, bid_qty=kw.get("bid_qty", 1.0), ask_qty=kw.get("ask_qty", 1.0)), account=_account(equity, positions=kw.get("positions", {})), open_orders=kw.get("open_orders", ()), trade_path=kw.get("trade_path"), intent=kw.get("intent"), ) def _noop(): return FulfilmentAction(ActionKind.NOOP, None, None, 0, 0.0, 0) def _place(side, offset=0, frac=0.1, post_only=False, reduce_only=False): return FulfilmentAction( ActionKind.PLACE, side, OrderType.POST_ONLY if post_only else OrderType.LIMIT, offset, frac, 200, post_only=post_only, reduce_only=reduce_only, ) def _cross(side, frac=0.1): return FulfilmentAction(ActionKind.CROSS_SPREAD, side, OrderType.IOC, 0, frac, 50) def _cancel(order_id): return FulfilmentAction(ActionKind.CANCEL, Side.BUY, None, 0, 0.0, 0, cancel_order_id=order_id) def _oo(cid="c1", price=50000.0, qty=0.001, side=Side.BUY, ts=1_000_000_000): return OpenOrderState( client_order_id=cid, venue_order_id="v1", symbol="BTCUSDT", side=side, order_type=OrderType.POST_ONLY, price=price, qty=qty, remaining_qty=qty, queue_ahead_estimate=qty * 0.5, created_ts_ns=ts, last_update_ts_ns=ts, post_only=True, ) def _tp(side=Side.BUY, pnl=0.0, mae=-10.0, mfe=5.0, ts=1_000_000_000, failed_recovery_count=0): return TradePathState( symbol="BTCUSDT", side=side, entry_ts_ns=ts, now_ts_ns=ts, bars_held=5, seconds_held=50.0, pnl_bps=pnl, mae_bps=mae, mfe_bps=mfe, distance_from_mfe_bps=mfe - pnl, distance_from_entry_bps=abs(pnl), time_to_mfe_s=20.0, time_in_loss_s=30.0, time_in_profit_s=20.0, time_since_last_profit_s=5.0, time_since_deep_mae_s=10.0, loss_to_profit_transitions=1, deep_loss_recoveries=0, failed_recovery_count=failed_recovery_count, recovery_velocity_bps_per_s=1.0, adverse_velocity_bps_per_s=-0.5, dolphin_regime_score=0.5, jericho_signal_strength=0.3, volatility_bps=15.0, orderflow_toxicity=0.3, queue_churn_score=0.2, book_imbalance=0.1, cross_venue_lead_score=0.1, ) # ══════════════════════════════════════════════════════════════════════════════ # 1. TICK / LOT ROUNDING # ══════════════════════════════════════════════════════════════════════════════ class TestTickRounding: def test_round_tick_exact(self): assert _round_tick(50000.0, 0.1) == 50000.0 def test_round_tick_up(self): assert _round_tick(50000.06, 0.1) == pytest.approx(50000.1, abs=1e-9) def test_round_tick_down(self): assert _round_tick(50000.04, 0.1) == 50000.0 def test_round_tick_tiny_tick(self): assert _round_tick(50000.055, 0.01) == 50000.06 def test_round_tick_large_tick(self): assert _round_tick(50005.0, 1.0) == 50005.0 def test_round_tick_large_tick_rounds_down(self): # round(50004.9 / 1.0) = round(50004.9) = 50005 (banker's rounds to even) assert _round_tick(50004.4, 1.0) == 50004.0 class TestLotRounding: def test_round_lot_exact(self): assert _round_lot(0.001, 0.001) == 0.001 def test_round_lot_up(self): assert _round_lot(0.0015, 0.001) == 0.002 def test_round_lot_down(self): assert _round_lot(0.0014, 0.001) == 0.001 def test_round_lot_large_lot(self): assert _round_lot(1.5, 1.0) == 2.0 class TestClipLots: def test_clip_above_min(self): assert _clip_lots(0.005, 0.001, 0.001) == 0.005 def test_clip_below_min_returns_zero(self): assert _clip_lots(0.0005, 0.001, 0.001) == 0.0 def test_clip_exact_min(self): assert _clip_lots(0.001, 0.001, 0.001) == 0.001 def test_clip_rounds_to_lot(self): assert _clip_lots(0.0017, 0.001, 0.001) == 0.002 def test_clip_zero_qty(self): assert _clip_lots(0.0, 0.001, 0.001) == 0.0 # ══════════════════════════════════════════════════════════════════════════════ # 2. FILL FROM LEVELS (price-time priority) # ══════════════════════════════════════════════════════════════════════════════ class TestFillFromLevels: def test_fill_single_level_full(self): levels = [PriceLevel(50000.0, 1.0)] filled, avg, remaining = _fill_from_levels(levels, 0.5, 0.001, 0.001) assert filled == 0.5 assert avg == 50000.0 assert len(remaining) == 1 assert remaining[0].qty == 0.5 def test_fill_single_level_exact(self): levels = [PriceLevel(50000.0, 1.0)] filled, avg, remaining = _fill_from_levels(levels, 1.0, 0.001, 0.001) assert filled == 1.0 assert len(remaining) == 0 def test_fill_multi_level(self): levels = [PriceLevel(50000.0, 0.5), PriceLevel(50001.0, 0.5)] filled, avg, remaining = _fill_from_levels(levels, 0.8, 0.001, 0.001) assert filled == 0.8 assert abs(avg - (50000.0 * 0.5 + 50001.0 * 0.3) / 0.8) < 0.01 assert len(remaining) == 1 assert remaining[0].price == 50001.0 assert remaining[0].qty == 0.2 def test_fill_exhausts_all_levels(self): levels = [PriceLevel(50000.0, 0.3), PriceLevel(50001.0, 0.3)] filled, avg, remaining = _fill_from_levels(levels, 1.0, 0.001, 0.001) assert filled == 0.6 assert len(remaining) == 0 def test_fill_empty_levels(self): filled, avg, remaining = _fill_from_levels([], 1.0, 0.001, 0.001) assert filled == 0.0 assert remaining == [] def test_fill_preserves_price_order(self): levels = [PriceLevel(50001.0, 0.5), PriceLevel(50000.0, 0.5)] filled, avg, remaining = _fill_from_levels(levels, 0.3, 0.001, 0.001) # Should fill from 50001.0 first (first in list = highest priority) assert avg == 50001.0 def test_fill_lot_rounding(self): levels = [PriceLevel(50000.0, 1.0)] filled, avg, remaining = _fill_from_levels(levels, 0.555, 0.1, 0.1) assert filled == pytest.approx(0.6, abs=0.01) # rounded to 0.1 lot def test_fill_below_min_qty(self): levels = [PriceLevel(50000.0, 1.0)] filled, avg, remaining = _fill_from_levels(levels, 0.0005, 0.001, 0.001) assert filled == 0.0 def test_fill_three_levels(self): levels = [ PriceLevel(50000.0, 0.1), PriceLevel(50001.0, 0.1), PriceLevel(50002.0, 0.1), ] filled, avg, remaining = _fill_from_levels(levels, 0.25, 0.001, 0.001) assert filled == 0.25 assert avg == (50000.0 * 0.1 + 50001.0 * 0.1 + 50002.0 * 0.05) / 0.25 assert len(remaining) == 1 assert remaining[0].price == 50002.0 assert remaining[0].qty == pytest.approx(0.05, abs=0.001) # ══════════════════════════════════════════════════════════════════════════════ # 3. POST-ONLY REJECTION # ══════════════════════════════════════════════════════════════════════════════ class TestPostOnlyRejection: def test_buy_at_ask_rejected(self): cwm = MinimalCryptoLOBCWM() s = _state() a = _place(Side.BUY, offset=-10, frac=0.1, post_only=True) r = cwm.transition(s, (a,)) assert r.account.equity == s.account.equity assert len(r.open_orders) == len(s.open_orders) def test_sell_at_bid_rejected(self): cwm = MinimalCryptoLOBCWM() s = _state() a = _place(Side.SELL, offset=-10, frac=0.1, post_only=True) r = cwm.transition(s, (a,)) assert r.account.equity == s.account.equity def test_buy_inside_spread_accepted(self): cwm = MinimalCryptoLOBCWM() s = _state() a = _place(Side.BUY, offset=0, frac=0.1, post_only=True) r = cwm.transition(s, (a,)) assert any(o.side == Side.BUY for o in r.open_orders) def test_sell_inside_spread_accepted(self): cwm = MinimalCryptoLOBCWM() s = _state() a = _place(Side.SELL, offset=0, frac=0.1, post_only=True) r = cwm.transition(s, (a,)) assert any(o.side == Side.SELL for o in r.open_orders) def test_buy_one_tick_below_ask_accepted(self): cwm = MinimalCryptoLOBCWM() s = _state(bid=50000.0, ask=50001.0) # price = 50000.0 - (-9)*0.1 = 50000.9 < 50001.0 a = _place(Side.BUY, offset=-9, frac=0.1, post_only=True) r = cwm.transition(s, (a,)) assert any(o.side == Side.BUY for o in r.open_orders) def test_sell_one_tick_above_bid_accepted(self): cwm = MinimalCryptoLOBCWM() s = _state(bid=50000.0, ask=50001.0) # price = 50001.0 + (-9)*0.1 = 50000.1 > 50000.0 a = _place(Side.SELL, offset=-9, frac=0.1, post_only=True) r = cwm.transition(s, (a,)) assert any(o.side == Side.SELL for o in r.open_orders) def test_wide_spread_allows_more_offsets(self): cwm = MinimalCryptoLOBCWM() s = _state(bid=49000.0, ask=51000.0) # price = 49000.0 - (-10)*0.1 = 49001.0 < 51000.0 a = _place(Side.BUY, offset=-10, frac=0.1, post_only=True) r = cwm.transition(s, (a,)) assert any(o.side == Side.BUY for o in r.open_orders) # ══════════════════════════════════════════════════════════════════════════════ # 4. CROSS_SPREAD (immediate fill) # ══════════════════════════════════════════════════════════════════════════════ class TestCrossSpread: def test_cross_buy_fills(self): cwm = MinimalCryptoLOBCWM() s = _state() a = _cross(Side.BUY, frac=0.1) r = cwm.transition(s, (a,)) assert r.account.equity < s.account.equity def test_cross_sell_fills(self): cwm = MinimalCryptoLOBCWM() s = _state() a = _cross(Side.SELL, frac=0.1) r = cwm.transition(s, (a,)) assert r.account.equity <= s.account.equity def test_cross_buy_updates_book(self): cwm = MinimalCryptoLOBCWM() s = _state(ask=50001.0, ask_qty=1.0) a = _cross(Side.BUY, frac=0.5) r = cwm.transition(s, (a,)) # Ask should be reduced total_ask_qty = sum(l.qty for l in r.book.asks) assert total_ask_qty < 1.0 def test_cross_buy_fills_at_best_ask(self): cwm = MinimalCryptoLOBCWM() s = _state() a = _cross(Side.BUY, frac=0.1) r = cwm.transition(s, (a,)) assert r.book.last_trade_price == 50001.0 def test_cross_sell_fills_at_best_bid(self): cwm = MinimalCryptoLOBCWM() s = _state() a = _cross(Side.SELL, frac=0.1) r = cwm.transition(s, (a,)) assert r.book.last_trade_price == 50000.0 def test_cross_partial_fill(self): cwm = MinimalCryptoLOBCWM() s = _state(ask_qty=0.002) a = _cross(Side.BUY, frac=0.5) # wants more than available r = cwm.transition(s, (a,)) # Should fill what's available assert r.account.equity < s.account.equity def test_cross_consumes_levels_sequentially(self): cwm = MinimalCryptoLOBCWM() s = _state(asks=((50001.0, 0.1), (50002.0, 0.1))) a = _cross(Side.BUY, frac=0.5) r = cwm.transition(s, (a,)) # Should consume from 50001 first, then 50002 assert r.book.last_trade_price <= 50002.0 def test_cross_creates_position(self): cwm = MinimalCryptoLOBCWM() s = _state() a = _cross(Side.BUY, frac=0.1) r = cwm.transition(s, (a,)) pos = r.account.positions.get("BTCUSDT") assert pos is not None assert pos.qty > 0 def test_cross_no_fill_when_zero_qty(self): cwm = MinimalCryptoLOBCWM() s = _state() a = _cross(Side.BUY, frac=0.0) r = cwm.transition(s, (a,)) assert r.account.equity == s.account.equity def test_cross_updates_last_trade(self): cwm = MinimalCryptoLOBCWM() s = _state() a = _cross(Side.BUY, frac=0.1) r = cwm.transition(s, (a,)) assert r.book.last_trade_side == Side.BUY assert r.book.last_trade_qty > 0 # ══════════════════════════════════════════════════════════════════════════════ # 5. CANCEL ORDER # ══════════════════════════════════════════════════════════════════════════════ class TestCancelOrder: def test_cancel_removes_order(self): oo = _oo("c1") cwm = MinimalCryptoLOBCWM() s = _state(open_orders=(oo,)) a = _cancel("c1") r = cwm.transition(s, (a,)) assert len(r.open_orders) == 0 def test_cancel_wrong_id_keeps_order(self): oo = _oo("c1") cwm = MinimalCryptoLOBCWM() s = _state(open_orders=(oo,)) a = _cancel("wrong") r = cwm.transition(s, (a,)) assert len(r.open_orders) == 1 def test_cancel_only_one_order(self): oo1 = _oo("c1") oo2 = _oo("c2") cwm = MinimalCryptoLOBCWM() s = _state(open_orders=(oo1, oo2)) a = _cancel("c1") r = cwm.transition(s, (a,)) assert len(r.open_orders) == 1 assert r.open_orders[0].client_order_id == "c2" def test_cancel_nonexistent_id(self): cwm = MinimalCryptoLOBCWM() s = _state(open_orders=(_oo("c1"),)) a = _cancel("nonexistent") r = cwm.transition(s, (a,)) assert len(r.open_orders) == 1 def test_cancel_empty_book(self): cwm = MinimalCryptoLOBCWM() s = _state() a = _cancel("c1") r = cwm.transition(s, (a,)) assert len(r.open_orders) == 0 # ══════════════════════════════════════════════════════════════════════════════ # 6. PASSIVE PLACEMENT # ══════════════════════════════════════════════════════════════════════════════ class TestPassivePlacement: def test_passive_buy_added(self): cwm = MinimalCryptoLOBCWM() s = _state() a = _place(Side.BUY, offset=1, frac=0.1, post_only=True) r = cwm.transition(s, (a,)) assert len(r.open_orders) == 1 assert r.open_orders[0].side == Side.BUY def test_passive_sell_added(self): cwm = MinimalCryptoLOBCWM() s = _state() a = _place(Side.SELL, offset=1, frac=0.1, post_only=True) r = cwm.transition(s, (a,)) assert len(r.open_orders) == 1 assert r.open_orders[0].side == Side.SELL def test_passive_price_correct(self): cwm = MinimalCryptoLOBCWM() s = _state() a = _place(Side.BUY, offset=1, frac=0.1, post_only=True) r = cwm.transition(s, (a,)) assert r.open_orders[0].price == 49999.9 def test_passive_qty_correct(self): cwm = MinimalCryptoLOBCWM() s = _state() a = _place(Side.BUY, offset=1, frac=0.1, post_only=True) r = cwm.transition(s, (a,)) expected_qty = 0.1 * 10000.0 / 49999.9 assert r.open_orders[0].qty > 0 def test_passive_order_id_unique(self): cwm = MinimalCryptoLOBCWM() s = _state() a1 = _place(Side.BUY, offset=1, frac=0.1, post_only=True) r1 = cwm.transition(s, (a1,)) # Use r1 as input (different ts_ns) for second order a2 = _place(Side.BUY, offset=2, frac=0.1, post_only=True) r2 = cwm.transition(r1, (a2,)) assert r1.open_orders[0].client_order_id != r2.open_orders[-1].client_order_id def test_multiple_passive_orders(self): cwm = MinimalCryptoLOBCWM() s = _state() a = _place(Side.BUY, offset=1, frac=0.1, post_only=True) r = cwm.transition(s, (a,)) a2 = _place(Side.BUY, offset=2, frac=0.1, post_only=True) r2 = cwm.transition(r, (a2,)) assert len(r2.open_orders) == 2 def test_passive_no_position_change(self): cwm = MinimalCryptoLOBCWM() s = _state() a = _place(Side.BUY, offset=1, frac=0.1, post_only=True) r = cwm.transition(s, (a,)) pos = r.account.positions.get("BTCUSDT") assert pos is None or pos.qty == 0 # ══════════════════════════════════════════════════════════════════════════════ # 7. FEES # ══════════════════════════════════════════════════════════════════════════════ class TestFeeApplication: def test_taker_fee_reduces_equity(self): cwm = MinimalCryptoLOBCWM() s = _state() a = _cross(Side.BUY, frac=0.1) r = cwm.transition(s, (a,)) fee = 0.001 * 50001.0 * 0.5 / 10_000 # taker fee assert r.account.equity < s.account.equity def test_maker_fee_rebate(self): """Counterparty fill should not charge us taker fees.""" cwm = MinimalCryptoLOBCWM() s = _state() cp = CounterpartyAction( AgentRole.TOXIC_TAKER, ActionKind.CROSS_SPREAD, Side.BUY, 0, 0.1, toxicity=0.8, ) r = cwm.transition(s, (_noop(), cp)) # CP fill touches book but doesn't go through our fee path # available_balance should be reduced (position opened via maker fill) assert r.account.available_balance <= s.account.available_balance # ══════════════════════════════════════════════════════════════════════════════ # 8. POSITION UPDATE # ══════════════════════════════════════════════════════════════════════════════ class TestPositionUpdate: def test_open_long_position(self): cwm = MinimalCryptoLOBCWM() s = _state() a = _cross(Side.BUY, frac=0.1) r = cwm.transition(s, (a,)) pos = r.account.positions.get("BTCUSDT") assert pos is not None assert pos.qty > 0 assert pos.side == Side.BUY def test_open_short_position(self): cwm = MinimalCryptoLOBCWM() s = _state() a = _cross(Side.SELL, frac=0.1) r = cwm.transition(s, (a,)) pos = r.account.positions.get("BTCUSDT") assert pos is not None assert pos.qty < 0 assert pos.side == Side.SELL def test_add_to_long(self): cwm = MinimalCryptoLOBCWM() pos = PositionState( symbol="BTCUSDT", qty=0.01, avg_entry=50000.0, unrealized_pnl=0.0, realized_pnl=0.0, liquidation_price=None, leverage=0.05, side=Side.BUY, ) s = _state(positions={"BTCUSDT": pos}) a = _cross(Side.BUY, frac=0.1) r = cwm.transition(s, (a,)) new_pos = r.account.positions.get("BTCUSDT") assert new_pos.qty > 0.01 def test_reduce_long(self): cwm = MinimalCryptoLOBCWM() pos = PositionState( symbol="BTCUSDT", qty=0.1, avg_entry=50000.0, unrealized_pnl=0.0, realized_pnl=0.0, liquidation_price=None, leverage=0.5, side=Side.BUY, ) s = _state(positions={"BTCUSDT": pos}) a = _cross(Side.SELL, frac=0.1) r = cwm.transition(s, (a,)) new_pos = r.account.positions.get("BTCUSDT") assert new_pos.qty < 0.1 def test_avg_entry_updates_on_add(self): cwm = MinimalCryptoLOBCWM() pos = PositionState( symbol="BTCUSDT", qty=0.01, avg_entry=50000.0, unrealized_pnl=0.0, realized_pnl=0.0, liquidation_price=None, leverage=0.05, side=Side.BUY, ) s = _state(bid=49000.0, ask=49001.0, positions={"BTCUSDT": pos}) a = _cross(Side.BUY, frac=0.1) r = cwm.transition(s, (a,)) new_pos = r.account.positions.get("BTCUSDT") assert new_pos.avg_entry != 50000.0 def test_realized_pnl_on_reduce(self): cwm = MinimalCryptoLOBCWM() pos = PositionState( symbol="BTCUSDT", qty=0.1, avg_entry=50000.0, unrealized_pnl=0.0, realized_pnl=0.0, liquidation_price=None, leverage=0.5, side=Side.BUY, ) s = _state(bid=51000.0, ask=51001.0, positions={"BTCUSDT": pos}) a = _cross(Side.SELL, frac=0.1) r = cwm.transition(s, (a,)) new_pos = r.account.positions.get("BTCUSDT") assert new_pos.realized_pnl > 0 def test_no_position_no_change(self): cwm = MinimalCryptoLOBCWM() s = _state() a = _noop() r = cwm.transition(s, (a,)) assert "BTCUSDT" not in r.account.positions def test_leverage_calculation(self): cwm = MinimalCryptoLOBCWM() s = _state() a = _cross(Side.BUY, frac=0.5) r = cwm.transition(s, (a,)) pos = r.account.positions.get("BTCUSDT") assert pos.leverage > 0 # ══════════════════════════════════════════════════════════════════════════════ # 9. MARK-TO-MARKET # ══════════════════════════════════════════════════════════════════════════════ class TestMarkToMarket: def test_mtM_updates_on_fill(self): cwm = MinimalCryptoLOBCWM() pos = PositionState( symbol="BTCUSDT", qty=0.1, avg_entry=50000.0, unrealized_pnl=0.0, realized_pnl=0.0, liquidation_price=None, leverage=0.5, side=Side.BUY, ) # Large book so CP doesn't empty it s = _state(bid=51000.0, ask=51001.0, ask_qty=10.0, positions={"BTCUSDT": pos}) a = _cross(Side.BUY, frac=0.5) r = cwm.transition(s, (a,)) new_pos = r.account.positions.get("BTCUSDT") assert new_pos is not None assert new_pos.qty > 0.1 def test_mtM_equity_changes_on_fill(self): cwm = MinimalCryptoLOBCWM() pos = PositionState( symbol="BTCUSDT", qty=0.1, avg_entry=50000.0, unrealized_pnl=0.0, realized_pnl=0.0, liquidation_price=None, leverage=0.5, side=Side.BUY, ) s = _state(bid=51000.0, ask=51001.0, ask_qty=10.0, positions={"BTCUSDT": pos}) a = _cross(Side.BUY, frac=0.5) r = cwm.transition(s, (a,)) assert r.account.equity != s.account.equity # ══════════════════════════════════════════════════════════════════════════════ # 10. PATH-STATE UPDATE # ══════════════════════════════════════════════════════════════════════════════ class TestPathStateUpdate: def test_new_position_creates_path(self): cwm = MinimalCryptoLOBCWM() s = _state() a = _cross(Side.BUY, frac=0.1) r = cwm.transition(s, (a,)) assert r.trade_path is not None assert r.trade_path.side == Side.BUY def test_path_entry_timestamp(self): cwm = MinimalCryptoLOBCWM() s = _state() a = _cross(Side.BUY, frac=0.1) r = cwm.transition(s, (a,)) assert r.trade_path.entry_ts_ns == r.ts_ns def test_path_pnl_updates(self): cwm = MinimalCryptoLOBCWM() path = _tp(side=Side.BUY, pnl=0.0, mae=-5.0, mfe=10.0) s = _state(trade_path=path) a = _noop() r = cwm.transition(s, (a,)) assert r.trade_path is not None def test_path_mae_tracking(self): cwm = MinimalCryptoLOBCWM() path = _tp(side=Side.BUY, mae=-20.0) s = _state(trade_path=path) a = _noop() r = cwm.transition(s, (a,)) assert r.trade_path.mae_bps <= -20.0 def test_path_mfe_tracking(self): cwm = MinimalCryptoLOBCWM() path = _tp(side=Side.BUY, mfe=15.0) s = _state(trade_path=path) a = _noop() r = cwm.transition(s, (a,)) assert r.trade_path.mfe_bps >= 15.0 # ══════════════════════════════════════════════════════════════════════════════ # 11. COUNTERPARTY FILLS # ══════════════════════════════════════════════════════════════════════════════ class TestCounterpartyFills: def test_cp_buy_consumes_asks(self): cwm = MinimalCryptoLOBCWM() s = _state(asks=((50001.0, 0.5),)) # fraction=5.0 means cp wants to buy 5.0 * 10000 / 50001 = ~1.0 units # Should consume all 0.5 from top level cp = CounterpartyAction( AgentRole.TOXIC_TAKER, ActionKind.CROSS_SPREAD, Side.BUY, 0, 5.0, toxicity=0.8, ) r = cwm.transition(s, (_noop(), cp)) total_ask = sum(l.qty for l in r.book.asks) assert total_ask < 0.5 # consumed from top level def test_cp_sell_consumes_bids(self): cwm = MinimalCryptoLOBCWM() s = _state(bids=((50000.0, 0.5),)) cp = CounterpartyAction( AgentRole.TOXIC_TAKER, ActionKind.CROSS_SPREAD, Side.SELL, 0, 5.0, toxicity=0.8, ) r = cwm.transition(s, (_noop(), cp)) total_bid = sum(l.qty for l in r.book.bids) assert total_bid < 0.5 # consumed from top level def test_cp_fill_updates_book(self): cwm = MinimalCryptoLOBCWM() s = _state(asks=((50001.0, 0.1), (50002.0, 0.1))) cp = CounterpartyAction( AgentRole.TOXIC_TAKER, ActionKind.CROSS_SPREAD, Side.BUY, 0, 0.5, toxicity=0.8, ) r = cwm.transition(s, (_noop(), cp)) assert r.book.last_trade_price is not None def test_cp_fill_reduces_available(self): cwm = MinimalCryptoLOBCWM() s = _state() cp = CounterpartyAction( AgentRole.NOISE_TRADER, ActionKind.CROSS_SPREAD, Side.BUY, 0, 0.1, toxicity=0.1, ) r = cwm.transition(s, (_noop(), cp)) assert r.account.available_balance <= s.account.available_balance # ══════════════════════════════════════════════════════════════════════════════ # 12. DETERMINISM # ══════════════════════════════════════════════════════════════════════════════ class TestDeterminism: def test_same_input_same_output(self): cwm = MinimalCryptoLOBCWM() s = _state() a = _cross(Side.BUY, frac=0.1) r1 = cwm.transition(s, (a,)) r2 = cwm.transition(s, (a,)) assert r1.ts_ns == r2.ts_ns assert r1.account.equity == r2.account.equity def test_input_not_mutated(self): cwm = MinimalCryptoLOBCWM() s = _state() orig_ts = s.ts_ns orig_equity = s.account.equity cwm.transition(s, (_cross(Side.BUY, frac=0.1),)) assert s.ts_ns == orig_ts assert s.account.equity == orig_equity def test_timestamp_advances(self): cwm = MinimalCryptoLOBCWM() s = _state() r = cwm.transition(s, (_noop(),)) assert r.ts_ns > s.ts_ns def test_book_state_independent(self): cwm = MinimalCryptoLOBCWM() s1 = _state(bid=50000.0, ask=50001.0) s2 = _state(bid=49000.0, ask=49001.0) r1 = cwm.transition(s1, (_noop(),)) r2 = cwm.transition(s2, (_noop(),)) assert r1.book.mid != r2.book.mid # ══════════════════════════════════════════════════════════════════════════════ # 13. EDGE CASES # ══════════════════════════════════════════════════════════════════════════════ class TestEdgeCases: def test_noop_preserves_everything(self): cwm = MinimalCryptoLOBCWM() s = _state() r = cwm.transition(s, (_noop(),)) assert r.account.equity == s.account.equity assert r.book.best_bid == s.book.best_bid assert len(r.open_orders) == len(s.open_orders) def test_zero_equity(self): cwm = MinimalCryptoLOBCWM() s = _state(equity=0.0) a = _cross(Side.BUY, frac=0.1) r = cwm.transition(s, (a,)) # Should not crash assert isinstance(r.account.equity, float) def test_empty_book_no_fill(self): cwm = MinimalCryptoLOBCWM() s = _state() # Remove all asks s = MarketWorldState( ts_ns=s.ts_ns, mode=s.mode, venue=s.venue, book=OrderBookState(ts_ns=s.book.ts_ns, symbol=s.book.symbol, bids=s.book.bids, asks=()), account=s.account, ) a = _cross(Side.BUY, frac=0.1) r = cwm.transition(s, (a,)) assert r.account.equity == s.account.equity def test_very_small_qty(self): cwm = MinimalCryptoLOBCWM() s = _state() a = _cross(Side.BUY, frac=0.0001) r = cwm.transition(s, (a,)) # Might be clipped to zero assert isinstance(r.account.equity, float) def test_consecutive_transitions(self): cwm = MinimalCryptoLOBCWM() s = _state() for _ in range(10): s = cwm.transition(s, (_noop(),)) assert isinstance(s.account.equity, float) def test_consecutive_with_actions(self): cwm = MinimalCryptoLOBCWM() s = _state() for i in range(5): a = _place(Side.BUY, offset=i, frac=0.05, post_only=True) s = cwm.transition(s, (a,)) assert len(s.open_orders) == 5 # ══════════════════════════════════════════════════════════════════════════════ # 14. TERMINAL # ══════════════════════════════════════════════════════════════════════════════ class TestTerminal: def test_depth_zero(self): cwm = MinimalCryptoLOBCWM() assert cwm.terminal(_state(), 0) def test_no_intent(self): cwm = MinimalCryptoLOBCWM() assert cwm.terminal(_state(), 5) def test_with_intent_and_depth(self): cwm = MinimalCryptoLOBCWM() intent = ExecutionIntent( intent_id="t1", ts_ns=1, symbol="BTCUSDT", kind=IntentKind.ENTER_LONG, target_qty=0.01, max_notional=500.0, urgency=0.5, alpha_horizon_s=60.0, alpha_bps=2.0, max_slippage_bps=5.0, prefer_maker=True, reduce_only=False, ttl_s=300.0, reason="test", ) s = _state(intent=intent) assert not cwm.terminal(s, 3) # ══════════════════════════════════════════════════════════════════════════════ # 15. REWARD # ══════════════════════════════════════════════════════════════════════════════ class TestReward: def _params(self): return FulfilmentPolicyParams( version="test", ucb_c=1.414, max_sims=64, max_depth=2, rollout_depth=2, root_temperature=0.5, min_root_entropy=0.25, quote_offsets_ticks=(0, 1), quote_size_fractions=(0.25,), passive_ttl_ms=200, aggressive_ttl_ms=50, maker_edge_min_bps=0.5, cross_spread_edge_min_bps=5.0, adverse_toxicity_cancel_threshold=0.5, queue_churn_cancel_threshold=0.5, mae_tail_cut_bps=50.0, mfe_giveback_cut_fraction=0.5, max_time_in_loss_s=300.0, failed_recovery_cut_count=3, recovery_velocity_min_bps_per_s=0.0, max_symbol_notional_fraction=0.20, max_single_order_notional_fraction=0.05, reduce_when_global_up_fraction=0.30, session_profit_lock_fraction=0.02, w_expected_pnl=1.0, w_fill_probability=0.5, w_adverse_selection=2.0, w_queue_priority=0.5, w_inventory_risk=1.5, w_tail_loss=5.0, w_fee_quality=0.5, w_time_decay=0.3, w_policy_entropy=0.5, robust_tail_weight=2.0, toxic_counterparty_weight=3.0, low_liquidity_weight=2.0, latency_stress_weight=1.0, ) def test_noop_reward_zero(self): cwm = MinimalCryptoLOBCWM() s = _state() r = cwm.transition(s, (_noop(),)) assert cwm.reward(s, _noop(), r, self._params()) == 0.0 def test_cross_spread_penalty(self): cwm = MinimalCryptoLOBCWM() s = _state() a = _cross(Side.BUY, frac=0.1) r = cwm.transition(s, (a,)) assert cwm.reward(s, a, r, self._params()) < 0 def test_higher_w_tail_more_penalty(self): import dataclasses cwm = MinimalCryptoLOBCWM() path = _tp(mae=-40.0, failed_recovery_count=2) s = _state(trade_path=path) a = _noop() r = cwm.transition(s, (a,)) p1 = self._params() p2_dict = dataclasses.asdict(p1) p2_dict["w_tail_loss"] = 10.0 p2_dict["version"] = "t2" p2 = FulfilmentPolicyParams(**p2_dict) assert cwm.reward(s, a, r, p2) < cwm.reward(s, a, r, p1)