CWM (103): core mechanics, exhaustive edge cases, numba, exchange mechanics Replay (118): exhaustive verification, microstructure, trajectory Training (190): asset classification, phase0 extensive, pipeline, exhaustive DSL (102): v2 syntax, expanded, new features ASEx (33): validate-before-mutate, single-writer Planner (48): MCTS, alternatives, hooks Counterparties (19): 9 adversarial agent policies Clock (30): event-driven reactor BingX (28): venue adapter IPC (8): Zinc SHM Storage (9): ClickHouse Risk (4): hard invariants State (17): frozen dataclass invariants Integration: E2E, concurrency, sync/async seams, hypothesis, fuzz, adversarial
276 lines
12 KiB
Python
276 lines
12 KiB
Python
"""
|
|
Tests for extended counterparties and structured observability.
|
|
"""
|
|
import pytest
|
|
from malkhut.state import (
|
|
AccountState, MarketWorldState, Mode, OrderBookState, PriceLevel,
|
|
PositionState, Side, TradePathState, VenueRules,
|
|
)
|
|
from malkhut.actions import ActionKind, AgentRole
|
|
from malkhut.counterparties_extended import (
|
|
MomentumTakerPolicy, MeanReversionTakerPolicy,
|
|
InventoryMarketMakerPolicy, LiquidationFlowPolicy,
|
|
StaleQuoteAttackerPolicy, extended_counterparty_ecology,
|
|
)
|
|
from malkhut.training.structured_obs import StructuredObservability, DecisionMetrics
|
|
|
|
|
|
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 _state(**kw):
|
|
tp = kw.get("trade_path")
|
|
pos = kw.get("position")
|
|
positions = {"BTCUSDT": pos} if pos else {}
|
|
return MarketWorldState(
|
|
ts_ns=kw.get("ts", 1_000_000_000), mode=Mode.REPLAY_NO_IMPACT, venue=_venue(),
|
|
book=OrderBookState(ts_ns=1, symbol="BTCUSDT",
|
|
bids=(PriceLevel(kw.get("bid", 50000.0), 1.0),),
|
|
asks=(PriceLevel(kw.get("ask", 50001.0), 1.0),)),
|
|
account=AccountState(ts_ns=1, equity=10000.0, wallet_balance=10000.0,
|
|
available_balance=10000.0, margin_used=0.0,
|
|
total_notional=0.0, positions=positions),
|
|
trade_path=tp, open_orders=kw.get("open_orders", ()),
|
|
)
|
|
|
|
|
|
def _tp(**kw):
|
|
d = dict(symbol="BTCUSDT", side=Side.BUY, entry_ts_ns=0, now_ts_ns=100_000_000,
|
|
bars_held=5, seconds_held=50.0, pnl_bps=0.0, mae_bps=-10.0,
|
|
mfe_bps=15.0, distance_from_mfe_bps=15.0, distance_from_entry_bps=0.0,
|
|
time_to_mfe_s=20.0, time_in_loss_s=50.0, time_in_profit_s=50.0,
|
|
time_since_last_profit_s=10.0, time_since_deep_mae_s=10.0,
|
|
loss_to_profit_transitions=1, deep_loss_recoveries=0,
|
|
failed_recovery_count=0, 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)
|
|
d.update(kw)
|
|
return TradePathState(**d)
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
# EXTENDED COUNTERPARTIES (20 tests)
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
class TestExtendedCounterparties:
|
|
def test_momentum_taker_buy_on_upward(self):
|
|
p = MomentumTakerPolicy()
|
|
tp = _tp(pnl_bps=50.0)
|
|
s = _state(trade_path=tp)
|
|
rng = random.Random(42)
|
|
a = p.rollout_action(s, rng)
|
|
assert a.kind == ActionKind.CROSS_SPREAD
|
|
assert a.side == Side.BUY
|
|
|
|
def test_momentum_taker_sell_on_downward(self):
|
|
p = MomentumTakerPolicy()
|
|
tp = _tp(pnl_bps=-50.0)
|
|
s = _state(trade_path=tp)
|
|
rng = random.Random(42)
|
|
a = p.rollout_action(s, rng)
|
|
assert a.kind == ActionKind.CROSS_SPREAD
|
|
assert a.side == Side.SELL
|
|
|
|
def test_momentum_taker_noop_when_flat(self):
|
|
p = MomentumTakerPolicy()
|
|
tp = _tp(pnl_bps=0.0)
|
|
s = _state(trade_path=tp)
|
|
rng = random.Random(42)
|
|
a = p.rollout_action(s, rng)
|
|
assert a.kind == ActionKind.NOOP
|
|
|
|
def test_mean_reversion_buy_on_drop(self):
|
|
p = MeanReversionTakerPolicy()
|
|
tp = _tp(pnl_bps=-60.0) # abs(60) > 0.5*100 = 50
|
|
s = _state(trade_path=tp)
|
|
rng = random.Random(42)
|
|
a = p.rollout_action(s, rng)
|
|
assert a.kind == ActionKind.CROSS_SPREAD
|
|
assert a.side == Side.BUY
|
|
|
|
def test_mean_reversion_sell_on_rise(self):
|
|
p = MeanReversionTakerPolicy()
|
|
tp = _tp(pnl_bps=60.0) # abs(60) > 0.5*100 = 50
|
|
s = _state(trade_path=tp)
|
|
rng = random.Random(42)
|
|
a = p.rollout_action(s, rng)
|
|
assert a.kind == ActionKind.CROSS_SPREAD
|
|
assert a.side == Side.SELL
|
|
|
|
def test_inventory_mm_reduces_high_inventory(self):
|
|
p = InventoryMarketMakerPolicy(max_inventory=0.05)
|
|
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(position=pos)
|
|
rng = random.Random(42)
|
|
a = p.rollout_action(s, rng)
|
|
assert a.kind == ActionKind.PLACE
|
|
assert a.side == Side.SELL
|
|
|
|
def test_inventory_mm_noop_when_balanced(self):
|
|
p = InventoryMarketMakerPolicy(max_inventory=0.2)
|
|
s = _state()
|
|
rng = random.Random(42)
|
|
a = p.rollout_action(s, rng)
|
|
assert a.kind == ActionKind.NOOP
|
|
|
|
def test_liquidation_flow_triggers_on_deep_loss(self):
|
|
p = LiquidationFlowPolicy(trigger_bps=50.0)
|
|
tp = _tp(mae_bps=-60.0)
|
|
s = _state(trade_path=tp)
|
|
rng = random.Random(42)
|
|
a = p.rollout_action(s, rng)
|
|
assert a.kind == ActionKind.CROSS_SPREAD
|
|
assert a.side == Side.SELL
|
|
assert a.toxicity == 0.9
|
|
|
|
def test_liquidation_flow_noop_when_no_loss(self):
|
|
p = LiquidationFlowPolicy(trigger_bps=50.0)
|
|
tp = _tp(mae_bps=-10.0)
|
|
s = _state(trade_path=tp)
|
|
rng = random.Random(42)
|
|
a = p.rollout_action(s, rng)
|
|
assert a.kind == ActionKind.NOOP
|
|
|
|
def test_stale_quote_attacker_attacks(self):
|
|
p = StaleQuoteAttackerPolicy()
|
|
from malkhut.state import OpenOrderState
|
|
oo = OpenOrderState(client_order_id="c1", venue_order_id="v1", symbol="BTCUSDT",
|
|
side=Side.BUY, order_type=OrderType.LIMIT, price=50000.0,
|
|
qty=0.001, remaining_qty=0.001, queue_ahead_estimate=0.001,
|
|
created_ts_ns=1, last_update_ts_ns=1)
|
|
s = _state(open_orders=(oo,))
|
|
rng = random.Random(42)
|
|
a = p.rollout_action(s, rng)
|
|
assert a.kind == ActionKind.CROSS_SPREAD
|
|
|
|
def test_stale_quote_attacker_noop_when_no_orders(self):
|
|
p = StaleQuoteAttackerPolicy()
|
|
s = _state()
|
|
rng = random.Random(42)
|
|
a = p.rollout_action(s, rng)
|
|
assert a.kind == ActionKind.NOOP
|
|
|
|
def test_extended_ecology_has_9_agents(self):
|
|
eco = extended_counterparty_ecology()
|
|
assert len(eco) == 9
|
|
|
|
def test_extended_ecology_unique_roles(self):
|
|
eco = extended_counterparty_ecology()
|
|
roles = [p.role for p in eco]
|
|
assert len(set(roles)) == 9
|
|
|
|
def test_all_agents_produce_valid_actions(self):
|
|
eco = extended_counterparty_ecology()
|
|
s = _state()
|
|
rng = random.Random(42)
|
|
for agent in eco:
|
|
a = agent.rollout_action(s, rng)
|
|
assert a.kind is not None
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
# STRUCTURED OBSERVABILITY (15 tests)
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
class TestStructuredObservability:
|
|
def test_record_decision(self):
|
|
from malkhut.actions import FulfilmentAction, PlannedPolicy, RiskDecision
|
|
so = StructuredObservability()
|
|
s = _state()
|
|
a = FulfilmentAction(ActionKind.NOOP, None, None, 0, 0.0, 0)
|
|
planned = PlannedPolicy(actions=(a,), probabilities=(1.0,),
|
|
selected_action=a, diagnostics={"entropy": 0.5, "sims": 10})
|
|
decision = RiskDecision(approved=True, action=a, reason="ok")
|
|
so.record_decision(s, planned, decision, plan_ns=1000, regime="normal")
|
|
assert so.total_decisions == 1
|
|
|
|
def test_feature_importance(self):
|
|
from malkhut.actions import FulfilmentAction, PlannedPolicy, RiskDecision
|
|
so = StructuredObservability()
|
|
s = _state()
|
|
a = FulfilmentAction(ActionKind.NOOP, None, None, 0, 0.0, 0)
|
|
planned = PlannedPolicy(actions=(a,), probabilities=(1.0,),
|
|
selected_action=a, diagnostics={"entropy": 0.5, "sims": 10})
|
|
decision = RiskDecision(approved=True, action=a, reason="ok")
|
|
for _ in range(10):
|
|
so.record_decision(s, planned, decision, plan_ns=1000, regime="normal")
|
|
importance = so.get_feature_importance(top_n=5)
|
|
assert len(importance) > 0
|
|
|
|
def test_regime_approval_rate(self):
|
|
from malkhut.actions import FulfilmentAction, PlannedPolicy, RiskDecision
|
|
so = StructuredObservability()
|
|
s = _state()
|
|
a = FulfilmentAction(ActionKind.NOOP, None, None, 0, 0.0, 0)
|
|
planned = PlannedPolicy(actions=(a,), probabilities=(1.0,),
|
|
selected_action=a, diagnostics={"entropy": 0.5, "sims": 10})
|
|
# Approved
|
|
decision = RiskDecision(approved=True, action=a, reason="ok")
|
|
so.record_decision(s, planned, decision, plan_ns=1000, regime="normal")
|
|
# Rejected
|
|
decision2 = RiskDecision(approved=False, action=a, reason="kill")
|
|
so.record_decision(s, planned, decision2, plan_ns=1000, regime="normal")
|
|
rate = so.get_regime_approval_rate("normal")
|
|
assert rate == 0.5
|
|
|
|
def test_avg_latency(self):
|
|
from malkhut.actions import FulfilmentAction, PlannedPolicy, RiskDecision
|
|
so = StructuredObservability()
|
|
s = _state()
|
|
a = FulfilmentAction(ActionKind.NOOP, None, None, 0, 0.0, 0)
|
|
planned = PlannedPolicy(actions=(a,), probabilities=(1.0,),
|
|
selected_action=a, diagnostics={"entropy": 0.5, "sims": 10})
|
|
decision = RiskDecision(approved=True, action=a, reason="ok")
|
|
so.record_decision(s, planned, decision, plan_ns=1000)
|
|
so.record_decision(s, planned, decision, plan_ns=2000)
|
|
assert so.avg_latency_ns == 1500.0
|
|
|
|
def test_avg_entropy(self):
|
|
from malkhut.actions import FulfilmentAction, PlannedPolicy, RiskDecision
|
|
so = StructuredObservability()
|
|
s = _state()
|
|
a = FulfilmentAction(ActionKind.NOOP, None, None, 0, 0.0, 0)
|
|
planned = PlannedPolicy(actions=(a,), probabilities=(1.0,),
|
|
selected_action=a, diagnostics={"entropy": 0.5, "sims": 10})
|
|
decision = RiskDecision(approved=True, action=a, reason="ok")
|
|
so.record_decision(s, planned, decision, plan_ns=1000)
|
|
so.record_decision(s, planned, decision, plan_ns=1000)
|
|
assert so.avg_entropy == 0.5
|
|
|
|
def test_total_decisions(self):
|
|
from malkhut.actions import FulfilmentAction, PlannedPolicy, RiskDecision
|
|
so = StructuredObservability()
|
|
s = _state()
|
|
a = FulfilmentAction(ActionKind.NOOP, None, None, 0, 0.0, 0)
|
|
planned = PlannedPolicy(actions=(a,), probabilities=(1.0,),
|
|
selected_action=a, diagnostics={"entropy": 0.5, "sims": 10})
|
|
decision = RiskDecision(approved=True, action=a, reason="ok")
|
|
for _ in range(20):
|
|
so.record_decision(s, planned, decision, plan_ns=1000)
|
|
assert so.total_decisions == 20
|
|
|
|
def test_feature_importance_sorted(self):
|
|
from malkhut.actions import FulfilmentAction, PlannedPolicy, RiskDecision
|
|
so = StructuredObservability()
|
|
s = _state()
|
|
a = FulfilmentAction(ActionKind.NOOP, None, None, 0, 0.0, 0)
|
|
planned = PlannedPolicy(actions=(a,), probabilities=(1.0,),
|
|
selected_action=a, diagnostics={"entropy": 0.5, "sims": 10})
|
|
decision = RiskDecision(approved=True, action=a, reason="ok")
|
|
for _ in range(10):
|
|
so.record_decision(s, planned, decision, plan_ns=1000)
|
|
importance = so.get_feature_importance(top_n=5)
|
|
for i in range(len(importance) - 1):
|
|
assert importance[i][1] >= importance[i+1][1]
|
|
|
|
|
|
import random
|
|
from malkhut.actions import OrderType
|