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 / 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=, 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.
"""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_ 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_ and lay_ instead, so the function reads price and falls back to back_. An outcome with neither is skipped, which drops an exchange's lay-only market, where back_ 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.
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.
{
"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:
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.
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:
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.
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_ 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.
--bookmakerstakes venue keys as/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.v2/ bookmakers - Every partner on the row. Raw's
requireBookmakerskeeps only the events where each named venue quotes the market. Add it toFILTERSand give it an option like the others. - A click-out per venue. With
includeLinks=, each venue carries atrue linkto 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_, 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, useupdate last_from keyedseen /, which is free.v2/ coverage - The whole board, then only what changed. Follow the cursor until it runs out and dedupe by event
id. After that, walk again withupdatedSinceset to the start of your previous walk, and a recentupdatedSincecosts less. That walk returns only the venues that changed, so merge each changed venue's markets into the events you already hold and runbest_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.prices() - 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 passmax_. It returns the page intokens .data, sobest_runs unchanged on each event inprices() 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
- How odds comparison sites use odds dataA comparison site is a best-price table. The hard part is proving every column is the same bet.
- How to normalise odds across bookmakersBookmakers name the same fixture and selection differently. Where matching goes wrong, and how to join them.
- 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.
18+ · OddsRelay supplies odds data only and takes no bets. Please gamble responsibly.