158 lines
5.5 KiB
Python
158 lines
5.5 KiB
Python
|
|
"""
|
||
|
|
Fuzz testing — random state/action sequences through CWM.
|
||
|
|
|
||
|
|
Verifies CWM never crashes, always produces valid outputs,
|
||
|
|
and preserves invariants under random stress.
|
||
|
|
"""
|
||
|
|
import random
|
||
|
|
import math
|
||
|
|
import pytest
|
||
|
|
from malkhut.state import (
|
||
|
|
AccountState, FulfilmentPolicyParams, IntentKind, MarketWorldState,
|
||
|
|
Mode, OpenOrderState, OrderBookState, PriceLevel, PositionState,
|
||
|
|
Side, TradePathState, VenueRules,
|
||
|
|
)
|
||
|
|
from malkhut.cwm.core import MinimalCryptoLOBCWM
|
||
|
|
from malkhut.actions import ActionKind, FulfilmentAction, OrderType
|
||
|
|
|
||
|
|
|
||
|
|
def _venue():
|
||
|
|
return VenueRules(
|
||
|
|
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,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _random_book(rng):
|
||
|
|
bid = rng.uniform(100.0, 100000.0)
|
||
|
|
ask = bid + rng.uniform(0.1, 100.0)
|
||
|
|
return OrderBookState(
|
||
|
|
ts_ns=rng.randint(1, 2**62), symbol="BTCUSDT",
|
||
|
|
bids=(PriceLevel(bid, rng.uniform(0.001, 10.0)),),
|
||
|
|
asks=(PriceLevel(ask, rng.uniform(0.001, 10.0)),),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _random_action(rng):
|
||
|
|
kind = rng.choice(list(ActionKind))
|
||
|
|
side = rng.choice([Side.BUY, Side.SELL, None])
|
||
|
|
ot = rng.choice(list(OrderType))
|
||
|
|
offset = rng.randint(-20, 20)
|
||
|
|
frac = rng.uniform(0.0, 1.0)
|
||
|
|
ttl = rng.randint(0, 5000)
|
||
|
|
cancel_id = f"r_{rng.randint(0, 1000)}" if kind in (ActionKind.CANCEL, ActionKind.CANCEL_REPLACE) else None
|
||
|
|
return FulfilmentAction(
|
||
|
|
kind=kind, side=side, order_type=ot,
|
||
|
|
price_ticks_from_best=offset, qty_fraction=frac, ttl_ms=ttl,
|
||
|
|
cancel_order_id=cancel_id,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _random_state(rng):
|
||
|
|
book = _random_book(rng)
|
||
|
|
equity = rng.uniform(100.0, 100000.0)
|
||
|
|
pos_qty = rng.choice([0.0, rng.uniform(-0.5, 0.5)])
|
||
|
|
pos = None
|
||
|
|
positions = {}
|
||
|
|
if pos_qty != 0.0:
|
||
|
|
pos = PositionState(
|
||
|
|
symbol="BTCUSDT", qty=pos_qty, avg_entry=book.mid,
|
||
|
|
unrealized_pnl=0.0, realized_pnl=0.0,
|
||
|
|
liquidation_price=None, leverage=abs(pos_qty * book.mid) / max(equity, 1.0),
|
||
|
|
side=Side.BUY if pos_qty > 0 else Side.SELL,
|
||
|
|
)
|
||
|
|
positions["BTCUSDT"] = pos
|
||
|
|
|
||
|
|
return MarketWorldState(
|
||
|
|
ts_ns=rng.randint(1, 2**62), mode=Mode.REPLAY_NO_IMPACT,
|
||
|
|
venue=_venue(), book=book,
|
||
|
|
account=AccountState(
|
||
|
|
ts_ns=rng.randint(1, 2**62), equity=equity,
|
||
|
|
wallet_balance=equity, available_balance=equity,
|
||
|
|
margin_used=0.0, total_notional=abs(pos_qty * book.mid),
|
||
|
|
positions=positions,
|
||
|
|
),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class TestCWMFuzz:
|
||
|
|
def test_100_random_noop_transitions(self):
|
||
|
|
cwm = MinimalCryptoLOBCWM()
|
||
|
|
rng = random.Random(42)
|
||
|
|
for _ in range(100):
|
||
|
|
s = _random_state(rng)
|
||
|
|
a = FulfilmentAction(ActionKind.NOOP, None, None, 0, 0.0, 0)
|
||
|
|
r = cwm.transition(s, (a,))
|
||
|
|
assert r.ts_ns >= s.ts_ns
|
||
|
|
assert r.account.equity >= 0
|
||
|
|
|
||
|
|
def test_100_random_action_transitions(self):
|
||
|
|
cwm = MinimalCryptoLOBCWM()
|
||
|
|
rng = random.Random(123)
|
||
|
|
for _ in range(100):
|
||
|
|
s = _random_state(rng)
|
||
|
|
a = _random_action(rng)
|
||
|
|
r = cwm.transition(s, (a,))
|
||
|
|
assert r.ts_ns >= s.ts_ns
|
||
|
|
assert r.account.equity >= 0
|
||
|
|
|
||
|
|
def test_100_random_multi_counterparty(self):
|
||
|
|
from malkhut.counterparties import (
|
||
|
|
ToxicTakerPolicy, PassiveMakerPolicy, NoiseTraderPolicy,
|
||
|
|
)
|
||
|
|
cwm = MinimalCryptoLOBCWM()
|
||
|
|
rng = random.Random(456)
|
||
|
|
cps = [ToxicTakerPolicy(), PassiveMakerPolicy(), NoiseTraderPolicy()]
|
||
|
|
for _ in range(100):
|
||
|
|
s = _random_state(rng)
|
||
|
|
a = _random_action(rng)
|
||
|
|
cp_actions = tuple(cp.rollout_action(s, rng) for cp in cps)
|
||
|
|
r = cwm.transition(s, (a, *cp_actions))
|
||
|
|
assert r.ts_ns >= s.ts_ns
|
||
|
|
# Equity can go negative with aggressive counterparties (realistic)
|
||
|
|
assert isinstance(r.account.equity, float)
|
||
|
|
|
||
|
|
def test_50_sequential_transitions(self):
|
||
|
|
cwm = MinimalCryptoLOBCWM()
|
||
|
|
rng = random.Random(789)
|
||
|
|
s = _random_state(rng)
|
||
|
|
for i in range(50):
|
||
|
|
a = FulfilmentAction(ActionKind.NOOP, None, None, 0, 0.0, 0)
|
||
|
|
s = cwm.transition(s, (a,))
|
||
|
|
assert s.account.equity >= 0
|
||
|
|
|
||
|
|
def test_50_sequential_with_actions(self):
|
||
|
|
cwm = MinimalCryptoLOBCWM()
|
||
|
|
rng = random.Random(101)
|
||
|
|
s = _random_state(rng)
|
||
|
|
for i in range(50):
|
||
|
|
a = _random_action(rng)
|
||
|
|
s = cwm.transition(s, (a,))
|
||
|
|
# Equity can go negative (realistic: overleveraged position)
|
||
|
|
assert isinstance(s.account.equity, float)
|
||
|
|
|
||
|
|
def test_input_never_mutated(self):
|
||
|
|
cwm = MinimalCryptoLOBCWM()
|
||
|
|
rng = random.Random(202)
|
||
|
|
for _ in range(50):
|
||
|
|
s = _random_state(rng)
|
||
|
|
orig_ts = s.ts_ns
|
||
|
|
orig_equity = s.account.equity
|
||
|
|
a = _random_action(rng)
|
||
|
|
cwm.transition(s, (a,))
|
||
|
|
assert s.ts_ns == orig_ts
|
||
|
|
assert s.account.equity == orig_equity
|
||
|
|
|
||
|
|
def test_determinism_under_fuzz(self):
|
||
|
|
cwm = MinimalCryptoLOBCWM()
|
||
|
|
rng = random.Random(303)
|
||
|
|
for _ in range(50):
|
||
|
|
s = _random_state(rng)
|
||
|
|
a = _random_action(rng)
|
||
|
|
r1 = cwm.transition(s, (a,))
|
||
|
|
r2 = cwm.transition(s, (a,))
|
||
|
|
assert r1.ts_ns == r2.ts_ns
|
||
|
|
assert r1.account.equity == r2.account.equity
|