If two sportsbooks disagree on the odds for the same event, you can bet both sides and guarantee a profit regardless of the outcome. That’s arbitrage — and building a bot to find and execute these opportunities is one of the most concrete applications of algorithmic betting.
This guide walks through the full pipeline: the underlying math, the four-stage architecture, working Python code for each component, and the operational realities of running an arb bot in production.
Prerequisites: Basic Python, familiarity with REST APIs, understanding of how sportsbook odds work. If you need a refresher on odds formats, see the Odds Converter.
What Is Sports Betting Arbitrage?
Arbitrage (or “arbing”) exploits pricing differences between sportsbooks. When two books price the same event differently enough, you can place offsetting bets that guarantee a profit no matter who wins.
A Simple Example
Consider an NBA game: Lakers vs. Celtics.
| Outcome | DraftKings | FanDuel |
|---|---|---|
| Lakers win | +180 (35.7%) | +150 (40.0%) |
| Celtics win | -180 (64.3%) | -200 (66.7%) |
Neither book offers an arb on its own — their implied probabilities sum to 100%+ (that’s the vig). But if you cherry-pick the best odds per outcome across books:
| Outcome | Best Odds | Book | Implied Prob |
|---|---|---|---|
| Lakers win | +180 | DraftKings | 35.7% |
| Celtics win | -150 | BetMGM | 60.0% |
Total implied probability: 35.7% + 60.0% = 95.7%
That’s below 100%. The 4.3% gap is your arbitrage margin. On a $1,000 total stake, that’s approximately $43 in guaranteed profit before fees.
Why Arbs Exist
Sportsbooks set odds independently. They each have different models, different customer bases creating different risk exposure, and different reaction speeds to news. These factors create transient pricing disagreements — usually lasting seconds to minutes.
The window is short because:
- Sharp bettors exploit the mispricing, causing the book to adjust
- Odds feeds propagate line changes across the market
- Automated systems at the books detect and correct outliers
This time pressure is why manual arb betting is impractical at scale, and why bots dominate this space.
The Math: Implied Probability and Overround
Before building anything, you need to be fluent in the core math. Every calculation in an arb bot starts with converting odds to implied probabilities.
Odds Conversion Formulas
def american_to_implied(odds: float) -> float:
"""Convert American odds to implied probability (0-1)."""
if odds > 0:
return 100 / (odds + 100)
else:
return abs(odds) / (abs(odds) + 100)
def decimal_to_implied(odds: float) -> float:
"""Convert decimal odds to implied probability (0-1)."""
return 1 / odds
def fractional_to_implied(num: float, den: float) -> float:
"""Convert fractional odds to implied probability (0-1)."""
return den / (num + den)
For an interactive version, see the Odds Converter tool.
Overround (Vig)
A sportsbook’s overround is the amount by which their implied probabilities exceed 100%. It’s the book’s built-in margin.
def calculate_overround(implied_probs: list[float]) -> float:
"""Calculate overround from a list of implied probabilities.
Returns the overround as a decimal.
Example: 0.05 means 105% total implied probability (5% vig).
"""
return sum(implied_probs) - 1.0
| Scenario | Total Implied | Overround | Meaning |
|---|---|---|---|
| Single book, fair odds | 100.0% | 0% | No vig (theoretical) |
| Single book, typical vig | 104.5% | 4.5% | Book’s profit margin |
| Best odds across books | 98.2% | −1.8% | Arbitrage opportunity |
When the overround is negative (total implied < 100%), an arb exists.
The Arb Percentage Formula
The arb percentage tells you the guaranteed return as a fraction of your total stake:
def arb_percentage(implied_probs: list[float]) -> float:
"""Calculate arb percentage. Positive = arb exists."""
total = sum(implied_probs)
return ((1 / total) - 1) * 100
An arb percentage of 2.5% on a $1,000 total stake means $25 guaranteed profit.
Worked Example
Event: NFL game, Rams vs. 49ers (moneyline).
| Outcome | Best Odds | Book | Decimal | Implied Prob |
|---|---|---|---|---|
| Rams | +210 | BetMGM | 3.10 | 32.26% |
| 49ers | -190 | DraftKings | 1.526 | 65.52% |
Total implied: 32.26% + 65.52% = 97.78%
Arb percentage: (1/0.9778 − 1) × 100 = 2.27%
On a $1,000 stake:
total_stake = 1000
implieds = [0.3226, 0.6552]
total_implied = sum(implieds) # 0.9778
# Optimal stakes (proportional to implied probability)
stake_rams = total_stake * (implieds[0] / total_implied) # $330.00
stake_49ers = total_stake * (implieds[1] / total_implied) # $670.00
# Payouts if each outcome wins
payout_rams = stake_rams * 3.10 # $1,023.00
payout_49ers = stake_49ers * 1.526 # $1,022.42
# Profit (payout - total_stake) is ~$22-23 regardless of outcome
Use the Arbitrage Calculator to run these calculations instantly.
How Arb Bots Work: Architecture Overview
A production arb bot follows a four-stage pipeline:
┌──────────────┐ ┌──────────────────┐ ┌─────────────────┐ ┌──────────────────┐
│ DATA LAYER │───▶│ COMPARISON ENGINE │───▶│ STAKE CALCULATOR │───▶│ EXECUTION ENGINE │
│ │ │ │ │ │ │ │
│ Multi-book │ │ Best odds per │ │ Optimal stake │ │ Parallel bet │
│ odds feeds │ │ outcome, arb │ │ distribution, │ │ placement, │
│ │ │ detection │ │ fee adjustment │ │ failure handling │
└──────────────┘ └──────────────────┘ └─────────────────┘ └──────────────────┘
10+ books <1 second <1 second <3 seconds
Each stage has distinct responsibilities, and you can develop and test them independently. Let’s build each one.
Stage 1: The Data Layer
The data layer is responsible for collecting real-time odds from multiple sportsbooks and normalizing them into a common format.
Why Scraping Sportsbooks Is a Bad Idea
The obvious approach — scrape odds directly from sportsbook websites — is fragile and often counterproductive:
- CAPTCHAs and bot detection block automated access
- DOM changes break scrapers without warning
- IP bans are common and aggressive
- Terms of service explicitly prohibit scraping
- Rate limits restrict how fast you can poll
- Legal risk varies by jurisdiction
For a prototype, you could scrape one or two sites. For production, you need an odds data provider.
Using an Odds API
The most reliable approach is a dedicated odds API that aggregates data from multiple sportsbooks into a single normalized feed. TheOddsPulse provides real-time odds from 10+ sportsbooks through a single REST endpoint, pre-normalized into a consistent format.
import requests
ODDS_API_BASE = "https://api.theoddspulse.com/v1"
API_KEY = "your-api-key"
def fetch_odds(sport: str, market: str = "h2h") -> list[dict]:
"""Fetch live odds from multiple sportsbooks for a sport.
Args:
sport: Sport key (e.g., 'basketball_nba', 'football_nfl')
market: Market type ('h2h' for moneyline, 'spreads', 'totals')
Returns:
List of events with odds from each bookmaker.
"""
response = requests.get(
f"{ODDS_API_BASE}/odds",
params={
"sport": sport,
"markets": market,
"oddsFormat": "decimal",
"apiKey": API_KEY,
}
)
response.raise_for_status()
return response.json()
The response gives you odds from every available book for every live event, already normalized to decimal format. No scraping, no parsing, no format conversion.
Polling vs. WebSockets
For arb detection, freshness matters. Stale odds lead to phantom arbs that disappear by the time you try to bet.
| Approach | Latency | Rate Limit Impact | Best For |
|---|---|---|---|
| Polling (every 5s) | 0–5s stale | High — burns API calls | Getting started, low-frequency markets |
| Polling (every 30s) | 0–30s stale | Moderate | Less competitive markets |
| WebSocket | Near real-time | Minimal | Production, competitive markets |
If your odds provider supports WebSocket feeds, use them. Polling at aggressive intervals (sub-5-second) will burn through API quotas fast.
Stage 2: The Comparison Engine
The comparison engine takes normalized odds data and finds the best available odds per outcome across all books, then checks for arbitrage.
from dataclasses import dataclass
@dataclass
class ArbOpportunity:
event: str
outcomes: list[dict] # [{name, book, odds, implied_prob, stake}]
total_implied: float
arb_pct: float
profit: float
total_stake: float
def find_arbs(events: list[dict], min_arb_pct: float = 0.5) -> list[ArbOpportunity]:
"""Scan events for arbitrage opportunities.
Args:
events: Normalized event data from the odds API.
min_arb_pct: Minimum arb percentage to report (filters noise).
Returns:
List of ArbOpportunity objects for profitable arbs.
"""
arbs = []
for event in events:
# Find best odds for each outcome across all books
best_odds = {} # outcome_name -> {book, odds, implied}
for bookmaker in event.get("bookmakers", []):
for market in bookmaker.get("markets", []):
for outcome in market.get("outcomes", []):
name = outcome["name"]
odds = outcome["price"]
implied = 1 / odds
if name not in best_odds or odds > best_odds[name]["odds"]:
best_odds[name] = {
"name": name,
"book": bookmaker["key"],
"odds": odds,
"implied": implied,
}
if len(best_odds) < 2:
continue
# Check for arb
outcomes = list(best_odds.values())
total_implied = sum(o["implied"] for o in outcomes)
if total_implied >= 1.0:
continue # No arb
arb_pct = ((1 / total_implied) - 1) * 100
if arb_pct < min_arb_pct:
continue # Below threshold
arbs.append(ArbOpportunity(
event=event.get("home_team", "") + " vs " + event.get("away_team", ""),
outcomes=outcomes,
total_implied=total_implied,
arb_pct=arb_pct,
profit=0, # Calculated in stage 3
total_stake=0,
))
return arbs
Pre-Filtering for Speed
If you’re scanning hundreds of events across 10+ books, the comparison engine needs to be fast. Two optimizations:
- Skip stale events — ignore events that started more than N minutes ago
- Track previous state — only re-evaluate an event when any book’s odds change (use a hash of the odds vector)
Stage 3: Stake Calculation
Once you’ve found an arb, you need to calculate exactly how much to stake on each outcome. The goal: equal profit regardless of which outcome wins.
Equal-Profit Staking
def calculate_stakes(
outcomes: list[dict],
total_stake: float = 1000.0,
fee_pct: float = 0.0
) -> list[dict]:
"""Calculate optimal stakes for equal profit across all outcomes.
Args:
outcomes: List of dicts with 'odds' (decimal) and 'implied' keys.
total_stake: Total amount to distribute across all bets.
fee_pct: Platform fee as a percentage (e.g., 2.0 for 2%).
Returns:
Updated outcomes list with 'stake', 'payout', and 'profit' keys.
"""
total_implied = sum(o["implied"] for o in outcomes)
fee_mult = 1 - (fee_pct / 100)
for outcome in outcomes:
# Stake proportional to implied probability
outcome["stake"] = total_stake * (outcome["implied"] / total_implied)
# Payout if this outcome wins, after fees
outcome["payout"] = outcome["stake"] * outcome["odds"] * fee_mult
# Profit = payout minus total stake (not just this outcome's stake)
outcome["profit"] = outcome["payout"] - total_stake
return outcomes
Fee-Adjusted Calculation
Different platforms charge different fees:
| Platform | Fee Structure | Effective Fee |
|---|---|---|
| Most sportsbooks | Vig baked into odds | 0% additional (already reflected) |
| Polymarket | ~2% on net winnings | 2% on profit portion |
| Kalshi | 0% on contract payouts | 0% |
| Betfair Exchange | 2–5% commission on net winnings | Varies by market |
When arbing across sportsbook-to-sportsbook, the vig is already in the odds you see — so fee_pct should typically be 0. When arbing across sportsbook-to-exchange or sportsbook-to-prediction-market, you may need to factor in the exchange/platform fee.
Bankroll Management
Not every dollar should go into a single arb. A simple approach:
def kelly_stake(arb_pct: float, bankroll: float, fraction: float = 0.25) -> float:
"""Calculate stake using fractional Kelly criterion.
Args:
arb_pct: Arb percentage (e.g., 2.5 for 2.5%).
bankroll: Total available bankroll.
fraction: Kelly fraction (0.25 = quarter Kelly, conservative).
Returns:
Maximum total stake for this arb.
"""
# For arbs, the edge is known and risk-free, so Kelly = edge / 1
# But we use fractional Kelly for practical reasons (account limits,
# execution risk, keeping capital available for multiple arbs)
kelly_pct = arb_pct / 100
return bankroll * kelly_pct * fraction
Quarter-Kelly is a common starting point. It keeps you from over-concentrating capital in a single arb, which matters when you need liquidity for the next opportunity.
Stage 4: Execution
Detection without execution is just research. The execution engine places the actual bets.
Speed Is Everything
The window for a typical arb is measured in seconds. Your execution layer needs to:
- Place all legs in parallel — don’t wait for one bet to confirm before placing the next
- Use pre-authenticated sessions — no login delays at execution time
- Minimize network hops — run from a low-latency location (cloud VMs near the books’ servers)
import asyncio
import aiohttp
async def place_bet(session, book_api, bet_details):
"""Place a single bet via a sportsbook's API."""
try:
async with session.post(
book_api["endpoint"],
json=bet_details,
headers=book_api["auth_headers"],
timeout=aiohttp.ClientTimeout(total=5)
) as response:
result = await response.json()
return {"book": book_api["name"], "success": True, "result": result}
except Exception as e:
return {"book": book_api["name"], "success": False, "error": str(e)}
async def execute_arb(arb: ArbOpportunity, book_apis: dict):
"""Execute all legs of an arb simultaneously."""
async with aiohttp.ClientSession() as session:
tasks = []
for outcome in arb.outcomes:
book = outcome["book"]
if book not in book_apis:
continue
bet = {
"event_id": arb.event_id,
"outcome": outcome["name"],
"stake": round(outcome["stake"], 2),
"odds": outcome["odds"],
}
tasks.append(place_bet(session, book_apis[book], bet))
results = await asyncio.gather(*tasks)
return results
Handling Partial Fills
The worst scenario: one leg of your arb executes but the other fails (odds moved, bet rejected, account limited). You’re now exposed to one-sided risk.
Mitigation strategies:
- Check odds immediately before execution — abort if the arb has disappeared
- Set maximum acceptable odds drift — if odds have moved more than X% since detection, skip
- Have a hedging plan — if one leg fails, immediately hedge the executed leg at the best available odds (accepting a reduced profit or small loss)
- Track partial execution rate — if a specific book frequently rejects bets, deprioritize it
Why Automation Is Essential
Manual arb betting — scanning odds by eye, calculating stakes on a calculator, placing bets by hand — doesn’t work at scale for three reasons:
- Speed: Arbs last 30 seconds to 5 minutes. By the time you spot one, do the math, and navigate two sportsbook apps, the odds have moved.
- Scale: Monitoring 10+ books across hundreds of events requires checking thousands of odds combinations. No human can maintain this.
- Consistency: A bot runs 24/7, never gets distracted, never fat-fingers a stake, and never second-guesses a profitable opportunity.
The operational edge in arb betting is entirely in the automation. The math is simple — every bettor knows about arbitrage. The winners are the ones who detect and execute faster.
Putting It All Together
Here’s a simplified main loop that ties all four stages together:
import time
def run_arb_scanner(
sports: list[str],
bankroll: float = 10000,
min_arb_pct: float = 1.0,
poll_interval: int = 10,
):
"""Main loop: scan for arbs, calculate stakes, log opportunities."""
print(f"Starting arb scanner | Bankroll: ${bankroll:,.0f} | Min arb: {min_arb_pct}%")
while True:
for sport in sports:
# Stage 1: Fetch odds
events = fetch_odds(sport)
# Stage 2: Find arbs
arbs = find_arbs(events, min_arb_pct=min_arb_pct)
for arb in arbs:
# Stage 3: Calculate stakes
max_stake = kelly_stake(arb.arb_pct, bankroll)
arb.total_stake = min(max_stake, bankroll * 0.1) # Cap at 10% of bankroll
arb.outcomes = calculate_stakes(arb.outcomes, arb.total_stake)
arb.profit = min(o["profit"] for o in arb.outcomes)
print(f"\n{'='*60}")
print(f"ARB FOUND: {arb.event}")
print(f"Arb: {arb.arb_pct:.2f}% | Stake: ${arb.total_stake:.2f} | Profit: ${arb.profit:.2f}")
for o in arb.outcomes:
print(f" {o['name']} @ {o['odds']:.3f} on {o['book']} — ${o['stake']:.2f}")
# Stage 4: Execute (uncomment when ready for live betting)
# results = asyncio.run(execute_arb(arb, BOOK_APIS))
# handle_results(results, arb)
time.sleep(poll_interval)
# Run it
run_arb_scanner(
sports=["basketball_nba", "football_nfl", "soccer_epl"],
bankroll=5000,
min_arb_pct=1.0,
poll_interval=10,
)
This is a starting point. A production system adds: persistent logging, database for tracking bets and P&L, alerting (Telegram/Discord), account balance tracking per book, and graceful shutdown handling.
From Sports Arb to Cross-Market Arb
Traditional sports arb compares odds across sportsbooks for the same event. But some of the biggest pricing inefficiencies exist between market types — specifically between sportsbooks and prediction markets like Polymarket and Kalshi.
Consider an event like “Will the Eagles win the Super Bowl?” This is priced simultaneously on:
- Sportsbooks — as futures odds (e.g., +350 on DraftKings)
- Polymarket — as a binary contract (e.g., $0.22 per share)
- Kalshi — as a binary contract (e.g., $0.24 per share)
These markets have different participants, different fee structures, and different settlement mechanisms — creating larger and more persistent pricing discrepancies than you’d find between two sportsbooks alone.
Research from IMDEA Networks found that Polymarket traders captured over $40 million in arbitrage profits during the 2024 U.S. election cycle, primarily from cross-market price discrepancies.
The data pipeline for cross-market arb combines:
- Sportsbook odds via TheOddsPulse — normalized across 10+ books
- Polymarket prices via the Polymarket API — real-time CLOB data
- Kalshi prices via the Kalshi API — REST + WebSocket feeds
The Cross-Market Arbitrage Guide covers this in detail, including fee-adjusted calculations, settlement risk, and a complete event-matching pipeline. The Cross-Market Arb Finder tool shows live cross-market pricing in action.
Frequently Asked Questions
What is a sports betting arbitrage bot?
A sports betting arbitrage bot is software that automatically scans odds across multiple sportsbooks, detects when combined implied probabilities total less than 100% (an arbitrage opportunity), calculates optimal stakes for guaranteed profit regardless of outcome, and optionally executes the bets. The bot profits from pricing inefficiencies between books.
How much can you make with an arb betting bot?
Typical arb margins are 1–3% per opportunity. With $1,000 per arb and 5–10 arbs per day, gross profit ranges from $50–300 daily before accounting for fees, account limits, and execution failures. Profits scale with bankroll size and the number of sportsbooks monitored.
Is sports betting arbitrage legal?
Arbitrage betting is legal in most jurisdictions. However, sportsbooks discourage it and may limit or close accounts of suspected arb bettors. Using multiple sportsbooks, varying bet sizes, and mixing arb bets with recreational bets can help extend account longevity.
What programming language is best for an arb bot?
Python is the most common choice due to its ecosystem (requests, websockets, pandas) and rapid development speed. For latency-critical execution, some teams use Go, Rust, or C++ for the execution layer while keeping Python for the scanning and calculation components.
How fast do sports arb opportunities disappear?
Most sports arb opportunities last between 30 seconds and 5 minutes. Sharp lines move within seconds of an odds change at one book. This is why automation is essential — manual arb betting cannot consistently capture opportunities before they close.
See Also
- Arbitrage Calculator — Calculate arb percentages and optimal stakes instantly
- Odds Converter — Convert between American, decimal, and fractional odds
- Cross-Market Arbitrage Guide — Arb across sportsbooks and prediction markets
- Cross-Market Arb Finder — Live cross-market arb scanner
- Polymarket API Guide — Full Polymarket API reference
- Kalshi API Guide — Full Kalshi API reference
- Agent Intelligence Guide — Building AI agents for prediction markets
This guide is maintained by AgentBets.ai. Found an error? Let us know on Twitter.
Not financial advice. Built for builders.