925 lines
38 KiB
Python
925 lines
38 KiB
Python
|
|
"""
|
||
|
|
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)
|