# How to build an odds screen in Python

> Build an odds screen in Python on a raw odds board: the best back price for each selection across every venue, ties kept, and the name mapping it needs.

Published 13 October 2026

**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](https://oddsrelay.io/blog/anatomy-of-an-odds-api-response) walks through the raw response. [How odds comparison sites use odds data](https://oddsrelay.io/blog/how-odds-comparison-sites-use-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](https://oddsrelay.io/blog/odds-api-in-python). `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](https://github.com/oddsrelay/examples/tree/main/odds-screen) 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:

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

```sh
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](https://oddsrelay.io/products/raw-feed): 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:

```json
{
  "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:

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

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

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

```python
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](https://oddsrelay.io/products/matched-feed) 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](https://oddsrelay.io/blog/how-to-normalise-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](https://oddsrelay.io/docs/guides/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](https://oddsrelay.io/for/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](https://oddsrelay.io/docs/guides/walking-raw) has the details, and [polling an odds feed](https://oddsrelay.io/blog/polling-an-odds-feed-efficiently) covers the loop around it.
- **A package in place of the helper.** [The oddsrelay package on PyPI](https://pypi.org/project/oddsrelay/) 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](https://oddsrelay.io/docs/reference) lists every filter the raw board takes. A comparison page that other people use 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/build-an-odds-screen
