"""Independent risk gateway. EVERY order — backtest, paper or live — must pass `RiskGateway.evaluate`. Default-deny: any missing/stale input, kill switch, reconciliation failure or internal error rejects the order. The gateway is pure (no I/O) so it can be unit-tested and reused unchanged by all engines; engines are responsible for feeding it fresh `PortfolioSnapshot`/`MarketState` values. """ from __future__ import annotations import math from typing import Literal from pydantic import BaseModel, Field Side = Literal["buy", "sell"] class RiskLimits(BaseModel): max_order_notional: float = Field(1_000.0, description="Quote-currency notional per order") max_position_notional: float = Field(5_000.0, description="Per-symbol absolute notional") max_gross_exposure: float = Field(1.0, description="Σ|position notional| / equity") max_net_exposure: float = Field(1.0) max_concentration: float = Field(0.6, description="Largest |position| / equity") daily_loss_limit: float = Field(0.03, description="Fraction of start-of-day equity") max_drawdown_limit: float = Field(0.15, description="Fraction from peak equity (rolling)") min_cash: float = Field(0.0, description="Quote currency that must remain after a buy") max_open_orders: int = 20 max_participation: float = Field(0.1, description="Order qty / recent bar volume") max_price_deviation: float = Field(0.05, description="|limit or mark − last close| / last close") stale_data_seconds: float = 120.0 max_clock_drift_seconds: float = 5.0 var_limit_95: float | None = Field(None, description="Optional 1-bar 95% VaR limit (fraction of equity)") class OrderIntent(BaseModel): symbol: str side: Side qty: float order_type: Literal["market", "limit"] = "market" limit_price: float | None = None strategy: str = "manual" client_order_id: str | None = None class PortfolioSnapshot(BaseModel): equity: float cash: float positions: dict[str, float] = Field(default_factory=dict, description="symbol → qty") marks: dict[str, float] = Field(default_factory=dict, description="symbol → mark price") open_orders: int = 0 day_start_equity: float | None = None peak_equity: float | None = None reconciled: bool = True var_95: float | None = None class MarketState(BaseModel): last_price: float data_age_seconds: float = 0.0 recent_bar_volume: float | None = None best_bid: float | None = None best_ask: float | None = None clock_drift_seconds: float = 0.0 exchange_status: str = "online" pair_status: str = "online" ordermin: float = 0.0 costmin: float = 0.0 class RiskDecision(BaseModel): allowed: bool reasons: list[str] = Field(default_factory=list) checks: dict[str, bool] = Field(default_factory=dict) adjusted_qty: float | None = None class RiskGateway: def __init__(self, limits: RiskLimits | None = None, kill_switch: bool = False) -> None: self.limits = limits or RiskLimits() self.kill_switch = kill_switch def evaluate(self, intent: OrderIntent, pf: PortfolioSnapshot | None, mkt: MarketState | None) -> RiskDecision: L = self.limits reasons: list[str] = [] checks: dict[str, bool] = {} def check(name: str, ok: bool, why: str) -> None: checks[name] = bool(ok) if not ok: reasons.append(why) try: check("kill_switch", not self.kill_switch, "kill switch active") check("inputs_present", pf is not None and mkt is not None, "missing portfolio or market state (default-deny)") if pf is None or mkt is None: return RiskDecision(allowed=False, reasons=reasons, checks=checks) check("reconciled", pf.reconciled, "ledger not reconciled with exchange") check("exchange_online", mkt.exchange_status == "online", f"exchange status {mkt.exchange_status}") check("pair_online", mkt.pair_status in ("online", "limit_only" if intent.order_type == "limit" else "online"), f"pair status {mkt.pair_status}") check("data_fresh", mkt.data_age_seconds <= L.stale_data_seconds, f"market data stale ({mkt.data_age_seconds:.0f}s)") check("clock", abs(mkt.clock_drift_seconds) <= L.max_clock_drift_seconds, f"clock drift {mkt.clock_drift_seconds:.1f}s") check("qty_finite", math.isfinite(intent.qty) and intent.qty > 0, "invalid quantity") check("price_finite", math.isfinite(mkt.last_price) and mkt.last_price > 0, "invalid reference price") if not (checks["qty_finite"] and checks["price_finite"]): return RiskDecision(allowed=False, reasons=reasons, checks=checks) ref = intent.limit_price if (intent.order_type == "limit" and intent.limit_price) else mkt.last_price if intent.limit_price is not None: check("price_deviation", abs(intent.limit_price - mkt.last_price) / mkt.last_price <= L.max_price_deviation, "limit price deviates from last") notional = intent.qty * ref check("min_size", intent.qty >= mkt.ordermin and notional >= mkt.costmin, f"below exchange minimum (ordermin {mkt.ordermin}, costmin {mkt.costmin})") check("order_notional", notional <= L.max_order_notional, f"order notional {notional:.2f} > {L.max_order_notional}") check("open_orders", pf.open_orders < L.max_open_orders, "too many open orders") pos = pf.positions.get(intent.symbol, 0.0) new_pos = pos + intent.qty if intent.side == "buy" else pos - intent.qty check("no_short", new_pos >= -1e-12, "would create a short position (spot account)") check("position_notional", abs(new_pos) * ref <= L.max_position_notional, "position notional limit") if intent.side == "buy": check("min_cash", pf.cash - notional >= L.min_cash, "insufficient cash after order") eq = max(pf.equity, 1e-9) gross = sum(abs(q) * pf.marks.get(s, ref if s == intent.symbol else 0.0) for s, q in pf.positions.items() if s != intent.symbol) + abs(new_pos) * ref check("gross_exposure", gross / eq <= L.max_gross_exposure + 1e-9, f"gross exposure {gross / eq:.2f} > {L.max_gross_exposure}") check("concentration", abs(new_pos) * ref / eq <= L.max_concentration + 1e-9, f"concentration > {L.max_concentration}") if pf.day_start_equity: check("daily_loss", (pf.equity / pf.day_start_equity - 1) > -L.daily_loss_limit, "daily loss limit hit") if pf.peak_equity: check("drawdown", (pf.equity / pf.peak_equity - 1) > -L.max_drawdown_limit, "drawdown limit hit") if mkt.recent_bar_volume: check("participation", intent.qty <= L.max_participation * mkt.recent_bar_volume, "order exceeds participation cap") if L.var_limit_95 is not None and pf.var_95 is not None: check("var", pf.var_95 <= L.var_limit_95, "VaR limit") except Exception as e: # noqa: BLE001 — default-deny on internal error reasons.append(f"risk gateway error: {e}") checks["internal"] = False # Sells that *reduce* an existing long are allowed even when loss/exposure limits are breached (de-risking); # every other failed check blocks. Buys are never exempt. reducing = intent.side == "sell" and pf is not None and pf.positions.get(intent.symbol, 0.0) > 0 exempt = {"daily_loss", "drawdown", "position_notional", "concentration", "gross_exposure", "var"} if reducing else set() failed = [name for name, ok in checks.items() if not ok and name not in exempt] return RiskDecision(allowed=not failed, reasons=reasons, checks=checks)