# Historical odds in Python: one event's moves

> Read historical odds in Python with the standard library: one bookmaker's price changes for an event, how many moves, and where the recording has gaps.

Published 2 November 2026

**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](https://oddsrelay.io/products/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](https://oddsrelay.io/docs/guides/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:

```sh
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](https://oddsrelay.io/blog/odds-api-in-python), 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:

```python
"""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:

```python
"""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:

```text
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:

```json
{
  "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](https://oddsrelay.io/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_out_of_range` | 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_history` | 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_event` | No event by that id in the window | Check the id and the window |
| 403 `not_in_key_scope` | The key does not include the Historical odds product | Use a key that does |
| 403 `exchange_history_unavailable` | The call used `exchanges` or `exchangesOnly=true`. An exchange passed in `bookmakers` is a 400 | Ask for bookmakers only |
| 402 `insufficient_tokens` | 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](https://oddsrelay.io/blog/how-to-store-and-version-odds-data) covers the wider design.
- Open the rows in a sheet. The [CSV export post](https://oddsrelay.io/blog/export-odds-to-csv) 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](https://oddsrelay.io/docs/guides/historical-odds) 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](https://oddsrelay.io/enterprise).

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

Source: https://oddsrelay.io/blog/historical-odds-in-python
