""" Path risk / trade path state — SL/TP path-aware logic. Tests that the CWM reward and risk functions correctly respond to various trade path states (MAE, MFE, recovery velocity, failed recovery). """ import math import pytest from malkhut.state import ( AccountState, FulfilmentPolicyParams, IntentKind, MarketWorldState, Mode, OrderBookState, PriceLevel, Side, TradePathState, VenueRules, ) from malkhut.cwm.core import MinimalCryptoLOBCWM def _params(**kw): d = dict( 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, ) d.update(kw) return FulfilmentPolicyParams(**d) def _path(**kw): d = dict( symbol="BTCUSDT", side=Side.BUY, entry_ts_ns=0, now_ts_ns=100_000_000, bars_held=10, seconds_held=100.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=30.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=20.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) def _state(**kw): book = OrderBookState( ts_ns=1_000_000_000, symbol="BTCUSDT", bids=(PriceLevel(50000.0, 1.0),), asks=(PriceLevel(50001.0, 1.0),), ) return MarketWorldState( ts_ns=1_000_000_000, mode=Mode.REPLAY_NO_IMPACT, venue=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, ), book=book, account=kw.get("account", AccountState( ts_ns=1_000_000_000, equity=10000.0, wallet_balance=10000.0, available_balance=10000.0, margin_used=0.0, total_notional=0.0, )), trade_path=kw.get("trade_path"), ) class TestPathRiskExitDecision: def test_no_exit_when_no_path(self): from malkhut.planner.action_menu import _path_risk_says_exit s = _state() assert not _path_risk_says_exit(s, _params()) def test_exit_when_mae_exceeds_threshold(self): from malkhut.planner.action_menu import _path_risk_says_exit path = _path(mae_bps=-60.0, recovery_velocity_bps_per_s=-2.0) s = _state(trade_path=path) assert _path_risk_says_exit(s, _params(mae_tail_cut_bps=50.0)) def test_no_exit_when_mae_below_threshold(self): from malkhut.planner.action_menu import _path_risk_says_exit # mae=30 < threshold=50, time_in_loss=50 < 300, failed_recovery=0 < 3 # mfe=15, distance=3 => giveback=3/15=0.2 < 0.5 path = _path(mae_bps=-30.0, distance_from_mfe_bps=3.0) s = _state(trade_path=path) assert not _path_risk_says_exit(s, _params(mae_tail_cut_bps=50.0)) def test_exit_when_time_in_loss_exceeds(self): from malkhut.planner.action_menu import _path_risk_says_exit path = _path(time_in_loss_s=400.0) s = _state(trade_path=path) assert _path_risk_says_exit(s, _params(max_time_in_loss_s=300.0)) def test_exit_when_failed_recovery_count_exceeds(self): from malkhut.planner.action_menu import _path_risk_says_exit path = _path(failed_recovery_count=5) s = _state(trade_path=path) assert _path_risk_says_exit(s, _params(failed_recovery_cut_count=3)) def test_exit_on_mfe_giveback(self): from malkhut.planner.action_menu import _path_risk_says_exit # mfe=10, distance_from_mfe=8 => giveback = 8/10 = 0.8 > 0.5 path = _path(mfe_bps=10.0, distance_from_mfe_bps=8.0) s = _state(trade_path=path) assert _path_risk_says_exit(s, _params(mfe_giveback_cut_fraction=0.5)) def test_no_exit_when_mfe_giveback_below_threshold(self): from malkhut.planner.action_menu import _path_risk_says_exit # mfe=10, distance_from_mfe=3 => giveback = 3/10 = 0.3 < 0.5 path = _path(mfe_bps=10.0, distance_from_mfe_bps=3.0) s = _state(trade_path=path) assert not _path_risk_says_exit(s, _params(mfe_giveback_cut_fraction=0.5)) def test_exit_when_both_mae_and_slow_recovery(self): from malkhut.planner.action_menu import _path_risk_says_exit path = _path(mae_bps=-60.0, recovery_velocity_bps_per_s=-3.0) s = _state(trade_path=path) assert _path_risk_says_exit(s, _params(mae_tail_cut_bps=50.0, recovery_velocity_min_bps_per_s=0.0)) def test_no_exit_when_mae_high_but_recovery_fast(self): from malkhut.planner.action_menu import _path_risk_says_exit # mae=60 > threshold=50, but recovery_velocity=5.0 > min=0.0 → no MAE exit # time_in_loss=50 < 300, failed_recovery=0 < 3 # mfe=15, distance=3 => giveback=0.2 < 0.5 path = _path(mae_bps=-60.0, recovery_velocity_bps_per_s=5.0, distance_from_mfe_bps=3.0) s = _state(trade_path=path) assert not _path_risk_says_exit(s, _params(mae_tail_cut_bps=50.0, recovery_velocity_min_bps_per_s=0.0)) class TestPathRiskProxy: def test_zero_when_no_path(self): cwm = MinimalCryptoLOBCWM() s = _state() assert cwm._tail_risk_proxy(s) == 0.0 def test_increases_with_mae(self): cwm = MinimalCryptoLOBCWM() p1 = _path(mae_bps=-10.0) p2 = _path(mae_bps=-50.0) r1 = cwm._tail_risk_proxy(_state(trade_path=p1)) r2 = cwm._tail_risk_proxy(_state(trade_path=p2)) assert r2 > r1 def test_increases_with_time_in_loss(self): cwm = MinimalCryptoLOBCWM() p1 = _path(time_in_loss_s=10.0) p2 = _path(time_in_loss_s=100.0) r1 = cwm._tail_risk_proxy(_state(trade_path=p1)) r2 = cwm._tail_risk_proxy(_state(trade_path=p2)) assert r2 > r1 def test_increases_with_failed_recovery(self): cwm = MinimalCryptoLOBCWM() p1 = _path(failed_recovery_count=0) p2 = _path(failed_recovery_count=3) r1 = cwm._tail_risk_proxy(_state(trade_path=p1)) r2 = cwm._tail_risk_proxy(_state(trade_path=p2)) assert r2 > r1 class TestInventoryRisk: def test_zero_when_no_position(self): cwm = MinimalCryptoLOBCWM() s = _state() assert cwm._inventory_risk(s) == 0.0 def test_positive_when_position_exists(self): from malkhut.state import PositionState, AccountState 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(account=AccountState( ts_ns=1_000_000_000, equity=10000.0, wallet_balance=10000.0, available_balance=10000.0, margin_used=0.0, total_notional=5000.0, positions={"BTCUSDT": pos}, )) risk = cwm._inventory_risk(s) assert risk > 0 class TestRewardPathSensitivity: def test_higher_w_tail_loss_more_penalty_for_deep_mae(self): cwm = MinimalCryptoLOBCWM() path = _path(mae_bps=-40.0, failed_recovery_count=2) s = _state(trade_path=path) from malkhut.actions import FulfilmentAction, ActionKind a = FulfilmentAction(ActionKind.NOOP, None, None, 0, 0.0, 0) r = cwm.transition(s, (a,)) p1 = _params(w_tail_loss=1.0) p2 = _params(w_tail_loss=10.0) # reward = 0 - tail_risk * w_tail_loss - ... # Higher w_tail_loss should produce more negative reward assert cwm.reward(s, a, r, p2) < cwm.reward(s, a, r, p1)