Horizon provides Kelly criterion functions implemented in Rust for zero-overhead position sizing. These compute the mathematically optimal fraction of your bankroll to risk on a binary prediction market trade, given your estimated probability and the current market price.
Full Code
"""Kelly criterion for position sizing in prediction markets."""
import horizon as hz
# Basic Kelly: how much to bet on a binary outcome
fair_prob = 0.60 # You think 60% chance of Yes
market_price = 0.50 # Market says 50/50
fraction = hz.kelly(fair_prob, market_price)
print(f"Full Kelly: {fraction:.2%} of bankroll")
# Fractional Kelly (safer)
half_kelly = hz.fractional_kelly(fair_prob, market_price, fraction=0.5)
print(f"Half Kelly: {half_kelly:.2%}")
# Position size in contracts
size = hz.kelly_size(fair_prob, market_price, 1000.0, 0.5, 500.0)
print(f"Position size: {size:.1f} contracts")
# Edge
e = hz.edge(fair_prob, market_price)
print(f"Edge: {e:.4f}")
Output:
Full Kelly: 20.00% of bankroll
Half Kelly: 10.00%
Position size: 200.0 contracts
Edge: 0.1000
The Math
For a binary prediction market with two outcomes (Yes at price P, No at price 1 - P):
Kelly for Yes (buying Yes)
When you believe the true probability p exceeds the market price P:
This is the fraction of your bankroll to risk. The formula maximizes the expected log-growth of your capital.
Kelly for No (buying No)
When you believe the market is overpriced (p < P):
Edge
The expected value per dollar risked:
Positive edge means you have an advantage. Negative edge means the market has it right (or you are wrong).
All Kelly Functions
hz.kelly(prob, market_price)
Full Kelly fraction for the Yes side. Returns 0.0 if no edge.
import horizon as hz
# Strong edge: 70% prob at 50 cent price
print(hz.kelly(0.70, 0.50)) # 0.40 (40% of bankroll)
# Moderate edge: 55% prob at 50 cent price
print(hz.kelly(0.55, 0.50)) # 0.10 (10% of bankroll)
# No edge: prob <= price
print(hz.kelly(0.50, 0.50)) # 0.0
print(hz.kelly(0.40, 0.60)) # 0.0
hz.kelly_no(prob, market_price)
Kelly fraction for the No side. Use when you think the market is too high.
import horizon as hz
# Market at 0.60 but you think true prob is 0.40
print(hz.kelly_no(0.40, 0.60)) # 0.333 (33% of bankroll on No)
# Market at 0.50 but you think true prob is 0.35
print(hz.kelly_no(0.35, 0.50)) # 0.30
# No edge on the No side
print(hz.kelly_no(0.60, 0.50)) # 0.0 (prob >= price, no No edge)
hz.fractional_kelly(prob, market_price, fraction)
Multiplies the raw Kelly by a scaling factor. Using fractional Kelly reduces variance at the cost of slightly lower expected growth.
import horizon as hz
full = hz.kelly(0.70, 0.50) # 0.40
half = hz.fractional_kelly(0.70, 0.50, fraction=0.5) # 0.20
quarter = hz.fractional_kelly(0.70, 0.50, fraction=0.25) # 0.10
print(f"Full: {full:.2%}")
print(f"Half: {half:.2%}")
print(f"Quarter: {quarter:.2%}")
Most professional traders use quarter or half Kelly. Full Kelly is theoretically optimal for long-run growth but produces extreme drawdowns in practice. Half Kelly achieves 75% of the growth rate with substantially lower variance.
hz.kelly_size(prob, market_price, bankroll, fraction, max_size)
Converts the Kelly fraction into an actual contract count:
contracts = (fractional_kelly * bankroll) / market_price
Capped at max_size.
import horizon as hz
# prob=0.60, price=0.50, bankroll=$1000, half Kelly, max 500 contracts
size = hz.kelly_size(0.60, 0.50, 1000.0, 0.5, 500.0)
print(f"Size: {size:.0f} contracts") # 200
# With a tiny bankroll, size scales down
size = hz.kelly_size(0.60, 0.50, 100.0, 0.5, 500.0)
print(f"Size: {size:.0f} contracts") # 20
# Strong edge but capped by max_size
size = hz.kelly_size(0.90, 0.50, 10000.0, 1.0, 100.0)
print(f"Size: {size:.0f} contracts") # 100.0 (capped)
hz.edge(prob, market_price)
Raw expected edge. Can be negative (no edge).
import horizon as hz
print(hz.edge(0.60, 0.50)) # 0.10 (10 cents edge)
print(hz.edge(0.50, 0.50)) # 0.00 (no edge)
print(hz.edge(0.40, 0.50)) # -0.10 (negative edge, don't trade!)
Kelly for the No Side
When you think a market is overpriced, bet on No:
import horizon as hz
# Market prices "Will it rain tomorrow?" at 0.70
# You estimate the true probability is only 0.45
fair_prob = 0.45
market_price = 0.70
# Yes-side Kelly: 0 (no edge buying Yes at 0.70 when prob is 0.45)
print(f"Yes Kelly: {hz.kelly(fair_prob, market_price):.4f}") # 0.0
# No-side Kelly: bet on No
no_fraction = hz.kelly_no(fair_prob, market_price)
print(f"No Kelly: {no_fraction:.4f}") # 0.3571
# The No contract costs 1 - 0.70 = 0.30
no_price = 1.0 - market_price
bankroll = 1000.0
risk_amount = no_fraction * bankroll # $357.14
contracts = risk_amount / no_price # 1190 contracts
print(f"No contracts: {contracts:.0f}")
Multi-Position Kelly
When you have edge across multiple markets simultaneously, use multi_kelly to prevent over-allocation:
import horizon as hz
# Three markets where you have edge
probs = [0.65, 0.55, 0.70]
prices = [0.50, 0.45, 0.55]
# Independent Kelly fractions
individual = [hz.kelly(p, px) for p, px in zip(probs, prices)]
print(f"Individual: {individual}")
# [0.30, 0.182, 0.333], total = 0.815
# Multi-Kelly with max total allocation of 0.50
fractions = hz.multi_kelly(probs, prices, max_total=0.50)
print(f"Multi-Kelly: {[f'{f:.4f}' for f in fractions]}")
# Proportionally scaled down so sum <= 0.50
total = sum(fractions)
print(f"Total allocation: {total:.4f}") # <= 0.50
The algorithm:
- Computes Kelly fractions independently for each market.
- If the sum exceeds
max_total, proportionally scales all fractions down.
- Markets with no edge (Kelly = 0) stay at 0.
Liquidity-Adjusted Kelly
In thin prediction markets, placing your full Kelly size would eat through the book. liquidity_adjusted_kelly uses square-root scaling to dampen sizing as you approach available liquidity:
import horizon as hz
# Standard Kelly says 400 contracts
raw = hz.kelly_size(0.70, 0.50, 1000.0, 1.0, 1000.0)
print(f"Raw size: {raw:.0f}") # 800
# But only 50 contracts available near the target price
adjusted = hz.liquidity_adjusted_kelly(
prob=0.70,
market_price=0.50,
bankroll=1000.0,
fraction=1.0,
available_liquidity=50.0,
max_size=1000.0,
)
print(f"Liquidity-adjusted: {adjusted:.1f}") # Much smaller
# The adjustment formula:
# ratio = min(available_liquidity / raw_size, 1.0)
# adjusted = raw_size * sqrt(ratio)
# Capped at min(max_size, available_liquidity)
available_liquidity should reflect the actual depth near your target price, not the total book depth. Use feed data (bid/ask sizes) or the orderbook snapshot to estimate this.
Pipeline Integration
The kelly_sizer function creates a pipeline-compatible sizing stage for use with hz.run():
"""Kelly sizing integrated into a live trading pipeline."""
import horizon as hz
from horizon import kelly_sizer, kelly_sizer_with_liquidity
from horizon.context import FeedData
def estimate_prob(ctx: hz.Context) -> float:
"""Estimate the true probability from feed data."""
feed = ctx.feeds.get("model", FeedData())
if feed.price > 0:
return feed.price # Use model output as probability
return 0.50
def to_quotes(ctx: hz.Context, size: float) -> list[hz.Quote]:
"""Convert Kelly size into quotes."""
if size <= 0:
return []
feed = ctx.feeds.get("default", FeedData())
fair = feed.price if feed.price > 0 else 0.50
return hz.quotes(fair, spread=0.04, size=size)
# Pipeline: estimate_prob -> kelly_sizer -> to_quotes
hz.run(
name="kelly_mm",
markets=["btc-100k"],
pipeline=[
estimate_prob,
kelly_sizer(fraction=0.25, bankroll=1000.0, max_size=50.0),
to_quotes,
],
risk=hz.Risk(max_position=100, max_drawdown_pct=5),
interval=1.0,
mode="paper",
)
kelly_sizer parameters
| Parameter | Default | Description |
|---|
fraction | 0.25 | Kelly scaling factor (0.25 = quarter Kelly) |
bankroll | 1000.0 | Total capital (overridden by ctx.params["bankroll"] if set) |
max_size | 100.0 | Hard cap on contract count |
The sizer reads the market price from the first available feed’s bid/ask midpoint. It returns 0.0 if no feed data is available or if there is no edge.
Liquidity-adjusted pipeline sizer
from horizon import kelly_sizer_with_liquidity
# Uses sqrt dampening based on available liquidity
hz.run(
name="kelly_liq_mm",
markets=["btc-100k"],
pipeline=[
estimate_prob,
kelly_sizer_with_liquidity(fraction=0.25, bankroll=1000.0, max_size=50.0),
to_quotes,
],
risk=hz.Risk(max_position=100),
params={"available_liquidity": 200.0}, # Passed via Context.params
interval=1.0,
mode="paper",
)
When NOT to Use Kelly
Kelly criterion assumes you know the true probability. In practice, several conditions make Kelly dangerous:
No edge
If your estimated probability equals the market price, Kelly returns 0. Do not override this. The market is efficient and you should not trade.
import horizon as hz
# Market at 0.50, you think 0.50, no trade
print(hz.kelly(0.50, 0.50)) # 0.0
print(hz.kelly_size(0.50, 0.50, 1000.0, 1.0, 100.0)) # 0.0
Bad calibration
If your probability estimates are systematically wrong (overconfident or underconfident), Kelly will oversize or undersize. Use hz.backtest() with outcomes to measure your Brier score before going live.
# Test your calibration first
result = hz.backtest(
data=historical_data,
pipeline=[my_model, quoter],
risk=hz.Risk(max_position=50),
outcomes=known_outcomes,
)
print(f"Brier score: {result.metrics.brier_score:.4f}")
# If Brier > 0.25, your model is worse than a coin flip
multi_kelly treats markets as independent. If your positions are correlated (e.g., multiple BTC price markets), the true optimal sizing is lower. Use a smaller max_total:
import horizon as hz
# Correlated markets, use conservative max_total
correlated_fractions = hz.multi_kelly(
probs=[0.65, 0.60, 0.70],
prices=[0.50, 0.50, 0.50],
max_total=0.20, # Very conservative for correlated bets
)
Thin liquidity
Full Kelly in a thin market causes massive slippage. Always use liquidity_adjusted_kelly or set a low max_size cap.
Function Reference
| Function | Signature | Returns |
|---|
hz.kelly | (prob, market_price) | Kelly fraction for Yes side |
hz.kelly_no | (prob, market_price) | Kelly fraction for No side |
hz.fractional_kelly | (prob, market_price, fraction) | Scaled Kelly fraction |
hz.kelly_size | (prob, market_price, bankroll, fraction, max_size) | Contract count |
hz.multi_kelly | (probs, prices, max_total) | List of scaled fractions |
hz.liquidity_adjusted_kelly | (prob, market_price, bankroll, fraction, available_liquidity, max_size) | Liquidity-dampened contract count |
hz.edge | (prob, market_price) | Raw edge (prob - market_price) |
hz.kelly_sizer | (fraction, bankroll, max_size) | Pipeline function |
hz.kelly_sizer_with_liquidity | (fraction, bankroll, max_size) | Pipeline function with liquidity adjustment |
All core functions (kelly, kelly_no, fractional_kelly, kelly_size, multi_kelly, liquidity_adjusted_kelly, edge) are implemented in Rust with #[inline] for zero-overhead calls from Python via PyO3.