Position sizing is where most bettors — and most betting bots — fail. You can find genuine edges, build a beautiful +EV scanner, and still go broke by betting too much or too little. Bet too aggressively and a cold streak wipes you out. Bet too conservatively and your bankroll barely moves.

The Kelly criterion solves this mathematically. It’s the formula that tells you exactly how much of your bankroll to risk on each bet to maximize your long-run growth rate. It’s been the secret weapon of professional gamblers and quantitative investors since the 1950s, and it’s the backbone of any serious automated betting system.

In this tutorial, you’ll build a KellyBankrollManager in Python that takes your edge estimates and odds, calculates optimal stake sizes, and manages your bankroll dynamically as bets resolve.

The Kelly Formula

The Kelly criterion gives you the optimal fraction of your bankroll to wager:

f* = (bp - q) / b

Where:

  • f* = fraction of bankroll to bet
  • b = net decimal odds (decimal odds - 1). For American odds of -110, decimal odds are 1.909, so b = 0.909
  • p = your estimated probability of winning
  • q = probability of losing (1 - p)

The intuition behind Kelly is that it maximizes the expected logarithm of your wealth. Why logarithm? Because bankroll growth is multiplicative, not additive. If you double your money then lose half, you’re back to even — not up by 50%. The log function captures this compounding reality, and Kelly optimizes for it.

A Concrete Example

You’ve identified a -110 line at an offshore book where your model estimates a 55% true win probability. The book’s implied probability is about 52.4%, giving you a 2.6% edge.

  • Decimal odds: 1.909
  • b = 0.909
  • p = 0.55, q = 0.45
  • f* = (0.909 × 0.55 - 0.45) / 0.909
  • f* = (0.500 - 0.45) / 0.909
  • f = 0.055 (5.5% of bankroll)*

On a $10,000 bankroll, Kelly says to bet $550. The formula scales naturally: larger edges and shorter odds produce bigger recommended stakes, while thin edges or long shots produce smaller ones. If your edge is zero or negative, Kelly returns zero or a negative number — telling you not to bet at all.

What the Formula Tells You

Two key relationships drive Kelly sizing:

  1. Bet more when your edge is larger. A 5% edge on a coin flip produces a bigger Kelly stake than a 2% edge.
  2. Bet less on longshots. Even with a large percentage edge, long-odds bets get smaller Kelly stakes because one loss wipes out many wins.

This is why Kelly works so well for sports betting at standard odds ranges (-110 to +200) — the formula naturally moderates stake sizes in exactly the range where you’re most likely to find real edges.

Why Full Kelly Is Too Aggressive

Here’s the problem: full Kelly assumes your edge estimate is exactly correct. In practice, you’re estimating probabilities from models, sharp benchmarks, or historical data — and every estimate carries noise.

If your true edge is 3% but you think it’s 6%, full Kelly sizes as if you have twice the edge you actually do. Over hundreds of bets, that systematic overestimation leads to dramatically larger drawdowns and a meaningful risk of practical ruin (hitting the book’s minimum bet before your bankroll recovers).

Full Kelly’s variance profile is brutal:

  • Expected drawdowns of 50%+ are common even with a genuine edge
  • The standard deviation of outcomes is enormous relative to the mean
  • Recovery from deep drawdowns takes hundreds of bets

Fractional Kelly: The Practical Solution

The fix is simple — multiply the Kelly fraction by a safety factor:

f_adjusted = kelly_fraction × f*

Where kelly_fraction is typically between 0.25 (quarter Kelly) and 0.5 (half Kelly).

The math on the tradeoff is remarkable:

Kelly FractionGrowth Rate (% of Full)Variance (% of Full)
1.0 (Full)100%100%
0.5 (Half)75%25%
0.25 (Quarter)44%6.25%

Half Kelly gives you 75% of the maximum growth rate with only 25% of the variance. That’s an extraordinary deal — you give up a quarter of your growth to eliminate three-quarters of the volatility. For any automated system running on estimated edges, half Kelly or less is the rational default.

Building the Kelly Bot

Here’s a complete KellyBankrollManager class that handles stake sizing, bet tracking, and bankroll management:

from dataclasses import dataclass, field
from datetime import datetime


@dataclass
class BetRecord:
    timestamp: datetime
    event: str
    odds: float
    edge: float
    kelly_full: float
    kelly_adjusted: float
    stake: float
    result: float | None = None
    bankroll_after: float | None = None


class KellyBankrollManager:
    def __init__(
        self,
        bankroll: float,
        kelly_fraction: float = 0.5,
        min_bet: float = 5.0,
        max_bet_pct: float = 0.05,
        max_exposure_pct: float = 0.20,
    ):
        self.bankroll = bankroll
        self.kelly_fraction = kelly_fraction
        self.min_bet = min_bet
        self.max_bet_pct = max_bet_pct
        self.max_exposure_pct = max_exposure_pct
        self.peak_bankroll = bankroll
        self.history: list[BetRecord] = []
        self.pending: list[BetRecord] = []

    def kelly_stake(self, odds: float, win_prob: float) -> dict:
        """Calculate Kelly stake for a bet opportunity.

        Args:
            odds: Decimal odds (e.g. 1.909 for -110)
            win_prob: Estimated probability of winning (0 to 1)
        """
        b = odds - 1
        q = 1 - win_prob
        edge = win_prob - (1 / odds)

        full_kelly = (b * win_prob - q) / b
        if full_kelly <= 0:
            return {"stake": 0, "full_kelly": full_kelly, "edge": edge, "skip": True}

        adjusted = full_kelly * self.kelly_fraction
        stake = self.bankroll * adjusted

        max_bet = self.bankroll * self.max_bet_pct
        stake = min(stake, max_bet)

        pending_exposure = sum(p.stake for p in self.pending)
        remaining_exposure = (self.bankroll * self.max_exposure_pct) - pending_exposure
        stake = min(stake, max(0, remaining_exposure))

        if stake < self.min_bet:
            return {"stake": 0, "full_kelly": full_kelly, "edge": edge, "skip": True}

        return {
            "stake": round(stake, 2),
            "full_kelly": round(full_kelly, 4),
            "adjusted_kelly": round(adjusted, 4),
            "edge": round(edge, 4),
            "skip": False,
        }

    def place_bet(self, event: str, odds: float, win_prob: float) -> BetRecord | None:
        sizing = self.kelly_stake(odds, win_prob)
        if sizing["skip"]:
            return None

        record = BetRecord(
            timestamp=datetime.now(),
            event=event,
            odds=odds,
            edge=sizing["edge"],
            kelly_full=sizing["full_kelly"],
            kelly_adjusted=sizing["adjusted_kelly"],
            stake=sizing["stake"],
        )
        self.pending.append(record)
        return record

    def resolve_bet(self, record: BetRecord, won: bool):
        profit = record.stake * (record.odds - 1) if won else -record.stake
        self.bankroll += profit
        record.result = profit
        record.bankroll_after = self.bankroll
        self.peak_bankroll = max(self.peak_bankroll, self.bankroll)

        if record in self.pending:
            self.pending.remove(record)
        self.history.append(record)

    @property
    def current_drawdown(self) -> float:
        if self.peak_bankroll == 0:
            return 0
        return (self.peak_bankroll - self.bankroll) / self.peak_bankroll

    @property
    def max_drawdown(self) -> float:
        if not self.history:
            return 0
        peak = self.history[0].bankroll_after or self.bankroll
        max_dd = 0
        for record in self.history:
            if record.bankroll_after is None:
                continue
            peak = max(peak, record.bankroll_after)
            dd = (peak - record.bankroll_after) / peak
            max_dd = max(max_dd, dd)
        return max_dd

    def summary(self) -> dict:
        resolved = [r for r in self.history if r.result is not None]
        wins = sum(1 for r in resolved if r.result > 0)
        total_profit = sum(r.result for r in resolved)
        return {
            "bankroll": round(self.bankroll, 2),
            "total_bets": len(resolved),
            "wins": wins,
            "losses": len(resolved) - wins,
            "total_profit": round(total_profit, 2),
            "roi_pct": round((total_profit / sum(r.stake for r in resolved)) * 100, 2)
            if resolved
            else 0,
            "max_drawdown_pct": round(self.max_drawdown * 100, 2),
            "current_drawdown_pct": round(self.current_drawdown * 100, 2),
        }

Usage Example

mgr = KellyBankrollManager(bankroll=10_000, kelly_fraction=0.5)

bet1 = mgr.place_bet("Chiefs -3 vs Ravens", odds=1.909, win_prob=0.55)
bet2 = mgr.place_bet("Lakers ML vs Celtics", odds=2.10, win_prob=0.52)

print(f"Bet 1: ${bet1.stake} on {bet1.event} (edge: {bet1.edge:.1%})")
print(f"Bet 2: ${bet2.stake} on {bet2.event} (edge: {bet2.edge:.1%})")

mgr.resolve_bet(bet1, won=True)
mgr.resolve_bet(bet2, won=False)

print(mgr.summary())
# {'bankroll': 10199.55, 'total_bets': 2, 'wins': 1, 'losses': 1,
#  'total_profit': 199.55, 'roi_pct': 34.21, ...}

The manager dynamically adjusts every stake based on the current bankroll. After a win, your bankroll grows and Kelly sizes up. After a loss, your bankroll shrinks and Kelly sizes down. This is the core advantage of Kelly over flat staking — it’s self-correcting.

Multi-Bet Kelly

When your scanner finds multiple simultaneous opportunities, you need to manage total exposure. The KellyBankrollManager above already handles this with max_exposure_pct, but here’s the logic made explicit:

def size_multiple_bets(
    mgr: KellyBankrollManager,
    opportunities: list[dict],
) -> list[dict]:
    """Size a batch of simultaneous bets with exposure management."""
    sized = []
    for opp in sorted(opportunities, key=lambda x: x["edge"], reverse=True):
        result = mgr.kelly_stake(opp["odds"], opp["win_prob"])
        if not result["skip"]:
            record = mgr.place_bet(opp["event"], opp["odds"], opp["win_prob"])
            if record:
                sized.append({"event": opp["event"], "stake": record.stake})
    return sized

The key details:

  • Sort by edge descending so the highest-edge bets get sized first and consume available exposure before lower-edge bets
  • The exposure cap ensures that even if Kelly recommends 5% on each of six concurrent bets, total exposure stays within your 20% cap
  • Treat bets as independent unless you have correlated outcomes (e.g., two bets on the same game), which should share a single Kelly allocation

For most sports betting scenarios — different games, different sports, different markets — independence is a safe assumption. If you’re making multiple bets on the same event (spread + total + prop), you need to account for correlation manually or simply cap per-event exposure.

Practical Considerations

Edge Estimation Is Everything

Kelly is only as good as your edge estimate. If you consistently overestimate your edge by 2%, Kelly will systematically oversize your bets and your bankroll will erode instead of grow.

The best way to calibrate: track your closing line value. If your CLV consistently shows 3% and your Kelly model assumes 3%, you’re calibrated. If CLV shows 1.5% but you’re sizing as if you have 3%, cut your Kelly fraction or fix your edge model.

Book Constraints

Offshore sportsbooks impose constraints that interact with Kelly:

  • Minimum bets ($5-$25 depending on the book) create a floor. If Kelly says bet $3, you either skip the bet or accept the over-sizing risk.
  • Maximum bets cap your upside. If you have a huge edge and Kelly says bet $2,000 but the book limits you to $500, you’re under-betting relative to optimal — which is fine for bankroll safety but limits growth.
  • Account limits can reduce your max bet over time if the book identifies you as sharp. Factor declining limits into your long-term bankroll projections.

Ruin Probability

Under full Kelly with accurate edge estimates, the theoretical probability of your bankroll ever dropping to a fraction f of its peak is exactly f. So there’s a 10% chance of a 90% drawdown under full Kelly — which is why nobody runs full Kelly in production.

Under half Kelly, these probabilities drop dramatically. The probability of a 50% drawdown under half Kelly with a 3% average edge is roughly 6%, compared to ~50% under full Kelly. This alone justifies the fractional approach.

When to Re-evaluate

Your Kelly system needs periodic auditing:

  • Monthly: Compare actual results vs Kelly projections. Are drawdowns within expected ranges?
  • After 500+ bets: Run a formal CLV analysis to verify your edge estimates are calibrated
  • After any 20%+ drawdown: Pause automated betting and evaluate whether your edge model is still valid or the market has shifted

What’s Next

Kelly staking is one piece of the sharp betting stack. Combine it with edge detection and market analysis: