Pandas + Odds API: Turn a 59 MB Board into 79,503 Rows
One OddsPapi /v4/odds call on Chelsea v Tottenham returned 59 MB of JSON nested four levels deep: bookmaker, market, outcome, player. This post turns that board into a flat pandas DataFrame of 79,503 rows across 275 bookmakers, then runs five recipes on it: best price per outcome, a stale-feed filter, margin per book with duplicate feeds grouped, price spread per outcome, and an arb scanner with guards.
Every output block below comes from one run at 13:13 UTC on 22 October 2026, about 51 hours before kick-off at Stamford Bridge. To follow along, grab an OddsPapi API key and pick any soccer fixture ID. The structure is the same across sports. If you have never called the API before, start with your first odds API call in Python.
Why You Want a DataFrame, Not a Dict
Most odds-API tutorials hand you a Python dict and stop. That works when you check one bookmaker on one market. When you compare prices, compute fair odds or scan for arbs, you write the same nested loops again for every question.
Flatten once into a long table, one row per bookmaker, market and outcome, and each question becomes a groupby. The work is getting from the raw payload to that table fast, then leaving the dict alone.
| Question | With nested dicts | With pandas |
|---|---|---|
| Best price per outcome | Loop over books, compare by hand, track the max | df.groupby("outcome")["price"].idxmax() |
| Margin per bookmaker | Re-shape per book, sum inverse prices by hand | pivot_table plus one vector sum |
| Which books share one feed | Hash every book’s prices yourself | groupby on the price columns |
| Arbs across 275 books | Three nested loops | groupby("market_id") and a filter |
Step 1: The JSON-to-DataFrame Snippet
Pull the board, flatten it with one comprehension, parse the timestamps. Keep changedAt: Step 3 needs it.
import requests, pandas as pd
API_KEY = "YOUR_API_KEY"
odds = requests.get("https://api.oddspapi.io/v4/odds", params={
"apiKey": API_KEY,
"fixtureId": "id1000001772221336", # Chelsea v Tottenham, 24 Oct
}).json()
df = pd.DataFrame([
{"book": book, "market_id": int(mid), "outcome_id": int(oid),
"price": p["price"], "changed": p["changedAt"]}
for book, b in odds["bookmakerOdds"].items()
for mid, m in b["markets"].items()
for oid, o in m["outcomes"].items()
for p in [o["players"].get("0")] if p and p.get("active") and p["price"] > 0
])
df["changed"] = pd.to_datetime(df["changed"], utc=True)
Output:
>>> df.shape
(79503, 5)
>>> df.book.nunique()
275
>>> df.market_id.nunique()
593
>>> df.head()
book market_id outcome_id price changed
0 198bet 10264 10264 19.00 2026-10-15 17:18:16.133000+00:00
1 198bet 10172 10173 2.31 2026-10-22 11:16:11+00:00
2 198bet 10172 10172 1.60 2026-10-22 11:16:11+00:00
3 198bet 10602 10602 1.83 2026-10-22 11:04:07.687000+00:00
4 198bet 10602 10603 1.99 2026-10-22 11:04:07.687000+00:00
That is 79,503 active match-market prices from 275 books on 593 markets, one row each. The download took 1.5 seconds.
Three filters inside the comprehension
players.get("0")keeps match markets and skips player props. The 1X2, totals, handicaps, corners and bookings ship one price under the key"0". Props key each price by player ID instead, and on this board they are a large share of the payload: Pitfall 4 builds them into their own DataFrame.activedrops suspended prices. The raw board held 176,962 price rows, and 24,817 of them were inactive.price > 0guards yourmin()and division. No active row on this board had a zero price. The filter costs nothing, so keep it.
Step 2: Add Human-Readable Names
Market and outcome IDs are fine for maths and hard to read. The /v4/markets catalogue gives you the lookup tables, plus marketLength, the number of outcomes a market should have. The arb scanner in Step 6 uses it.
cat = requests.get("https://api.oddspapi.io/v4/markets",
params={"apiKey": API_KEY, "sportId": 10}).json()
mkt_name = {m["marketId"]: m["marketName"] for m in cat}
mkt_len = {m["marketId"]: m["marketLength"] for m in cat}
out_name = {(m["marketId"], o["outcomeId"]): o["outcomeName"]
for m in cat for o in m["outcomes"]}
df["market"] = df.market_id.map(mkt_name)
df["outcome"] = [out_name.get(k) for k in zip(df.market_id, df.outcome_id)]
With sportId=10 the catalogue returned 1,122 soccer market definitions, and every one of the 593 market IDs on the board found a name. Without sportId the same call returns 46,901 definitions across all sports, so pass it.
Step 3: Best Price Per Outcome, Then Cut the Stale Feeds
The first question a line shopper asks: which book pays the most on each outcome?
m101 = df[df.market_id == 101] # Full Time Result (1X2)
best = m101.loc[m101.groupby("outcome")["price"].idxmax()]
print(best[["outcome", "book", "price", "changed"]])
outcome book price changed
10313 1 bet365.bet.br 1.76 2026-10-16 20:53:55.273000+00:00
8622 2 bet3000 5.50 2026-10-22 07:45:41.184000+00:00
8623 X bet3000 4.50 2026-10-22 07:45:41.184000+00:00
Read the changed column before you act on that Chelsea price. The 1.76 last moved on 16 October, six days before the pull, and that book had changed no price on the fixture since then. A price can stay active: true after the book stops updating it, so give every best-price query two clocks:
- Book age: how long since the book’s newest price on this fixture changed. If the whole feed is quiet, drop the book.
- Price lag: how far one price trails that same book’s newest price. A market the book has stopped trading falls behind the rest of its board.
now = pd.Timestamp.now(tz="UTC")
def drop_stale(d, book_max_age="24h", price_lag_max="24h"):
newest = d.groupby("book")["changed"].transform("max")
fresh_book = (now - newest) <= pd.Timedelta(book_max_age)
fresh_price = (newest - d["changed"]) <= pd.Timedelta(price_lag_max)
return d[fresh_book & fresh_price]
live = drop_stale(df)
print(f"{len(df):,} rows -> {len(live):,} rows, "
f"{df.book.nunique()} books -> {live.book.nunique()} books")
m101 = live[live.market_id == 101]
best = m101.loc[m101.groupby("outcome")["price"].idxmax()]
print(best[["outcome", "book", "price"]])
79,503 rows -> 62,040 rows, 275 books -> 270 books
outcome book price
44225 1 kalshi 1.754
8622 2 bet3000 5.500
8623 X bet3000 4.500
Five books fail the book-age test. The rest of the cut comes from the price-lag test. Set both windows from the horizon: two days out, many soft books leave the 1X2 alone for hours while they trade props, so 24 hours is a fair window. On match day, tighten both to an hour. The Chelsea leg now goes to Kalshi at 1.754. This is the core of line shopping, and pandas does it in three lines.
Step 4: Margin Per Book, With Shared Feeds Grouped
The margin (vig) is the bookmaker’s built-in edge: sum the inverse decimal prices across a market’s outcomes, and the excess over 1.0 is the margin. Pivot the 1X2 so each book is one row, compute the margin, then group books that quote the exact same three prices.
wide = m101.pivot_table(index="book", columns="outcome", values="price").dropna()
wide["margin_pct"] = ((1 / wide[["1", "X", "2"]]).sum(axis=1) - 1) * 100
feeds = (wide.reset_index()
.groupby(["1", "X", "2", "margin_pct"])["book"]
.agg(["count", "first"])
.reset_index()
.sort_values("margin_pct"))
print(f"{len(wide)} books quote the 1X2, {len(feeds)} distinct price tuples")
print(feeds.head(8).to_string(index=False))
print("...")
print(feeds.tail(4).to_string(index=False))
201 books quote the 1X2, 88 distinct price tuples
1 X 2 margin_pct count first
1.750 4.500 4.700 0.641675 5 betfair-ex
1.754 4.348 4.762 1.011203 1 kalshi
1.743 4.444 4.706 1.124066 1 sx.bet
1.746 4.400 4.700 1.277637 1 prophetx
1.748 4.455 4.575 1.512851 1 1xbet
1.741 4.440 4.550 1.938798 1 4casters
1.724 4.348 4.762 2.003300 1 polymarket
1.739 4.348 4.651 2.004145 1 novig.us
...
1 X 2 margin_pct count first
1.60 4.00 4.25 11.029412 2 pokerstars
1.63 3.95 4.10 11.056393 1 pmu
1.62 3.90 4.10 11.759665 1 leovegas.it
1.61 3.75 3.80 15.094257 1 bwin.fr
Two findings sit in that table. First, the 201 fresh books quote only 88 distinct 1X2 prices. Five slugs (betfair-ex, fair999-ex, orbitx, piwi247-ex, sharpxch) carry the Betfair Exchange price to the third decimal. If you average across slugs to build a fair price, those five count five times. Group on the price tuple first, the way the consensus odds calculator does.
Second, the margin ladder runs from 0.64% to 15.09% on one match. The exchanges and prediction markets (Betfair, Kalshi, SX Bet, ProphetX, Polymarket, Novig) sit at the top because they take their cut as commission or trading fees, which the price does not show. For a sportsbook-only ranking, filter them out by slug. Pinnacle quoted the same market at 3.67%.
Step 5: Price Spread Per Outcome
Where do the books disagree most? Group on market ID and outcome, never on the market name: every handicap and total line shares a name, so a name group mixes a -1 line with a +2 line. Use the 10th and 90th percentiles rather than min and max. One wild row moves the max, and it cannot move the 90th percentile of 150 quotes.
key = ["market_id", "market", "outcome"]
spread = live.groupby(key)["price"].quantile([0.1, 0.9]).unstack()
spread.columns = ["p10", "p90"]
spread["count"] = live.groupby(key).size()
spread["spread_pct"] = (spread["p90"] / spread["p10"] - 1) * 100
deep = spread[spread["count"] >= 150].reset_index()
deep = deep[~deep.market.str.contains("Score")] # long-shot scorelines swamp the list
print(deep.sort_values("spread_pct", ascending=False).head(8).to_string(index=False))
market_id market outcome p10 p90 count spread_pct
101919 Half Time / Full Time 1/2 29.00 43.190 161 48.931034
101919 Half Time / Full Time 2/1 16.00 22.000 175 37.500000
101919 Half Time / Full Time 2/2 6.09 7.705 160 26.518883
10137 European Handicap 2 8.50 10.320 174 21.411765
102222 Over Under Team 2 First Half Over 6.70 8.000 152 19.402985
10930 Bookings - Over Under Full Time Under 2.10 2.500 155 19.047619
1016 Over Under Full Time Over 6.40 7.500 196 17.187500
10137 European Handicap X 5.75 6.725 166 16.956522
Correct-score rows top the unfiltered list because a 0:4 scoreline is a long shot that every book prices its own way. With them gone, Half Time / Full Time leads: 161 books quote Chelsea-at-half, Tottenham-at-full-time, and the 90th percentile pays 49% more than the 10th. For comparison, the Tottenham win in the 1X2 spread 11.0% across 239 fresh quotes. Wide spreads on deep markets are where a value betting scanner earns its keep.
Step 6: Arb Scanner With Guards
An arb exists when the inverse sum of the best prices across every outcome is under 1.0. The scanner below checks that each market carries all its outcomes (marketLength from Step 2) and that the best legs come from at least two books. A single book never pays you to back both sides, so a one-book “arb” means two of its prices are out of step.
def guard(d, band=1.2, min_books=5):
key = ["market_id", "outcome_id"]
median = d.groupby(key)["price"].transform("median")
books = d.groupby(key)["book"].transform("nunique")
return d[(books >= min_books) & (d["price"] <= median * band)]
def scan(d):
hits = []
for mid, sub in d.groupby("market_id"):
if sub.outcome_id.nunique() != mkt_len.get(mid):
continue # a leg is missing, the sum means nothing
legs = sub.loc[sub.groupby("outcome_id")["price"].idxmax()]
if legs.book.nunique() < 2:
continue # one book never pays you to bet both sides
invsum = (1 / legs["price"]).sum()
if invsum < 1:
hits.append({"market": mkt_name[mid], "margin_pct": round((1 - invsum) * 100, 2),
"legs": " / ".join(f"{b} {p}" for b, p in zip(legs.book, legs.price))})
return pd.DataFrame(hits).sort_values("margin_pct", ascending=False)
raw = scan(df)
safe = scan(guard(live))
safe = safe[safe.margin_pct < 5]
print(f"raw scan: {len(raw)} hits, guarded scan: {len(safe)} hits")
print(safe.head(6).to_string(index=False))
raw scan: 47 hits, guarded scan: 15 hits
market margin_pct legs
Corners - Over Under Team 2 1.88 pokerstars 2.1 / 20bet 1.98
Corners - Over Under Team 2 1.64 atg.se 1.53 / 20bet 3.03
Corners - Over Under Full Time 1.00 fanduel 2.0 / polymarket 2.041
Corners - Over Under Full Time 0.82 fanduel 2.62 / polymarket 1.639
First Half Result 0.78 polymarket 2.326 / polymarket 2.703 / bet3000 5.2
Over Under Full Time 0.59 polymarket 2.439 / 1xbet 1.712
What each guard removes
drop_stale(Step 3) removes legs whose book stopped trading the market. On this board, several of the widest raw hits had a leg that trailed its own book’s newest price by 40 to 66 hours.- The median band drops any price more than 20% above the median for that outcome. A fresh price far off the market usually means the book mapped an outcome to the wrong ID, and the band catches it without a per-book whitelist.
- The five-book floor keeps thin markets out. Two books on a corners line give you no median to check against.
- The 5% ceiling is a last net. On this board every guarded hit sat under 2%. Treat a bigger number as a data question first.
Treat the survivors as candidates. Re-poll the fixture before you stake, check each leg’s changed stamp again, and confirm that the book takes your stake size. The arbitrage betting bot tutorial adds stake splitting and alerting on top of this scan.
From One Fixture to a Full Round
To run the same recipes over a round, pull the fixture list, then build one frame per fixture. Filter /v4/fixtures on tournamentId: a bare sportId=10 window returns every soccer fixture in the world, and each /odds board on a top tournament runs to tens of megabytes.
from datetime import datetime, timedelta, timezone
import time
now = datetime.now(timezone.utc)
end = now + timedelta(days=4)
fixtures = requests.get("https://api.oddspapi.io/v4/fixtures", params={
"apiKey": API_KEY,
"sportId": 10,
"tournamentId": 17, # English Premier League
"from": now.strftime("%Y-%m-%dT%H:%M:%S"),
"to": end.strftime("%Y-%m-%dT%H:%M:%S"),
}).json()
frames = []
for f in fixtures:
if not f.get("hasOdds"):
continue
r = requests.get("https://api.oddspapi.io/v4/odds", params={
"apiKey": API_KEY, "fixtureId": f["fixtureId"]
})
if r.status_code != 200:
print(f["fixtureId"], r.status_code, r.text[:200])
time.sleep(1.0)
continue
o = r.json()
rows = [
{
"fixture_id": f["fixtureId"],
"match": f"{f['participant1Name']} v {f['participant2Name']}",
"book": book, "market_id": int(mid),
"outcome_id": int(oid), "price": p["price"],
}
for book, b in o.get("bookmakerOdds", {}).items()
for mid, m in b["markets"].items()
for oid, oc in m["outcomes"].items()
for p in [oc["players"].get("0")]
if p and p.get("active") and p["price"] > 0
]
frames.append(pd.DataFrame(rows))
time.sleep(1.0) # one call per second to the same endpoint
slate = pd.concat(frames, ignore_index=True)
print(f"Slate: {len(slate):,} rows across {slate.fixture_id.nunique()} fixtures")
print(f"{slate.memory_usage(deep=True).sum() / 1e6:.0f} MB in memory")
print(slate.groupby("match").book.nunique().sort_values())
Slate: 766,644 rows across 10 fixtures
174 MB in memory
match
Hull City v Brentford FC 272
Liverpool FC v Brighton & Hove Albion 273
Arsenal FC v Everton FC 274
Aston Villa v Manchester City 274
Crystal Palace v Newcastle United 275
Coventry City v Fulham FC 275
Sunderland AFC v Leeds United 275
Chelsea FC v Tottenham Hotspur 276
Manchester United v AFC Bournemouth 276
Ipswich Town v Nottingham Forest 277
The full Premier League round took 30 seconds and fits in 174 MB of RAM, with 272 to 277 books on every fixture. Keep the one-second sleep between /odds calls, and never fire them in parallel: concurrent calls to the same endpoint come back 429 (rate limited). Check status_code before you read a body, because a 429 body is valid JSON too.
Why OddsPapi Pairs Well With Pandas
| Capability | What it means for a pandas workflow |
|---|---|
| 350+ bookmakers in one response | No feed merging. One /v4/odds call put 275 books on this fixture into one nested dict, ready to flatten. |
| Free historical data | The same flatten works on /v4/historical-odds, with a list of snapshots per outcome in place of one price. Backtest without buying snapshots. |
| One ID scheme across books | Every book’s Full Time Result is market 101, so a groupby across 275 books needs no name matching. |
| Exchanges and prediction markets in the same payload | Betfair Exchange, Kalshi, Polymarket, ProphetX, Novig and SX Bet flatten into the same DataFrame as the sportsbooks. Exchange depth lives under exchangeMeta. |
| Native player props | Props ship on the same endpoint, keyed by player ID. Pitfall 4 flattens them. |
Common Pitfalls
1. Do not loop with iterrows()
Row-by-row Python is the slow path in pandas. Before you write for row in df.iterrows(), reach for groupby, transform, pivot_table or merge.
2. Do not trust the widest arb
The raw scan in Step 6 flagged 47 markets and the guarded scan kept 15. The guards removed the widest raw hits first, so run them on every scan.
3. Parse timestamps as UTC
Both changedAt on /odds and createdAt on /historical-odds are ISO strings in UTC. Parse with pd.to_datetime(..., utc=True) and compare against pd.Timestamp.now(tz="UTC"), never a naive local time.
4. Player props need their own DataFrame
The Step 1 snippet keeps only players["0"]. Props key each price by player ID and carry a playerName. Build a parallel frame:
props = pd.DataFrame([
{"book": book, "market_id": int(mid), "outcome_id": int(oid),
"player_id": pid, "player_name": p.get("playerName"), "price": p["price"]}
for book, b in odds["bookmakerOdds"].items()
for mid, m in b["markets"].items()
for oid, o in m["outcomes"].items()
for pid, p in o["players"].items()
if pid != "0" and p.get("active") and p["price"] > 0
])
On the same Chelsea v Tottenham board that frame held 72,635 rows: 178 books pricing 73 players across 52 prop markets. Names come as “Last, First”, for example “Colwill, Levi”. The player props API tutorial goes deeper.
5. Historical odds need a list-aware flattener
/v4/historical-odds takes up to three bookmakers per call, names its top-level key bookmakers (not bookmakerOdds), and ships players["0"] as a list of snapshots. Use .get("0", []): prop outcomes have no "0" key, and a hard ["0"] raises KeyError on the first one.
hist = requests.get("https://api.oddspapi.io/v4/historical-odds", params={
"apiKey": API_KEY,
"fixtureId": "id1000001772221318", # Everton v Chelsea, played 17 Oct
"bookmakers": "pinnacle,bet365,singbet", # max 3 per call
}).json()
hist_df = pd.DataFrame([
{"book": book, "market_id": int(mid), "outcome_id": int(oid),
"price": snap["price"], "ts": snap["createdAt"]}
for book, b in hist["bookmakers"].items()
for mid, m in b["markets"].items()
for oid, o in m["outcomes"].items()
for snap in o["players"].get("0", []) # props are keyed by player ID
if snap.get("active") and snap["price"] > 0
])
hist_df["ts"] = pd.to_datetime(hist_df["ts"], utc=True)
# Pinnacle's 1X2 close: the last active snapshot before kick-off
ko = pd.Timestamp("2026-10-17T11:30:00Z")
close = (hist_df[(hist_df.book == "pinnacle") & (hist_df.market_id == 101) & (hist_df.ts < ko)]
.sort_values("ts").groupby("outcome_id").last())
print(close)
book market_id price ts
outcome_id
101 pinnacle 101 3.00 2026-10-17 11:29:16.863000+00:00
102 pinnacle 101 3.67 2026-10-17 11:29:16.863000+00:00
103 pinnacle 101 2.37 2026-10-17 11:29:16.863000+00:00
That call returned 110,358 snapshot rows for the three books, back to 19 September. Pinnacle alone logged 1,220 snapshots on the 1X2, with 356 to 396 actual price changes per outcome. Count changes, not snapshots. For CSV export and a backtest loop, see Historical Odds CSV Export.
What to Build With This DataFrame
- Value betting scanner: compare each book’s price against a fair-odds benchmark and flag positive-EV prices.
- Consensus odds calculator: group duplicate feeds as in Step 4, then de-vig the consensus into a fair probability per outcome.
- Kelly criterion stake sizing: size the stake on each +EV price your scanner finds.
- Live odds dashboard: the same DataFrame in Streamlit with auto-refresh.
- Steam move detector: use
changedAtand the price history to catch sharp moves. For sub-second updates, swap polling for the WebSocket feed.
FAQ
Why use pandas instead of dicts and loops?
A long-format DataFrame turns each question (best price, margin, spread, arbs) into a groupby of a line or two. The dict version needs its own nested loops for every question.
How big does the odds DataFrame get?
One Premier League fixture flattened to about 79,500 match-market rows. A ten-fixture Premier League round came to 766,644 rows and 174 MB in memory, which a laptop handles. For many rounds, write each one to Parquet and load only what you need.
Why is players["0"] the convention?
Match markets (1X2, totals, handicaps, corners) are about the fixture, not one player, so OddsPapi files their price under the key "0". Player props key each price by the player’s ID. Filter on "0" to split match markets from props.
How do I keep stale prices out of the DataFrame?
Keep changedAt as a column. Drop a book when its newest price on the fixture is older than your window, and drop a price when it trails that book’s newest price by more than the same window. Two days out, 24 hours works; on match day, use an hour.
Can I store the DataFrame between runs?
Yes. Parquet keeps dtypes and loads faster than CSV: df.to_parquet("odds.parquet"). For long-running capture, write one file per fixture and poll: df.to_parquet(f"odds/{fixture_id}/{ts}.parquet").
Does this work with Polars?
Yes. The flatten is plain Python, so pass the same list of dicts to pl.DataFrame(...). The groupby and aggregation calls change name but keep the same shape.
Get Your API Key
Every recipe above ran on one call per fixture: 350+ bookmakers, exchanges and prediction markets in one payload, with free historical odds on the same key. Get your OddsPapi API key and flatten your first board.