DEV Community

OddsRelay
OddsRelay

Posted on Originally published at oddsrelay.io AI-assisted

Back and lay prices paired in Python, with only the standard library

Call a JSON API from Python without requests, and urllib asks for an uncompressed reply. You never wrote that header. CPython's http.client adds Accept-Encoding: identity to every request unless you set the header yourself, and the standard board of the odds API used below answers such a request with a 400.

This tutorial fixes that in one small helper, then puts a short script on top of it. The script lines up each bookmaker's back price with the lowest exchange lay price for the same outcome and sorts the pairs. It needs Python 3.9 or later and installs nothing.

Two files in one folder

Save api.py and main.py side by side, put your key in the environment and run:

export ODDSRELAY_KEY="your API key"
python3 main.py --sports tennis
python3 main.py --board bog --commission 0.02 --top 10
Enter fullscreen mode Exit fullscreen mode

A helper for gzip, errors, retries and spending

api.py keeps four habits in one place:

  1. The key travels in an Authorization: Bearer header, never in the query string.
  2. Every request sends Accept-Encoding: gzip, and a gzipped reply is decompressed before json.loads sees it.
  3. A 429 waits as many seconds as Retry-After says, three tries in all.
  4. A metered call is priced before it is paid for. This API returns a request's cost in place of its data when you add quote=true, and the quote itself is free.
"""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
Enter fullscreen mode Exit fullscreen mode

Three details are easy to miss.

urlopen raises HTTPError for a 304 and for every 4xx and 5xx. The exception still carries the status, the headers and the body, so the helper reads all three from it and every status takes the same path afterwards.

The key is read with .strip(). Paste a key with a trailing newline and http.client raises a ValueError whose message quotes the header value, key included, straight into your terminal or your logs.

Any other error exits with the API's own error code, its message and the request id, the thing to quote when you ask why a call failed. The key never appears in anything the script prints.

afford() is the spending guard. It goes ahead when the quote is zero or within --max-tokens, stops when the quote is above that ceiling, asks at the terminal when you set none, and stops an unattended run before anything is spent. If the API you call has no way to price a request, delete afford() and the quote call with it.

Each back price meets the lowest lay

The reply nests events, then markets, then outcomes. Each outcome holds a back list of bookmaker offers and a lay list of exchange offers, and the lays arrive sorted by price. The first priced lay is therefore the lowest one on offer, good for as much as its available field says.

main.py sets every priced back offer against that lay, scores the pair and prints the best first. Leave out the docstrings, one comment and the blank lines, and it is under 50 lines.

"""An oddsmatcher in about 50 lines: each bookmaker back price on a matched board beside the
lowest exchange lay price, sorted by a rating this script calculates (the feed sends no rating).

Run: ODDSRELAY_KEY=<your key> python3 main.py --sports tennis
"""
from __future__ import annotations

import argparse
import sys
from typing import Iterator

from api import afford, get

FILTERS = ("region", "sports", "bookmakers", "exchanges", "markets")


def rating(back: float, lay: float, commission: float) -> float:
    """100 x back x (1 - commission) / (lay - commission): 100 means the two prices balance exactly."""
    return 100 * back * (1 - commission) / (lay - commission)


def label(event: dict) -> str:
    if event.get("home_team"):
        return f"{event['home_team']} v {event.get('away_team')}"
    return event.get("event_name") or event.get("venue") or event["event_id"]


def pairs(events: list[dict], commission: float) -> Iterator[tuple]:
    """(rating, event, market, outcome, back offer, lay offer) for every priced back offer."""
    for event in events:
        for market in event["markets"]:
            for outcome in market["outcomes"]:
                # Lays come in price order, so lays[0] is the lowest on offer.
                lays = [lay for lay in outcome.get("lay") or [] if lay.get("price")]
                if not lays or lays[0]["price"] <= commission:
                    continue
                lay = lays[0]
                for back in outcome["back"]:
                    if back.get("price"):
                        score = rating(back["price"], lay["price"], commission)
                        yield score, event, market, outcome, back, lay


def main() -> None:
    ap = argparse.ArgumentParser(description="Rate every back and lay pair on a matched board.")
    ap.add_argument("--board", choices=["standard", "2up", "bog"], default="standard")
    for name in FILTERS:
        ap.add_argument(f"--{name}", help="one region code" if name == "region" else "comma-separated keys")
    ap.add_argument("--commission", type=float, default=0.0, help="your exchange commission, as a fraction")
    ap.add_argument("--top", type=int, default=20, help="how many rows to print")
    ap.add_argument("--max-tokens", type=int, help="spend up to this many tokens without asking")
    args = ap.parse_args()
    if not 0 <= args.commission < 1:
        ap.error("--commission is a fraction from 0 to 1, such as 0.02")
    path, params = f"/v2/odds/{args.board}", {name: getattr(args, name) for name in FILTERS}
    if not afford(get(path, {**params, "quote": "true"})[2], args.max_tokens):
        sys.exit("not fetched: narrow the filters, or pass --max-tokens")
    rows = sorted(pairs(get(path, params)[2]["data"], args.commission), key=lambda row: row[0], reverse=True)
    for score, event, market, outcome, back, lay in rows[: args.top]:
        selection = outcome["name"] + (f" {outcome['point']}" if outcome.get("point") is not None else "")
        print(f"{score:6.1f}  {event['commence_time']}  {label(event)}  {market['key']}  {selection}"
              f"  back {back['bookmaker']} {back['price']}  lay {lay['exchange']} {lay['price']}"
              f" ({lay.get('available')} available)")


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

--board takes standard, 2up or bog (Best odds guaranteed), the boards where lay is a plain list. On the each-way and extra-place boards lay splits into win and place, which needs a different sum. Filter flags pass through to the API untouched.

One race, two lines

You can test the pairing without a network call by feeding the script a saved reply. This one is the Best odds guaranteed example from the API's OpenAPI file, wrapped in the data envelope, with the venue links left out:

{
  "data": [{
    "event_id": "or_evt_58ab94d71497",
    "sport_key": "horse_racing",
    "sport_title": "Horse Racing",
    "commence_time": "2026-09-17T12:30:00Z",
    "venue": "Ayr",
    "markets": [{
      "key": "win",
      "outcomes": [{
        "name": "Bassrah",
        "back": [
          { "bookmaker": "ladbrokes", "price": 4.0 },
          { "bookmaker": "betfred", "price": 4.0 }
        ],
        "lay": [
          { "exchange": "betfair_exchange", "price": 4.3, "available": 17.26 },
          { "exchange": "smarkets", "price": 4.4, "available": 75.93 }
        ]
      }]
    }]
  }]
}
Enter fullscreen mode Exit fullscreen mode

With --board bog --commission 0.02, the script printed:

  91.6  2026-09-17T12:30:00Z  Ayr  win  Bassrah  back ladbrokes 4.0  lay betfair_exchange 4.3 (17.26 available)
  91.6  2026-09-17T12:30:00Z  Ayr  win  Bassrah  back betfred 4.0  lay betfair_exchange 4.3 (17.26 available)
Enter fullscreen mode Exit fullscreen mode

Each line holds the score, the start time, the event, the market key and the selection, then the back offer and the lay offer, with the lay's available in brackets. A horse race has no home and away team, so label() falls back to the venue. Both bookmakers offer 4.0, both meet the same 4.3 lay, and the 4.4 lay behind it never reaches the output.

The score is one line of your own

rating = 100 * back * (1 - commission) / (lay - commission)
Enter fullscreen mode Exit fullscreen mode

The API sends prices and the liquidity at each lay, not a score. The score is 100 plus what a back stake of 100 nets once it is laid off at that price, the same whichever side wins, with the exchange's commission deducted when the lay side wins. A score of 100 means the back and lay prices cancel out. Under 100 the lay-off costs something, and over 100 the back price beats the lay with commission already paid.

For the race above, 4.0 against 4.3 gives 93.0 with no commission and 91.6 at 0.02. With commission at zero the formula is back divided by lay, times 100. Only your user knows their commission, so it is a flag rather than a constant, and the script refuses a value outside 0 to 1 before it makes a request.

Where to take it next

  • Poll without paying twice. Keep each reply's ETag and send it back as If-None-Match through the headers argument of get(). An unchanged reply then comes back as an empty 304, which the helper already returns with None for a body. On this API also add format=bare, because the meta times in the envelope change the ETag every time a new board is built. A bare reply is just the event list, with no ["data"] key to index.
  • Add stakes. The lay stake for a back stake is back price ร— stake รท (lay โˆ’ commission). Print it beside each line and flag any that would not fit inside available.
  • Reuse the helper. Apart from the base URL, the key's variable name and the shape of the error body, api.py knows nothing about odds. Point API at another JSON service that compresses its replies and answers 429 with Retry-After, delete afford() if there is nothing to quote, and keep the rest.

Written by the OddsRelay team with AI assistance, then edited and checked by a person. The API in the examples is ours. The helper works on any HTTP JSON API.

Top comments (0)