Files
polymarket-bot/bot/risk/manager.py
T
chemavx 63d9f637ff
CI/CD / build-and-push (push) Successful in 2m30s
feat(bot): 5-phase strategy upgrade — edge neto, families, GNews priority, regimes
Phase 1 — Edge neto real (paper.py, bayesian.py, risk/manager.py, db.py):
- Trade records now store edge_gross, edge_net, prior_prob, final_prob,
  mid_price, spread_estimate, commission, family_key
- edge_net = edge_gross - SPREAD_ESTIMATE(0.02) - COMMISSION_RATE(0.02)
  NOTE: both constants are heuristics, not exact Polymarket exchange costs
- Execution gate changed from edge_gross > MIN_EDGE to edge_net > regime_min_edge

Phase 2 — Market families (polymarket.py):
- market_family_key(market) groups related markets:
    texas-republican-2026, fed-april-2026, openai-2026, etc.
- At most 1 trade per family per cycle; occupied_families propagated via main.py
- Family key logged on every TRADE and SKIP line

Phase 3 — GNews priority (news.py, bayesian.py, main.py):
- NewsClient.get_freshness() returns 1.0/0.75/0.40/0.10 by cache age
- gnews_priority(market, news) = uncertainty × volume_score × freshness
- Politics markets sorted by priority DESC before eval so best markets get
  the 5-query/cycle GNews budget first

Phase 4 — Regime min-edge by category/horizon (bayesian.py):
- politics >60d → 0.12, 30-60d → 0.10, <30d → 0.08
- tech / crypto/finance → 0.10
- All thresholds applied to edge_net (not edge_gross)

Phase 5 — Observability (bayesian.py, main.py):
- Structured skip labels: SKIP_UNSUPPORTED, SKIP_NO_SIGNALS,
  SKIP_PRIOR_EXTREME, SKIP_FAMILY, SKIP_GNEWS_PRIORITY, SKIP_EDGE_NET
- TRADE lines now include family_key, edge_gross, edge_net, regime_min, days
- schema.sql: 8 new cols on trades, 7 new cols on signals (via ALTER TABLE IF NOT EXISTS)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 15:34:46 +00:00

151 lines
4.7 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
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,
)