diff --git a/src/fxhnt/domain/execution/capacity.py b/src/fxhnt/domain/execution/capacity.py index e025a8b..82b18c7 100644 --- a/src/fxhnt/domain/execution/capacity.py +++ b/src/fxhnt/domain/execution/capacity.py @@ -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 diff --git a/src/fxhnt/domain/execution/risk_gates.py b/src/fxhnt/domain/execution/risk_gates.py index 3981d34..0d28560 100644 --- a/src/fxhnt/domain/execution/risk_gates.py +++ b/src/fxhnt/domain/execution/risk_gates.py @@ -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)) diff --git a/tests/unit/test_capacity.py b/tests/unit/test_capacity.py index d90f38c..2d39b8d 100644 --- a/tests/unit/test_capacity.py +++ b/tests/unit/test_capacity.py @@ -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 diff --git a/tests/unit/test_risk_gates.py b/tests/unit/test_risk_gates.py index 8d5e1be..47d8390 100644 --- a/tests/unit/test_risk_gates.py +++ b/tests/unit/test_risk_gates.py @@ -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"