"""Kelly position sizer with Favorite-Longshot bias correction. Kelly formula (half-Kelly by default for safety): p = estimated win probability (from signal strength) b = payoff ratio: (1 - price)/price for BUY, price/(1-price) for SELL q = 1 - p f* = (b * p - q) / b use = f* * kelly_fraction (default 0.5 → half-Kelly) Favorite-Longshot bias correction (research-driven): factor = (1 - 2 * |price - 0.5|)^beta Edge at extreme prices (<0.10 or >0.90) is reduced. """ import math from src.config import get_settings class KellySizer: """Position sizing per the research framework.""" def __init__(self): self.settings = get_settings() def win_probability(self, strength: float) -> float: """Map signal strength [0,1] → win probability. strength=0.5 → p=0.55 (baseline) strength=1.0 → p=0.85 (strong consensus) strength=0.0 → p=0.50 (coin flip) """ strength = max(0.0, min(1.0, strength)) return 0.50 + 0.35 * strength def payoff_ratio(self, price: float, side: str) -> float: """How much we win vs how much we risk.""" p = max(0.01, min(0.99, price)) if side == "BUY": return (1.0 - p) / p # win=(1-p), risk=p # SELL: assume we already hold the position at avg price p, hedge at current return p / (1.0 - p) def favorite_longshot_correction(self, price: float, beta: float = 1.5) -> float: """Smooth penalty for extreme prices. 1.0 at price=0.5, ~0 at extremes.""" return (1.0 - 2.0 * abs(price - 0.5)) ** beta def fraction( self, signal_strength: float, price: float, side: str, beta: float = 1.5, ) -> float: """Compute Kelly fraction (capped 0..1) for a single signal.""" p = self.win_probability(signal_strength) q = 1.0 - p b = self.payoff_ratio(price, side) f_star = max(0.0, (b * p - q) / b) f_star *= self.favorite_longshot_correction(price, beta) f_star *= self.settings.kelly_fraction return min(f_star, self.settings.max_position_pct) def position_usd(self, fraction: float, capital: float) -> float: """Translate fraction → dollar size, capped by per-trade position limit.""" size = fraction * capital max_size = capital * self.settings.max_position_pct return min(size, max_size)