---
title: "Horizon Kelly Criterion"
description: "Optimal position sizing with Kelly criterion functions."
icon: "calculator"
---

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

```python
"""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.

```python
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.

```python
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.

```python
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%}")
```

<Note>
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.
</Note>

### 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`.

```python
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).

```python
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:

```python
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:

```python
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:

```python
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)
```

<Warning>
`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.
</Warning>

## Pipeline Integration

The `kelly_sizer` function creates a pipeline-compatible sizing stage for use with `hz.run()`:

```python
"""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

```python
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.

```python
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.

```python
# 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`:

```python
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.
