Polymarket Arbitrage: Find Arbs Between Prediction Markets & Your Sportsbook

Polymarket Arbitrage - OddsPapi API Blog
How To Guides March 25, 2026

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

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.