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 / 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_ in your shell. GET / lists the ids of events on the board now, and GET / lists a past day's events, so either one gives you an id to pass in.
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= price first and spends nothing until that price is within --max-tokens or you say yes at the prompt.
"""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"""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:
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:
{
"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"
}
]
}timestampis the board version the row took effect in, andlast_is when the bookmaker last moved that market.update removed: truemarks 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.indexappears only on the second and later outcomes of one name in one market, so the script keeps them apart by name and index.event_on a row is the id it was recorded under, andid meta.idslists 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_ 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
| Reply | What it means | What to do |
|---|---|---|
400 date_ | An instant outside the range the API accepts, or not yet released. The body gives earliest and latest | Ask for a window inside those two |
404 no_ | No board was recorded at or before the start of the window | Start the window later, since earliest can sit before the first recorded day |
404 unknown_ | No event by that id in the window | Check the id and the window |
403 not_ | The key does not include the Historical odds product | Use a key that does |
403 exchange_ | The call used exchanges or exchangesOnly=. An exchange passed in bookmakers is a 400 | Ask for bookmakers only |
402 insufficient_ | The page costs more than your balance | Stop, 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
csvmodule on a live board, and aDictWriterwhose fieldnames are these rows' fields,indexandremovedincluded, 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.
Print one event's moves
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
- Odds API in Python: an oddsmatcher in 50 linesPython's urllib asks for an uncompressed reply by default, and the standard board answers that with a 400.
- Export betting odds to CSV in PythonPut each back and lay offer on its own row and a spreadsheet can filter by side, venue and depth.
- How to store and version odds dataKeep your own odds history: an append-only table, the feed's timestamps and fixture keys you own.
18+ · OddsRelay supplies odds data only and takes no bets. Please gamble responsibly.