8479a63174
CI/CD / build-and-push (push) Successful in 1m56s
Adds feat_fg_lo / feat_mom_lo / feat_news_lo / feat_mfld_lo / feat_btc_dom_lo to every trade, all normalized to log-odds contribution for direct comparability. - fg / mom / btc_dom: raw probability-delta × 2 → log-odds - news / mfld: already log-odds (LOGODDS_WEIGHT already applied), no scaling - btc_dom tracked separately in bayesian.py instead of bundled in total_adj - reasoning string updated to fg_lo= / mom_lo= notation for self-documentation Schema: 5 new DOUBLE PRECISION columns + 2 partial indexes Stack: TradingSignal → Order → Trade → save_trade all carry feat fields Startup: backfill_feature_columns() recovers fg/mom/news/mfld from old reasoning strings (×2 applied to fg/mom); btc_dom_lo stays NULL for legacy API: /api/metrics/features — triggered/material split per feature with two-level thresholds (0.05 for fg/mom/btc_dom, 0.10 for news/mfld) API: /api/trades/legacy — exposes pre-Phase-1 trades (edge_net IS NULL) API: _enrich_trade backward-compat: reads DB columns first, falls back to reasoning regex with unit conversion for pre-Phase-6 trades Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
163 lines
5.2 KiB
Python
163 lines
5.2 KiB
Python
"""
|
|
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,
|
|
)
|