Any non-finite input to gross_scaler now returns 0.0 (flat) instead of silently
propagating through min()/max() to the non-NaN argument and potentially inverting
to maximum exposure. A negative/misconfigured gross_cap is clamped to 0.0 (not
negative). pretrade_block blocks on NaN session_pnl_pct ("session_pnl_unknown")
and on a misconfigured session_dd that is non-finite or non-negative
("bad_session_dd_config"). cap_weights zeros all outputs on non-finite NLV, zeros
individual symbols on non-finite weights, and skips the ADV cap when ADV is
non-finite (rather than weakening the per_symbol_cap). GateConfig gains kelly_cap
field for cohesion. Locked by 13 new NaN/edge-case unit tests (166 total, all pass).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
169 lines
7.7 KiB
Python
169 lines
7.7 KiB
Python
"""risk_gates: hard pre-trade gate + dynamic gross scaler (edge-decay θ × Kelly fraction)."""
|
||
from __future__ import annotations
|
||
|
||
from fxhnt.domain.execution.risk_gates import GateConfig, gross_scaler, pretrade_block
|
||
|
||
|
||
def _cfg(**kw):
|
||
base = dict(allow_live=True, kill_switch=False, testnet=False, gross_cap=1.0, session_dd=-0.1, kelly_cap=0.25)
|
||
base.update(kw)
|
||
return GateConfig(**base)
|
||
|
||
|
||
def test_kill_switch_blocks() -> None:
|
||
assert pretrade_block(_cfg(kill_switch=True), session_pnl_pct=0.0) == "kill_switch"
|
||
|
||
|
||
def test_live_requires_allow_live() -> None:
|
||
assert pretrade_block(_cfg(allow_live=False, testnet=False), session_pnl_pct=0.0) == "allow_live"
|
||
|
||
|
||
def test_session_drawdown_breaker() -> None:
|
||
assert pretrade_block(_cfg(session_dd=-0.1), session_pnl_pct=-0.12) == "session_drawdown"
|
||
|
||
|
||
def test_clear_when_safe() -> None:
|
||
assert pretrade_block(_cfg(), session_pnl_pct=-0.02) is None
|
||
|
||
|
||
def test_testnet_bypasses_allow_live() -> None:
|
||
assert pretrade_block(_cfg(allow_live=False, testnet=True), session_pnl_pct=0.0) is None
|
||
|
||
|
||
def test_gross_scaler_shrinks_with_decay_and_kelly() -> None:
|
||
full = gross_scaler(edge_theta=1.0, kelly_fraction=1.0, kelly_cap=0.25, gross_cap=1.0)
|
||
assert abs(full - 0.25) < 1e-9 # kelly capped at 0.25, theta=1 -> 0.25
|
||
decayed = gross_scaler(edge_theta=0.2, kelly_fraction=1.0, kelly_cap=0.25, gross_cap=1.0)
|
||
assert decayed < full # edge decay shrinks gross
|
||
assert gross_scaler(edge_theta=0.0, kelly_fraction=1.0, kelly_cap=0.25, gross_cap=1.0) == 0.0
|
||
|
||
|
||
# --- additional rigorous edge-branch tests ---
|
||
|
||
def test_kill_switch_takes_priority_over_allow_live() -> None:
|
||
"""kill_switch checked first — even if allow_live is False, reason is kill_switch."""
|
||
assert pretrade_block(_cfg(kill_switch=True, allow_live=False, testnet=False),
|
||
session_pnl_pct=0.0) == "kill_switch"
|
||
|
||
|
||
def test_session_dd_exact_threshold_is_blocked() -> None:
|
||
"""session_pnl_pct == session_dd is at the threshold (<=) — must block."""
|
||
assert pretrade_block(_cfg(session_dd=-0.10), session_pnl_pct=-0.10) == "session_drawdown"
|
||
|
||
|
||
def test_session_dd_just_above_threshold_clears() -> None:
|
||
"""session_pnl_pct just above threshold (e.g. -0.09) must not block."""
|
||
assert pretrade_block(_cfg(session_dd=-0.10), session_pnl_pct=-0.09) is None
|
||
|
||
|
||
def test_gross_scaler_kelly_cap_clamp() -> None:
|
||
"""kelly_fraction > kelly_cap is clamped to kelly_cap before multiplying."""
|
||
result = gross_scaler(edge_theta=1.0, kelly_fraction=5.0, kelly_cap=0.25, gross_cap=1.0)
|
||
assert abs(result - 0.25) < 1e-9
|
||
|
||
|
||
def test_gross_scaler_gross_cap_binding() -> None:
|
||
"""gross_cap is the outer ceiling: theta×kelly cannot exceed it."""
|
||
result = gross_scaler(edge_theta=1.0, kelly_fraction=1.0, kelly_cap=1.0, gross_cap=0.5)
|
||
assert abs(result - 0.5) < 1e-9
|
||
|
||
|
||
def test_gross_scaler_theta_clamped_to_zero() -> None:
|
||
"""Negative theta is clamped to 0.0 — result must be 0."""
|
||
assert gross_scaler(edge_theta=-5.0, kelly_fraction=1.0, kelly_cap=0.25, gross_cap=1.0) == 0.0
|
||
|
||
|
||
def test_gross_scaler_theta_clamped_to_one() -> None:
|
||
"""theta > 1 is clamped to 1.0 — must not exceed what theta=1 gives."""
|
||
result_over = gross_scaler(edge_theta=2.0, kelly_fraction=1.0, kelly_cap=0.25, gross_cap=1.0)
|
||
result_one = gross_scaler(edge_theta=1.0, kelly_fraction=1.0, kelly_cap=0.25, gross_cap=1.0)
|
||
assert abs(result_over - result_one) < 1e-9
|
||
|
||
|
||
def test_gross_scaler_theta_zero_always_zero() -> None:
|
||
"""theta=0 means no edge; result must be 0 regardless of kelly / gross_cap."""
|
||
assert gross_scaler(edge_theta=0.0, kelly_fraction=1.0, kelly_cap=1.0, gross_cap=10.0) == 0.0
|
||
|
||
|
||
def test_gross_scaler_partial_theta_and_kelly() -> None:
|
||
"""Intermediate values: theta=0.5, kelly=0.5 (< kelly_cap=0.25? no: 0.5>0.25 -> clamp to 0.25).
|
||
Expect: 0.5 * 0.25 = 0.125, bounded by gross_cap=1.0."""
|
||
result = gross_scaler(edge_theta=0.5, kelly_fraction=0.5, kelly_cap=0.25, gross_cap=1.0)
|
||
assert abs(result - 0.5 * 0.25) < 1e-9
|
||
|
||
|
||
def test_gross_scaler_kelly_below_cap_used_directly() -> None:
|
||
"""If kelly_fraction < kelly_cap, use kelly_fraction directly (no clamp needed)."""
|
||
result = gross_scaler(edge_theta=1.0, kelly_fraction=0.10, kelly_cap=0.25, gross_cap=1.0)
|
||
assert abs(result - 0.10) < 1e-9
|
||
|
||
|
||
def test_testnet_plus_kill_switch_still_blocks() -> None:
|
||
"""testnet bypasses allow_live check but kill_switch must still block."""
|
||
assert pretrade_block(_cfg(kill_switch=True, testnet=True, allow_live=False),
|
||
session_pnl_pct=0.0) == "kill_switch"
|
||
|
||
|
||
def test_testnet_session_dd_still_blocks() -> None:
|
||
"""testnet bypasses allow_live but session_drawdown check is independent."""
|
||
assert pretrade_block(_cfg(testnet=True, allow_live=False, session_dd=-0.10),
|
||
session_pnl_pct=-0.15) == "session_drawdown"
|
||
|
||
|
||
# --- M1: NaN / non-finite / misconfig safety tests ---
|
||
|
||
def test_gross_scaler_nan_edge_theta_returns_zero() -> None:
|
||
"""NaN edge_theta -> 0.0 (flat). EMA not yet warmed up must not invert to max exposure."""
|
||
assert gross_scaler(edge_theta=float("nan"), kelly_fraction=1.0, kelly_cap=0.25, gross_cap=1.0) == 0.0
|
||
|
||
|
||
def test_gross_scaler_nan_kelly_fraction_returns_zero() -> None:
|
||
"""NaN kelly_fraction (e.g. avg_win=0 divide-by-zero) -> 0.0 (flat)."""
|
||
assert gross_scaler(edge_theta=1.0, kelly_fraction=float("nan"), kelly_cap=0.25, gross_cap=1.0) == 0.0
|
||
|
||
|
||
def test_gross_scaler_nan_kelly_cap_returns_zero() -> None:
|
||
"""NaN kelly_cap -> 0.0 (flat). Misconfigured cap must not silently pass through."""
|
||
assert gross_scaler(edge_theta=1.0, kelly_fraction=1.0, kelly_cap=float("nan"), gross_cap=1.0) == 0.0
|
||
|
||
|
||
def test_gross_scaler_nan_gross_cap_returns_zero() -> None:
|
||
"""NaN gross_cap -> 0.0 (flat). Missing config must not open max exposure."""
|
||
assert gross_scaler(edge_theta=1.0, kelly_fraction=1.0, kelly_cap=0.25, gross_cap=float("nan")) == 0.0
|
||
|
||
|
||
def test_gross_scaler_inf_gross_cap_returns_zero() -> None:
|
||
"""inf gross_cap -> 0.0 (flat). Non-finite is not a valid configuration."""
|
||
assert gross_scaler(edge_theta=1.0, kelly_fraction=1.0, kelly_cap=0.25, gross_cap=float("inf")) == 0.0
|
||
|
||
|
||
def test_gross_scaler_negative_gross_cap_returns_zero() -> None:
|
||
"""Negative gross_cap (misconfiguration) must return 0.0, never a negative value that inverts positions."""
|
||
result = gross_scaler(edge_theta=1.0, kelly_fraction=1.0, kelly_cap=0.25, gross_cap=-0.5)
|
||
assert result == 0.0
|
||
|
||
|
||
def test_pretrade_block_nan_session_pnl_blocks() -> None:
|
||
"""NaN session_pnl_pct -> block with 'session_pnl_unknown'. Unknown PnL state must not allow trading."""
|
||
assert pretrade_block(_cfg(), session_pnl_pct=float("nan")) == "session_pnl_unknown"
|
||
|
||
|
||
def test_pretrade_block_bad_session_dd_positive_blocks() -> None:
|
||
"""session_dd >= 0 is a misconfiguration (must be negative threshold) -> block with 'bad_session_dd_config'."""
|
||
assert pretrade_block(_cfg(session_dd=0.10), session_pnl_pct=0.0) == "bad_session_dd_config"
|
||
|
||
|
||
def test_pretrade_block_bad_session_dd_zero_blocks() -> None:
|
||
"""session_dd == 0 is non-negative, hence misconfigured -> block."""
|
||
assert pretrade_block(_cfg(session_dd=0.0), session_pnl_pct=0.0) == "bad_session_dd_config"
|
||
|
||
|
||
def test_pretrade_block_bad_session_dd_nan_blocks() -> None:
|
||
"""NaN session_dd is non-finite, hence misconfigured -> block with 'bad_session_dd_config'."""
|
||
assert pretrade_block(_cfg(session_dd=float("nan")), session_pnl_pct=0.0) == "bad_session_dd_config"
|
||
|
||
|
||
def test_pretrade_block_kill_switch_priority_over_bad_session_dd() -> None:
|
||
"""kill_switch is checked before session_dd config validation — kill_switch wins."""
|
||
assert pretrade_block(_cfg(kill_switch=True, session_dd=0.10), session_pnl_pct=0.0) == "kill_switch"
|