Line Shopping in Python: Best Odds Across 350+ Books (Free API)
Why Line Shopping Is the Sharpest Habit You’re Not Automating
Betfair Exchange pays 1.92 on Manchester City. FanDuel pays 1.83. BetRivers pays 1.83. Same game, same side, same moment. If you’re placing £100 on City and clicking the first sportsbook you see, you’re leaking 5%+ on every bet. Over a season, that’s the difference between a profitable bettor and a losing one.
Line shopping — scanning every book you have an account with and betting at the best available price — is the single highest-EV habit in sports betting. It’s also trivial to automate. This post builds a Python CLI tool that queries 350+ bookmakers via the OddsPapi API, finds the best price for every outcome on any fixture, and flags the pricing gaps worth acting on.
Tested on a live Premier League game (Manchester City vs Arsenal) with 131 bookmakers reporting odds. Code is under 60 lines.
What This Tool Is (and Isn’t)
| This post | Other posts |
|---|---|
| Find the best price per outcome across every book | Arb bot — scans for risk-free opportunities across the whole market |
| CLI-style utility, readable output, no GUI | Streamlit dashboard — live web UI with charts |
| Raw line shopping (pure price comparison) | Value scanner — finds +EV bets vs fair odds |
Building block: best_price() as a reusable function |
Steam detector — watches Pinnacle for line moves |
Think of line shopping as the foundation every other tool is built on. Get this right first.
Step 1: One API Call to Get Every Book’s Price
OddsPapi’s /odds endpoint returns the current price from every bookmaker covering a fixture in a single response. Auth is a query parameter:
import requests
API_KEY = "YOUR_API_KEY"
BASE_URL = "https://api.oddspapi.io/v4"
fixture_id = "id1000001761301173" # Man City vs Arsenal, Apr 19
r = requests.get(
f"{BASE_URL}/odds",
params={"apiKey": API_KEY, "fixtureId": fixture_id},
)
data = r.json()
books = data["bookmakerOdds"]
print(f"Books covering this game: {len(books)}")
# Books covering this game: 131
One call. 131 bookmakers. No scraping, no rate-limit juggling, no cookie farms. This is the entire premise.
Step 2: The best_price() Function
The bookmakerOdds response is deeply nested — bookmaker → market → outcome → players → price. Here’s a clean helper that walks the nesting and returns the best active price for any outcome:
def best_price(books, market_id, outcome_id):
"""Return (best_odds, best_book) across all active bookmakers."""
best_odds, best_book = 0, None
for slug, b in books.items():
outcome = (b.get("markets", {})
.get(str(market_id), {})
.get("outcomes", {})
.get(str(outcome_id), {}))
player = outcome.get("players", {}).get("0", {})
# Skip inactive outcomes — they ship with stale prices
if not player.get("active"):
continue
price = player.get("price")
if price and price > best_odds:
best_odds, best_book = price, slug
return best_odds, best_book
Three defensive details matter:
- Always check
active. Suspended markets still ship apricefield — it’s just stale. Filter them out or you’ll shop for lines that no one will honor. - Always use
.get()chains. Not every book carries every market. Expect missing keys. - Cast market IDs to strings. They come back as strings in the response (
"101", not101).
Step 3: Shop the Match Result (1X2)
Soccer 1X2 lives at market 101. Outcomes are 101=Home, 102=Draw, 103=Away:
OUTCOMES = {
101: "Home",
102: "Draw",
103: "Away",
}
print(f"\n{'Outcome':<8} {'Best':<7} {'Book':<18}")
for outcome_id, name in OUTCOMES.items():
price, book = best_price(books, 101, outcome_id)
print(f"{name:<8} {price:<7} {book}")
Live result for Man City vs Arsenal (131 books, filtered to active outcomes):
| Outcome | Best Price | Book | Average | Gap vs Avg |
|---|---|---|---|---|
| Home (City) | 2.20 | paddypower | 1.855 | +18.6% |
| Draw | 4.348 | kalshi | 3.586 | +21.2% |
| Away (Arsenal) | 4.60 | unibet.nl | 4.220 | +9.0% |
The worst price at each outcome was ~1.56 on City (novig.us), ~3.20 on the Draw (pokerstars.fr), and ~2.80 on Arsenal (pokerstars.fr). Betting Arsenal at 2.80 instead of 4.60 means giving up 64% of your potential profit on a winning ticket.
Step 4: The “Book Sum” Sanity Check
Add the implied probabilities of the best prices. A fair book with no margin would sum to 1.00. Real bookmakers build in a 4–8% margin, so book sums of 1.04–1.08 are normal. If your sum is below 1.00, you’ve spotted an arbitrage. If it’s far below 1.00 (like 0.90), one of your prices is probably bogus:
best_h, _ = best_price(books, 101, 101)
best_d, _ = best_price(books, 101, 102)
best_a, _ = best_price(books, 101, 103)
book_sum = 1/best_h + 1/best_d + 1/best_a
margin = (book_sum - 1) * 100
print(f"Best prices: {best_h} / {best_d} / {best_a}")
print(f"Book sum: {book_sum:.4f} ({margin:+.2f}% margin)")
# Output:
# Best prices: 2.2 / 4.348 / 4.6
# Book sum: 0.9019 (-9.81% margin)
A 9.8% arb is too good to be true. In practice, one of three things is happening:
- Enhanced/boosted price:
paddypower‘s 2.2 on Man City is likely a price-boost promo. Limits will be tiny, and the TOS often bans arbing these. - Regional quirk:
unibet.nlandkalshiare pricing for a different equilibrium than mainstream UK/US books. - Stale line: a book hasn’t updated since the line moved.
The tool does its job — it surfaces the outliers. You decide whether to trust them.
Step 5: Filter to Books You Actually Bet With
Line shopping is only useful if you have money at each book. Filter your scan to a whitelist:
MY_BOOKS = {
"pinnacle", "bet365", "draftkings", "fanduel",
"betmgm", "caesars", "betrivers", "betfair-ex",
}
def best_price_filtered(books, market_id, outcome_id, whitelist):
filtered = {slug: b for slug, b in books.items() if slug in whitelist}
return best_price(filtered, market_id, outcome_id)
for outcome_id, name in OUTCOMES.items():
price, book = best_price_filtered(books, 101, outcome_id, MY_BOOKS)
print(f"{name:<8} {price:<7} {book}")
Filtered to sharp-trio + major US books, the spread shrinks and the prices are all actually takeable:
| Outcome | Best | Book |
|---|---|---|
| Home (City) | 1.92 | betfair-ex |
| Draw | 3.80 | betfair-ex |
| Away (Arsenal) | 4.50 | betfair-ex |
Book sum = 1/1.92 + 1/3.80 + 1/4.50 = 1.006 → 0.6% margin. That’s tight — the exchange is pricing near-fair across all three sides. For this fixture, the exchange beats every US sportsbook on every outcome. Whether you take it depends on commission — Betfair charges 2–5% on winnings, which eats most of the edge vs Pinnacle (1.877 / 3.65 / 4.37).
Step 6: Expand to Over/Under Totals
The same function works for any two-way market. Over/Under 2.5 Goals in soccer lives at market 1010:
TOTALS_25 = {
1010: "Over 2.5",
1011: "Under 2.5",
}
print(f"\nOver/Under 2.5 Goals:")
for outcome_id, name in TOTALS_25.items():
price, book = best_price(books, 1010, outcome_id)
print(f" {name:<10} {price:<7} {book}")
# Output (114 books carry this market):
# Over 2.5 2.07 betfair-ex
# Under 2.5 1.92 betfair-ex
Betfair Exchange wins both sides here — exchange pricing tends to be tighter than sportsbook pricing because there’s no bookmaker margin, just a commission on winnings.
Step 7: Put It Together — A Real CLI Shopper
Wire everything into a single script you can run from the terminal:
import sys
import requests
API_KEY = "YOUR_API_KEY"
BASE = "https://api.oddspapi.io/v4"
# Market IDs for soccer. Look up more via /v4/markets?sportId=10
MARKETS = {
101: ("Match Result (1X2)", {101: "Home", 102: "Draw", 103: "Away"}),
1010: ("Over/Under 2.5 Goals", {1010: "Over 2.5", 1011: "Under 2.5"}),
104: ("Both Teams to Score", {104: "Yes", 105: "No"}),
}
def best_price(books, market_id, outcome_id):
best_odds, best_book = 0, None
for slug, b in books.items():
player = (b.get("markets", {})
.get(str(market_id), {})
.get("outcomes", {})
.get(str(outcome_id), {})
.get("players", {})
.get("0", {}))
if not player.get("active"):
continue
price = player.get("price")
if price and price > best_odds:
best_odds, best_book = price, slug
return best_odds, best_book
def shop(fixture_id):
r = requests.get(f"{BASE}/odds",
params={"apiKey": API_KEY, "fixtureId": fixture_id})
r.raise_for_status()
books = r.json().get("bookmakerOdds", {})
print(f"Books covering fixture: {len(books)}\n")
for market_id, (market_name, outcomes) in MARKETS.items():
print(f"=== {market_name} ===")
for outcome_id, name in outcomes.items():
price, book = best_price(books, market_id, outcome_id)
if price:
print(f" {name:<12} {price:<7} @ {book}")
print()
if __name__ == "__main__":
shop(sys.argv[1])
Run it:
$ python line_shopper.py id1000001761301173
Books covering fixture: 131
=== Match Result (1X2) ===
Home 2.2 @ paddypower
Draw 4.348 @ kalshi
Away 4.6 @ unibet.nl
=== Over/Under 2.5 Goals ===
Over 2.5 2.07 @ betfair-ex
Under 2.5 1.92 @ betfair-ex
=== Both Teams to Score ===
Yes 2.0 @ betfair-ex
No 2.0 @ betfair-ex
Sixty lines of Python and you now have a scanner that outperforms 95% of retail bettors’ workflow.
Step 8: Loop Across Every Fixture Today
Line shopping one game is a party trick. Line shopping every game on the slate, sorted by biggest pricing gap, is a business. Here’s the outer loop:
import time
from datetime import date, timedelta
today = date.today().isoformat()
tomorrow = (date.today() + timedelta(days=1)).isoformat()
# Get all soccer fixtures for the next 24 hours
r = requests.get(f"{BASE}/fixtures", params={
"apiKey": API_KEY, "sportId": 10,
"from": today, "to": tomorrow,
})
fixtures = [f for f in r.json() if f.get("hasOdds")]
gaps = []
for f in fixtures:
time.sleep(0.2) # ~0.88s cooldown per endpoint — be polite
r2 = requests.get(f"{BASE}/odds",
params={"apiKey": API_KEY, "fixtureId": f["fixtureId"]})
books = r2.json().get("bookmakerOdds", {})
if len(books) < 20:
continue
# Find the biggest gap on the favorite (outcome 101 or 103)
for side_oid in (101, 103):
best_odds, best_book = best_price(books, 101, side_oid)
worst_odds = 999
for slug, b in books.items():
p = (b.get("markets", {}).get("101", {})
.get("outcomes", {}).get(str(side_oid), {})
.get("players", {}).get("0", {}))
if p.get("active") and p.get("price") and p["price"] < worst_odds:
worst_odds = p["price"]
if worst_odds < 999:
gap_pct = (best_odds / worst_odds - 1) * 100
gaps.append((gap_pct, f, side_oid, best_odds, best_book))
# Biggest line-shopping edges on today's slate
gaps.sort(reverse=True)
for gap, f, oid, best, book in gaps[:10]:
teams = f"{f['participant1Name']} vs {f['participant2Name']}"
side = "Home" if oid == 101 else "Away"
print(f"{gap:6.1f}% {teams:<50} {side} {best} @ {book}")
A 30–50% gap on a major-league fixture is noise (limits, promos, regional quirks). A consistent 5–10% gap across games at a specific book tells you which book to be targeting routinely.
What Market IDs Do I Look Up?
Every sport has its own market catalog. Look them up instead of hardcoding:
r = requests.get(f"{BASE}/markets", params={"apiKey": API_KEY, "sportId": 10})
cat = [m for m in r.json() if m["sportId"] == 10]
print(f"Soccer markets: {len(cat)}")
# Build a name lookup
lookup = {m["marketId"]: m["marketName"] for m in cat if m["handicap"] == 0}
print(lookup[101]) # 'Full Time Result'
print(lookup[104]) # 'Both Teams To Score'
Common market IDs worth hardcoding:
| Sport | Market | Market ID | Outcome IDs |
|---|---|---|---|
| Soccer | Match Result (1X2) | 101 | 101/102/103 |
| Soccer | Over/Under 2.5 Goals | 1010 | 1010/1011 |
| Soccer | Both Teams To Score | 104 | 104/105 |
| NBA | Moneyline | 111 | 111/112 |
| NHL | Moneyline (incl. OT) | 151 | 151/152 |
| MLB | Moneyline | 131 | 131/132 |
Production Notes
- Rate limits: ~0.88s cooldown per endpoint on the free tier. Loop with
time.sleep(0.2)for safety. - WebSocket: polling 100 fixtures every minute is fine, but for true real-time shopping use the WebSocket feed — prices push as they change. See the WebSocket tutorial.
- Exchange odds: Betfair Exchange, Polymarket, SX Bet, ProphetX all return back-prices in the main
pricefield. If you want lay-side access, checkexchangeMeta. - Regional clones: some bookmakers appear as multiple slugs (
betano,betano.bet.br,betano.co.uk). The/v4/bookmakersendpoint has acloneOffield — dedupe if you’re treating “best price” as “best unique book.”
Stop Clicking Five Tabs. Start Shopping.
Line shopping isn’t glamorous. It’s not a model, it’s not a strategy, it’s not a system. It’s a 60-line script that runs in 2 seconds and guarantees you never leave money on the table. Every +EV bettor runs something like this. Most retail bettors don’t.
Grab a free OddsPapi API key and you’re running this scanner on today’s slate in the next ten minutes.
FAQ
What’s the difference between line shopping and arbitrage?
Line shopping is finding the single best price for one outcome. Arbitrage is finding a combination of prices across outcomes that guarantees profit regardless of result. Line shopping is a prerequisite for arbitrage — you can’t arb without knowing which book has the best price on each side.
How many bookmakers should I actually compare?
As many as you have accounts with. Realistic numbers: US sharp bettors typically hold 5–10 books (DraftKings, FanDuel, BetMGM, Caesars, BetRivers, Pinnacle via proxy, Betfair Exchange). European bettors often hold 15–20. OddsPapi covers all of them on one endpoint.
Why are some “best prices” obviously wrong?
Enhanced price boosts, stale lines on suspended markets, regional Unibet/Bet365/bwin variants with different pricing, and prediction-market venues (Kalshi, Polymarket) all show up in raw scans. Always filter to active: True outcomes and a whitelist of books you actually use.
Is historical line-shopping data free?
Yes. OddsPapi’s /historical-odds endpoint is on the free tier. Use it to backtest a line-shopping strategy — find every game in the last 12 months where your best-price combo beat the closing line by 3%+ and measure your edge.
Can I run this in real time?
Polling works for most use cases. For true tick-level shopping (arb scanners, latency-sensitive strategies), subscribe to the WebSocket feed and push every price change directly into your best-price table.