Skip to main content

Horizon Kelly Criterion

Horizon includes a complete suite of Kelly criterion functions for optimal position sizing in prediction markets, equities, options, and crypto. All functions are implemented in Rust for maximum performance and exposed to Python via PyO3.
The Kelly criterion tells you the theoretically optimal fraction of your bankroll to wager given your edge. In practice, fractional Kelly (typically 0.25x to 0.5x) is preferred to reduce variance and account for estimation error.
Kelly works for any binary outcome with a known probability and price — prediction market contracts, stock directional bets, option payoffs, or crypto positions. The math is the same: estimate the true probability, compare to the market-implied price, and size accordingly.

Overview

Edge Calculation

hz.edge() computes the expected value of a bet given your probability estimate and the market price.

Full Kelly

hz.kelly() and hz.kelly_no() compute the optimal fraction for Yes and No sides.

Fractional Kelly

hz.fractional_kelly() scales the Kelly fraction to reduce variance and risk of ruin.

Position Sizing

hz.kelly_size() converts the fraction into a concrete position size in contract units.

Core Functions

hz.edge

Compute the expected edge (expected value) of a Yes bet.
import horizon as hz

edge = hz.edge(0.65, 0.55)
print(f"Edge: {edge:.4f}")  # 0.1000 (10 cents of edge)
The edge is fair_prob - market_price. A positive edge on Yes means the market underprices the event.

hz.kelly

Full Kelly fraction for the Yes side.
fraction = hz.kelly(0.65, 0.55)
print(f"Kelly fraction: {fraction:.4f}")  # Optimal % of bankroll to bet
Formula: (fair_prob * (1 - market_price) - (1 - fair_prob) * market_price) / (1 - market_price) If the result is negative, there is no edge on the Yes side.

hz.kelly_no

Full Kelly fraction for the No side.
fraction = hz.kelly_no(0.35, 0.55)
print(f"Kelly No fraction: {fraction:.4f}")
Use this when you believe the event is less likely than the market implies and want to bet No.

hz.fractional_kelly

Scale the Kelly fraction by a conservative multiplier.
fraction = hz.fractional_kelly(0.65, 0.55, 0.5)  # Half-Kelly
print(f"Half-Kelly fraction: {fraction:.4f}")
ParameterTypeDescription
probfloatYour estimated true probability
market_pricefloatCurrent market price
fractionfloatKelly multiplier (0.0 to 1.0)
Half-Kelly (fraction=0.5) is the most common choice in practice. It achieves 75% of the growth rate of full Kelly while cutting variance in half. Quarter-Kelly (fraction=0.25) is even more conservative and suitable when your probability estimates are noisy.

hz.kelly_size

Convert a Kelly fraction into a concrete position size in contract units.
size = hz.kelly_size(0.65, 0.55, 10000.0, 0.5, 10000.0)
print(f"Optimal position: {size:.1f} contracts")
ParameterTypeDescription
probfloatYour estimated true probability
market_pricefloatCurrent market price
bankrollfloatTotal available capital
fractionfloatKelly multiplier (0.0 to 1.0)
max_sizefloatHard cap on position size (contracts)
Returns the number of contracts to buy. A return value of 0.0 or negative means no bet.

hz.multi_kelly

Optimal sizing across multiple simultaneous positions.
probs  = [0.65, 0.40, 0.75]   # Your estimated probabilities
prices = [0.55, 0.30, 0.60]   # Current market prices

sizes = hz.multi_kelly(probs, prices, 1.0)
for i, size in enumerate(sizes):
    print(f"Position {i+1}: fraction={size:.4f}")
ParameterTypeDescription
probslist[float]Your estimated probabilities for each market
priceslist[float]Current market prices for each market
max_totalfloatMaximum total Kelly fraction across all positions
multi_kelly returns Kelly fractions (not contract sizes). The sum of fractions will not exceed max_total. This is preferred over calling kelly independently for each position, which could over-allocate capital.

hz.liquidity_adjusted_kelly

Adjusts the Kelly size for market impact based on available liquidity.
size = hz.liquidity_adjusted_kelly(0.65, 0.55, 10000.0, 0.5, 500.0, 1000.0)
print(f"Liquidity-adjusted size: {size:.1f}")
ParameterTypeDescription
probfloatYour estimated true probability
market_pricefloatCurrent market price
bankrollfloatTotal available capital
fractionfloatKelly multiplier (0.0 to 1.0)
available_liquidityfloatContracts available at/near the market price
max_sizefloatHard cap on position size (contracts)
The function computes a raw Kelly size then scales it down using a square-root dampening factor based on the ratio of available liquidity to the raw size. The result is capped at both max_size and available_liquidity.

Pipeline Helpers

Horizon provides two helper functions designed for use inside hz.run() pipeline functions.

hz.kelly_sizer

Factory function that returns a pipeline-compatible sizing function for use with hz.run().
sizer = hz.kelly_sizer(fraction=0.25, bankroll=10000.0, max_size=100.0)

# Use in a pipeline - the returned function takes (ctx, estimated_prob)
hz.run(
    name="my-strategy",
    markets=["some-market"],
    feeds={"some-market": "polymarket_book"},
    pipeline=[estimate_prob, sizer, to_quotes],
)
ParameterTypeDefaultDescription
fractionfloat0.25Kelly multiplier
bankrollfloat1000.0Total available capital
max_sizefloat100.0Hard cap on position size
Returns a callable (Context, float) -> float that extracts the market price from ctx.feeds and calls hz.kelly_size() internally.

hz.kelly_sizer_with_liquidity

Like kelly_sizer but adjusts for available liquidity using hz.liquidity_adjusted_kelly().
sizer = hz.kelly_sizer_with_liquidity(fraction=0.25, bankroll=10000.0, max_size=100.0)

# Use in a pipeline
hz.run(
    name="my-strategy",
    markets=["some-market"],
    feeds={"some-market": "polymarket_book"},
    pipeline=[estimate_prob, sizer, to_quotes],
)
ParameterTypeDefaultDescription
fractionfloat0.25Kelly multiplier
bankrollfloat1000.0Total available capital
max_sizefloat100.0Hard cap on position size
Returns a callable (Context, float) -> float that extracts both market price and available liquidity from ctx and calls hz.liquidity_adjusted_kelly() internally.

Examples

Basic Kelly Sizing

import horizon as hz

# Your model says 65% probability, market is at 55 cents
fair = 0.65
market = 0.55
bankroll = 5000.0

# Check the edge first
edge = hz.edge(fair, market)
print(f"Edge: {edge:.2%}")  # 10.00%

if edge > 0:
    # Full Kelly
    full = hz.kelly(fair, market)
    print(f"Full Kelly: {full:.2%} of bankroll")

    # Half Kelly (recommended)
    half = hz.fractional_kelly(fair, market, 0.5)
    print(f"Half Kelly: {half:.2%} of bankroll")

    # Concrete size (prob, market_price, bankroll, fraction, max_size)
    size = hz.kelly_size(fair, market, bankroll, 0.5, 10000.0)
    print(f"Position size: {size:.0f} contracts")

Multi-Position Portfolio

import horizon as hz

bankroll = 20000.0

# Multiple markets with different edges
markets = {
    "election-winner":   (0.62, 0.50),
    "fed-rate-cut":      (0.45, 0.35),
    "btc-above-100k":    (0.70, 0.55),
    "recession-2025":    (0.30, 0.40),  # Negative edge on Yes
}

# Filter to markets with positive edge
probs_list = []
prices_list = []
market_names = []
for name, (fair, price) in markets.items():
    e = hz.edge(fair, price)
    if e > 0:
        probs_list.append(fair)
        prices_list.append(price)
        market_names.append(name)
        print(f"{name}: edge={e:.2%}")
    else:
        print(f"{name}: no edge (skipping)")

# Multi-Kelly allocation (returns fractions, not contract sizes)
if probs_list:
    fractions = hz.multi_kelly(probs_list, prices_list, 1.0)
    print("\n--- Allocations ---")
    for name, frac in zip(market_names, fractions):
        contracts = (frac * bankroll) / prices_list[market_names.index(name)]
        print(f"{name}: fraction={frac:.4f}, ~{contracts:.0f} contracts")

Liquidity-Aware Sizing

import horizon as hz

fair = 0.70
price = 0.58
bankroll = 10000.0

# Without liquidity adjustment (prob, market_price, bankroll, fraction, max_size)
naive_size = hz.kelly_size(fair, price, bankroll, 0.5, 10000.0)
print(f"Naive Kelly size: {naive_size:.0f} contracts")

# With liquidity adjustment
# Only 200 contracts available near the current price
# (prob, market_price, bankroll, fraction, available_liquidity, max_size)
adjusted_size = hz.liquidity_adjusted_kelly(fair, price, bankroll, 0.5, 200.0, 10000.0)
print(f"Liquidity-adjusted size: {adjusted_size:.0f} contracts")
# Will be scaled down based on available liquidity

Kelly in a Pipeline

The most common usage is inside an hz.run() pipeline where Kelly determines order size dynamically.
import horizon as hz

BANKROLL = 10000.0
KELLY_FRACTION = 0.25  # Quarter-Kelly for safety

def model(ctx):
    """Your probability model."""
    # Replace with real model logic
    return 0.62

def kelly_quoter(ctx, fair):
    """Size and quote using Kelly criterion."""
    price = ctx.feed.price
    e = hz.edge(fair, price)

    if e < 0.02:
        return None  # Minimum 2% edge to trade

    # (prob, market_price, bankroll, fraction, available_liquidity, max_size)
    size = hz.liquidity_adjusted_kelly(
        fair, price, BANKROLL, KELLY_FRACTION, 1000.0, 1000.0
    )

    if size < 1.0:
        return None  # Minimum order size

    return hz.quotes(price, spread=0.04, size=round(size, 1))

hz.run(
    name="kelly-strategy",
    markets=["election-winner"],
    feeds={"election-winner": "polymarket_book"},
    pipeline=[model, kelly_quoter],
)

Comparing Kelly Fractions

import horizon as hz

fair = 0.65
price = 0.55
bankroll = 10000.0

print("Fraction | Size    | Expected Growth Rate")
print("-" * 45)

for frac in [0.1, 0.25, 0.5, 0.75, 1.0]:
    size = hz.kelly_size(fair, price, bankroll, frac, 10000.0)
    # Approximate log growth rate
    full_kelly = hz.kelly(fair, price)
    # Growth = f * edge - f^2 * variance / 2 (approximate)
    edge = hz.edge(fair, price)
    growth = frac * full_kelly * edge - (frac * full_kelly) ** 2 / 2
    print(f"  {frac:.2f}   | {size:7.0f} | {growth:.6f}")

Mathematical Background

For a binary outcome (Yes/No) with market price p and your estimated probability q:Yes Kelly fraction = (q * (1-p) - (1-q) * p) / (1-p)No Kelly fraction = ((1-q) * p - q * (1-p)) / pThis maximizes the expected logarithm of wealth (geometric growth rate).
Full Kelly is optimal only if:
  1. Your probability estimates are perfectly calibrated
  2. You have infinite time horizon
  3. You can tolerate extreme drawdowns
In practice, none of these hold. Fractional Kelly (f < 1.0) provides:
  • Lower variance (proportional to f^2)
  • Lower drawdowns
  • Robustness to estimation error
  • Only marginally lower expected growth (at half-Kelly, growth is 75% of full Kelly)
When holding multiple positions, independent Kelly calculations can over-allocate capital. hz.multi_kelly() solves the joint optimization problem, ensuring total allocation respects the bankroll constraint.
Kelly sizing assumes your probability estimates are accurate. If your model is poorly calibrated, Kelly will aggressively over-size positions. Always validate calibration using hz.backtest() with the outcomes parameter before deploying Kelly sizing in production.