Files
poly-maker/src/polymaker/risk/manager.py
T
Daniel Sapkota c7b686116e Getting there
2026-07-05 17:50:04 -04:00

147 lines
5.7 KiB
Python

"""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)