"""RiskManager: pre-trade gates and circuit breakers (docs 04 §6, 02). Consulted by the engine before every quote set. Returns a per-market decision (size scale / reduce-only / halt) and owns the global kill switches. Position and order data come from the StateStore; fair-value marks are pushed in by the engine so PnL is always current. """ from __future__ import annotations from dataclasses import dataclass from polymaker.config import RiskConfig from polymaker.domain import Fill, MarketMeta, Side from polymaker.logging import get_logger from polymaker.state.store import StateStore log = get_logger("risk.manager") @dataclass(frozen=True, slots=True) class RiskDecision: halt: bool # HALTED regime for this market reduce_only: bool # REDUCE_ONLY regime for this market size_scale: float # multiply quote sizes by this [0,1] reason: str = "" class RiskManager: def __init__(self, cfg: RiskConfig, store: StateStore) -> None: self._cfg = cfg self._store = store self._marks: dict[str, float] = {} # token_id -> fair value self._net_cash = 0.0 # cumulative signed cash from fills (+sell, -buy) self._day_start_equity = 0.0 self._killed = False self._order_attempts = 0 self._order_errors = 0 # ── PnL bookkeeping ───────────────────────────────────────────────── def note_fill(self, fill: Fill) -> None: self._net_cash += (fill.price * fill.size) * (1 if fill.side is Side.SELL else -1) def update_mark(self, token_id: str, fv: float) -> None: self._marks[token_id] = fv def _inventory_value(self) -> float: total = 0.0 for tok, pos in self._store.positions.items(): if pos.size > 0: total += pos.size * self._marks.get(tok, pos.avg_price) return total @property def equity(self) -> float: return self._net_cash + self._inventory_value() @property def daily_pnl(self) -> float: return self.equity - self._day_start_equity def reset_day(self) -> None: self._day_start_equity = self.equity # ── error-rate breaker ────────────────────────────────────────────── def note_order_result(self, ok: bool) -> None: self._order_attempts += 1 if not ok: self._order_errors += 1 @property def error_rate(self) -> float: return self._order_errors / self._order_attempts if self._order_attempts >= 20 else 0.0 # ── global kill switch ────────────────────────────────────────────── def global_halt(self) -> tuple[bool, str]: if self._killed: return True, "manual_kill" if self.daily_pnl <= -self._cfg.daily_loss_kill_usdc: return True, f"daily_loss {self.daily_pnl:.0f}" if self.error_rate >= self._cfg.max_order_error_rate: return True, f"error_rate {self.error_rate:.2f}" return False, "" def kill(self) -> None: self._killed = True log.critical("kill_switch_engaged") # ── per-market evaluation ─────────────────────────────────────────── def evaluate( self, meta: MarketMeta, *, ws_stale: bool, event_group_cost: float ) -> RiskDecision: halted, why = self.global_halt() if halted: return RiskDecision(True, False, 0.0, why) if ws_stale: return RiskDecision(True, False, 0.0, "ws_stale") market_notional = self._market_notional(meta) total_exposure = self._total_exposure() # hard caps -> reduce only if market_notional >= self._cfg.max_market_notional_usdc: return RiskDecision(False, True, 1.0, "market_cap") if event_group_cost >= self._cfg.max_event_group_loss_usdc: return RiskDecision(False, True, 1.0, "event_group_cap") if total_exposure >= self._cfg.max_total_exposure_usdc: return RiskDecision(False, True, 1.0, "total_exposure_cap") # soft scaling: taper size as any cap is approached (worst-binding wins) scale = min( _headroom(market_notional, self._cfg.max_market_notional_usdc), _headroom(total_exposure, self._cfg.max_total_exposure_usdc), _headroom(event_group_cost, self._cfg.max_event_group_loss_usdc), ) return RiskDecision(False, False, scale, "") def _market_notional(self, meta: MarketMeta) -> float: total = 0.0 for tok in (meta.yes.token_id, meta.no.token_id): pos = self._store.position(tok) total += pos.size * self._marks.get(tok, pos.avg_price or 0.5) for o in self._store.orders_for(tok): if o.side is Side.BUY: total += o.notional return total def _total_exposure(self) -> float: total = 0.0 for tok, pos in self._store.positions.items(): if pos.size > 0: total += pos.size * self._marks.get(tok, pos.avg_price or 0.5) for o in self._store.orders.values(): if o.side is Side.BUY: total += o.notional return total def _headroom(current: float, cap: float) -> float: """1.0 well below the cap, tapering to 0 as we approach it (from 70%).""" if cap <= 0: return 1.0 frac = current / cap if frac <= 0.7: return 1.0 return max(0.0, (1.0 - frac) / 0.3)