209 lines
8.8 KiB
Python
209 lines
8.8 KiB
Python
|
|
"""
|
||
|
|
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)
|