Skip to main content
Value Bets API: Track Closing Line Value in Python
Back to Blog

Value Bets API: Track Closing Line Value in Python

James Whitfield

James Whitfield

8 min read

A value bet is a price above the true probability of the outcome. If the real chance is 45 percent, fair odds are 2.22, and any book offering 2.35 is paying you more than the risk costs. Take enough of those and profit follows. The catch is the word enough. Betting outcomes are so noisy that a genuinely profitable strategy can sit at a loss after 500 bets, and a worthless one can show a tidy graph over the same sample. Bankroll alone cannot tell you which one you are running.

Closing line value can. The closing line is the last price before kickoff, after the market has absorbed every bet and every piece of news, and it is the best public estimate of the true probability. If you consistently beat the close, you are betting at better prices than the most informed version of the market, and that signal stabilises hundreds of times faster than profit does. This tutorial builds the full loop in Python against the Odds-API.io v3 API: pull value bets, size them, log them, then grade every one against the closing line.

Pulling value bets from the API

The /v3/value-bets endpoint does the fair-price modelling server side. It compares one bookmaker's prices against a consensus built from a panel of sharp, high-limit books, and returns every selection where the offered price exceeds the fair price. The consensus is recomputed every 5 seconds. Two parameters are required, apiKey and bookmaker; sport and league are optional filters, and includeEventDetails=true attaches team names and kickoff times so you do not need a second call.

curl -G "https://api.odds-api.io/v3/value-bets" \
  --data-urlencode "apiKey=YOUR_KEY" \
  --data-urlencode "bookmaker=Bet365" \
  --data-urlencode "sport=football" \
  --data-urlencode "includeEventDetails=true"

This is a real captured response from 7 September 2026, trimmed from 348 entries to two:

[
  {
    "id": "71945264-Spread HT-away-Bet365-0",
    "expectedValue": 100.70927603728106,
    "expectedValueUpdatedAt": "2026-09-07T11:19:42.261Z",
    "betSide": "away",
    "market": { "name": "Spread HT", "home": "2.231", "away": "1.812", "max": 500 },
    "bookmaker": "Bet365",
    "bookmakerOdds": { "home": "1.975", "away": "1.825", "hdp": "0" },
    "eventId": 71945264,
    "event": {
      "home": "Venezia FC", "away": "ACF Fiorentina",
      "date": "2026-09-11T18:45:00Z",
      "sport": "Football", "league": "Italy - Serie A"
    }
  },
  {
    "id": "72230736-Totals HT-home-Bet365-1.25",
    "expectedValue": 100.37462070948817,
    "betSide": "home",
    "market": { "name": "Totals HT", "hdp": 1.25, "home": "2.092", "away": "1.916", "max": 150 },
    "bookmaker": "Bet365",
    "bookmakerOdds": { "home": "2.100", "away": "1.700", "hdp": "1.25" },
    "eventId": 72230736,
    "event": {
      "home": "Ruch Chorzow", "away": "Unia Skierniewice",
      "date": "2026-09-07T17:00:00Z",
      "sport": "Football", "league": "Poland - 1. Liga"
    }
  }
]

Each entry is one bet. betSide names the outcome to back. bookmakerOdds holds the prices the named book is offering, and market holds the fair prices from the sharp consensus, margin removed, so the implied probabilities of a market object sum to 1. expectedValue is the offered price divided by the fair price, times 100. Check the first entry: 1.825 offered against 1.812 fair is 100.71, meaning a 0.71 percent edge, and 100 exactly is break-even. The hdp field is the line for handicap and totals markets, and max is the consensus stake limit, a useful proxy for how liquid the market behind the fair price is.

If you want the consensus itself rather than the diffs, it is exposed as a regular bookmaker named ON Sharp in /v3/bookmakers, so you can request it through /v3/odds like any other book. The related /v3/dropping-odds endpoint tracks line movement from the same sharp panel, useful as a second filter when you only want edges the smart money is moving toward.

Filtering and sizing in Python

Do not bet the raw list. A 0.3 percent edge is inside the noise of the consensus itself, and small edges on illiquid markets vanish before you click. Filter on expectedValue and on max, then size the survivors. Flat staking works, but fractional Kelly grows the bankroll faster at the same risk of ruin. The maths is covered in the Kelly criterion tutorial; here it collapses to a few lines because the fair price gives you the probability directly.

import requests

API = "https://api.odds-api.io/v3"
KEY = "YOUR_KEY"
BANKROLL = 1000.0
KELLY_FRACTION = 0.25
MIN_EV = 101.0        # skip edges under 1 percent
MIN_LIQUIDITY = 250   # consensus max stake

def kelly(p, odds):
    b = odds - 1
    return max(0.0, (b * p - (1 - p)) / b)

vbs = requests.get(f"{API}/value-bets", params={
    "apiKey": KEY, "bookmaker": "Bet365",
    "sport": "football", "includeEventDetails": "true",
}).json()

for vb in vbs:
    if vb["expectedValue"] < MIN_EV:
        continue
    if vb["market"].get("max", 0) < MIN_LIQUIDITY:
        continue
    side = vb["betSide"]
    offered = float(vb["bookmakerOdds"][side])
    fair = float(vb["market"][side])
    stake = BANKROLL * KELLY_FRACTION * kelly(1 / fair, offered)
    print(vb["event"]["home"], "v", vb["event"]["away"],
          vb["market"]["name"], side, offered, round(stake, 2))

Quarter Kelly is deliberate. Full Kelly assumes your probability estimate is exact, and yours is a consensus snapshot that moves every 5 seconds. Betting a quarter of the optimal fraction gives up little growth and absorbs most of the estimation error.

Log every bet at the moment you place it

CLV grading needs the price you took and the fair price at that instant, and neither is recoverable later. Log both before anything else. SQLite is enough:

import sqlite3, time

db = sqlite3.connect("bets.db")
db.execute("""CREATE TABLE IF NOT EXISTS bets(
    event_id INTEGER, market TEXT, hdp REAL, side TEXT,
    odds REAL, fair_at_bet REAL, stake REAL,
    placed_at INTEGER, clv REAL)""")

def log_bet(vb, stake):
    m = vb["market"]
    side = vb["betSide"]
    db.execute("INSERT INTO bets VALUES (?,?,?,?,?,?,?,?,NULL)",
        (vb["eventId"], m["name"], m.get("hdp"), side,
         float(vb["bookmakerOdds"][side]), float(m[side]),
         stake, int(time.time())))
    db.commit()

The clv column stays NULL until the event settles. That is the point of the next section.

Grading against the close

The /v3/historical/closing-lines endpoint returns the closing odds for every settled event in a league and date range in one request, scores included. It is available on paid plans, and every parameter is required: sport, leagues (up to 10 slugs), from and to in RFC3339 (up to 366 days apart, so a full season fits one sweep), markets, and bookmakers (up to 30). Responses are capped at 100 events, paginated with limit and skip.

curl -G "https://api.odds-api.io/v3/historical/closing-lines" \
  --data-urlencode "apiKey=YOUR_KEY" \
  --data-urlencode "sport=football" \
  --data-urlencode "leagues=england-premier-league" \
  --data-urlencode "from=2026-08-29T00:00:00Z" \
  --data-urlencode "to=2026-09-01T00:00:00Z" \
  --data-urlencode "markets=ML" \
  --data-urlencode "bookmakers=Bet365,ON Sharp"

One event from the real response, captured 7 September 2026:

{
  "id": 72221214,
  "home": "AFC Bournemouth", "away": "Everton FC",
  "date": "2026-08-29T14:00:00Z",
  "status": "settled",
  "scores": { "home": 1, "away": 1,
              "periods": { "ft": { "home": 1, "away": 1 },
                           "p1": { "home": 1, "away": 0 } } },
  "bookmakers": {
    "Bet365": [{ "name": "ML", "updatedAt": "2026-08-29T13:59:36.979Z",
                 "odds": [{ "home": "2.200", "draw": "3.500", "away": "3.250" }] }],
    "ON Sharp": [{ "name": "ML", "updatedAt": "2026-08-29T13:59:36.979Z",
                   "odds": [{ "home": "2.252", "draw": "3.563", "away": "3.330" }] }]
  }
}

Grade against the ON Sharp close, not against the book you bet with. A soft book's closing price still carries its margin and its liability skew. Remove the vig from the sharp close, invert to a fair closing price, and compare it with the price you logged:

def devig(prices):
    imps = [1 / p for p in prices]
    s = sum(imps)
    return [i / s for i in imps]

close = [2.252, 3.563, 3.330]          # ON Sharp ML close
probs = devig(close)                    # [0.433, 0.274, 0.293]
fair_close = 1 / probs[0]               # 2.309 on the home side

my_odds = 2.40                          # price logged at bet time
clv = my_odds / fair_close - 1
print(f"CLV: {clv:+.1%}")               # CLV: +3.9%

Backing Bournemouth at 2.40 against a fair close of 2.309 is +3.9 percent CLV, regardless of the 1-1 result. Run this over the whole log after each matchday: fetch the window, match rows on event_id, market name and hdp, and fill the clv column. For a single event, /v3/historical/odds takes an eventId plus a bookmakers list and returns the same shape, and /v3/historical/events lists settled fixtures for a league in windows of up to 31 days if you need to map ids first.

Reading the results

The asymmetry between CLV and profit is the whole reason to build this. Average CLV is an average of small numbers with small variance, so it separates from zero quickly: after 200 to 300 bets, a true +2 percent CLV strategy almost never shows a negative average. Profit is an average of large numbers, plus or minus a full stake per bet. At even odds with a genuine 2 percent edge, the standard deviation of one bet is about one unit against an expectation of 0.02 units, and you need on the order of 10,000 bets before profit clears two standard deviations. That is years of betting. Your CLV column answers the same question in a month.

So the operating rule is: positive average CLV and negative profit means keep going, variance owes you money. Negative average CLV and positive profit means stop, you are running lucky on a losing process. Beating the close is the process; the bankroll is just a delayed measurement of it.

Gotchas

Market names are exact strings. The core markets are ML, Spread and Totals, plus variants like Spread HT and Totals HT as in the capture above. There is no market called Match Winner or Over/Under, and a wrong name in the markets parameter returns nothing rather than an error.

Match on the line, not just the market. A Totals bet at 1.25 goals must be graded against the 1.25 closing line. Compare hdp values before computing CLV, and skip the bet as ungradeable if the close no longer quotes your line.

updatedAt is a last-change timestamp, not a last-fetch timestamp. An old value means the price has been stable, not that the data is stale. The same field appears throughout the API, and the odds comparison guide covers how to read it when comparing books.

Mind the quota. The free tier is 100 requests per hour with no card, paid plans start at 5,000. Polling /v3/value-bets in a tight loop burns it fast. /v3/odds/multi prices up to 10 events in one call that counts as a single request, /v3/odds/updated returns only events that changed for one bookmaker since a Unix timestamp up to 90 seconds old, and the WebSocket feed removes polling entirely.

Value betting and arbitrage share a data layer. The same grouped-by-line odds structure this pipeline consumes also powers the scanner in the Python arbitrage bot tutorial, and the two strategies hedge each other well: arbs pay immediately, value bets pay in CLV first and cash later. With 365+ bookmakers, 34 sports and 12,000+ leagues behind one key, the limiting factor is not data. It is the discipline to log every bet and let the closing line tell you the truth.