Getting there
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user