Skip to main content
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:
f* = (p - P) / (1 - 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):
f* = (P - p) / P

Edge

The expected value per dollar risked:
edge = p - P
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:
  1. Computes Kelly fractions independently for each market.
  2. If the sum exceeds max_total, proportionally scales all fractions down.
  3. 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

ParameterDefaultDescription
fraction0.25Kelly scaling factor (0.25 = quarter Kelly)
bankroll1000.0Total capital (overridden by ctx.params["bankroll"] if set)
max_size100.0Hard 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

Correlated positions

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

FunctionSignatureReturns
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.