70 lines
2.4 KiB
Python
70 lines
2.4 KiB
Python
"""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)
|