Polymarket Arbitrage: A 0.44% Median Edge on $4,023 of Depth
Polymarket arbitrage means you back one side of a market on Polymarket and the other side at a sportsbook, at prices whose implied probabilities add up to less than 100%. On 1 October 2026 at 13:10 UTC we pulled 28 full boards and found 2,938 such pairs. After six screens and a dedupe, 42 distinct arbs were left. The median edge was 0.44%, the best was 1.53%, and the median Polymarket rung behind them held $4,023.
The rest were placeholder prices, stale quotes, suspended books, off-market lines and skins that copy one feed. A scanner that skips those screens alerts on a 29% “arb” against a $1.15 order. This guide builds the scanner with the screens in place, in Python, against one API that carries Polymarket and 350+ sportsbooks in the same payload.
Where Polymarket Prices Today
We sampled 75 upcoming fixtures across seven sports on 1 October 2026 (kick-offs 1 to 3 October) and asked each one for bookmakers=polymarket. Polymarket priced 70 of them: UEFA Nations League soccer, Euroleague basketball, WTA Beijing tennis, MLB, the NFL and college football, NHL and UFC 332.
Polymarket prices the main markets tight and the wings wide. On the winner market its median overround was 1.0% in basketball, tennis, American football, ice hockey and MMA. Soccer full-time over/under ran at 0.98%. On NFL and college quarter-by-quarter handicaps and totals the median was 96% to 98%. Those quarter quotes are placeholder rungs with a few dollars behind them, and they produce the biggest fake arbs on the board.
| Factor | Polymarket | Sportsbook |
|---|---|---|
| Price source | Order book | Trading desk |
| Winner-market overround (1 Oct 2026) | 1.0% median | Pinnacle 3.86%, bet365 7.12% on Hapoel Tel-Aviv v Real Madrid |
| Depth on each price | limit in USD on every rung |
null on most books (Pinnacle and exchanges publish it) |
| Format in OddsPapi | Decimal odds (share price already converted) | Decimal, American, fractional |
| Separate US feed | polymarket.us, its own prices and depth |
n/a |
polymarket and polymarket.us are two feeds. They quote different prices and carry different depth. On the worked example below, polymarket.us showed 2.50 / 1.639 with a limit of 0.0 on both sides, while polymarket showed 2.439 / 1.667 with $4,325 on the home side. The scanner reads polymarket and drops any counter-leg whose limit is 0.
Old Way vs OddsPapi
| Task | Polymarket API + sportsbook scrapers | OddsPapi |
|---|---|---|
| Get both sides of the arb | Gamma and CLOB calls, plus one scraper per book | One /odds call: up to 290 books per fixture in our sample |
| Match the market | Map Polymarket slugs to each book’s own IDs | Same marketId on every book, same line, same settlement clock |
| Convert prices | Share price to decimal by hand | Decimal already |
| Read depth | Separate order-book call | limit and exchangeMeta ladder inline |
| Backtest | Collect your own history | Free /historical-odds, Polymarket included |
Step 1: Authenticate
The key goes in the query string as apiKey. The helper retries on a 429, reads the retryMs the server sends, and treats a 404 on /fixtures as an empty window.
import os
import time
import statistics
from collections import Counter, defaultdict
from datetime import datetime, timedelta, timezone
import requests
API_KEY = os.environ.get("ODDSPAPI_API_KEY", "YOUR_API_KEY")
BASE = "https://api.oddspapi.io/v4"
def get(path, **params):
"""GET with the key as a query parameter, a 429 retry and a status check."""
for _ in range(5):
r = requests.get(f"{BASE}{path}", params={"apiKey": API_KEY, **params}, timeout=120)
if r.status_code == 429:
time.sleep(r.json().get("error", {}).get("retryMs", 2000) / 1000 + 0.5)
continue
if r.status_code == 404 and path == "/fixtures":
return [] # empty window, not a failure
body = r.json()
if r.status_code != 200 or (isinstance(body, dict) and "error" in body):
raise RuntimeError(f"{path} {r.status_code}: {body}")
return body
raise RuntimeError(f"{path}: still rate-limited after 5 tries")
Step 2: Find Fixtures Polymarket Prices
A probe with bookmakers=polymarket returns a small payload and tells you if Polymarket is on the fixture. Pass tournamentId to keep the fixture list short. The free plan gives you 250 requests a month, and each probe counts, so probe one tournament at a time.
def polymarket_fixtures(sport_id, days=3, tournament_id=None):
"""Fixtures in the next `days` days that Polymarket prices (one small call each)."""
now = datetime.now(timezone.utc)
params = {"sportId": sport_id,
"from": now.strftime("%Y-%m-%d"),
"to": (now + timedelta(days=days + 1)).strftime("%Y-%m-%d")}
if tournament_id:
params["tournamentId"] = tournament_id
hits = []
for f in get("/fixtures", **params):
if not f.get("hasOdds") or f["startTime"] < now.isoformat():
continue
time.sleep(1.0)
probe = get("/odds", fixtureId=f["fixtureId"], bookmakers="polymarket")
if "polymarket" in (probe.get("bookmakerOdds") or {}):
hits.append(f)
return hits
Step 3: Flatten the Board
One /odds call without a bookmakers filter returns every book on the fixture. The NFL board for Cleveland Browns v Pittsburgh Steelers came back at 290 books and 73.9 MB. flatten() keeps the moneyline-style fields you need and records each book’s newest changedAt, which the freshness screen uses. It also drops the internal test feeds (pinnacle+5, pinnacle+30, demo) that ship in live payloads.
def ts(s):
return datetime.fromisoformat(s.replace("Z", "+00:00"))
def flatten(board):
"""{slug: {"live": bool, "newest": datetime, "m": {marketId: {outcomeId: price dict}}}}"""
books = {}
for slug, b in (board.get("bookmakerOdds") or {}).items():
if slug.startswith("pinnacle+") or slug == "demo":
continue # internal test feeds
markets, newest = {}, None
for mid, m in b.get("markets", {}).items():
for oid, o in m.get("outcomes", {}).items():
p = o.get("players", {}).get("0")
if not p:
continue
if p.get("changedAt"):
c = ts(p["changedAt"])
newest = c if newest is None or c > newest else newest
if p.get("price"):
markets.setdefault(mid, {})[oid] = p
books[slug] = {"live": b.get("bookmakerIsActive") and not b.get("suspended"),
"newest": newest, "m": markets}
return books
def overround(pair):
return sum(1 / p["price"] for p in pair.values()) - 1
Step 4: Screen Every Two-Way Market
The scanner compares Polymarket with each book on every two-way market that both price under the same marketId. The same ID means the same line and the same settlement clock, so you never pair a regulation-time price with an overtime-inclusive one. Then it applies six screens and a dedupe, and counts what survives each one.
def scan_board(board, min_depth=500, max_pm_margin=0.05, band=0.10, max_age_h=24):
"""Every Polymarket-vs-book two-way arb on one board, with a count per screen."""
books = flatten(board)
pm = books.get("polymarket")
funnel, found = Counter(), {}
if not pm:
return funnel, []
for mid, pm_pair in pm["m"].items():
if len(pm_pair) != 2:
continue # two-way markets only
# board median per outcome, on distinct active prices
median = {oid: statistics.median({b["m"][mid][oid]["price"] for b in books.values()
if oid in b["m"].get(mid, {}) and b["m"][mid][oid].get("active")}
or {0})
for oid in pm_pair}
for slug, book in books.items():
pair = book["m"].get(mid, {})
if slug == "polymarket" or set(pair) != set(pm_pair):
continue # same market ID = same line and same settlement clock
for pm_leg in pm_pair:
bk_leg = next(o for o in pm_pair if o != pm_leg)
p, q = pm_pair[pm_leg], pair[bk_leg]
edge = 1 - (1 / p["price"] + 1 / q["price"])
if edge <= 0:
continue
funnel["0 raw"] += 1
if not (pm["live"] and book["live"] and p.get("active") and q.get("active")):
continue
funnel["1 active, not suspended"] += 1
if any(not x.get("changedAt") or src["newest"] - ts(x["changedAt"]) > timedelta(hours=max_age_h)
for x, src in ((p, pm), (q, book))):
continue
funnel["2 fresh"] += 1
if overround(pair) <= 0 or overround(pm_pair) <= 0:
continue # one venue arbs itself: a broken feed, not an edge
funnel["3 no self-arb"] += 1
if overround(pm_pair) > max_pm_margin:
continue # Polymarket placeholder rung
funnel["4 PM margin <= 5%"] += 1
if any(abs(x["price"] / median[o] - 1) > band for x, o in ((p, pm_leg), (q, bk_leg))):
continue # off-market quote, usually a mapping or stale-line error
funnel["5 within 10% of board median"] += 1
if (p.get("limit") or 0) < min_depth or q.get("limit") == 0:
continue
funnel["6 depth"] += 1
key = (mid, pm_leg, p["price"], q["price"]) # dedupe on the price tuple
found.setdefault(key, {"marketId": mid, "pm_outcome": pm_leg, "pm_price": p["price"],
"pm_limit": p["limit"], "book_outcome": bk_leg,
"book_price": q["price"], "edge_pct": round(edge * 100, 2),
"books": []})["books"].append(slug)
funnel["7 distinct price tuples"] = len(found)
return funnel, sorted(found.values(), key=lambda a: -a["edge_pct"])
This is the funnel on the 28 boards we pulled at 13:10 UTC on 1 October 2026:
| Screen | Pairs left | What it removes |
|---|---|---|
| Raw pairs under 100% | 2,938 | n/a |
| Both prices active, neither venue suspended | 1,884 | Parked boards and pulled prices |
| Each price within 24 h of its book’s newest change | 1,064 | Quotes the book stopped updating |
| Neither venue arbs itself | 992 | Broken two-way pairs on one book |
| Polymarket overround 5% or less | 902 | Placeholder rungs |
| Both prices within 10% of the board median | 214 | Off-market quotes and line-mapping errors |
| $500+ on the Polymarket rung, counter-leg depth above 0 | 108 | Orders you cannot fill |
| Distinct price tuples | 42 | Skins that copy one feed |
The board-median screen does the most work. On the Hapoel Tel-Aviv v Real Madrid board, Duelbits offered Real Madrid at 1.96 against a board median of 1.633. That price was 20% above the median and had not moved since 29 September. Paired with Polymarket’s 2.439 on Hapoel, it shows an 8.0% “arb” that no desk would honour.
The dedupe step matters too. One 1.22% NCAAF handicap arb showed up eight times, once for each Betano skin quoting the same price. Count it once.
Step 5: Size the Stakes Against the Depth
The equal-payout split puts more money on the shorter price. stakes() also caps the total so the Polymarket leg fits inside its top rung.
def stakes(pm_price, book_price, total, pm_limit):
"""Equal-payout split. Caps the total so the Polymarket leg fits its top rung."""
s = 1 / pm_price + 1 / book_price
total = min(total, pm_limit * s * pm_price)
pm_stake = total / (pm_price * s)
return {"polymarket": round(pm_stake, 2), "book": round(total - pm_stake, 2),
"payout": round(pm_stake * pm_price, 2), "profit": round(pm_stake * pm_price - total, 2)}
Run the full scan on the NFL and the Euroleague:
totals = Counter()
for sport_id, tournament_id in [(14, 31), (11, 138)]: # NFL, Euroleague
for f in polymarket_fixtures(sport_id, days=3, tournament_id=tournament_id)[:2]:
time.sleep(1.0)
board = get("/odds", fixtureId=f["fixtureId"]) # one call = every book on the fixture
funnel, arbs = scan_board(board)
totals.update(funnel)
print(f"{f['participant1Name']} v {f['participant2Name']}: {len(arbs)} arbs")
for a in arbs[:3]:
print(f" +{a['edge_pct']}% market {a['marketId']} | polymarket {a['pm_price']} "
f"(${a['pm_limit']:,.0f}) vs {a['books'][0]} {a['book_price']}")
print(" ", stakes(a["pm_price"], a["book_price"], 1000, a["pm_limit"]))
for stage in sorted(totals):
print(stage, totals[stage])
Our run at 13:15 UTC on 1 October 2026 returned 14 distinct arbs across four fixtures. The best one:
| Hapoel Tel-Aviv v Real Madrid (Euroleague, market 111) | Hapoel | Real Madrid | Overround |
|---|---|---|---|
polymarket |
2.439 ($4,325) | 1.667 ($90.78) | 0.99% |
fanduel |
2.08 | 1.74 | 5.55% |
pinnacle |
2.34 | 1.636 | 3.86% |
kalshi |
2.50 | 1.639 | 1.01% |
| Board median (252 active books) | 2.24 | 1.633 | n/a |
Hapoel on Polymarket at 2.439 plus Real Madrid at FanDuel at 1.74 sums to 98.47%, a 1.53% edge. On $1,000 you stake $416.37 on Polymarket and $583.63 at FanDuel, and either result pays $1,015.52, a $15.52 profit before fees. FanDuel’s price had last changed at 20:53 UTC the night before. Check that the book still offers it before you place the Polymarket leg.
Step 6: Backtest on Polymarket History
Historical odds are free, and they include Polymarket. /historical-odds takes Polymarket only on its own and only with one outcomeId. A call with bookmakers=polymarket,pinnacle returns 400 INVALID_PARAMETER.
# Polymarket on /historical-odds: one bookmaker and exactly one outcomeId per call
hist = get("/historical-odds", fixtureId="id1400003171515810", # Bills v Chargers, 27 Sep 2026
bookmakers="polymarket", outcomeId="141")
snaps = hist["bookmakers"]["polymarket"]["markets"]["141"]["outcomes"]["141"]["players"]["0"]
pre = sorted((s for s in snaps if s["active"] and s["createdAt"] < "2026-09-27T17:00:00"),
key=lambda s: s["createdAt"])
close = pre[-1]
print(f"{len(snaps):,} snapshots, {len(pre):,} active before kick-off")
print(f"closing price {close['price']} with ${close['limit']:,.2f} on the top rung")
print("ladder in history:", close["exchangeMeta"])
On Buffalo Bills v Los Angeles Chargers (27 September 2026) this returns 20,046 snapshots, 14,222 of them active before kick-off, and a closing price of 1.333 with $27,819.28 on the top rung. The exchangeMeta ladder is null in history, so limit is the only depth field you can backtest on. Sleep 4.6 seconds after each historical call.
Use it to measure:
- How long a screened arb stays open before one leg moves
- Which counter-books keep a quote that sits off the board median, and for how long
- How Polymarket’s top-rung
limitgrows as kick-off gets closer
Polymarket Arbs vs Sportsbook-Only Arbs
| Factor | Polymarket vs sportsbook | Sportsbook vs sportsbook |
|---|---|---|
| Depth you can see | Polymarket limit on every rung |
Usually null |
| Settlement | Polymarket resolves the market on chain | The book grades the bet |
| Funding | USDC wallet on one leg | Fiat accounts on both legs |
| Edge on 1 Oct 2026 (screened) | Median 0.44%, best 1.53% | See the arb bot guide |
The scanner ignores trading fees, deposit costs and the time it takes to move money onto each venue. At a 0.44% median edge, check each venue’s fee schedule before you trade.
FAQ
Can you arbitrage Polymarket against a sportsbook?
Yes. On 1 October 2026 we screened 28 boards and found 42 distinct Polymarket-versus-sportsbook arbs across 16 fixtures. The median edge was 0.44% and the best was 1.53%, on a Euroleague winner market.
Why is the biggest arb on the board usually unfillable?
The biggest raw edges come from Polymarket placeholder rungs with a few dollars of depth, and from sportsbook quotes that sit far off the board median. A 29% raw edge on an NCAAF total had $1.15 behind the Polymarket side. Screen on Polymarket overround, distance from the board median and the limit field.
What is the difference between polymarket and polymarket.us?
They are two separate feeds in OddsPapi with their own prices and depth. On Hapoel Tel-Aviv v Real Madrid, polymarket quoted 2.439 with $4,325 on the rung and polymarket.us quoted 2.50. Read the limit field on each feed before you size a stake.
Do I need a Polymarket wallet to run the scanner?
No. The scanner reads Polymarket prices through OddsPapi. You need a funded Polymarket wallet only to trade the Polymarket leg.
How many API requests does one scan use?
One probe per fixture to find Polymarket, then one /odds call per fixture for the full board. The free plan gives 250 requests a month, so probe one tournament at a time.
Does OddsPapi keep Polymarket price history?
Yes. Call /historical-odds with bookmakers=polymarket and one outcomeId. One NFL winner outcome returned 20,046 snapshots, each with a price and a limit.
Related Guides
- Polymarket Negative Risk: Size a 42-Leg Basket at the Bid: the arb inside Polymarket itself, across a multi-outcome event.
- Polymarket API & Kalshi API: Python Guide to Prediction Market Data: start here if Polymarket data is new to you.
- How to Build an Arbitrage Betting Bot with Python: the sportsbook-only version of this scanner.
- Market Making on Polymarket: Use Sportsbook Odds as Your Edge: quote inside the spread instead of crossing it.
- WebSocket Odds API: Real-Time Betting Data from 350+ Bookmakers: push updates when a 0.44% edge closes in seconds.
Run the Screen on Tonight’s Board
Polymarket and 350+ sportsbooks come back in one /odds call, with depth on every Polymarket rung and free history to backtest the screens. When polling is too slow for a 0.44% edge, move to the WebSocket feed.
Get your API key and point the scanner at the next Euroleague round.