---
title: "Kelly Criterion"
description: "Optimal position sizing with Rust-native Kelly criterion functions. Single, fractional, multi-asset, and liquidity-adjusted Kelly."
icon: "calculator"
---

# Horizon Kelly Criterion

Horizon includes a complete suite of Kelly criterion functions for optimal position sizing in prediction markets. All functions are implemented in Rust for maximum performance and exposed to Python via PyO3.

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

## Overview

<CardGroup cols={2}>
  <Card title="Edge Calculation" icon="bullseye">
    `hz.edge()` computes the expected value of a bet given your probability estimate and the market price.
  </Card>
  <Card title="Full Kelly" icon="maximize">
    `hz.kelly()` and `hz.kelly_no()` compute the optimal fraction for Yes and No sides.
  </Card>
  <Card title="Fractional Kelly" icon="sliders">
    `hz.fractional_kelly()` scales the Kelly fraction to reduce variance and risk of ruin.
  </Card>
  <Card title="Position Sizing" icon="ruler-combined">
    `hz.kelly_size()` converts the fraction into a concrete position size in contract units.
  </Card>
</CardGroup>

---

## Core Functions

### hz.edge

Compute the expected edge (expected value) of a Yes bet.

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

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

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

```python
fraction = hz.fractional_kelly(0.65, 0.55, 0.5)  # Half-Kelly
print(f"Half-Kelly fraction: {fraction:.4f}")
```

| Parameter | Type | Description |
|---|---|---|
| `prob` | `float` | Your estimated true probability |
| `market_price` | `float` | Current market price |
| `fraction` | `float` | Kelly multiplier (0.0 to 1.0) |

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

### hz.kelly_size

Convert a Kelly fraction into a concrete position size in contract units.

```python
size = hz.kelly_size(0.65, 0.55, 10000.0, 0.5, 10000.0)
print(f"Optimal position: {size:.1f} contracts")
```

| Parameter | Type | Description |
|---|---|---|
| `prob` | `float` | Your estimated true probability |
| `market_price` | `float` | Current market price |
| `bankroll` | `float` | Total available capital |
| `fraction` | `float` | Kelly multiplier (0.0 to 1.0) |
| `max_size` | `float` | Hard 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.

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

| Parameter | Type | Description |
|---|---|---|
| `probs` | `list[float]` | Your estimated probabilities for each market |
| `prices` | `list[float]` | Current market prices for each market |
| `max_total` | `float` | Maximum total Kelly fraction across all positions |

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

### hz.liquidity_adjusted_kelly

Adjusts the Kelly size for market impact based on available liquidity.

```python
size = hz.liquidity_adjusted_kelly(0.65, 0.55, 10000.0, 0.5, 500.0, 1000.0)
print(f"Liquidity-adjusted size: {size:.1f}")
```

| Parameter | Type | Description |
|---|---|---|
| `prob` | `float` | Your estimated true probability |
| `market_price` | `float` | Current market price |
| `bankroll` | `float` | Total available capital |
| `fraction` | `float` | Kelly multiplier (0.0 to 1.0) |
| `available_liquidity` | `float` | Contracts available at/near the market price |
| `max_size` | `float` | Hard 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()`.

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

| Parameter | Type | Default | Description |
|---|---|---|---|
| `fraction` | `float` | `0.25` | Kelly multiplier |
| `bankroll` | `float` | `1000.0` | Total available capital |
| `max_size` | `float` | `100.0` | Hard 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()`.

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

| Parameter | Type | Default | Description |
|---|---|---|---|
| `fraction` | `float` | `0.25` | Kelly multiplier |
| `bankroll` | `float` | `1000.0` | Total available capital |
| `max_size` | `float` | `100.0` | Hard 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

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

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

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

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

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

<AccordionGroup>
  <Accordion title="Kelly Formula for Binary Markets">
    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)) / p`

    This maximizes the expected logarithm of wealth (geometric growth rate).
  </Accordion>
  <Accordion title="Why Fractional Kelly?">
    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)
  </Accordion>
  <Accordion title="Multi-Position 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.
  </Accordion>
</AccordionGroup>

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