""" Risk Manager — Kelly Criterion position sizing with safety constraints. Uses 1/4 Kelly fraction to be conservative during paper trading phase. Hard limits: max 5% per position, max 30% total exposure. """ import logging from dataclasses import dataclass from typing import Optional from bot.strategy.bayesian import TradingSignal log = logging.getLogger(__name__) KELLY_FRACTION = 0.25 # Quarter Kelly — conservative @dataclass class Portfolio: cash: float positions: dict[str, float] # market_id -> USDC amount allocated @property def total_value(self) -> float: return self.cash + sum(self.positions.values()) @property def total_exposure(self) -> float: return sum(self.positions.values()) @property def exposure_pct(self) -> float: if self.total_value == 0: return 0 return self.total_exposure / self.total_value @dataclass class Order: market_id: str question: str direction: str # "BUY_YES" | "BUY_NO" size_usdc: float # Amount to risk in USDC market_price: float # Polymarket YES price (0-1) — used for entry_price calculation signal_edge: float signal_confidence: float reasoning: str # Phase 1 — edge neto audit fields (passed through from TradingSignal) edge_gross: float = 0.0 edge_net: float = 0.0 prior_prob: float = 0.0 final_prob: float = 0.0 mid_price: float = 0.0 spread_estimate: float = 0.02 # Phase 2 — market family family_key: str = "" # Phase 4 — regime threshold applied regime_min_edge: float = 0.10 # Phase 6 — per-feature log-odds contributions (see TradingSignal for semantics) feat_fg_lo: float = 0.0 feat_mom_lo: float = 0.0 feat_news_lo: float = 0.0 feat_mfld_lo: float = 0.0 feat_btc_dom_lo: float = 0.0 class RiskManager: def __init__( self, max_position_pct: float = 0.05, max_exposure_pct: float = 0.30, ) -> None: self.max_position_pct = max_position_pct self.max_exposure_pct = max_exposure_pct def size_order( self, signal: TradingSignal, portfolio: Portfolio, ) -> Optional[Order]: """ Apply Kelly criterion to size the order. Returns None if constraints are not met. """ # Check total exposure limit if portfolio.exposure_pct >= self.max_exposure_pct: log.info( "Exposure limit reached: %.1f%% >= %.1f%%", portfolio.exposure_pct * 100, self.max_exposure_pct * 100, ) return None # Check if already in this market if signal.market_id in portfolio.positions: log.debug("Already have position in market %s", signal.market_id) return None # Kelly formula: f = (bp - q) / b # b = odds (1/price - 1), p = estimated_prob, q = 1 - p price = signal.polymarket_price if signal.direction == "BUY_YES" else (1 - signal.polymarket_price) if price <= 0 or price >= 1: return None b = (1 / price) - 1 # decimal odds p = signal.estimated_prob if signal.direction == "BUY_YES" else (1 - signal.estimated_prob) q = 1 - p kelly_full = (b * p - q) / b if kelly_full <= 0: log.debug("Kelly fraction negative — no edge after fees") return None kelly_fraction = kelly_full * KELLY_FRACTION # Apply position size limits max_by_kelly = portfolio.total_value * kelly_fraction max_by_rule = portfolio.total_value * self.max_position_pct remaining_exposure = portfolio.total_value * self.max_exposure_pct - portfolio.total_exposure size = min(max_by_kelly, max_by_rule, remaining_exposure, portfolio.cash) if size < 5: # Minimum trade size $5 log.debug("Order too small: $%.2f", size) return None log.info( "Order sized: %s %s $%.2f (kelly=%.1f%% capped at %.1f%%)", signal.direction, signal.question[:40], size, kelly_fraction * 100, self.max_position_pct * 100, ) return Order( market_id=signal.market_id, question=signal.question, direction=signal.direction, size_usdc=size, market_price=signal.polymarket_price, signal_edge=signal.edge, signal_confidence=signal.confidence, reasoning=signal.reasoning, # Phase 1 — pass audit fields through to executor edge_gross=signal.edge_gross, edge_net=signal.edge_net, prior_prob=signal.prior_prob, final_prob=signal.final_prob, mid_price=signal.mid_price, spread_estimate=signal.spread_estimate, # Phase 2 — family family_key=signal.family_key, # Phase 4 — regime regime_min_edge=signal.regime_min_edge, # Phase 6 — feature log-odds feat_fg_lo=signal.feat_fg_lo, feat_mom_lo=signal.feat_mom_lo, feat_news_lo=signal.feat_news_lo, feat_mfld_lo=signal.feat_mfld_lo, feat_btc_dom_lo=signal.feat_btc_dom_lo, )