BlogBuilding with odds data

Historical odds in Python: one event's moves

For historical odds, Python's standard library and one OddsRelay route are enough: ask for an event's price changes over a window, follow the cursor, and print each move with any recording gaps.

The script below does that for one bookmaker on one event.

How do you get historical odds in Python?

With OddsRelay, you call GET /v2/historical/events/{id}/odds with a from and a to, and read the rows in order. Historical odds hold the bookmakers' prices since 17 October 2026, at the live feed's base URL, on a key that includes the Historical odds product. Each UTC day joins the archive the night after it ends.

The first rows are the event's prices at from, so they can carry a timestamp just before your window opens. Every row after those is a change: one outcome, at one bookmaker, in the board version where its price moved, or where it joined or left the board. Fold them in order and you hold the event's prices at any instant in the window.

A window spans at most 24 hours. For a longer stretch, call the route once per window and set each from to the last to. The route also lists any recording gaps, the part we would build in first.

Two files in one folder

The script needs Python 3.9 or later and installs nothing. Save both files below side by side, get a key through the quickstart, and set ODDSRELAY_KEY in your shell. GET /v2/events lists the ids of events on the board now, and GET /v2/historical/closing lists a past day's events, so either one gives you an id to pass in.

Run it · example

export ODDSRELAY_KEY="your API key"
python3 main.py or_evt_2d7dd94b072d --bookmaker bet365 --from 2026-10-24T11:00:00Z --to 2026-10-24T15:00:00Z
python3 main.py or_evt_2d7dd94b072d --bookmaker bet365 --from 2026-10-24T11:00:00Z --to 2026-10-24T15:00:00Z --outcome Arsenal

api.py is the helper from the Python oddsmatcher tutorial, unchanged. It sends the key in a header, asks for gzip, waits out a 429 and stops on any other error with the API's own code and message. afford() reads the quote=true price first and spends nothing until that price is within --max-tokens or you say yes at the prompt.

api.py · example

"""The small HTTP helper every example here shares. Each folder keeps an identical copy, so any
folder runs on its own with nothing but the Python standard library."""
from __future__ import annotations

import gzip
import json
import os
import sys
import time
from http.client import HTTPMessage
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen

API = "https://api.oddsrelay.io"


def get(path: str, params: dict, headers: dict | None = None) -> tuple[int, HTTPMessage, Any]:
    """GET one route with gzip: (status, headers, decoded JSON or None). Waits out 429s, exits on errors."""
    key = os.environ.get("ODDSRELAY_KEY", "").strip()  # a stray newline would put the key in a ValueError
    if not key:
        sys.exit("set ODDSRELAY_KEY to your API key")
    query = urlencode({k: v for k, v in params.items() if v is not None})
    for attempt in range(3):
        req = Request(f"{API}{path}" + (f"?{query}" if query else ""), headers={
            "Authorization": f"Bearer {key}", "Accept-Encoding": "gzip", **(headers or {})})
        try:
            with urlopen(req, timeout=30) as res:
                status, hdrs, body = res.status, res.headers, res.read()
        except HTTPError as err:  # urllib raises for 304 and for every 4xx and 5xx
            status, hdrs, body = err.code, err.headers, err.read()
        except URLError as err:
            sys.exit(f"could not reach {API}: {err.reason}")
        if body and hdrs.get("Content-Encoding") == "gzip":
            body = gzip.decompress(body)
        if status == 429:  # rate-limited: wait as long as the reply says, unless that was the last try
            if attempt < 2:
                time.sleep(int(hdrs.get("Retry-After", "1")))
            continue
        if status >= 400:
            error = json.loads(body).get("error", {}) if body[:1] == b"{" else {}
            sys.exit(f"HTTP {status} {error.get('code', '')}: {error.get('message', '')} "
                     f"(request id {error.get('request_id', '-')})")
        return status, hdrs, json.loads(body) if body else None
    sys.exit("still rate-limited after 3 tries")


def afford(quote: dict, max_tokens: int | None) -> bool:
    """True when a quoted price is fine: free, within --max-tokens, or confirmed at the prompt."""
    left = "unknown" if quote["remaining"] is None else quote["remaining"]
    print(f"quote: {quote['cost']} tokens ({left} left)", file=sys.stderr)
    if quote["cost"] == 0 or (max_tokens is not None and quote["cost"] <= max_tokens):
        return True
    if max_tokens is None and sys.stdin.isatty():
        print("fetch it? [y/N] ", end="", file=sys.stderr, flush=True)  # stderr: stdout may be redirected
        return input().strip().lower() == "y"
    return False

main.py · example

"""One bookmaker's price changes for one event over a window, with the recording gaps.

Run: ODDSRELAY_KEY=<your key> python3 main.py <event id> --bookmaker bet365 --from <time> --to <time>
"""
from __future__ import annotations

import argparse
import sys
from typing import Iterator

from api import afford, get


def pages(path: str, params: dict) -> Iterator[dict]:
    """Every page of the window, following next_cursor until it is null."""
    cursor = None
    while True:
        page = get(path, {**params, "cursor": cursor})[2]
        yield page
        cursor = page["meta"]["next_cursor"]
        if cursor is None:
            return


def report(rows: list[dict], outcome: str | None) -> int:
    """Print a line per row, in order, and return how many rows moved a price the code already held."""
    held: dict[tuple, float | None] = {}
    moves = 0
    for row in rows:
        if outcome and row["outcome"] != outcome:
            continue
        key = (row["outcome"], row.get("index", 0))
        name = f"{row['outcome']} ({key[1] + 1})" if key[1] else str(row["outcome"])
        if row.get("removed"):
            print(f"{row['timestamp']}  {name}  left the board")
            held[key] = None
            continue
        if held.get(key) is not None and row["price"] != held[key]:
            moves += 1
        print(f"{row['timestamp']}  {name}  {row['price']}")
        held[key] = row["price"]
    return moves


def main() -> None:
    ap = argparse.ArgumentParser(description="Print one bookmaker's price changes for one event.")
    ap.add_argument("event_id")
    ap.add_argument("--bookmaker", required=True, help="one bookmaker key, such as bet365")
    ap.add_argument("--from", dest="start", required=True, help="the window's start, ISO 8601")
    ap.add_argument("--to", dest="end", required=True, help="the window's end, at most 24 hours later")
    ap.add_argument("--market", default="h2h", help="one market key")
    ap.add_argument("--outcome", help="one outcome's name; every outcome when left out")
    ap.add_argument("--max-tokens", type=int, help="spend up to this many tokens a page without asking")
    args = ap.parse_args()
    path = f"/v2/historical/events/{args.event_id}/odds"
    params = {"from": args.start, "to": args.end, "bookmakers": args.bookmaker, "markets": args.market}
    if not afford(get(path, {**params, "quote": "true"})[2], args.max_tokens):
        sys.exit("not fetched: pass --max-tokens, or say yes at the prompt")
    rows, gaps = [], []
    for page in pages(path, params):
        rows += page["data"]
        gaps += page["meta"]["gaps"]
    print(f"{report(rows, args.outcome)} moves")
    for gap in gaps:
        print(f"recording gap at {gap['timestamp']}: {gap.get('why') or 'no reason given'}")


if __name__ == "__main__":
    main()

The first command in the Run it block, on Arsenal v Everton on 24 October 2026, from three hours before kick-off to one hour after, printed this once its quote was accepted:

Output · example

2026-10-24T10:59:59.543Z  Arsenal  1.42
2026-10-24T10:59:59.543Z  Draw  4.5
2026-10-24T10:59:59.543Z  Everton  8.0
2026-10-24T13:05:29.852Z  Draw  4.75
2026-10-24T13:05:29.852Z  Everton  7.0
2026-10-24T13:25:39.499Z  Arsenal  1.4
2026-10-24T13:25:39.499Z  Draw  5.0
2026-10-24T13:32:26.339Z  Arsenal  1.38
2026-10-24T13:32:26.339Z  Everton  7.5
2026-10-24T13:33:56.907Z  Arsenal  1.364
2026-10-24T13:33:56.907Z  Everton  8.0
2026-10-24T14:00:04.326Z  Arsenal  left the board
2026-10-24T14:00:04.326Z  Draw  left the board
2026-10-24T14:00:04.326Z  Everton  left the board
8 moves

The first three lines and the three left the board lines do not count as moves, which leaves 8. All three outcomes leave the board at kick-off, and this window had no recording gaps, so no gap line appears.

What the rows look like

This example comes from the API contract, with meta trimmed. It is dated the day before the archive starts, so read it for its shape. Its second row is a change:

GET /v2/historical/events/{id}/odds · example

{
  "meta": {
    "feed_type": "historical_event",
    "event_id": "or_evt_da71d5873e85",
    "ids": ["or_evt_da71d5873e85"],
    "from": "2026-10-16T19:00:00.000Z",
    "to": "2026-10-16T19:45:00.000Z",
    "count": 2,
    "next_cursor": null,
    "historical": true,
    "gaps": []
  },
  "data": [
    {
      "timestamp": "2026-10-16T18:59:58.120Z",
      "event_id": "or_evt_da71d5873e85",
      "bookmaker": "bet365",
      "market": "h2h",
      "outcome": "Arsenal",
      "price": 2.2,
      "last_update": "2026-10-16T18:59:51Z"
    },
    {
      "timestamp": "2026-10-16T19:12:03.441Z",
      "event_id": "or_evt_da71d5873e85",
      "bookmaker": "bet365",
      "market": "h2h",
      "outcome": "Arsenal",
      "price": 2.1,
      "last_update": "2026-10-16T19:12:01Z"
    }
  ]
}
  • timestamp is the board version the row took effect in, and last_update is when the bookmaker last moved that market.
  • removed: true marks an outcome leaving the board, so that row has no price to read. The script prints it as a line of its own and starts that outcome again if it comes back, without counting the return as a move.
  • index appears only on the second and later outcomes of one name in one market, so the script keeps them apart by name and index.
  • event_id on a row is the id it was recorded under, and meta.ids lists the event's id and every id merged with it so far. A reply's rows can carry more than one of them, so the script does not filter on the row's id.

pages() follows meta.next_cursor until it is null. A page stops at about 5,000 rows, and can run a few over because it never splits a board version. Each page with rows is charged on its own, while a page with none and the quote are free. The script's quote prices one page, so --max-tokens is a ceiling per page, and a yes at the prompt covers every page. Pricing has the rate for this route.

What should the code do with a gap?

Your code should report the gap and stop trusting the held price until the event's next row. meta.gaps lists each one on the page, with its timestamp and a why that can be null.

An empty stretch reads two ways. With no gap listed, a stretch with no rows normally means the price did not change. With a gap listed, nothing was recorded from its timestamp until recording resumed. A restart inside the window is listed too, with a why starting resume:, and without one the API gives no end time. A flat line through a gap would show a price nobody saw, so a chart should break its line at each gap, and a range should restart there.

A gap still open at from has no start entry and the window then has no opening rows, so treat an outcome with no row yet as unknown. The script prints gaps after the moves so you can check them against the moves by eye.

The errors worth handling

ReplyWhat it meansWhat to do
400 date_out_of_rangeAn instant outside the range the API accepts, or not yet released. The body gives earliest and latestAsk for a window inside those two
404 no_historyNo board was recorded at or before the start of the windowStart the window later, since earliest can sit before the first recorded day
404 unknown_eventNo event by that id in the windowCheck the id and the window
403 not_in_key_scopeThe key does not include the Historical odds productUse a key that does
403 exchange_history_unavailableThe call used exchanges or exchangesOnly=true. An exchange passed in bookmakers is a 400Ask for bookmakers only
402 insufficient_tokensThe page costs more than your balanceStop, because a retry costs the same

get() exits on each of these with the API's message, so swap its sys.exit for your own handling if the script runs in a loop. Historical odds carry bookmaker prices only, with no starting prices or results, so a past bookmaker price has no lay beside it here.

Where this script stops

The script stores nothing and does not work out what any price was worth. That arithmetic is yours. From here you can do one of three things.

  • Write each row to a table keyed on event, bookmaker, market, outcome, index (0 when absent) and timestamp, and give each gap a row of its own. Storing and versioning odds data covers the wider design.
  • Open the rows in a sheet. The CSV export post shows the csv module on a live board, and a DictWriter whose fieldnames are these rows' fields, index and removed included, takes them.
  • Take a whole day in bulk. For more than a handful of events, the closing lines and day files in the historical odds guide beat one call per fixture.

Pick a recent event, quote the call and run the script over the hours before kick-off. A chart or table that other people see, or history a business uses, runs on Enterprise.

Related posts

18+ · OddsRelay supplies odds data only and takes no bets. Please gamble responsibly.