spb/crypto-trade-bot
Public
Python 54.7%
TypeScript 42.5%
SQL 1.9%
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