BlogBuilding with odds data

How to build an odds screen in Python

An odds screen is a table with one row per selection, showing the best price on offer and every venue offering it. On OddsRelay's raw board, 58 lines of Python plus a small helper build one: walk each venue's outcomes, keep the highest back price, and keep every venue that ties.

We ran the script on a recorded Europa League event and on the live board. It works, and its output shows the job every screen adds: fold the several spellings one selection arrives under.

What does an odds screen need from an odds API?

An odds screen needs every venue's price for the same selection in one reply. GET /v2/odds/raw returns that: one entry per event, with each bookmaker and exchange that prices it listed under bookmakers. Each venue lists its markets under the feed's shared market keys, such as h2h, and keeps its own selection names.

The anatomy of an odds API response walks through the raw response. How odds comparison sites use odds data covers the rest of the job around the table, from click-outs to freshness.

The whole screen is one function

main.py holds the screen. It imports get() and afford() from api.py, the standard-library helper printed in full in our Python oddsmatcher tutorial. get() sends the key as a Bearer header and asks for gzip. The raw board answers a request that refuses gzip with a 400, and Python's urllib refuses it unless told otherwise. Both files sit in the odds-screen folder of our public examples repo, with tests that run on the contract's example replies.

Before anything is spent, main() prices the call with quote=true, which costs nothing, and afford() goes ahead when that price is zero or within --max-tokens. With no --max-tokens set, it asks you at the terminal first.

main.py · example

"""An odds screen: the best back price for each selection across every venue on the raw board,
and which venues offer it.

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

import argparse
import sys

from api import afford, get

FILTERS = ("region", "sports", "bookmakers", "markets", "limit", "cursor")


def best_prices(event: dict) -> dict[tuple[str, str], tuple[float, list[str]]]:
    """{(market, selection): (best back price, venues offering it)} for one raw event."""
    best: dict[tuple[str, str], tuple[float, list[str]]] = {}
    for venue in event["bookmakers"]:
        for market in venue["markets"]:
            for outcome in market["outcomes"]:
                # A bookmaker sends price; an exchange quoting both sides sends back_price.
                price = outcome.get("price") or outcome.get("back_price")
                if not price:
                    continue
                key = (market["key"], outcome["name"])
                top, venues = best.get(key, (0.0, []))
                if price > top:
                    best[key] = (price, [venue["key"]])
                elif price == top:
                    venues.append(venue["key"])
    return best


def main() -> None:
    ap = argparse.ArgumentParser(description="Best back price per selection on the raw board.")
    for name in ("region", "sports", "bookmakers"):
        ap.add_argument(f"--{name}", help="one region code" if name == "region" else "comma-separated keys")
    ap.add_argument("--markets", default="h2h", help="comma-separated market keys")
    ap.add_argument("--limit", type=int, help="events in the page")
    ap.add_argument("--cursor", help="the cursor printed by the run before")
    ap.add_argument("--max-tokens", type=int, help="spend up to this many tokens without asking")
    args = ap.parse_args()
    params = {name: getattr(args, name) for name in FILTERS}
    if not afford(get("/v2/odds/raw", {**params, "quote": "true"})[2], args.max_tokens):
        sys.exit("not fetched: narrow the filters, or pass --max-tokens")
    page = get("/v2/odds/raw", params)[2]
    for event in page["data"]:
        name = f"{event['home_team']} v {event['away_team']}" if event.get("home_team") else event["id"]
        print(f"\n{event['commence_time']}  {name}  ({event['sport_title']})")
        for (market, selection), (price, venues) in sorted(best_prices(event).items()):
            print(f"  {market:<14} {selection:<30} {price:>7}  {', '.join(venues)}")
    if page["meta"].get("next_cursor"):
        print(f"\nnext page: --cursor {page['meta']['next_cursor']}", file=sys.stderr)


if __name__ == "__main__":
    main()

best_prices() walks one event the way raw nests it: venues under bookmakers, each venue's markets, then each market's outcomes. A bookmaker sends price. A venue quoting a lay side sends back_price and lay_price instead, so the function reads price and falls back to back_price. An outcome with neither is skipped, which drops an exchange's lay-only market, where back_price is null.

Each row is keyed on the market key and the selection name. A higher price takes the row over, and an equal one adds its venue, so a tie lists every venue at that price. Ties are common, because bookmakers often quote the same price on a selection. Keeping them lets an affiliate table link whichever tied venue it works with.

main() then fetches one page and prints each event's rows, sorted by market and selection. When the board holds more events than the page, it prints the next cursor on stderr. A cursor expires, so pass it back with --cursor soon after the page before.

Run it · example

export ODDSRELAY_KEY="your API key"
python3 main.py --sports soccer --limit 20
python3 main.py --sports tennis --bookmakers 7bet,ladbrokes,betdaq

Python 3.9 or later runs it, with nothing to install. A run with no terminal, such as a scheduled job, cannot answer afford()'s question, so it prints not fetched and stops before spending unless you pass --max-tokens with your own ceiling. Raw pages run in kick-off order, so a --sports soccer page can open with esports football fixtures, which sit under soccer_ keys too.

One match came out as six rows

Here is a real event, recorded on 17 Sep 2026 and shown on the raw feed page: Celtic against Ferencváros in the Europa League, trimmed to 7bet, BetGoodwin and the exchange Betdaq. This copy keeps only the h2h markets, which is what the script's default --markets h2h asks for.

GET /v2/odds/raw, one event, h2h markets only · example

{
  "meta": { "feed_type": "raw", "region": "uk", "odds_format": "decimal", "processed_at": "2026-09-17T16:32:17.554Z" },
  "data": [{
    "id": "or_evt_ed3ca8739560",
    "sport_title": "UEFA Europa League",
    "commence_time": "2026-09-17T19:00:00Z",
    "home_team": "Celtic FC",
    "away_team": "Ferencvaros",
    "bookmakers": [
      {
        "key": "7bet",
        "last_update": "2026-09-17T16:31:51Z",
        "markets": [{
          "key": "h2h",
          "last_update": "2026-09-17T15:45:04Z",
          "outcomes": [
            { "name": "Celtic FC", "price": 1.71 },
            { "name": "Draw", "price": 4.0 },
            { "name": "Ferencvaros TC", "price": 4.0 }
          ]
        }]
      },
      {
        "key": "betgoodwin",
        "last_update": "2026-09-17T16:31:43Z",
        "markets": [{
          "key": "h2h",
          "last_update": "2026-09-17T13:39:55Z",
          "outcomes": [
            { "name": "Celtic", "price": 1.7 },
            { "name": "Draw", "price": 4.0 },
            { "name": "Ferencváros", "price": 4.5 }
          ]
        }]
      },
      {
        "key": "betdaq",
        "last_update": "2026-09-17T16:32:11Z",
        "markets": [{
          "key": "h2h",
          "last_update": "2026-09-17T16:32:11Z",
          "outcomes": [
            { "name": "Celtic", "price": 1.74 },
            { "name": "Draw", "price": 4.2 },
            { "name": "Ferencvárosi TC", "price": 5.1 }
          ]
        }]
      }
    ]
  }]
}

With that page in place of the network call, the script printed:

Output · example

2026-09-17T19:00:00Z  Celtic FC v Ferencvaros  (UEFA Europa League)
  h2h            Celtic                            1.74  betdaq
  h2h            Celtic FC                         1.71  7bet
  h2h            Draw                               4.2  betdaq
  h2h            Ferencvaros TC                     4.0  7bet
  h2h            Ferencváros                        4.5  betgoodwin
  h2h            Ferencvárosi TC                    5.1  betdaq

Three selections came out as six rows. Celtic arrives under two spellings and Ferencváros under three, a different one at each venue. Only the draw lines up, because every venue spells it the same way. A screen keyed on 7bet's spelling shows Ferencváros at 4.0, while BetGoodwin has 4.5 and Betdaq 5.1 on the same selection under other names.

Fold the names before you take the highest price

The fix is a mapping your code owns: every spelling you have checked, pointing to the one name your screen shows. In the script that is a dictionary and a one-line change to the row key.

Your code: one name per selection · example

NAMES = {"Celtic FC": "Celtic", "Ferencvaros TC": "Ferencváros", "Ferencvárosi TC": "Ferencváros"}

key = (market["key"], NAMES.get(outcome["name"], outcome["name"]))

Run on the same page, the script printed three rows:

Output, names folded · example

2026-09-17T19:00:00Z  Celtic FC v Ferencvaros  (UEFA Europa League)
  h2h            Celtic                            1.74  betdaq
  h2h            Draw                               4.2  betdaq
  h2h            Ferencváros                        5.1  betdaq

Every row now goes to Betdaq, which needs one more decision. Betdaq is an exchange, and the raw board applies no commission to any price, so its 5.1 is the price before the commission your user pays on a winning bet. Net it off before you compare, or give exchanges a column of their own.

Your code: an exchange back price after commission · example

net = 1 + (price - 1) * (1 - commission)  # commission as a fraction, such as 0.02

Netted at an illustrative commission of 0.05, Betdaq's 1.74 on Celtic comes to about 1.70, below 7bet's 1.71, and the row changes hands.

One level up, the raw board joins many venues' spellings of a fixture into one event, but a fixture often still arrives as two or more events, each holding a group of venues. Group events on commence_time and your folded team names before you take the best price. Inside an event, each venue keeps its own selection names, which is why the mapping above stays your code's job.

Where an exchange lays the market, the matched boards fold most of these spellings already. On the standard board an outcome normally carries one name and a back list of the bookmakers' prices for it, best first, so a screen row is the first back offer plus any offer at the same price. The matched feed page shows that shape.

A few fixtures, mostly in US sports, can still show a team under a second spelling there, such as a city name for an NFL team, so keep your mapping for those too. Normalising odds across bookmakers covers the mapping for every other market.

Fitting it to a comparison page

  • Only the venues you list. --bookmakers takes venue keys as /v2/bookmakers lists them. A narrower call can cost less, and the free quote shows the new price before you spend it. Filters and tokens sets out how a call is priced.
  • Every partner on the row. Raw's requireBookmakers keeps only the events where each named venue quotes the market. Add it to FILTERS and give it an option like the others.
  • A click-out per venue. With includeLinks=true, each venue carries a link to its own page for the event, left out where it has none. Affiliates and comparison sites shows where that fits on a page.
  • The age of each price. Every market carries last_update, the time that venue's market last changed. Carry it into the row, so your page can show it beside the price. For how recently each venue was read, use last_seen from keyed /v2/coverage, which is free.
  • The whole board, then only what changed. Follow the cursor until it runs out and dedupe by event id. After that, walk again with updatedSince set to the start of your previous walk, and a recent updatedSince costs less. That walk returns only the venues that changed, so merge each changed venue's markets into the events you already hold and run best_prices() on the merged event, never on the changed venues alone. Walking raw has the details, and polling an odds feed covers the loop around it.
  • A package in place of the helper. The oddsrelay package on PyPI has a raw() method that takes the same filters and quotes first when you pass max_tokens. It returns the page in .data, so best_prices() runs unchanged on each event in page.data["data"].

Point the screen at the venues you list

Run it with --bookmakers set to the venues your page lists, add each new spelling to your mapping as it turns up, and check a few rows against the venues' own pages before a market goes live. The API reference lists every filter the raw board takes. A comparison page that other people use runs on Enterprise.

Related posts

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