SPB Git forge
1commits 1branches 0releases
189.0 KBsize
maindefault branch
5 h agolast push
Python 54.7% TypeScript 42.5% SQL 1.9%
7.7 KB · 143 lines python
Raw Blame History
1"""Independent risk gateway. EVERY order — backtest, paper or live — must pass `RiskGateway.evaluate`.23Default-deny: any missing/stale input, kill switch, reconciliation failure or internal error rejects the order.4The gateway is pure (no I/O) so it can be unit-tested and reused unchanged by all engines; engines are responsible5for feeding it fresh `PortfolioSnapshot`/`MarketState` values.6"""78from __future__ import annotations910import math11from typing import Literal1213from pydantic import BaseModel, Field1415Side = Literal["buy", "sell"]161718class RiskLimits(BaseModel):19    max_order_notional: float = Field(1_000.0, description="Quote-currency notional per order")20    max_position_notional: float = Field(5_000.0, description="Per-symbol absolute notional")21    max_gross_exposure: float = Field(1.0, description="Σ|position notional| / equity")22    max_net_exposure: float = Field(1.0)23    max_concentration: float = Field(0.6, description="Largest |position| / equity")24    daily_loss_limit: float = Field(0.03, description="Fraction of start-of-day equity")25    max_drawdown_limit: float = Field(0.15, description="Fraction from peak equity (rolling)")26    min_cash: float = Field(0.0, description="Quote currency that must remain after a buy")27    max_open_orders: int = 2028    max_participation: float = Field(0.1, description="Order qty / recent bar volume")29    max_price_deviation: float = Field(0.05, description="|limit or mark − last close| / last close")30    stale_data_seconds: float = 120.031    max_clock_drift_seconds: float = 5.032    var_limit_95: float | None = Field(None, description="Optional 1-bar 95% VaR limit (fraction of equity)")333435class OrderIntent(BaseModel):36    symbol: str37    side: Side38    qty: float39    order_type: Literal["market", "limit"] = "market"40    limit_price: float | None = None41    strategy: str = "manual"42    client_order_id: str | None = None434445class PortfolioSnapshot(BaseModel):46    equity: float47    cash: float48    positions: dict[str, float] = Field(default_factory=dict, description="symbol → qty")49    marks: dict[str, float] = Field(default_factory=dict, description="symbol → mark price")50    open_orders: int = 051    day_start_equity: float | None = None52    peak_equity: float | None = None53    reconciled: bool = True54    var_95: float | None = None555657class MarketState(BaseModel):58    last_price: float59    data_age_seconds: float = 0.060    recent_bar_volume: float | None = None61    best_bid: float | None = None62    best_ask: float | None = None63    clock_drift_seconds: float = 0.064    exchange_status: str = "online"65    pair_status: str = "online"66    ordermin: float = 0.067    costmin: float = 0.0686970class RiskDecision(BaseModel):71    allowed: bool72    reasons: list[str] = Field(default_factory=list)73    checks: dict[str, bool] = Field(default_factory=dict)74    adjusted_qty: float | None = None757677class RiskGateway:78    def __init__(self, limits: RiskLimits | None = None, kill_switch: bool = False) -> None:79        self.limits = limits or RiskLimits()80        self.kill_switch = kill_switch8182    def evaluate(self, intent: OrderIntent, pf: PortfolioSnapshot | None, mkt: MarketState | None) -> RiskDecision:83        L = self.limits84        reasons: list[str] = []85        checks: dict[str, bool] = {}8687        def check(name: str, ok: bool, why: str) -> None:88            checks[name] = bool(ok)89            if not ok:90                reasons.append(why)9192        try:93            check("kill_switch", not self.kill_switch, "kill switch active")94            check("inputs_present", pf is not None and mkt is not None, "missing portfolio or market state (default-deny)")95            if pf is None or mkt is None:96                return RiskDecision(allowed=False, reasons=reasons, checks=checks)97            check("reconciled", pf.reconciled, "ledger not reconciled with exchange")98            check("exchange_online", mkt.exchange_status == "online", f"exchange status {mkt.exchange_status}")99            check("pair_online", mkt.pair_status in ("online", "limit_only" if intent.order_type == "limit" else "online"), f"pair status {mkt.pair_status}")100            check("data_fresh", mkt.data_age_seconds <= L.stale_data_seconds, f"market data stale ({mkt.data_age_seconds:.0f}s)")101            check("clock", abs(mkt.clock_drift_seconds) <= L.max_clock_drift_seconds, f"clock drift {mkt.clock_drift_seconds:.1f}s")102            check("qty_finite", math.isfinite(intent.qty) and intent.qty > 0, "invalid quantity")103            check("price_finite", math.isfinite(mkt.last_price) and mkt.last_price > 0, "invalid reference price")104            if not (checks["qty_finite"] and checks["price_finite"]):105                return RiskDecision(allowed=False, reasons=reasons, checks=checks)106107            ref = intent.limit_price if (intent.order_type == "limit" and intent.limit_price) else mkt.last_price108            if intent.limit_price is not None:109                check("price_deviation", abs(intent.limit_price - mkt.last_price) / mkt.last_price <= L.max_price_deviation, "limit price deviates from last")110            notional = intent.qty * ref111            check("min_size", intent.qty >= mkt.ordermin and notional >= mkt.costmin, f"below exchange minimum (ordermin {mkt.ordermin}, costmin {mkt.costmin})")112            check("order_notional", notional <= L.max_order_notional, f"order notional {notional:.2f} > {L.max_order_notional}")113            check("open_orders", pf.open_orders < L.max_open_orders, "too many open orders")114115            pos = pf.positions.get(intent.symbol, 0.0)116            new_pos = pos + intent.qty if intent.side == "buy" else pos - intent.qty117            check("no_short", new_pos >= -1e-12, "would create a short position (spot account)")118            check("position_notional", abs(new_pos) * ref <= L.max_position_notional, "position notional limit")119            if intent.side == "buy":120                check("min_cash", pf.cash - notional >= L.min_cash, "insufficient cash after order")121122            eq = max(pf.equity, 1e-9)123            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) * ref124            check("gross_exposure", gross / eq <= L.max_gross_exposure + 1e-9, f"gross exposure {gross / eq:.2f} > {L.max_gross_exposure}")125            check("concentration", abs(new_pos) * ref / eq <= L.max_concentration + 1e-9, f"concentration > {L.max_concentration}")126            if pf.day_start_equity:127                check("daily_loss", (pf.equity / pf.day_start_equity - 1) > -L.daily_loss_limit, "daily loss limit hit")128            if pf.peak_equity:129                check("drawdown", (pf.equity / pf.peak_equity - 1) > -L.max_drawdown_limit, "drawdown limit hit")130            if mkt.recent_bar_volume:131                check("participation", intent.qty <= L.max_participation * mkt.recent_bar_volume, "order exceeds participation cap")132            if L.var_limit_95 is not None and pf.var_95 is not None:133                check("var", pf.var_95 <= L.var_limit_95, "VaR limit")134        except Exception as e:  # noqa: BLE001 — default-deny on internal error135            reasons.append(f"risk gateway error: {e}")136            checks["internal"] = False137        # Sells that *reduce* an existing long are allowed even when loss/exposure limits are breached (de-risking);138        # every other failed check blocks. Buys are never exempt.139        reducing = intent.side == "sell" and pf is not None and pf.positions.get(intent.symbol, 0.0) > 0140        exempt = {"daily_loss", "drawdown", "position_notional", "concentration", "gross_exposure", "var"} if reducing else set()141        failed = [name for name, ok in checks.items() if not ok and name not in exempt]142        return RiskDecision(allowed=not failed, reasons=reasons, checks=checks)143