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>
This commit is contained in:
jgrusewski
2026-06-14 17:11:58 +02:00
parent 890824d040
commit df14b8a92f
4 changed files with 118 additions and 4 deletions

View File

@@ -2,16 +2,24 @@
participation_rate×ADV for that symbol (don't be a disproportionate share of a thin market)."""
from __future__ import annotations
import math
def cap_weights(weights: dict[str, float], *, nlv: float, adv_usd: dict[str, float],
per_symbol_cap: float, participation_rate: float) -> dict[str, float]:
if nlv <= 0:
# I1: NaN/inf NLV means capital is unknown — zero all weights rather than allow max exposure.
if not math.isfinite(nlv) or nlv <= 0:
return {s: 0.0 for s in weights}
out: dict[str, float] = {}
for s, w in weights.items():
# I2: a non-finite weight has no safe interpretation — zero that symbol.
if not math.isfinite(w):
out[s] = 0.0
continue
max_w = per_symbol_cap
adv = adv_usd.get(s)
if adv is not None and adv > 0:
# I2: a non-finite ADV must not weaken the cap; only apply ADV cap when ADV is finite and positive.
if adv is not None and math.isfinite(adv) and adv > 0:
max_w = min(max_w, participation_rate * adv / nlv)
out[s] = max(-max_w, min(max_w, w))
return out

View File

@@ -3,6 +3,7 @@ returns the [0,1+] multiplier applied to target gross = clamp(edge_theta) × min
bounded by gross_cap. Edge decays -> gross shrinks; edge dies (theta->0) -> flat."""
from __future__ import annotations
import math
from dataclasses import dataclass
@@ -13,6 +14,7 @@ class GateConfig:
testnet: bool
gross_cap: float
session_dd: float # negative threshold, e.g. -0.10
kelly_cap: float # maximum Kelly fraction, e.g. 0.25
def pretrade_block(cfg: GateConfig, *, session_pnl_pct: float) -> str | None:
@@ -20,12 +22,24 @@ def pretrade_block(cfg: GateConfig, *, session_pnl_pct: float) -> str | None:
return "kill_switch"
if not cfg.testnet and not cfg.allow_live:
return "allow_live"
# M3: misconfigured session_dd (non-finite or non-negative) must block, not pass.
# session_dd must be a negative threshold; anything else is a configuration error.
if not math.isfinite(cfg.session_dd) or cfg.session_dd >= 0:
return "bad_session_dd_config"
# C2: NaN session_pnl_pct is unknown state; fail safe -> block.
if not math.isfinite(session_pnl_pct):
return "session_pnl_unknown"
if session_pnl_pct <= cfg.session_dd:
return "session_drawdown"
return None
def gross_scaler(*, edge_theta: float, kelly_fraction: float, kelly_cap: float, gross_cap: float) -> float:
# Real money: any non-finite input -> 0 gross (flat). NaN is the natural sentinel from EMAs before
# warmup / Kelly with avg_win=0 / divide-by-zero; min()/max() silently pass the non-NaN arg, so we
# must guard explicitly or the "edge dies -> flat" contract inverts to max exposure.
if not all(math.isfinite(x) for x in (edge_theta, kelly_fraction, kelly_cap, gross_cap)):
return 0.0
theta = max(0.0, min(1.0, edge_theta))
kelly = max(0.0, min(kelly_cap, kelly_fraction))
return min(gross_cap, theta * kelly)
return max(0.0, min(gross_cap, theta * kelly))

View File

@@ -65,3 +65,37 @@ def test_weight_within_caps_unchanged() -> None:
out = cap_weights(w, nlv=10_000.0, adv_usd={"A": 1e9},
per_symbol_cap=0.05, participation_rate=1.0)
assert abs(out["A"] - 0.02) < 1e-12
# --- M1: NaN / non-finite safety tests ---
def test_nan_nlv_returns_all_zeros() -> None:
"""NaN NLV means capital is unknown — zero all weights rather than risk max exposure."""
out = cap_weights({"A": 0.5, "B": -0.3}, nlv=float("nan"), adv_usd={"A": 1e9, "B": 1e9},
per_symbol_cap=0.05, participation_rate=1.0)
assert out == {"A": 0.0, "B": 0.0}
def test_inf_nlv_returns_all_zeros() -> None:
"""inf NLV is not a valid capital figure — zero all weights."""
out = cap_weights({"A": 0.5}, nlv=float("inf"), adv_usd={"A": 1e9},
per_symbol_cap=0.05, participation_rate=1.0)
assert out == {"A": 0.0}
def test_nan_weight_zeroed_others_capped_normally() -> None:
"""A NaN weight for one symbol must be zeroed; other symbols still get normal cap treatment."""
out = cap_weights({"A": float("nan"), "B": 0.5}, nlv=10_000.0, adv_usd={"A": 1e9, "B": 1e9},
per_symbol_cap=0.05, participation_rate=1.0)
assert out["A"] == 0.0
assert abs(out["B"]) <= 0.05 + 1e-9
assert abs(out["B"]) > 0.0 # B is not zeroed — only A is affected
def test_nan_adv_falls_back_to_per_symbol_cap() -> None:
"""NaN ADV for a symbol must be ignored; only per_symbol_cap applies (no ADV weakening the cap)."""
out = cap_weights({"A": 0.99}, nlv=10_000.0, adv_usd={"A": float("nan")},
per_symbol_cap=0.10, participation_rate=0.05)
# ADV cap NOT applied (NaN ADV skipped) → per_symbol_cap=0.10 is the only ceiling
assert abs(out["A"]) <= 0.10 + 1e-9
assert abs(out["A"]) > 0.05 # participation cap (0.05 × NaN / NLV) was NOT applied

View File

@@ -5,7 +5,7 @@ from fxhnt.domain.execution.risk_gates import GateConfig, gross_scaler, pretrade
def _cfg(**kw):
base = dict(allow_live=True, kill_switch=False, testnet=False, gross_cap=1.0, session_dd=-0.1)
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)
@@ -108,3 +108,61 @@ 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"