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:
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user