147 lines
5.7 KiB
Python
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)
|