Polymarket Arbitrage: Find Arbs Between Prediction Markets & Your Sportsbook
Polymarket prices and sportsbook odds diverge by 3-8% on the same events. One uses crowd wisdom, the other uses sharp bookmakers. Here’s how to profit from the gap.
Prediction markets like Polymarket run on an order book — prices are set by retail traders bidding against each other. Sportsbooks use professional odds compilers backed by sharp money. These fundamentally different pricing models create structural price divergence, and where prices diverge, arbitrage opportunities appear.
The problem? Comparing Polymarket share prices against your local bookmaker’s odds manually is impossible at scale. You’d need accounts on both platforms, constant refreshing, and a spreadsheet to convert between formats.
The solution: OddsPapi aggregates Polymarket AND 350+ sportsbooks in a single API call, with all prices normalized to decimal odds. One request, both sides of the arb.
Why Polymarket and Sportsbook Prices Diverge
Before building the scanner, understand why these arbs exist:
| Factor | Polymarket | Traditional Sportsbooks |
|---|---|---|
| Pricing model | Order book (crowd sentiment) | Odds compiler + sharp flow |
| Liquidity | Thin on niche markets | Deep (institutional) |
| Market access | Crypto wallet (USDC) | Regulated accounts |
| Data format | Share prices $0–$1 | Decimal / American odds |
| Via OddsPapi | Normalized decimal odds | Normalized decimal odds |
| React to news | Slow (retail lag) | Fast (professional traders) |
The key insight: Polymarket reacts slower to information because its liquidity comes from retail traders, not professionals. After a lineup change, injury report, or weather update, sportsbooks adjust within seconds. Polymarket can take minutes or hours. That lag is your edge.
What You Need
- A free OddsPapi API key (covers both Polymarket and 350+ sportsbooks)
- Python 3.8+ with
requests - Accounts on Polymarket and at least one sportsbook to execute trades
pip install requests
Step 1: Authenticate & Discover Sports
OddsPapi uses a query parameter for authentication — no headers, no OAuth, no token refresh.
import requests
API_KEY = "YOUR_API_KEY"
BASE = "https://api.oddspapi.io/v4"
# Check which sports have Polymarket coverage
resp = requests.get(f"{BASE}/sports", params={"apiKey": API_KEY})
sports = resp.json()
for s in sports:
print(f"{s['sportId']:3d} | {s['sportName']}")
Polymarket currently covers MLB (sportId 13) and NBA (sportId 11) moneylines through OddsPapi, with the same fixtures available from 80-120+ traditional sportsbooks.
Step 2: Find Fixtures with Polymarket Odds
Not every fixture has Polymarket coverage. We need to fetch fixtures, then check which ones have the polymarket slug in the odds response.
from datetime import datetime, timedelta
def get_polymarket_fixtures(sport_id, days_ahead=7):
"""Find upcoming fixtures that have Polymarket odds."""
now = datetime.now()
params = {
"apiKey": API_KEY,
"sportId": sport_id,
"from": now.strftime("%Y-%m-%dT00:00:00Z"),
"to": (now + timedelta(days=days_ahead)).strftime("%Y-%m-%dT23:59:59Z"),
"limit": 100
}
resp = requests.get(f"{BASE}/fixtures", params=params)
fixtures = resp.json()
if isinstance(fixtures, dict) and "error" in fixtures:
print(f"Error: {fixtures['error']}")
return []
pm_fixtures = []
for f in fixtures:
fid = f["fixtureId"]
odds_resp = requests.get(
f"{BASE}/odds",
params={"apiKey": API_KEY, "fixtureId": fid}
)
odds = odds_resp.json()
bo = odds.get("bookmakerOdds", {})
if "polymarket" in bo:
f["bookmakerOdds"] = bo
f["num_books"] = len(bo)
pm_fixtures.append(f)
return pm_fixtures
# Scan MLB
fixtures = get_polymarket_fixtures(sport_id=13)
print(f"Found {len(fixtures)} MLB fixtures with Polymarket odds")
for f in fixtures:
print(f" {f['participant1Name']} vs {f['participant2Name']} "
f"({f['num_books']} bookmakers)")
Step 3: Compare Polymarket vs Sportsbook Prices
Now extract prices from both sides. OddsPapi normalizes everything to decimal odds, so Polymarket share prices and sportsbook odds are directly comparable.
def extract_moneyline(bookmaker_odds, market_id="131"):
"""Extract moneyline prices for all bookmakers on a fixture.
Market IDs: 131 = MLB Moneyline, 111 = NBA Moneyline
Outcome IDs: 131/111 = Home, 132/112 = Away
"""
prices = {}
outcome_ids = (
["131", "132"] if market_id == "131"
else ["111", "112"]
)
for slug, book_data in bookmaker_odds.items():
market = book_data.get("markets", {}).get(market_id, {})
outcomes = market.get("outcomes", {})
home = (outcomes.get(outcome_ids[0], {})
.get("players", {}).get("0", {}).get("price", 0))
away = (outcomes.get(outcome_ids[1], {})
.get("players", {}).get("0", {}).get("price", 0))
if home > 1 and away > 1:
prices[slug] = {"home": home, "away": away}
return prices
# Example: first fixture
fixture = fixtures[0]
bo = fixture["bookmakerOdds"]
prices = extract_moneyline(bo, market_id="131")
# Show Polymarket vs top sportsbooks
print(f"\n{fixture['participant1Name']} vs {fixture['participant2Name']}")
print(f"{'Bookmaker':20s} | {'Home':>8s} | {'Away':>8s} | {'Implied':>8s}")
print("-" * 55)
for slug in ["polymarket", "pinnacle", "bet365",
"draftkings", "fanduel", "betmgm"]:
if slug in prices:
p = prices[slug]
implied = round((1/p["home"] + 1/p["away"]) * 100, 1)
print(f"{slug:20s} | {p['home']:8.3f} | "
f"{p['away']:8.3f} | {implied:7.1f}%")
You’ll see something like this (real data from the OddsPapi API):
| Bookmaker | Home | Away | Overround |
|---|---|---|---|
| Polymarket | 2.174 | 1.852 | 100.0% |
| Pinnacle | 1.952 | 1.900 | 103.9% |
| Bet365 | 1.900 | 1.900 | 105.3% |
| DraftKings | 2.200 | 1.700 | 104.3% |
| BetMGM | 1.910 | 1.910 | 104.7% |
Notice: Polymarket has 0% overround (it’s an exchange), while sportsbooks bake in 3-5% vig. This structural difference is what creates cross-market arbs.
Step 4: Detect Arbitrage Opportunities
An arb exists when the combined implied probability of backing one side on Polymarket and the opposite side on a sportsbook is less than 100%.
def detect_polymarket_arbs(prices, match_name=""):
"""Find arbs between Polymarket and any other bookmaker."""
if "polymarket" not in prices:
return []
pm = prices["polymarket"]
arbs = []
for slug, book_prices in prices.items():
if slug == "polymarket":
continue
# Direction 1: Back Home on PM, Back Away on sportsbook
total_1 = (1 / pm["home"]) + (1 / book_prices["away"])
if total_1 < 1.0:
margin = round((1 - total_1) * 100, 2)
arbs.append({
"match": match_name,
"pm_side": "Home", "pm_price": pm["home"],
"book": slug,
"book_side": "Away",
"book_price": book_prices["away"],
"margin": margin
})
# Direction 2: Back Away on PM, Back Home on sportsbook
total_2 = (1 / pm["away"]) + (1 / book_prices["home"])
if total_2 < 1.0:
margin = round((1 - total_2) * 100, 2)
arbs.append({
"match": match_name,
"pm_side": "Away", "pm_price": pm["away"],
"book": slug,
"book_side": "Home",
"book_price": book_prices["home"],
"margin": margin
})
arbs.sort(key=lambda x: x["margin"], reverse=True)
return arbs
# Run on our fixture
match = (f"{fixture['participant1Name']} vs "
f"{fixture['participant2Name']}")
arbs = detect_polymarket_arbs(prices, match)
print(f"\nArbs found: {len(arbs)}")
for a in arbs[:10]:
print(f" +{a['margin']}% | PM({a['pm_side']}) "
f"@ {a['pm_price']:.3f} vs "
f"{a['book']}({a['book_side']}) "
f"@ {a['book_price']:.3f}")
On the Phillies vs Tigers fixture above, this scanner found 51 arb opportunities across different bookmakers, with margins up to +3.24%. The highest-margin arbs were against regional European and Brazilian books that were slow to adjust their lines.
Step 5: Build the Full Scanner
Now let's wrap everything into a scanner that checks multiple sports and fixtures automatically.
def scan_polymarket_arbs(sport_configs, days_ahead=3):
"""Scan multiple sports for Polymarket vs sportsbook arbs.
sport_configs: list of (sport_id, market_id, sport_name)
"""
all_arbs = []
for sport_id, market_id, sport_name in sport_configs:
print(f"\nScanning {sport_name}...")
fixtures = get_polymarket_fixtures(sport_id, days_ahead)
print(f" {len(fixtures)} fixtures with Polymarket odds")
for f in fixtures:
match = (f"{f['participant1Name']} vs "
f"{f['participant2Name']}")
prices = extract_moneyline(
f["bookmakerOdds"], market_id
)
arbs = detect_polymarket_arbs(prices, match)
if arbs:
best = arbs[0]
print(f" {match}: {len(arbs)} arbs "
f"(best: +{best['margin']}% "
f"via {best['book']})")
all_arbs.extend(arbs)
all_arbs.sort(key=lambda x: x["margin"], reverse=True)
return all_arbs
# Configure sports to scan
SPORTS = [
(13, "131", "MLB"), # Baseball moneyline
(11, "111", "Basketball"), # Basketball moneyline
]
# Run the scanner
arbs = scan_polymarket_arbs(SPORTS, days_ahead=5)
# Summary
print(f"\n{'='*60}")
print(f"TOTAL ARBS FOUND: {len(arbs)}")
print(f"{'='*60}")
if arbs:
print(f"\nTop opportunities:")
for a in arbs[:10]:
print(f" +{a['margin']:5.2f}% | {a['match']}")
print(f" PM({a['pm_side']}) @ {a['pm_price']:.3f}")
print(f" {a['book']}({a['book_side']}) "
f"@ {a['book_price']:.3f}\n")
Step 6: Calculate Optimal Stakes
Once you find an arb, you need to split your bankroll correctly to guarantee the same profit regardless of outcome.
def calculate_stakes(pm_price, book_price, total_stake=100):
"""Calculate optimal stake split for a 2-way arb."""
implied_total = (1 / pm_price) + (1 / book_price)
if implied_total >= 1.0:
return None # Not an arb
# Stake proportional to implied probability
stake_pm = (total_stake * (1 / pm_price)
/ implied_total)
stake_book = (total_stake * (1 / book_price)
/ implied_total)
# Profit is the same regardless of outcome
profit = (stake_pm * pm_price) - total_stake
return {
"stake_polymarket": round(stake_pm, 2),
"stake_sportsbook": round(stake_book, 2),
"total_stake": total_stake,
"guaranteed_profit": round(profit, 2),
"roi_pct": round((profit / total_stake) * 100, 2)
}
# Example: PM Home @ 2.174 vs Unibet Away @ 1.970
result = calculate_stakes(2.174, 1.970, total_stake=1000)
if result:
print(f"Stake on Polymarket: ${result['stake_polymarket']}")
print(f"Stake on Sportsbook: ${result['stake_sportsbook']}")
print(f"Total invested: ${result['total_stake']}")
print(f"Guaranteed profit: ${result['guaranteed_profit']}")
print(f"ROI: {result['roi_pct']}%")
For a $1,000 total stake on the 3.24% arb above, you'd put ~$466 on Polymarket (Home @ 2.174) and ~$534 on Unibet (Away @ 1.970), locking in ~$32 guaranteed profit regardless of outcome.
Step 7: Share Finds with Your Community
The real power of this scanner is running it on a schedule and pushing alerts to a Telegram group or Discord channel. Here's a minimal alert formatter:
def format_alert(arb, total_stake=1000):
"""Format an arb opportunity as a shareable alert."""
stakes = calculate_stakes(
arb["pm_price"], arb["book_price"], total_stake
)
if not stakes:
return None
return (
f"ARB FOUND: {arb['match']}\n"
f"Polymarket ({arb['pm_side']}) "
f"@ {arb['pm_price']:.3f}\n"
f"{arb['book']} ({arb['book_side']}) "
f"@ {arb['book_price']:.3f}\n"
f"Margin: +{arb['margin']}% | "
f"ROI: +${stakes['guaranteed_profit']} "
f"on ${total_stake}\n"
f"Stakes: PM ${stakes['stake_polymarket']} "
f"/ Book ${stakes['stake_sportsbook']}"
)
# Example output
if arbs:
alert = format_alert(arbs[0])
print(alert)
Run the scanner on a cron job, pipe alerts to your group chat, and you've got yourself a cross-market arb community. This is how Polymarket Telegram alpha groups operate — someone runs a scanner, everyone benefits from the edge.
Backtest Before Going Live
Before risking real money, use OddsPapi's free historical data to backtest which markets diverge most. Historical odds are included on the free tier — no upgrade needed.
# Fetch historical odds for a completed fixture
historical_resp = requests.get(
f"{BASE}/odds/history",
params={
"apiKey": API_KEY,
"fixtureId": "COMPLETED_FIXTURE_ID"
}
)
historical = historical_resp.json()
# Compare Polymarket prices vs closing lines
Key things to backtest:
- Which sports produce the most Polymarket arbs (MLB vs NBA vs specials)
- What time of day arbs appear most frequently (line movements after news)
- Which regional sportsbooks are slowest to adjust (your best counterparties)
- How long arbs stay open before closing (execution window)
How This Compares to Sportsbook-Only Arbs
| Factor | Polymarket Arbs | Sportsbook-Only Arbs |
|---|---|---|
| Frequency | Less frequent (fewer markets) | More frequent (thousands of markets) |
| Margin size | Larger (1-5% typical) | Smaller (0.5-2% typical) |
| Execution risk | Higher (blockchain settlement) | Lower (instant sportsbook bets) |
| Account limits | None on Polymarket | Sportsbooks limit winners |
| Coverage via OddsPapi | 350+ books + Polymarket | 350+ books |
The biggest advantage of Polymarket arbs: you can't get limited on Polymarket. Unlike sportsbooks that restrict sharp bettors, Polymarket is a decentralized exchange. Your account won't get flagged for winning too much.
FAQ
Can you arbitrage Polymarket?
Yes. Polymarket prices are set by an order book (crowd sentiment), while sportsbooks use professional odds compilers. These different pricing models create structural divergence that produces arbitrage opportunities, especially around news events when Polymarket is slower to react.
Is Polymarket arbitrage legal?
Arbitrage betting is legal in most jurisdictions. However, Polymarket has specific geographic restrictions (US residents face limitations under current CFTC regulations). Always check your local laws. This is not financial or legal advice.
How often do Polymarket arbs appear?
Polymarket arbs on major sports (MLB, NBA) appear multiple times daily, with margins typically between 1-5%. Niche markets with thinner liquidity diverge more frequently but have lower limits.
Do I need a Polymarket wallet to use this scanner?
No. The scanner uses OddsPapi to read Polymarket prices — no wallet needed. You only need a Polymarket wallet (with USDC) if you want to execute trades on the Polymarket side.
Which bookmakers produce the most arbs against Polymarket?
Regional books (European, Brazilian, Asian) that are slower to adjust their lines produce the highest margins. In our scan, books like Unibet, Paf, and Stake.bet.br showed 3%+ margins against Polymarket on MLB moneylines.
Related Guides
- Polymarket API & Kalshi API: Python Guide to Prediction Market Data — New to Polymarket data? Start here.
- How to Build an Arbitrage Betting Bot with Python — Want sportsbook-only arbs? See our full arb bot tutorial.
- Market Making on Polymarket: Use Sportsbook Odds as Your Edge — Prefer market making over arbing? Read this.
- The Brazil Betting Boom: Best Odds API for EstrelaBet, Betano & Brasileirão — Regional bookmaker coverage for Brazilian arbers.
Stop Comparing Prices Manually
Polymarket prices diverge from sportsbooks daily. Without a scanner, you're leaving money on the table. OddsPapi gives you Polymarket + 350 sportsbooks in one API call, with free historical data to backtest your strategy before going live.
Get your free API key and start scanning for cross-market arbs today.