Kelly Criterion Python Calculator: Stake Sizing with Free Odds API

Kelly Criterion - OddsPapi API Blog
How To Guides May 4, 2026

Kelly Criterion Staking: Stop Guessing Your Bet Size

You’ve found a +EV bet. Now how much do you stake?

Most retail bettors flat-stake — 1% of bankroll on every pick, regardless of edge or odds. Sharps use the Kelly Criterion — a formula that tells you the optimal stake to maximize long-run bankroll growth, given your edge and the decimal odds on offer. Bet too little and you leave growth on the table. Bet too much and variance wipes you out.

This post builds a Python Kelly calculator that uses Pinnacle’s no-vig lines as the “fair probability” benchmark, tested on three live fixtures pulled from the OddsPapi API. Includes the fractional Kelly variant every professional uses in practice, and a full scanner that finds +EV Kelly bets across today’s slate.

The Formula (60 Seconds)

For a two-outcome bet at decimal odds d, with your estimated true probability p of winning:

f* = (b * p - q) / b

where:
  b = d - 1      (net decimal odds, i.e. profit per £1 staked)
  p = true probability of winning
  q = 1 - p      (probability of losing)
  f* = fraction of bankroll to stake

Three things to notice:

  • If b*p < q, Kelly returns negative. This means the bet has negative EV — don’t bet. Clip to zero.
  • Kelly scales with edge. A 1% edge at 2.0 odds recommends 1% stake. A 10% edge at the same odds recommends 10%.
  • Kelly is ruthless. It assumes your probability estimate is exact. In practice, it’s not — which is why every real-world Kelly user bets a fraction of the formula’s output. More on that in Step 5.

The Hard Part: Where Does p Come From?

The formula is easy. The hard part is estimating the true probability p. You have three options:

  1. Build a model. This is what quants do — xG, Elo, regression — but it takes months to calibrate and years to trust.
  2. Use market consensus. Average implied probability across 350+ books. Fast and reasonable for most markets.
  3. Use Pinnacle no-vig. Pinnacle is the sharpest book globally. Strip out their 2–3% margin and the resulting implied probability is the de facto industry benchmark for “fair.”

Option 3 is the professional default. Pinnacle takes bets from sharps without limiting them, which forces their lines to reflect true probability more tightly than any soft book. Here’s the Python:

def devig(decimal_odds):
    """Power-method de-vig: normalize implied probabilities to sum to 1."""
    implied = [1/p for p in decimal_odds]
    total = sum(implied)
    return [i/total for i in implied], total

def kelly(decimal_odds, true_prob):
    """Kelly stake as fraction of bankroll. Clips negative to zero."""
    b = decimal_odds - 1
    p = true_prob
    q = 1 - p
    f = (b * p - q) / b
    return max(f, 0)

devig() uses the simplest normalization — divide each implied probability by the overround. For 3-way markets (soccer 1X2), power methods or Shin’s method give slightly more accurate results, but normalization is good enough for 95% of use cases and is what we’ll use throughout this post.

Example 1: When Kelly Says Bet Nothing

Pinnacle’s moneyline on Dallas Stars vs Minnesota Wild (NHL, market 151 — Winner incl. OT/SO):

import requests

API_KEY = "YOUR_API_KEY"
BASE = "https://api.oddspapi.io/v4"
fixture_id = "id1500023470558100"  # Dallas vs Minnesota

r = requests.get(f"{BASE}/odds",
                 params={"apiKey": API_KEY, "fixtureId": fixture_id})
books = r.json()["bookmakerOdds"]

pin = books["pinnacle"]["markets"]["151"]["outcomes"]
dal_odds = pin["151"]["players"]["0"]["price"]   # 1.847
min_odds = pin["152"]["players"]["0"]["price"]   # 2.07

fair_probs, vig = devig([dal_odds, min_odds])
print(f"Pinnacle raw: {dal_odds} / {min_odds}")
print(f"Vig: {(vig-1)*100:.2f}%")
print(f"Fair probs: Dallas={fair_probs[0]:.4f}  Minnesota={fair_probs[1]:.4f}")
print(f"Fair odds:  Dallas={1/fair_probs[0]:.4f}  Minnesota={1/fair_probs[1]:.4f}")
Pinnacle raw: 1.847 / 2.07
Vig: 2.45%
Fair probs: Dallas=0.5285  Minnesota=0.4715
Fair odds:  Dallas=1.8923  Minnesota=2.1207

Now check every US sportsbook — is any of them offering better than Pinnacle’s fair odds?

US_BOOKS = ["draftkings", "fanduel", "betmgm", "caesars", "betrivers", "bovada.lv"]

print(f"{'Book':<12} {'Side':<10} {'Offered':<8} {'Fair':<8} {'Edge':<8} {'Kelly':<8}")
for book in US_BOOKS:
    b = books.get(book, {}).get("markets", {}).get("151", {}).get("outcomes", {})
    for oid, name, fair_p in [("151", "Dallas", fair_probs[0]),
                               ("152", "Minnesota", fair_probs[1])]:
        price = b.get(oid, {}).get("players", {}).get("0", {}).get("price")
        if not price: continue
        fair_odds = 1 / fair_p
        edge = (price / fair_odds - 1) * 100
        f = kelly(price, fair_p) * 100
        print(f"{book:<12} {name:<10} {price:<8} {fair_odds:<8.3f} "
              f"{edge:+6.2f}%  {f:.2f}%")
Book         Side       Offered  Fair     Edge     Kelly
draftkings   Dallas     1.83     1.892    -3.29%   0.00%
draftkings   Minnesota  2.00     2.121    -5.69%   0.00%
fanduel      Dallas     1.83     1.892    -3.29%   0.00%
fanduel      Minnesota  2.00     2.121    -5.69%   0.00%
caesars      Dallas     1.833    1.892    -3.13%   0.00%
caesars      Minnesota  2.00     2.121    -5.69%   0.00%
betrivers    Dallas     1.82     1.892    -3.82%   0.00%
betrivers    Minnesota  2.07     2.121    -2.39%   0.00%
bovada.lv    Dallas     1.82     1.892    -3.82%   0.00%
bovada.lv    Minnesota  2.02     2.121    -4.75%   0.00%

Every US book is pricing worse than Pinnacle’s fair line. Kelly says bet zero on every one of them. That’s the discipline — most games don’t have an edge, and Kelly’s job is to tell you that.

Contrast this with flat-staking, where a retail bettor might slap 1% on Minnesota at DraftKings because they “like the value.” Kelly says no — you’re giving up 5.7% in expected value before the puck drops.

Example 2: A Real (Tiny) Edge

Manchester City vs Arsenal, Premier League, 1X2 market. Pinnacle’s three-way line:

pin = books["pinnacle"]["markets"]["101"]["outcomes"]
raw = [pin[oid]["players"]["0"]["price"] for oid in ["101", "102", "103"]]
# [1.877, 3.65, 4.37]

fair_probs, vig = devig(raw)
# Fair probs: H=0.5145  D=0.2646  A=0.2210
# Fair odds:  H=1.9438  D=3.7798  A=4.5254

Compare to Betfair Exchange’s draw price of 3.80 and Unibet.nl’s Arsenal price of 4.60:

Bet Offered Fair Edge Full Kelly 1/4 Kelly
Draw @ betfair-ex 3.80 3.780 +0.53% 0.19% 0.05%
Arsenal @ unibet.nl 4.60 4.525 +1.65% 0.46% 0.11%

This is what a realistic +EV Kelly bet looks like. Tiny edges, tiny stakes. On a £10,000 bankroll, full Kelly on Arsenal at unibet.nl is £46. Quarter Kelly is £11. That’s not glamorous — but compound it across 2,000 bets a year and you’re ahead.

If a Kelly calculator ever tells you to bet 10%+ of your bankroll on a single event, your probability estimate is almost certainly wrong. Which brings us to Example 3.

Example 3: When Kelly Lies (Enhanced Prices)

Paddypower was offering 2.20 on Manchester City in the same match — a huge price compared to Pinnacle’s 1.877 fair odds. Plug it into Kelly:

offered = 2.20
fair_p = 0.5145  # Pinnacle no-vig

edge = (offered / (1/fair_p) - 1) * 100   # +13.18%
full_kelly = kelly(offered, fair_p) * 100  # 10.99%

print(f"Edge: {edge:+.2f}%")
print(f"Full Kelly: {full_kelly:.2f}%")
print(f"Quarter Kelly: {full_kelly/4:.2f}%")

# Edge: +13.18%
# Full Kelly: 10.99%
# Quarter Kelly: 2.75%

Full Kelly says stake 10.99% of your bankroll. Do not do this.

A 13% edge over Pinnacle on a marquee Premier League game is not real. Paddypower’s 2.20 is almost certainly a price boost — a marketing promotion with a tiny max-stake cap (often £10–25) and a TOS clause that voids arbitrage-style betting. Kelly has no way to know this. It just sees “price + probability” and crunches.

This is exactly why professional bettors use fractional Kelly — typically 1/4 or 1/2 — to protect against the two things Kelly can’t see:

  1. Your p is wrong. Pinnacle isn’t a perfect oracle. Public news, lineup changes, and weather move true probability between scrapes.
  2. The price isn’t real. Boosts, stale lines, limit caps, and palpable-error voiding all mean the price you saw isn’t the price you’ll actually get to stake at size.

Fractional Kelly caps your downside when either of those assumptions breaks.

Step 5: The Fractional Kelly Function

def fractional_kelly(decimal_odds, true_prob, fraction=0.25):
    """Bet `fraction` of what full Kelly recommends.

    Standard choices:
      0.5  - Half Kelly. Sharper but still aggressive.
      0.25 - Quarter Kelly. Professional default.
      0.1  - Tenth Kelly. Ultra-conservative, common for model uncertainty.
    """
    return kelly(decimal_odds, true_prob) * fraction

# Usage on the Arsenal unibet.nl bet
stake_pct = fractional_kelly(4.60, 0.2210, fraction=0.25)
bankroll = 10_000
stake = bankroll * stake_pct
print(f"1/4 Kelly stake: £{stake:.2f} ({stake_pct*100:.2f}% of bankroll)")
# 1/4 Kelly stake: £11.49 (0.11% of bankroll)

Professional sports bettors almost universally use some variant of fractional Kelly, typically 0.25. A 1/4 Kelly bettor gives up about 3/4 of Kelly’s theoretical growth rate in exchange for dramatically lower variance — and most importantly, survives the inevitable runs where their probability estimates are systematically wrong.

Step 6: Full +EV Scanner Across Today’s Slate

Single bets are fine. The real workflow is looping the calculation across every game on the slate, every market, every book — and surfacing only the +EV bets worth taking:

import time
from datetime import date, timedelta

def scan_sport(sport_id, market_id, outcome_ids, min_edge=0.01,
               kelly_fraction=0.25, min_pin_vig=0.01, max_pin_vig=0.06):
    """Find all Kelly-positive bets for a given market on today's slate.

    Args:
      sport_id: OddsPapi sportId (10=soccer, 15=NHL, etc.)
      market_id: int, e.g. 101 for soccer 1X2 or 151 for NHL moneyline
      outcome_ids: list of outcome IDs for this market
      min_edge: require this much edge vs Pinnacle fair to flag
      kelly_fraction: 0.25 = quarter Kelly
      min/max_pin_vig: reject fixtures where Pinnacle's overround is
                       suspiciously low or high (data quality filter)
    """
    today = date.today().isoformat()
    tomorrow = (date.today() + timedelta(days=1)).isoformat()
    r = requests.get(f"{BASE}/fixtures", params={
        "apiKey": API_KEY, "sportId": sport_id,
        "from": today, "to": tomorrow,
    })
    fixtures = [f for f in r.json() if f.get("hasOdds")]
    results = []

    for f in fixtures:
        time.sleep(0.2)
        r2 = requests.get(f"{BASE}/odds",
                          params={"apiKey": API_KEY, "fixtureId": f["fixtureId"]})
        books = r2.json().get("bookmakerOdds", {})

        # Need Pinnacle as our fair benchmark
        pin = books.get("pinnacle", {}).get("markets", {}).get(str(market_id), {}).get("outcomes", {})
        pin_prices = []
        for oid in outcome_ids:
            p = pin.get(str(oid), {}).get("players", {}).get("0", {}).get("price")
            if not p: break
            pin_prices.append(p)
        if len(pin_prices) != len(outcome_ids):
            continue

        fair_probs, vig = devig(pin_prices)
        if not (1 + min_pin_vig <= vig <= 1 + max_pin_vig):
            continue  # Pinnacle data looks bogus, skip

        # Check every other book for +EV on every outcome
        for slug, b in books.items():
            if slug == "pinnacle": continue
            outs = b.get("markets", {}).get(str(market_id), {}).get("outcomes", {})
            for oid, fair_p in zip(outcome_ids, fair_probs):
                player = outs.get(str(oid), {}).get("players", {}).get("0", {})
                if not player.get("active"): continue
                price = player.get("price")
                if not price: continue
                fair_odds = 1 / fair_p
                edge = price / fair_odds - 1
                if edge < min_edge: continue
                stake = fractional_kelly(price, fair_p, kelly_fraction)
                results.append({
                    "fixture": f"{f['participant1Name']} vs {f['participant2Name']}",
                    "book": slug,
                    "outcome_id": oid,
                    "price": price,
                    "fair_odds": round(fair_odds, 3),
                    "edge_pct": round(edge*100, 2),
                    "kelly_pct": round(stake*100, 3),
                })

    return sorted(results, key=lambda x: -x["kelly_pct"])

# Scan soccer 1X2 for today
bets = scan_sport(sport_id=10, market_id=101, outcome_ids=[101, 102, 103])
for b in bets[:10]:
    print(b)

Pipe the output to a CSV, filter by your book whitelist, and size your bets. That’s a complete +EV workflow in 80 lines of Python.

Practical Guardrails

Things the formula won’t tell you but that will save your bankroll:

  • Correlated bets. Kelly assumes independent outcomes. Two bets on the same game (e.g., moneyline + over) are correlated — never sum their Kelly stakes. Pick the higher-edge one.
  • Limit caps. If the book caps you at £50, your “Kelly stake” is £50 even if the formula says £200. Factor this in.
  • Closing line value. The ultimate sanity check on your p estimate is whether your bets beat the closing line on average. OddsPapi’s historical odds endpoint is free — pull closing prices and track CLV on every bet.
  • Vig too tight or too wide. If Pinnacle’s overround is under 1% or over 8%, something’s off — a market that’s about to suspend, a long-tail sport, or bad data. Filter these out.
  • Minimum edge threshold. Don’t take +0.1% edges. Model uncertainty alone is usually 1–2% — you need a real cushion. Professional thresholds are usually 2–3% minimum edge.

Fractional Kelly Cheat Sheet

Fraction Use Case Variance Growth Rate
Full (1.0) Certainty about p — almost never Ruinous Maximum
Half (0.5) High-confidence model, well-calibrated High ~94% of max
Quarter (0.25) Professional default Moderate ~75% of max
Tenth (0.1) New model, low confidence, large drawdown budget Low ~36% of max
Flat staking Retail default Lowest Lowest

Growth rates are rough — see Thorp’s 1997 Kelly paper for the actual math. The pattern is consistent though: 1/4 Kelly captures most of the long-run growth of full Kelly with a fraction of the variance and drawdown risk.

Stop Flat Staking. Start Compounding.

You’ve got the formula, you’ve got a reliable probability source (Pinnacle no-vig via OddsPapi), and you’ve got a scanner that surfaces +EV bets automatically. Flat staking 1% per bet leaves money on the table when the edge is large and ruins your bankroll when the edge is thin.

Grab a free OddsPapi API key and you’re pulling Pinnacle no-vig probabilities into your Kelly calculator in the next ten minutes. Pair it with the line shopping tool to find the best available price, then let Kelly size the bet.

FAQ

What’s the difference between Kelly and EV betting?

EV (expected value) betting identifies +EV opportunities. Kelly tells you how much to stake on them. They’re complementary — Kelly assumes you’ve already done the edge calculation. Without an edge, Kelly outputs zero.

Why fractional Kelly instead of full Kelly?

Full Kelly assumes your probability estimate is perfectly accurate. It almost never is. Fractional Kelly (typically 1/4) trades a small amount of theoretical growth for dramatically lower variance and survival through the inevitable runs where your estimates are systematically off.

Can I use consensus odds across 350+ books instead of Pinnacle no-vig?

Yes, and for markets where Pinnacle doesn’t offer, you have to. Consensus odds (averaged across all active bookmakers) are a reasonable substitute — slightly noisier than Pinnacle alone, but more robust. Weight by book quality if you have the data. See our consensus odds tutorial.

How do I handle correlated bets?

Kelly assumes outcomes are independent. Two bets on the same game (moneyline + over, parlay legs, same-game props) are correlated — you cannot simply sum their Kelly stakes. Either pick the highest-edge single bet, or use joint-Kelly formulas that account for correlation. For simple workflows, just pick one bet per event.

Is historical data useful for Kelly?

Critical. Tracking your Closing Line Value (CLV) against historical closing prices is the only way to validate that your probability estimates are actually better than market. OddsPapi’s /historical-odds endpoint is on the free tier — use it to backtest your Kelly stakes against closing lines.