NFL Prediction Market Liquidity: Depth Ladders From 7 Venues
Pull the NFL Week 1 opener from 169 bookmakers and rank them by margin. The winner is polymarket.us at 0.49%, roughly six times tighter than Pinnacle. Then read the ladder behind that price and find $0.00. Nothing. The best number on the board cannot be filled for a dollar.
This is the part of prediction-market data that a price feed alone will never tell you. Kalshi, Polymarket, Betfair Exchange, Novig, SX Bet and Duel all quote NFL now, and their prices are excellent. Their depth ranges from five figures to literally zero, and the ranking by depth is close to the reverse of the ranking by price.
This post measures it. Seven venues across all 16 Week 1 fixtures, then the same measurement run backwards over Super Bowl LX with free historical data, so you can see what a fully-loaded NFL prediction market actually looks like. Everything below runs on the free tier.
The finding in one table
Every venue below quotes a complete, active moneyline on all 16 NFL Week 1 fixtures. Measured 26 August 2026, about 14 days before the opener. “Thinnest side” means the worse of the two outcomes, because that is the side that caps an arbitrage or a hedge.
| Venue | Median margin | Thinnest side, top of book | Thinnest side, full ladder | Deepest fixture |
|---|---|---|---|---|
polymarket.us |
0.50% | $0.00 | $0.00 | $0.00 |
kalshi |
1.01% | $751.29 | $3,901.06 | $15,341.74 |
polymarket |
1.99% | $79.03 | $2,393.52 | $3,458.21 |
duel |
2.10% | not published | not published | not published |
betfair-ex |
2.76% | $2.33 | $2.33 | $199.45 |
novig.us |
2.76% | $231.06 | $624.44 | $1,341.00 |
sx.bet |
no active quote | $0.00 | $0.00 | $0.00 |
Read the first column and the third column together. polymarket.us is first on price and last on depth. kalshi is third on price and first on depth by a factor of six. sx.bet is on all 16 fixtures and has zero two-sided active quotes: both legs ship active: false at a price of zero.
For contrast, the sharp sportsbook on the same fixture: Pinnacle ranks 8th of 150 books at 3.04% margin, and it will take $2,717 on New England and $1,500 on Seattle. It is six times wider than polymarket.us and it is the only quote in the top eight that you can put real money into.
Where liquidity lives in the payload
OddsPapi exposes two separate depth fields, and they are not interchangeable.
limitsits on the price object. On a sportsbook it is the maximum stake the book accepts. Only Pinnacle and the exchanges populate it; the other 140-odd books on an NFL fixture returnnull.exchangeMetasits on the same price object and only appears on exchange-type venues. It carries abackand alayladder, each a list of price levels, best first. Each level has aprice, asize(the payout available) and alimit(the stake needed to take it).
The top of the back ladder equals the outcome’s headline price. So a venue can post a beautiful number with one rung holding three dollars, which is exactly what Polymarket does on the opener.
import requests
API_KEY = "YOUR_API_KEY"
BASE_URL = "https://api.oddspapi.io/v4"
# apiKey is a QUERY PARAMETER, not a header.
r = requests.get(f"{BASE_URL}/sports", params={"apiKey": API_KEY})
print(r.status_code) # 200
Step 1: find the NFL Week 1 board
American Football is sportId 14, and it carries NCAA, CFL and the European leagues alongside the NFL. The NFL itself is tournamentId 31. Pass it to /fixtures to cut the payload by about twenty times.
import requests, time
API_KEY = "YOUR_API_KEY"
BASE_URL = "https://api.oddspapi.io/v4"
def get(path, **params):
"""One retry on the documented 429 body, which carries retryMs."""
for _ in range(4):
params["apiKey"] = API_KEY
r = requests.get(f"{BASE_URL}/{path}", params=params)
if r.status_code == 429:
time.sleep(r.json()["error"].get("retryMs", 1500) / 1000 + 1.2)
continue
return r.status_code, r.json()
return r.status_code, {}
# NOTE: `to` is a midnight-UTC instant. Set it to the day AFTER your last day.
status, fixtures = get("fixtures", sportId=14, tournamentId=31,
**{"from": "2026-09-09", "to": "2026-09-16"})
for f in sorted(fixtures, key=lambda x: x["startTime"]):
print(f["fixtureId"], f["startTime"][:16],
f["participant2Name"], "@", f["participant1Name"])
That returns the full 16-fixture Week 1 slate. participant1 is the home team. Two schedule traps are worth knowing before you build anything on this: an empty window returns HTTP 404, not an empty array, so a season-long loop over the off-season will kill itself on raise_for_status(); and prime-time games shift to the next calendar day in UTC, so never group an NFL slate by its UTC date. The NFL schedule API guide covers the full-season pull.
Step 2: pull the board and pick out the exchanges
Call /odds with no bookmakers filter. The opener returns 169 bookmakers, 46,120 prices and 1,679 distinct market IDs in a single response, which is what 350+ bookmaker coverage looks like on one American football game.
Two filters matter before you count anything:
- Filter internal test feeds.
pinnacle+30andpinnacle+5quote the identical number topinnacle. Drop any slug matchingpinnacle+ordemoor you will count the sharp three times. - Filter on the price-level
activeflag. Only 39.0% of the opener’s 46,120 prices are active. Books post a line early and flag it suspended, and the wider the board the worse the ratio gets. Note thatactivelives on the price object atplayers["0"], not on the outcome.
OPENER = "id1400003171515752" # Seahawks v Patriots, 10 Sep 2026
MONEYLINE = "141" # NFL Winner (incl. overtime)
status, odds = get("odds", fixtureId=OPENER)
books = odds["bookmakerOdds"]
def moneyline(book):
"""Return {outcomeId: price_object} for active, two-sided quotes only."""
market = book.get("markets", {}).get(MONEYLINE)
if not market:
return None
legs = {}
for outcome_id, outcome in market["outcomes"].items():
price = outcome["players"].get("0") # game lines key on "0"
if price and price.get("active") and price.get("price"):
legs[outcome_id] = price
return legs if len(legs) == 2 else None
clean = {slug: legs for slug, book in books.items()
if not slug.startswith("pinnacle+") and slug != "demo"
and (legs := moneyline(book))}
print(len(books), "books ->", len(clean), "with a complete active moneyline")
# 169 books -> 150 with a complete active moneyline
Then dedupe. Several distinct slugs ship byte-identical prices while the catalogue reports cloneOf: null, so a raw book count always overstates. On this fixture 150 quotes collapse to 57 independent prices, a 62.0% collapse. Dedupe on the price tuple, per fixture, never off a static clone list.
tuples = {(legs["141"]["price"], legs["142"]["price"]) for legs in clean.values()}
print(len(clean), "quotes ->", len(tuples), "independent")
# 150 quotes -> 57 independent
Step 3: read the ladder, not the price
Now score each venue on depth. The rule that makes this useful: take the thinnest side, because a two-sided position is capped by whichever leg runs out of money first.
def depth(price_obj):
"""Top-of-book stake and full back-ladder stake for one outcome."""
meta = price_obj.get("exchangeMeta") or {}
ladder = meta.get("back") or []
if ladder:
top = ladder[0].get("limit") or 0.0
total = sum((level.get("limit") or 0.0) for level in ladder)
return top, total, len(ladder)
# Sportsbook, or an exchange with no ladder: fall back to `limit`.
lim = price_obj.get("limit")
return (lim, lim, 0) if lim is not None else (None, None, 0)
for slug in ("kalshi", "polymarket", "polymarket.us", "novig.us", "betfair-ex", "pinnacle"):
legs = clean.get(slug)
if not legs:
continue
margin = (sum(1 / leg["price"] for leg in legs.values()) - 1) * 100
sides = [depth(leg) for leg in legs.values()]
thin_top = min(s[0] for s in sides) if all(s[0] is not None for s in sides) else None
thin_all = min(s[1] for s in sides) if all(s[1] is not None for s in sides) else None
print(f"{slug:<15} margin {margin:5.2f}% top ${thin_top or 0:>10,.2f} ladder ${thin_all or 0:>10,.2f}")
On the opener that prints the inversion in full:
| Venue | Patriots | Seahawks | Margin | Back ladder, thinnest side |
|---|---|---|---|---|
polymarket.us |
1.600 | 2.632 | 0.49% | 1.6 @ $0 |
novig.us |
1.575 | 2.667 | 0.99% | 2.667 @ $315, 2.632 @ $190, 2.5 @ $687 |
kalshi |
1.587 | 2.632 | 1.01% | 2.632 @ $103, 2.564 @ $9,940, 2.5 @ $5,298 |
polymarket |
1.587 | 2.632 | 1.01% | 1.587 @ $3, 1.538 @ $1,560, 1.515 @ $1,790 |
betfair-ex |
1.520 | 2.700 | 2.83% | 2.70 @ $2.33 |
pinnacle |
1.552 | 2.590 | 3.04% | $1,500 flat (no ladder) |
Two things fall out of this table that a price-only feed hides.
The best price on the underdog is Betfair Exchange at 2.70, and it holds $2.33. If you run a line-shopping scan that sorts on price alone, that is the number it hands you, and it is worth about one coffee.
Kalshi’s headline rung is its worst rung. The top of its Seahawks ladder is $103 at 2.632, and the level underneath holds $9,940 at 2.564. Sweep two rungs and you get a slightly worse average price with a hundred times the size. Depth is not a single number, it is a curve, and the ladder is where the curve lives.
The screen worth copying
A single condition removes every unfillable quote in the table above without removing anything useful:
def usable(legs, max_margin=0.04, min_stake=500.0):
"""A prediction-market quote is only a benchmark if you can trade it."""
margin = sum(1 / leg["price"] for leg in legs.values()) - 1
if margin > max_margin:
return False
thin = min((depth(leg)[1] or 0.0) for leg in legs.values())
return thin >= min_stake
Run across all 16 Week 1 fixtures, that screen passes kalshi 16 times out of 16, polymarket 14 times and novig.us 11 times. It rejects polymarket.us, sx.bet and betfair-ex on every fixture, and it rejects duel on every fixture too, for a different reason: Duel publishes no depth field at all, so there is nothing to screen. Tune min_stake to your own size. The point is that the check exists at all: run it before you treat any exchange quote as consensus.
Step 4: the Super Bowl benchmark, for free
Fifteen days out, an NFL regular-season game is a thin market everywhere. To see what a loaded one looks like, run the same measurement over a game that has already been played. Historical odds are free on OddsPapi, and retention is deep: Super Bowl LX, played 8 February 2026, still returns its full price history six months later.
Two rules for exchange history:
polymarketandbetfair-exreject a multi-book call. Both demand exactly one bookmaker and exactly oneoutcomeId, or you get HTTP 400INVALID_PARAMETER. Put either slug in a three-book batch and the whole batch fails.exchangeMetaisnullon every historical snapshot. The ladder is a live-only structure. History gives youlimit, which is top-of-book stake capacity. That is one number instead of three, and it is enough to chart the ramp.
SUPER_BOWL = "id1400003167426020" # Patriots v Seahawks, 8 Feb 2026
def exchange_history(fixture_id, slug, outcome_id):
status, data = get("historical-odds", fixtureId=fixture_id,
bookmakers=slug, outcomeId=outcome_id)
if status != 200:
return []
market = data["bookmakers"][slug]["markets"]["141"]
# players["0"] is a LIST here, not a dict. Historical shape != live shape.
return market["outcomes"][str(outcome_id)]["players"]["0"]
snapshots = exchange_history(SUPER_BOWL, "polymarket", 142)
print(len(snapshots), "snapshots") # 67865
print(snapshots[0]["createdAt"], snapshots[0]["price"], snapshots[0]["limit"])
Note the shape change. On /odds, players["0"] is a dict holding one current price. On /historical-odds the top-level key is bookmakers rather than bookmakerOdds, and players["0"] is a list of snapshots. Code written against one endpoint will not read the other.
Snapshots also keep recording after kick-off, so filter on createdAt < startTime for a true pre-game series. The tail is not noise, it is settlement: Polymarket’s losing side ends at a price of 500 and the winner collapses toward 1.0, which is how you recover the result without a scores feed.
What a loaded NFL market looks like
Super Bowl LX, Polymarket, both sides, pre-kickoff snapshots only. Median top-of-book stake capacity per day:
| Day | Days to kick-off | Patriots, median | Seahawks, median |
|---|---|---|---|
| 1 Feb | 8.0 | $86,251 | $1,482,381 |
| 3 Feb | 6.0 | $30,172 | $1,689,255 |
| 5 Feb | 4.0 | $100,523 | $1,816,374 |
| 7 Feb | 2.0 | $808,185 | $1,931,302 |
| 8 Feb | 1.0 | $673,741 | $1,504,591 |
Across the whole pre-game window Polymarket’s Seahawks side held a median $1,749,227 at the top of the book and peaked at $2,648,958. The Patriots side ran a median $629,882 with a $1,413,653 peak. That is a market where a five-figure bet is a rounding error.
Now put the two events side by side on the thinnest side, which is the only fair comparison:
| Venue | Super Bowl LX, thinnest side | Week 1 opener, thinnest side | Ratio |
|---|---|---|---|
polymarket |
$629,882 median | $3.15 top rung | ~200,000x |
betfair-ex |
$15,983 median | $2.33 | ~6,900x |
kalshi |
no recorded history | $751 top, $3,901 ladder (median) | — |
Be careful with that comparison and I will be explicit about why: the Super Bowl figures are measured from 8 days out to kick-off, and the Week 1 figures are measured 14 days out. Liquidity is a function of time-to-event, so some of that gap closes on its own. The honest way to size the effect is to measure the ramp itself.
The ramp is about 7x, not 7,000x
Betfair Exchange is the better ramp subject here because its history starts earlier, 12.8 days before the Super Bowl. Median top-of-book stake capacity on the Patriots, by day:
| Day | Days to kick-off | Median | Peak |
|---|---|---|---|
| 27 Jan | 12.8 | $4,426 | $9,070 |
| 30 Jan | 10.0 | $15,200 | $26,250 |
| 2 Feb | 7.0 | $13,331 | $15,765 |
| 5 Feb | 4.0 | $27,923 | $50,011 |
| 8 Feb | 1.0 | $31,304 | $126,435 |
From 12.8 days out to game day the median grew 7.1x, and the peak grew 13.9x. Apply a generous 10x ramp to Betfair’s current Week 1 number and it reaches about $23. The Super Bowl figure at the same 12.8-day horizon was $4,426, roughly 1,900 times larger. The gap is not a timing artefact. A regular-season NFL game and a Super Bowl are different products on the same venue.
The useful consequence for anyone building on this: you cannot calibrate a depth threshold once and reuse it. A min_stake of $500 is trivially permissive at a Super Bowl and rejects most of the board in Week 1. Set it per event class, and re-measure.
Seven venues now, two at the Super Bowl
One more thing changed between February and September, and it is the reason this post is worth writing now. Running the same history call for each venue against Super Bowl LX:
| Venue | Super Bowl LX history | Week 1 2026, live |
|---|---|---|
polymarket |
121k snapshots | 16 of 16 fixtures |
betfair-ex |
40k snapshots | 16 of 16 |
kalshi |
HTTP 404, none | 16 of 16 |
polymarket.us |
HTTP 404, none | 16 of 16 |
novig.us |
HTTP 404, none | 16 of 16 |
sx.bet |
HTTP 404, none | 16 of 16 |
duel |
not checked | 16 of 16 |
The peer-to-peer side of an NFL board went from two venues to seven in seven months. Five of them have no NFL history at all before this season, which means any backtest that treats “the exchange price” as one continuous series will silently change definition partway through. Pin the venue list to the era you are testing.
Gotchas worth writing down
- Kalshi’s historical payload is enormous. One NFL fixture, filtered to
kalshialone, returned 259.59 MB. Polymarket’s Super Bowl history is 5–7 MB per outcome and Betfair’s is about 2 MB. Never loop an exchange across a slate; spot-check single fixtures. - Rate limits are per endpoint and return a real 429 with a
retryMsfield. Usetime.sleep(1.0)between/oddscalls and about 4.5 seconds between/historical-oddscalls. Do not parallelise: at any worker count, almost every request comes back 429. - A 429 body is valid JSON. Code that only checks for a
bookmakerOddskey will read a rate-limit response as “no coverage”. Check the status code first. limitisnullon nearly every sportsbook. On the opener only Pinnacle and the exchanges publish it. Null-check before any arithmetic.- Pinnacle’s
limitis a capped max win, not a capped stake. The identitylimit = max(base, base / (price - 1))recovers a per-market base figure, and that base is the book’s own confidence signal. - On exchanges the ladder identity is exact: per level,
limit = size × cents. If it does not hold, you are parsing a different exchange shape. - No NFL player props exist on this board yet. Checked 26 August across Week 1 and a preseason game one day from kick-off: zero player-keyed prices on any fixture. Prop menus fill in closer to kick-off, so probe rather than assume.
Old way vs OddsPapi
| Direct venue APIs | OddsPapi | |
|---|---|---|
| Venues for one NFL game | Seven separate integrations, seven auth schemes | One call, 169 bookmakers |
| Order-book depth | Different shape per venue | exchangeMeta.back / .lay, one shape |
| Odds format | Share prices, American, fractional — convert yourself | Decimal, American and fractional pre-converted |
| Sportsbook comparison | Not available | Pinnacle and 160+ books in the same payload |
| Historical depth | Paid, or nonexistent | Free tier, retained months after the game |
Where to go next
- Kalshi & Polymarket NFL odds — the coverage and margin side of the same two venues, including how far into the season each one quotes.
- Betting limits API — how much you can actually bet at the best price, across sportsbooks rather than exchanges.
- Real-time odds feed for market makers — exchange depth and spread capture as a pricing input.
- Polymarket API deep dive — Gamma vs CLOB, and the token IDs that drop straight into the order book.
- Kalshi API vs Polymarket API — the developer-level head-to-head on auth and endpoints.
- Betfair exchange odds without the red tape — exchange access without an application process.
- SX Bet API — the crypto exchange that is on every Week 1 fixture and quoting none of them.
- How many bookmakers does your backtest need? — the same “is this sample enough” question, applied to the closing line.
- NFL odds API guide — lines, spreads and totals from scratch.
- Line shopping in Python — best price across the full board, and why you screen it before you trust it.
Stop reading prices. Start reading ladders.
A prediction-market price with nothing behind it is a quote, not a market. The distinction costs nothing to check: the ladder is in the same payload as the price, on the free tier, on all 350+ bookmakers including Pinnacle, SBOBET, Kalshi, Polymarket and Betfair Exchange. Historical depth is free too, which is the only reason the Super Bowl comparison in this post was possible at all.
Get your free API key and run the depth screen on the Week 1 board yourself. It is about forty lines.