Files
fxhnt/tests/unit/test_risk_gates.py
jgrusewski df14b8a92f fix(exec): fail-safe risk gates — NaN/misconfig guards (no fail-open to max exposure)
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>
2026-06-14 17:11:58 +02:00

169 lines
7.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"