Scraping Sportsbook Odds for Fun and Profit: A Guide to Betting Arbitrage
By Nicholas St. Germain —
Sports betting has exploded in the US since the Supreme Court struck down PASPA in 2018. With dozens of legal sportsbooks competing for bettors, each platform prices its odds slightly differently - and those differences create an opportunity that traders have exploited in financial markets for decades: arbitrage.
Arbitrage ("arb") betting means placing bets on all possible outcomes of an event across different sportsbooks so that you lock in a guaranteed profit regardless of the result. No prediction models, no gut feelings, no gambling. Pure math.
The catch? These opportunities exist for seconds to minutes before the books adjust. Finding them manually is nearly impossible. You need to scrape odds from multiple platforms in real time, calculate the arb, and act before the line moves. This guide covers how that pipeline works end to end - the real API endpoints, the bot protection you'll face, the math, and the infrastructure that makes it viable at speed.
How Arb Betting Actually Works
Arbitrage happens when two or more sportsbooks disagree on the odds for the same event enough that you can bet both sides and guarantee a profit. The core formula is simple: if the sum of the implied probabilities from the best available odds on each outcome is less than 100%, a guaranteed profit exists.
Simple example: An NBA game between the Lakers and Celtics.
- DraftKings has Lakers moneyline at +150 (implied probability: 40%)
- FanDuel has Celtics moneyline at +120 (implied probability: 45.45%)
The combined implied probability is 40% + 45.45% = 85.45%. Since that's below 100%, an arb exists. You can bet both sides with the right stake ratio and profit no matter who wins.
The formula for your stake on each side:
Stake_A = (Total_Investment × Implied_Prob_A) / Total_Implied_Prob
Stake_B = (Total_Investment × Implied_Prob_B) / Total_Implied_Prob
With a $1,000 total investment:
Stake on Lakers = ($1,000 × 0.4000) / 0.8545 = $468.11
Stake on Celtics = ($1,000 × 0.4545) / 0.8545 = $531.89
If Lakers win: $468.11 × 2.50 = $1,170.28 → Profit: $170.28
If Celtics win: $531.89 × 2.20 = $1,170.16 → Profit: $170.16
That's a guaranteed ~17% return. In practice, 98% of real arb opportunities return less than 1.2%, with typical margins in the 1-5% range. But they're risk-free - and when you're processing thousands of events per day, they compound.
Middles and +EV Plays
Arbs aren't the only profitable pattern. Two related strategies use the same scraping infrastructure:
Middles - When two sportsbooks disagree on a spread enough that a result can land between them, both bets win. For example, if Book A has Team X -3.5 and Book B has Team X +4.5, and Team X wins by exactly 4, you cash both tickets. NFL key numbers (3 and 7) make middles particularly valuable because final margins cluster around those numbers.
Positive Expected Value (+EV) - Instead of guaranteed profit, you identify odds at "soft" books (DraftKings, FanDuel) that are better than the no-vig "true" probability from a sharp book (Pinnacle). Over hundreds of bets, the math works in your favor:
# Remove vig from sharp book (Pinnacle) to get "true" probability
sharp_home_odds = 1.90 # Pinnacle home decimal odds
sharp_away_odds = 2.00 # Pinnacle away decimal odds
total_implied = (1/sharp_home_odds) + (1/sharp_away_odds)
true_prob_home = (1/sharp_home_odds) / total_implied # no-vig probability
# Compare soft book
soft_book_odds = 2.15 # FanDuel home decimal odds
ev = (true_prob_home * (soft_book_odds - 1)) - ((1 - true_prob_home) * 1)
# Positive ev = profitable long-term play
All three strategies - arbs, middles, and +EV - require the same thing: real-time odds from multiple sportsbooks.
What You're Actually Hitting
Not all platforms are created equal. Here's what you're actually dealing with at the API level.
DraftKings
The largest US sportsbook by market share. DraftKings' frontend is a React SPA that fetches odds data from internal JSON APIs. These endpoints are unauthenticated for read access - no API key or login required - but they are protected by bot detection at the network level.
The actual API endpoint:
https://sportsbook.draftkings.com//sites/US-SB/api/v5/eventgroups/{eventGroupId}?format=json
Note the double slash (//sites/...) - this is intentional and required. DraftKings also operates state-specific subdomains:
https://sportsbook-us-nj.draftkings.com//sites/US-NJ-SB/api/v5/eventgroups/{eventGroupId}?format=json
https://sportsbook-us-il.draftkings.com//sites/US-IL-SB/api/v5/eventgroups/{eventGroupId}?format=json
Event Group IDs (these are the sport/league identifiers):
| Sport | Event Group ID |
|---|---|
| NFL | 88808 |
| NBA | 42648 |
| NHL | 42133 |
| MLB | 84240 |
| College Football | 87637 |
For player props and deeper markets, DraftKings nests under categories and subcategories:
https://sportsbook.draftkings.com/sites/US-SB/api/v5/eventgroups/{eventGroupId}/categories/{categoryId}
https://sportsbook.draftkings.com/sites/US-SB/api/v5/eventgroups/{eventGroupId}/categories/{categoryId}/subcategories/{subcategoryId}
The actual JSON response structure from the v5 endpoint:
{
"eventGroup": {
"events": [
{
"eventId": "12345678",
"name": "Boston Bruins @ New York Rangers",
"startDate": "2025-02-10T00:00:00Z",
"teamName1": "Boston Bruins",
"teamName2": "New York Rangers"
}
],
"offerCategories": [
{
"name": "Game Lines",
"offerSubcategoryDescriptors": [
{
"name": "Game",
"offerSubcategory": {
"offers": [
[
{
"label": "Puck Line",
"outcomes": [
{
"label": "Boston Bruins",
"oddsAmerican": "+145",
"oddsDecimal": 2.45,
"oddsFractional": "29/20",
"line": 1.5,
"participant": "Boston Bruins"
},
{
"label": "New York Rangers",
"oddsAmerican": "-175",
"oddsDecimal": 1.571,
"line": -1.5,
"participant": "New York Rangers"
}
]
},
{
"label": "Total",
"outcomes": [
{ "label": "Over", "oddsAmerican": "-110", "oddsDecimal": 1.909, "line": 5.5 },
{ "label": "Under", "oddsAmerican": "-110", "oddsDecimal": 1.909, "line": 5.5 }
]
},
{
"label": "Moneyline",
"outcomes": [
{ "label": "Boston Bruins", "oddsAmerican": "+130", "oddsDecimal": 2.30 },
{ "label": "New York Rangers", "oddsAmerican": "-155", "oddsDecimal": 1.645 }
]
}
]
]
}
}
]
}
]
}
}
The key thing to understand: offers is an array of arrays. The outer array is indexed by game (in the same order as events), and each game contains an array of markets (spread, total, moneyline). You traverse it like this:
offers = data['eventGroup']['offerCategories'][0]['offerSubcategoryDescriptors'][0]['offerSubcategory']['offers']
for game_offers in offers:
for market in game_offers:
market_name = market['label'] # "Moneyline", "Puck Line", "Total"
for outcome in market['outcomes']:
team = outcome['label']
odds = outcome.get('oddsAmerican', 'N/A')
line = outcome.get('line', None)
Live odds via WebSocket: DraftKings uses Pusher for real-time updates:
wss://ws-draftkingseu.pusher.com/app/490c3809b82ef97880f2?protocol=7&client=js&version=7.3.0&flash=false
This delivers sub-second odds updates without polling, but requires maintaining a persistent WebSocket connection.
Bot protection: DraftKings uses Akamai Bot Manager. Akamai's detection works on multiple layers:
TLS/JA3 fingerprinting - During the TLS handshake, clients exchange supported cipher suites, extensions, and protocol versions. Each HTTP library has a distinct fingerprint. Python's
requests(which uses OpenSSL) has a completely different JA3 hash than Chrome (which uses BoringSSL). Akamai maintains a database of known browser fingerprints and blocks anything that doesn't match.The
_abckcookie - Akamai injects a JavaScript challenge that generates this cookie. A valid_abckcookie containing~0~indicates successful validation. Without it, subsequent requests are blocked or served a CAPTCHA.Sensor data - Akamai's client-side JavaScript collects an encrypted payload of browser characteristics: canvas fingerprint, WebGL renderer, screen resolution, installed plugins, mouse movement patterns, and more. This sensor data is POSTed to a dynamically generated endpoint.
Behavioral analysis - Uniform request intervals, identical headers across sessions, and machine-speed navigation get flagged.
The practical implication: simple requests.get() calls to the JSON API endpoints often work with minimal headers (suggesting the API endpoints have lighter protection than the main website). But if you start getting 403s, you need to address TLS fingerprinting - which is where curl_cffi comes in (more on this below).
FanDuel
FanDuel (owned by Flutter Entertainment, which also owns Betfair) is the second-largest US sportsbook. Their API structure reflects their Betfair heritage.
The actual API endpoint:
https://sbapi.{state}.sportsbook.fanduel.com/api/content-managed-page
Where {state} is the state abbreviation: nj, pa, il, ny, co, etc.
Full request with required parameters:
GET https://sbapi.il.sportsbook.fanduel.com/api/content-managed-page?betexRegion=GBR&capiJurisdiction=intl¤cyCode=USD&exchangeLocale=en_US&language=en®ionCode=NAMERICA&_ak=FhMFpcPWXMeyZxOx&page=CUSTOM&customPageId=nba
| Parameter | Value | Description |
|---|---|---|
_ak |
FhMFpcPWXMeyZxOx |
API key embedded in frontend JS |
page |
CUSTOM |
Page type |
customPageId |
nba, nfl, mlb, nhl |
Sport identifier |
betexRegion |
GBR |
Betfair exchange region |
currencyCode |
USD |
Currency |
regionCode |
NAMERICA |
Geographic region |
The response uses Betfair-style terminology:
{
"attachments": {
"events": { "12345": { "name": "Los Angeles Lakers @ Boston Celtics", "openDate": "..." } },
"markets": {
"42.448600011": {
"runners": [
{ "selectionId": 29165, "runnerName": "Los Angeles Lakers", "winRunnerOdds": { "americanOdds": "+150", "decimalOdds": 2.50 } },
{ "selectionId": 29166, "runnerName": "Boston Celtics", "winRunnerOdds": { "americanOdds": "-180", "decimalOdds": 1.556 } }
]
}
},
"competitions": { "12": { "name": "NBA" } }
}
}
Key terminology: markets contain runners (outcomes). Market IDs use dotted numerics like 42.448600011, and selection IDs are plain integers.
Bot protection: FanDuel does not use Cloudflare. They use HUMAN Security (formerly PerimeterX) Bot Defender, deployed on AWS CloudFront via Lambda@Edge. According to HUMAN Security's own case study, this integration mitigates 99.9% of bot traffic and handles up to 3,000 malicious requests per second during peak periods.
HUMAN Bot Defender's detection includes:
- Behavioral fingerprinting - Tracks mouse movements, click patterns, scrolling behavior. Bots that move the cursor in straight lines or click at perfectly regular intervals get flagged.
- Browser fingerprinting - Builds device profiles from screen resolution, installed fonts, WebGL renderer, audio context, and battery API. Cross-validates reported capabilities against actual behavior.
- Press-and-hold challenges - Instead of traditional CAPTCHAs, HUMAN uses a click-and-hold mechanism that analyzes subtle timing variations and cursor micro-movements. This is significantly harder for bots to solve than image selection CAPTCHAs.
- Risk cookies - Session-level cookies that accumulate behavioral evidence over time. Enough bot-like signals trigger escalating challenges.
This means FanDuel is one of the harder sportsbooks to scrape with direct HTTP requests. Most open-source FanDuel scrapers use headless browsers (undetected-chromedriver, Playwright, or Puppeteer) rather than direct API calls.
PrizePicks
PrizePicks operates in the daily fantasy sports space rather than traditional sports betting, which means it's legal in more states. Instead of moneylines and spreads, PrizePicks offers over/under projections on player props (e.g., "LeBron James Over 25.5 Points"). Arb opportunities exist when PrizePicks' projection lines diverge significantly from the player prop lines at traditional sportsbooks.
The actual API endpoints:
https://api.prizepicks.com/projections
https://partner-api.prizepicks.com/projections
https://partner-api.prizepicks.com/leagues
The partner-api endpoint historically has lighter bot protection. Query parameters:
| Parameter | Description | Example |
|---|---|---|
league_id |
Filter by league | league_id=7 (NBA) |
per_page |
Results per page | per_page=1000 |
single_stat |
Single-stat projections only | single_stat=true |
League IDs:
| ID | League | ID | League |
|---|---|---|---|
| 2 | MLB | 9 | NFL |
| 7 | NBA | 15 | CFB |
| 8 | NHL | 20 | CBB |
| 3 | WNBA | 12 | MMA |
PrizePicks uses the JSON:API specification for its response format:
{
"data": [
{
"id": "12345",
"type": "projection",
"attributes": {
"line_score": 25.5,
"stat_type": "Points",
"start_time": "2025-02-10T19:00:00-05:00",
"projection_type": "standard",
"is_promo": false,
"discount_percentage": null
},
"relationships": {
"new_player": {
"data": { "id": "67890", "type": "new_player" }
},
"league": {
"data": { "id": "7", "type": "league" }
}
}
}
],
"included": [
{
"id": "67890",
"type": "new_player",
"attributes": {
"name": "LeBron James",
"position": "SF",
"team": "LAL",
"image_url": "https://..."
}
}
]
}
The data array contains projections. Player details are in the included array - you join them via the relationships.new_player.data.id field. The line_score is what you compare against prop lines at traditional sportsbooks.
PrizePicks also has three projection types: standard (normal picks), demon (high-difficulty, higher payout), and goblin (safer picks, lower payout).
Bot protection: PrizePicks uses Cloudflare Bot Management. Direct requests.get() calls to api.prizepicks.com will likely be challenged. The partner-api.prizepicks.com endpoint may work with plain HTTP requests, but can also get blocked. Community workarounds include using Puppeteer with the stealth plugin or Selenium with Firefox/undetected-chromedriver to navigate to the API URL and extract JSON from the <pre> element on the response page.
Other Valuable Sources
- BetMGM - Often slow to adjust lines, creating arb windows. Uses Akamai Bot Manager.
- Caesars Sportsbook - Aggressive promotions lead to mispriced odds. Uses HUMAN Bot Defender.
- ESPN BET - Newer platform, lines sometimes lag the market.
- Pinnacle - The sharpest book in the world. Pinnacle closed public API access in July 2025, but guest API endpoints still exist at
https://guest.api.arcadia.pinnacle.com/0.1/leagues/{id}/markets/straightwith anx-api-keyheader. Even without direct API access, third-party aggregators like The Odds API provide Pinnacle data - invaluable as a "true odds" reference for +EV calculations.
Building Your Odds Scraper
The goal is a system that continuously pulls odds from multiple sportsbooks, normalizes the data, and identifies arbs in real time. Here's how to build it with real endpoints.
Getting Past TLS Fingerprinting
The single biggest obstacle to direct API scraping isn't rate limits - it's TLS fingerprinting. During every HTTPS connection, your client performs a TLS handshake that reveals which cipher suites, extensions, and protocol versions it supports. This handshake produces a JA3 fingerprint - an MD5 hash that uniquely identifies your HTTP library.
Python's requests library uses OpenSSL for TLS. Chrome uses BoringSSL. These produce completely different JA3 hashes. Anti-bot systems like Akamai maintain databases of known browser fingerprints. If your JA3 doesn't match a real browser, you're blocked before the HTTP request even starts.
curl_cffi solves this. It's a Python binding for curl-impersonate that mimics the TLS fingerprint of specific browser versions:
from curl_cffi import requests as curl_requests
# Impersonate Chrome 120 - TLS fingerprint matches real Chrome
session = curl_requests.Session(impersonate="chrome120")
# This request has Chrome's exact JA3 hash, HTTP/2 settings, and header order
resp = session.get(
"https://sportsbook.draftkings.com//sites/US-SB/api/v5/eventgroups/42648?format=json",
proxies={
"http": "http://user:pass@us.statproxies.com:3128",
"https": "http://user:pass@us.statproxies.com:3128"
},
timeout=5
)
data = resp.json()
curl_cffi supports impersonation of Chrome 99 through Chrome 131, and also supports HTTP/2 and HTTP/3 (which Python's requests library does not). For sportsbook scraping where TLS detection is the primary defense, this is the most important library in your stack.
Pulling Odds from DraftKings
Here's a complete DraftKings scraper using the real endpoints:
from curl_cffi import requests as curl_requests
import time
# Event Group IDs
SPORTS = {
"nfl": 88808,
"nba": 42648,
"nhl": 42133,
"mlb": 84240,
}
PROXY = {
"http": "http://user:pass@us.statproxies.com:3128",
"https": "http://user:pass@us.statproxies.com:3128"
}
session = curl_requests.Session(impersonate="chrome120")
def fetch_dk_odds(sport="nba"):
event_group_id = SPORTS[sport]
url = f"https://sportsbook.draftkings.com//sites/US-SB/api/v5/eventgroups/{event_group_id}?format=json"
resp = session.get(url, proxies=PROXY, timeout=5)
resp.raise_for_status()
data = resp.json()
results = []
events = data['eventGroup'].get('events', [])
for category in data['eventGroup'].get('offerCategories', []):
for descriptor in category.get('offerSubcategoryDescriptors', []):
offers = descriptor.get('offerSubcategory', {}).get('offers', [])
for game_idx, game_offers in enumerate(offers):
event_name = events[game_idx]['name'] if game_idx < len(events) else "Unknown"
for market in game_offers:
market_label = market['label']
for outcome in market.get('outcomes', []):
results.append({
"source": "draftkings",
"event": event_name,
"market": market_label,
"outcome": outcome['label'],
"american_odds": outcome.get('oddsAmerican'),
"decimal_odds": outcome.get('oddsDecimal'),
"line": outcome.get('line'),
"timestamp": time.time()
})
return results
For player props, use the category/subcategory endpoints. You can discover available category IDs from the response of the main endpoint - inspect offerCategories for the full list.
Pulling Odds from FanDuel
FanDuel's API requires the state-specific subdomain and embedded API key:
def fetch_fd_odds(sport="nba", state="nj"):
url = f"https://sbapi.{state}.sportsbook.fanduel.com/api/content-managed-page"
params = {
"betexRegion": "GBR",
"capiJurisdiction": "intl",
"currencyCode": "USD",
"exchangeLocale": "en_US",
"language": "en",
"regionCode": "NAMERICA",
"_ak": "FhMFpcPWXMeyZxOx",
"page": "CUSTOM",
"customPageId": sport,
}
resp = session.get(url, params=params, proxies=PROXY, timeout=5)
resp.raise_for_status()
data = resp.json()
results = []
attachments = data.get('attachments', {})
events = attachments.get('events', {})
markets = attachments.get('markets', {})
for market_id, market_data in markets.items():
runners = market_data.get('runners', [])
market_type = market_data.get('marketType', 'unknown')
for runner in runners:
odds_data = runner.get('winRunnerOdds', {})
results.append({
"source": "fanduel",
"event": market_data.get('eventName', 'Unknown'),
"market": market_type,
"outcome": runner.get('runnerName', ''),
"american_odds": odds_data.get('americanOdds'),
"decimal_odds": odds_data.get('decimalOdds'),
"line": runner.get('handicap'),
"timestamp": time.time()
})
return results
Important: FanDuel's HUMAN Bot Defender is more aggressive than DraftKings' Akamai. If curl_cffi alone gets blocked, you may need to fall back to a headless browser approach:
import undetected_chromedriver as uc
import json
def fetch_fd_odds_browser(sport="nba", state="nj"):
"""Fallback: use headless browser for FanDuel if API calls get blocked."""
options = uc.ChromeOptions()
options.add_argument("--proxy-server=http://us.statproxies.com:3128")
driver = uc.Chrome(options=options)
url = f"https://sbapi.{state}.sportsbook.fanduel.com/api/content-managed-page?betexRegion=GBR&capiJurisdiction=intl¤cyCode=USD&exchangeLocale=en_US&language=en®ionCode=NAMERICA&_ak=FhMFpcPWXMeyZxOx&page=CUSTOM&customPageId={sport}"
driver.get(url)
# The API response renders as JSON in a <pre> tag
pre_element = driver.find_element("tag name", "pre")
data = json.loads(pre_element.text)
driver.quit()
return data
Pulling PrizePicks Projections
PrizePicks is the easiest to scrape directly, but you need to handle Cloudflare:
def fetch_prizepicks_projections(league_id=7):
"""Fetch PrizePicks projections. league_id: 7=NBA, 9=NFL, 8=NHL, 2=MLB"""
url = "https://partner-api.prizepicks.com/projections"
params = {"league_id": league_id, "per_page": 1000, "single_stat": "true"}
resp = session.get(url, params=params, proxies=PROXY, timeout=5)
resp.raise_for_status()
data = resp.json()
# Build player lookup from included resources (JSON:API spec)
players = {}
for item in data.get('included', []):
if item['type'] == 'new_player':
players[item['id']] = item['attributes']
results = []
for proj in data.get('data', []):
attrs = proj['attributes']
player_id = proj['relationships']['new_player']['data']['id']
player = players.get(player_id, {})
results.append({
"source": "prizepicks",
"player": player.get('name', 'Unknown'),
"team": player.get('team', ''),
"stat_type": attrs['stat_type'],
"line": attrs['line_score'],
"projection_type": attrs.get('projection_type', 'standard'),
"start_time": attrs['start_time'],
"timestamp": time.time()
})
return results
If the partner-api endpoint gets blocked, use the same headless browser technique - navigate to the URL and parse JSON from the page.
The Odds API as Fallback
If direct scraping becomes unreliable (endpoint changes, aggressive blocking), The Odds API (the-odds-api.com) provides a legitimate, structured alternative. It aggregates odds from 40+ sportsbooks including DraftKings, FanDuel, BetMGM, and Caesars with a free tier (500 requests/month).
import requests
ODDS_API_KEY = "your_api_key_here"
def fetch_odds_api(sport="basketball_nba", markets="h2h,spreads,totals"):
"""Fetch odds from The Odds API - no bot protection to worry about."""
url = f"https://api.the-odds-api.com/v4/sports/{sport}/odds"
params = {
"apiKey": ODDS_API_KEY,
"regions": "us",
"markets": markets,
"oddsFormat": "american",
"bookmakers": "draftkings,fanduel,betmgm,caesars",
}
resp = requests.get(url, params=params, timeout=10)
resp.raise_for_status()
return resp.json()
The response is clean and normalized:
{
"id": "abc123",
"sport_key": "basketball_nba",
"commence_time": "2025-02-10T00:30:00Z",
"home_team": "Boston Celtics",
"away_team": "Los Angeles Lakers",
"bookmakers": [
{
"key": "draftkings",
"title": "DraftKings",
"markets": [
{
"key": "h2h",
"outcomes": [
{ "name": "Boston Celtics", "price": -180 },
{ "name": "Los Angeles Lakers", "price": 155 }
]
},
{
"key": "spreads",
"outcomes": [
{ "name": "Boston Celtics", "price": -110, "point": -4.5 },
{ "name": "Los Angeles Lakers", "price": -110, "point": 4.5 }
]
}
]
},
{
"key": "fanduel",
"title": "FanDuel",
"markets": [
{
"key": "h2h",
"outcomes": [
{ "name": "Boston Celtics", "price": -175 },
{ "name": "Los Angeles Lakers", "price": 160 }
]
}
]
}
]
}
The tradeoff: The Odds API updates every 30-60 seconds for pre-match odds. For live/in-play odds with sub-second updates, you need direct scraping or a premium real-time service like OpticOdds or Unabated (which offer WebSocket delivery at 1M+ odds per second).
Making the Data Match Up
Each sportsbook structures data differently, and the same team/player is often named differently across platforms. Fuzzy string matching is essential:
from dataclasses import dataclass, field
from fuzzywuzzy import fuzz
import time
@dataclass
class NormalizedOdds:
source: str
event_name: str
market: str
outcome: str
american_odds: int
decimal_odds: float
line: float = None
timestamp: float = field(default_factory=time.time)
def american_to_implied(odds: int) -> float:
if odds > 0:
return 100 / (odds + 100)
else:
return abs(odds) / (abs(odds) + 100)
def american_to_decimal(odds: int) -> float:
if odds > 0:
return (odds / 100) + 1
else:
return (100 / abs(odds)) + 1
def match_events(event_a: str, event_b: str, threshold: int = 80) -> bool:
"""Fuzzy match event names across sportsbooks.
'LA Lakers @ Boston Celtics' vs 'Los Angeles Lakers at Boston Celtics'
should match despite different naming conventions.
"""
return fuzz.token_sort_ratio(event_a.lower(), event_b.lower()) >= threshold
This matters because DraftKings might list "LA Lakers @ BOS" while FanDuel lists "Los Angeles Lakers at Boston Celtics". Without fuzzy matching, your arb detector treats them as different events and misses every opportunity.
The Arb Detection Logic
def find_arbs(odds_list: list[NormalizedOdds], min_profit_pct: float = 0.5):
"""
Find arbitrage opportunities across sportsbooks.
Groups odds by event+market, then checks all cross-book combinations.
"""
groups = {}
for odds in odds_list:
key = (odds.event_name, odds.market)
if key not in groups:
groups[key] = []
groups[key].append(odds)
arbs = []
for (event, market), market_odds in groups.items():
# Find best odds per outcome from different books
best_by_outcome = {}
for o in market_odds:
if o.outcome not in best_by_outcome:
best_by_outcome[o.outcome] = []
best_by_outcome[o.outcome].append(o)
outcome_names = list(best_by_outcome.keys())
if len(outcome_names) != 2:
continue
# Get the best available odds for each side
for a in best_by_outcome[outcome_names[0]]:
for b in best_by_outcome[outcome_names[1]]:
if a.source == b.source:
continue # Same book - no arb
impl_a = american_to_implied(a.american_odds)
impl_b = american_to_implied(b.american_odds)
total_impl = impl_a + impl_b
if total_impl < 1.0:
profit_pct = ((1 / total_impl) - 1) * 100
if profit_pct >= min_profit_pct:
arbs.append({
"event": event,
"market": market,
"side_a": {
"outcome": a.outcome,
"source": a.source,
"odds": a.american_odds,
"decimal": a.decimal_odds,
},
"side_b": {
"outcome": b.outcome,
"source": b.source,
"odds": b.american_odds,
"decimal": b.decimal_odds,
},
"profit_pct": round(profit_pct, 2),
"total_implied": round(total_impl, 4),
})
return sorted(arbs, key=lambda x: x["profit_pct"], reverse=True)
def calculate_stakes(arb: dict, total_investment: float = 1000.0):
"""Calculate optimal stakes that guarantee equal payout on either outcome."""
dec_a = arb["side_a"]["decimal"]
dec_b = arb["side_b"]["decimal"]
# Stakes are inversely proportional to decimal odds
stake_a = total_investment * (1/dec_a) / ((1/dec_a) + (1/dec_b))
stake_b = total_investment - stake_a
payout_a = stake_a * dec_a
payout_b = stake_b * dec_b
return {
"side_a_stake": round(stake_a, 2),
"side_b_stake": round(stake_b, 2),
"payout_if_a": round(payout_a, 2),
"payout_if_b": round(payout_b, 2),
"guaranteed_profit": round(min(payout_a, payout_b) - total_investment, 2),
}
PrizePicks Cross-Platform Edges
PrizePicks arbs work differently because you're comparing a fantasy projection against a traditional sportsbook prop line. If PrizePicks offers "LeBron James Over 25.5 Points" and DraftKings has the Over at +120 (implying the Over is less likely), but PrizePicks' standard payout structure implies 50/50, there's an edge.
def find_prizepicks_edges(pp_projections, sportsbook_props):
"""
Compare PrizePicks lines against traditional sportsbook player props.
PrizePicks standard entries pay ~1.0x-2.0x depending on combo size.
Individual props are effectively -100/even odds (implied 50%).
"""
edges = []
for pp in pp_projections:
# Find matching player prop at sportsbooks
for prop in sportsbook_props:
if (pp['player'].lower() in prop['outcome'].lower() and
pp['stat_type'].lower() in prop['market'].lower()):
pp_line = pp['line']
book_line = prop.get('line')
# If lines differ, there may be an edge
if pp_line and book_line and pp_line != book_line:
edges.append({
"player": pp['player'],
"stat": pp['stat_type'],
"prizepicks_line": pp_line,
"sportsbook": prop['source'],
"sportsbook_line": book_line,
"sportsbook_odds": prop['american_odds'],
"line_diff": abs(pp_line - book_line),
})
return sorted(edges, key=lambda x: x['line_diff'], reverse=True)
Why You Can't Do This Without Proxies
If you try to run this pipeline from your home IP or a cloud server, you'll hit walls within minutes. Here's the specific breakdown of what blocks you and why.
IP Reputation Kills You First
Every major sportsbook's bot protection starts with an IP reputation check before anything else happens. Akamai (DraftKings), HUMAN Security (FanDuel), and Cloudflare (PrizePicks) all query IP classification databases - MaxMind, IPQualityScore, IP2Location - on every request.
IPs are classified into categories:
- Residential - Home ISP connections. Trusted.
- Hosting/Datacenter - AWS, GCP, Azure, DigitalOcean. Flagged as likely automated.
- VPN/Proxy - Known VPN and proxy services. Blocked immediately.
If your IP comes back as "datacenter" or "proxy," you're blocked before the TLS handshake even completes. Every major cloud provider's IP ranges are catalogued. Running a scraper on an EC2 instance or a VPS means instant blocks on all three platforms.
TLS Fingerprints Give You Away Next
Even with a clean IP, the wrong TLS fingerprint gives you away. As discussed above, JA3 hashing identifies your HTTP client library. The standard Python stack (requests + urllib3 + OpenSSL) has a well-known JA3 hash that anti-bot systems recognize as "not a browser."
curl_cffi with browser impersonation solves the TLS problem, but only if combined with a clean IP. A datacenter IP with Chrome's TLS fingerprint is still flagged - because real Chrome users don't connect from AWS IP blocks.
You'll Burn Through Rate Limits Fast
A single IP gives you roughly 20-30 requests per minute to each sportsbook before triggering rate limits. An arb scanner monitoring:
- 4 sportsbooks
- 5 sports (NFL, NBA, NHL, MLB, CFB)
- Polling every 5 seconds
That's 240 requests per minute minimum. One IP can't sustain that. You need a deep pool of IPs - ideally dozens per sportsbook spread across multiple states - so you can rotate within a trusted set without any single IP hitting rate limits. The more IPs you have, the lower the request frequency per IP and the longer each one stays clean.
State-by-State Odds Need State-by-State IPs
DraftKings and FanDuel are licensed per state. The state-specific API subdomains (sportsbook-us-nj.draftkings.com, sbapi.il.sportsbook.fanduel.com) serve different odds based on your IP's geolocation. Some markets and promotions exist only in specific states. To capture the broadest set of odds - and therefore the most arb opportunities - you need IPs in multiple legal betting states.
Every Millisecond Costs You Money
An arb opportunity is a pricing inefficiency that the market corrects. Typical arb windows:
- Major markets (NFL, NBA moneylines): 10-60 seconds
- Secondary markets (player props, alt lines): 30 seconds to 5 minutes
- Cross-platform props (PrizePicks vs. traditional books): 1-15 minutes
- Live/in-game odds: Under 10 seconds
Traditional residential proxies route through actual consumer connections, adding 100-500ms of round-trip latency. If your proxy adds 300ms per request and you need 6 requests to detect, confirm, and begin acting on an arb (poll 3 books × 2 roundtrips), that's 1.8 extra seconds. On a 15-second arb window, that's 12% of your time burned on proxy overhead.
Why ISP Proxies (Not Resi or Datacenter)
ISP (static residential) proxies are purpose-built for exactly this kind of high-speed, detection-sensitive scraping.
Residential Trust, Datacenter Speed
ISP proxies are IPs registered to consumer ISPs but hosted on fast backbone infrastructure. When Akamai or HUMAN Security queries an IP reputation database, your proxy IP comes back classified as residential. Not datacenter, not VPN, not proxy.
But unlike traditional rotating residential proxies that route through actual consumer connections and inherit their latency, ISP proxies deliver datacenter-grade performance. Typical round-trip times are 10-30ms to major US endpoints. You get the trust classification of a home connection with the speed of a server.
Static IPs Build Trust Over Time
Rotating proxies are a liability for sportsbook scraping:
- Sportsbooks track session cookies per IP. A new IP means a new session, potentially triggering HUMAN Security's press-and-hold challenge or Akamai's full JavaScript challenge.
- Rapid IP changes trigger anomaly detection - real users don't change IPs every 30 seconds.
- Anti-bot systems accumulate trust over time via risk cookies. A persistent IP builds session history that lowers your risk score with every request.
Static ISP proxies give you a dedicated IP that's yours indefinitely. Your scraper builds trust with the sportsbook's bot detection system over days and weeks, making each subsequent request less likely to trigger challenges.
IPs in Every Legal Betting State
ISP proxy providers like Stat Proxies offer IPs in specific US states - New Jersey, Pennsylvania, Illinois, Colorado, Arizona, Virginia, Ohio, Massachusetts, and others. This lets you:
- Access state-specific odds and markets that only exist in certain jurisdictions
- Catch arbs between the same book in different states (yes, odds vary by state)
- Run parallel scrapers across every legal betting state simultaneously
- Avoid geo-restriction blocks entirely
A production setup that's actually competitive runs a few hundred IPs spread across 10+ legal betting states. That gives you 20-30 IPs per sportsbook per state - enough to keep request rates low on each individual IP while covering every jurisdiction where arbs can appear. More states means more odds variations, which means more arb opportunities that smaller operations miss entirely.
Running This at Scale
Isolating Each Book on Its Own IP Pool
Assign separate IP pools to each sportsbook so sessions stay isolated. If DraftKings flags one IP, you rotate to the next one in that pool without touching your FanDuel or PrizePicks scrapers. With a few hundred IPs across states, each book gets its own dedicated pool:
from curl_cffi import requests as curl_requests
BOOK_PROXIES = {
"draftkings": "http://user:pass@dk.statproxies.com:3128",
"fanduel": "http://user:pass@fd.statproxies.com:3128",
"betmgm": "http://user:pass@mgm.statproxies.com:3128",
"prizepicks": "http://user:pass@pp.statproxies.com:3128",
}
# One persistent session per sportsbook - maintains cookies and TLS state
sessions = {}
for book, proxy_url in BOOK_PROXIES.items():
s = curl_requests.Session(impersonate="chrome120")
s.proxies = {"http": proxy_url, "https": proxy_url}
sessions[book] = s
Polling Fast Enough to Catch Arbs
For production speed, use async polling instead of threading:
import asyncio
from curl_cffi.requests import AsyncSession
async def poll_odds():
async with AsyncSession(impersonate="chrome120") as session:
while True:
tasks = [
session.get(dk_url, proxies=BOOK_PROXIES["draftkings"]),
session.get(fd_url, proxies=BOOK_PROXIES["fanduel"]),
session.get(pp_url, proxies=BOOK_PROXIES["prizepicks"]),
]
responses = await asyncio.gather(*tasks, return_exceptions=True)
odds = []
for resp in responses:
if not isinstance(resp, Exception):
odds.extend(normalize(resp.json()))
arbs = find_arbs(odds)
for arb in arbs:
stakes = calculate_stakes(arb)
await send_alert(arb, stakes)
await asyncio.sleep(3) # Poll every 3 seconds
Getting Alerts to Your Phone
Email is too slow for arb alerts. Use a Discord webhook for sub-second delivery:
import httpx
DISCORD_WEBHOOK = "https://discord.com/api/webhooks/your/webhook"
async def send_alert(arb, stakes):
embed = {
"title": f"Arb: {arb['event']} ({arb['profit_pct']}%)",
"color": 0x00FF00,
"fields": [
{
"name": f"{arb['side_a']['source'].upper()}",
"value": f"{arb['side_a']['outcome']} @ {arb['side_a']['odds']}\nStake: ${stakes['side_a_stake']}",
"inline": True
},
{
"name": f"{arb['side_b']['source'].upper()}",
"value": f"{arb['side_b']['outcome']} @ {arb['side_b']['odds']}\nStake: ${stakes['side_b_stake']}",
"inline": True
},
{
"name": "Profit",
"value": f"${stakes['guaranteed_profit']} guaranteed",
"inline": False
}
]
}
async with httpx.AsyncClient() as client:
await client.post(DISCORD_WEBHOOK, json={"embeds": [embed]})
What Can Go Wrong
Arb betting isn't a magic money printer. Here's what the sportsbooks do about it and how to stay in the game.
How Books Catch Arb Bettors
Detection happens at the betting pattern level, not the scraping level. Your scraper can be invisible, but your betting account tells a story:
- Consistent profitability - The strongest signal. Recreational bettors lose over time. If you're consistently winning, you're flagged for review.
- Precise bet amounts - Placing $468.11 instead of $500 screams "calculated arbitrage." Sportsbooks' ML models flag precise, non-round amounts.
- Always taking the best line - If you consistently bet only when you have the best available odds, the pattern is obvious. Recreational bettors don't comparison shop every line.
- Market selection - Heavy action on obscure secondary markets (player props, alt lines) where arbs are most common is a red flag.
- Withdrawal frequency - Regular withdrawals after consistent wins signal a professional, not a recreational bettor.
- Cross-platform correlation - Sportsbooks share KYC data and can identify simultaneous opposite bets placed across platforms within seconds of each other.
Getting Limited (and Dealing with It)
When detected, sportsbooks don't ban you - they limit you. Your max bet gets reduced from thousands to single digits on certain markets. This is called being "limited" or "gubbed." It's the biggest long-term risk to profitability.
Mitigation strategies:
- Place occasional recreational parlays to mask the pattern
- Round bet amounts to common figures ($50, $100, $250) and accept slightly lower margins
- Spread action across as many sportsbooks as possible - more accounts = more runway
- Don't always take the best line; sometimes take the second-best
- Keep withdrawal requests moderate and infrequent
- Mix in some losing bets on entertainment lines
Lines Move Before You Can Click
The arb you detected 5 seconds ago might not exist anymore. Between detection and bet placement, lines can move. Always re-check odds immediately before placing. Build a confirmation step that re-fetches both sides before you commit capital.
The Legal Gray Area
Scraping sportsbook websites may violate their terms of service. Placing bets is fine - you're a paying customer - but automated data collection exists in a gray area. Using ISP proxies with proper TLS fingerprinting makes your scraper indistinguishable from a normal user browsing odds. Be aware of the legal landscape in your jurisdiction.
The Full Setup
Here's what a production arb-hunting setup looks like:
- A few hundred ISP proxy IPs from Stat Proxies - spread across 10+ legal betting states (NJ, PA, IL, CO, AZ, VA, OH, MA, and more), giving you deep pools per sportsbook per state
curl_cffiwith Chrome impersonation - TLS fingerprints match real browsers, passing Akamai and Cloudflare detection- Direct API scraping hitting real DraftKings (
/api/v5/eventgroups/), FanDuel (sbapi.{state}.sportsbook.fanduel.com), and PrizePicks (partner-api.prizepicks.com) endpoints - The Odds API as a backup data source when direct scraping gets blocked
- Fuzzy string matching to normalize event/player names across books
- Async polling loop refreshing odds every 3-5 seconds per book
- Arb + middle + EV detection running on every update cycle
- Discord/Telegram alerts with pre-calculated stakes pushed to your phone in real time
- Logging and analytics tracking arb frequency, duration, and which book combinations create the most opportunities
The proxy layer is the foundation of the entire stack. Without residential IPs that pass Akamai, HUMAN Security, and Cloudflare's IP reputation checks, your scraper gets blocked on the first request. Without low latency, arbs expire before you can act. Without static IPs, you're re-solving CAPTCHAs every session instead of building trust. And without enough IPs across enough states, you're seeing a fraction of the arb opportunities that actually exist.
ISP proxies from Stat Proxies give you residential classification that passes every IP reputation check, sub-30ms latency that lets you catch fleeting opportunities, and static IPs that build long-term session trust with bot detection systems. Running a few hundred across every legal betting state means you're scraping every jurisdiction in parallel, catching state-specific pricing discrepancies that operations with a handful of IPs never even see. It's the difference between a scraper that works in theory and one that prints money in practice.