Files
sentiment-engine/MALKHUT/malkhut/tests/test_cwm_exhaustive.py

925 lines
38 KiB
Python
Raw Normal View History

"""
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)