DEV Community

Cover image for How to Build a Sports Betting Arbitrage Scanner in Python
Mlaz-code
Mlaz-code

Posted on

How to Build a Sports Betting Arbitrage Scanner in Python

In my previous post, I walked through how I built SharpAPI — a real-time sports betting odds API using SSE streaming. Now let's flip to the other side: using that API to build something useful.

We're going to build a sports betting arbitrage scanner in Python. When two sportsbooks disagree on a game's outcome enough, you can bet both sides and lock in a guaranteed profit. The math is simple. The hard part is getting live odds from multiple books fast enough to catch these windows before they close.

By the end of this post, you'll have a working scanner (~200 lines of Python) that finds arb opportunities and pings you the moment one appears.


What Is Sports Betting Arbitrage?

Arbitrage exploits pricing differences between sportsbooks. Here's the simplest case — a two-way moneyline:

  • DraftKings has the Celtics at +150 (decimal 2.50)
  • FanDuel has the Heat at -130 (decimal 1.769)

Convert to implied probabilities and sum them:

1/2.50 + 1/1.769 = 0.400 + 0.565 = 0.965
Enter fullscreen mode Exit fullscreen mode

When the sum is less than 1.0, an arbitrage exists. The gap is your guaranteed profit margin — in this case, 3.5% no matter who wins.

You split your bankroll proportionally across both sides, and the math guarantees you come out ahead. No predictions, no gut feelings, just arithmetic.


What You'll Need

  • Python 3.9+
  • A free SharpAPI API key (no credit card required)
  • Basic familiarity with REST APIs and JSON
pip install sharpapi requests
Enter fullscreen mode Exit fullscreen mode

Head to sharpapi.io/dashboard/api-keys and grab your key. The free tier gives you 12 requests per minute and 2 sportsbooks — enough to build and test a working scanner.

export SHARPAPI_KEY="your_api_key_here"
Enter fullscreen mode Exit fullscreen mode

Step 1: Pull Live Odds from Multiple Sportsbooks

The SharpAPI Python SDK normalizes odds from multiple books into a single response. One call, all the data:

import os
from sharpapi import SharpAPI

client = SharpAPI(os.environ["SHARPAPI_KEY"])

odds = client.odds("nba")

for game in odds:
    print(f"\n{game['away_team']} @ {game['home_team']}")
    for book in game["bookmakers"]:
        print(f"  {book['name']}:")
        for market in book["markets"]:
            for outcome in market["outcomes"]:
                print(f"    {outcome['name']}: {outcome['price']} "
                      f"(implied: {1/outcome['price']:.1%})")
Enter fullscreen mode Exit fullscreen mode

You'll get back something like:

{
  "away_team": "Boston Celtics",
  "home_team": "Miami Heat",
  "bookmakers": [
    {
      "name": "DraftKings",
      "markets": [{
        "key": "h2h",
        "outcomes": [
          {"name": "Boston Celtics", "price": 1.55},
          {"name": "Miami Heat", "price": 2.50}
        ]
      }]
    },
    {
      "name": "FanDuel",
      "markets": [{
        "key": "h2h",
        "outcomes": [
          {"name": "Boston Celtics", "price": 1.48},
          {"name": "Miami Heat", "price": 2.75}
        ]
      }]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

FanDuel has Miami at 2.75 while DraftKings has Boston at 1.55. Let's see if those create an arb.


Step 2: Detect Arbitrage Opportunities

For every game, we find the best price for each outcome across all sportsbooks, then check if the implied probabilities sum to less than 1:

def find_arbs(game: dict) -> list[dict]:
    """Find arbitrage opportunities in a game's odds."""
    arbs = []
    markets = {}

    for book in game["bookmakers"]:
        for market in book["markets"]:
            key = market["key"]
            if key not in markets:
                markets[key] = {}
            for outcome in market["outcomes"]:
                name = outcome["name"]
                if name not in markets[key]:
                    markets[key][name] = []
                markets[key][name].append({
                    "price": outcome["price"],
                    "book": book["name"],
                })

    for market_key, outcomes in markets.items():
        outcome_names = list(outcomes.keys())
        if len(outcome_names) < 2:
            continue

        best = {}
        for name in outcome_names:
            best[name] = max(outcomes[name], key=lambda x: x["price"])

        implied_sum = sum(
            1.0 / best[name]["price"] for name in outcome_names
        )

        if implied_sum < 1.0:
            profit_pct = (1.0 - implied_sum) * 100
            arbs.append({
                "game": f"{game['away_team']} @ {game['home_team']}",
                "market": market_key,
                "profit_pct": round(profit_pct, 2),
                "legs": [
                    {
                        "outcome": name,
                        "book": best[name]["book"],
                        "odds": best[name]["price"],
                    }
                    for name in outcome_names
                ],
            })

    return arbs
Enter fullscreen mode Exit fullscreen mode

Step 3: Calculate Optimal Stake Sizing

When you find an arb, you need to split your bankroll so each leg returns the same total. This guarantees equal profit regardless of outcome:

def calculate_stakes(arb: dict, bankroll: float = 1000.0) -> dict:
    """Calculate optimal stakes for each leg of an arb."""
    legs = arb["legs"]
    implied_sum = sum(1.0 / leg["odds"] for leg in legs)

    stakes = []
    for leg in legs:
        implied_prob = 1.0 / leg["odds"]
        stake = bankroll * (implied_prob / implied_sum)
        payout = stake * leg["odds"]
        stakes.append({
            **leg,
            "stake": round(stake, 2),
            "payout": round(payout, 2),
        })

    guaranteed_return = stakes[0]["payout"]
    profit = guaranteed_return - bankroll

    return {
        **arb,
        "bankroll": bankroll,
        "stakes": stakes,
        "guaranteed_return": round(guaranteed_return, 2),
        "profit": round(profit, 2),
    }
Enter fullscreen mode Exit fullscreen mode

With our earlier example (DraftKings Celtics 1.55, FanDuel Heat 2.75):

DraftKings - Boston Celtics: stake $639.39, payout $991.05
FanDuel - Miami Heat:        stake $360.61, payout $991.67
Guaranteed profit: $12.83 on $1,000
Enter fullscreen mode Exit fullscreen mode

That's $12.83 guaranteed, win or lose. The math works every time.


Step 4: Build the Real-Time Scanner Loop

SharpAPI supports SSE streaming for real-time odds updates. This catches arb windows faster than polling because you see price changes the moment they happen. (I wrote about the SSE architecture here.)

Here's a scanner that supports both polling and streaming:

import json
import time
import requests

BASE_URL = "https://api.sharpapi.io/api/v1"

def scan_sport(client, sport: str, bankroll: float) -> int:
    """Scan a single sport for arbs via polling."""
    odds = client.odds(sport)
    found = 0
    for game in odds:
        for arb in find_arbs(game):
            result = calculate_stakes(arb, bankroll)
            print_arb(result)
            alert(result)
            found += 1
    return found

def stream_odds(sport: str, api_key: str):
    """Stream real-time odds updates via SSE."""
    url = f"{BASE_URL}/odds/{sport}/stream"
    headers = {
        "Authorization": f"Bearer {api_key}",
        "Accept": "text/event-stream",
    }
    with requests.get(url, headers=headers, stream=True) as resp:
        resp.raise_for_status()
        buffer = ""
        for chunk in resp.iter_content(decode_unicode=True):
            buffer += chunk
            while "\n\n" in buffer:
                event, buffer = buffer.split("\n\n", 1)
                data_lines = [
                    l[6:] for l in event.strip().split("\n")
                    if l.startswith("data: ")
                ]
                if data_lines:
                    yield json.loads("".join(data_lines))

def print_arb(result: dict):
    """Pretty-print an arbitrage opportunity."""
    print(f"\n{'='*60}")
    print(f"  ARB FOUND: {result['game']}")
    print(f"  Market: {result['market']}  |  "
          f"Profit: {result['profit_pct']}% (${result['profit']})")
    print(f"{''*60}")
    for s in result["stakes"]:
        print(f"  {s['book']:20s} {s['outcome']:25s} "
              f"odds {s['odds']:<8} stake ${s['stake']:>8}")
    print(f"  {'':20s} {'Guaranteed return':25s} "
          f"{'':8s}       ${result['guaranteed_return']:>8}")
    print(f"{'='*60}")
Enter fullscreen mode Exit fullscreen mode

For a simple multi-sport polling loop:

SPORTS = ["nba", "nfl", "mlb", "nhl"]

def run_scanner(client, bankroll=1000.0, interval=30):
    scan_count = 0
    arb_count = 0

    while True:
        scan_count += 1
        print(f"[Scan #{scan_count}] checking {len(SPORTS)} sports...")

        for sport in SPORTS:
            try:
                arb_count += scan_sport(client, sport, bankroll)
            except Exception as e:
                print(f"  Error scanning {sport}: {e}")
            time.sleep(1)

        print(f"  Total arbs found: {arb_count}")
        print(f"  Next scan in {interval}s...\n")
        time.sleep(interval)
Enter fullscreen mode Exit fullscreen mode

Step 5: Add Alerts (Telegram + Discord)

An arb that closes before you see it is worthless. Let's add real-time alerts:

from datetime import datetime

TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN")
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID")
DISCORD_WEBHOOK_URL = os.environ.get("DISCORD_WEBHOOK_URL")

def alert(result: dict):
    """Send arb alert to all configured channels."""
    msg = (
        f"Arb: {result['profit_pct']}% on {result['game']}\n"
        + "\n".join(
            f"  {s['book']} - {s['outcome']} @ {s['odds']} "
            f"(${s['stake']})"
            for s in result["stakes"]
        )
        + f"\nProfit: ${result['profit']} on "
          f"${result['bankroll']} bankroll"
    )

    if TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID:
        requests.post(
            f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}"
            f"/sendMessage",
            json={"chat_id": TELEGRAM_CHAT_ID,
                  "text": f"```
{% endraw %}
\n{msg}\n
{% raw %}
```",
                  "parse_mode": "Markdown"},
            timeout=5,
        )

    if DISCORD_WEBHOOK_URL:
        requests.post(
            DISCORD_WEBHOOK_URL,
            json={"content": f"```
{% endraw %}
\n{msg}\n
{% raw %}
```"},
            timeout=5,
        )
Enter fullscreen mode Exit fullscreen mode

Setup: create a Telegram bot via @BotFather, get your chat ID from @userinfobot, and set the env vars. For Discord, create a webhook in your channel settings.


The Complete Script

All of the above in one file — save as arb_scanner.py and run:

export SHARPAPI_KEY="your_key"
python arb_scanner.py --sport nba --bankroll 500 --interval 20
Enter fullscreen mode Exit fullscreen mode

Full source: arb_scanner.py gist (~200 lines — everything above combined with argparse CLI)


Setting Realistic Expectations

A few things to know before you run this in anger:

Arbs are rare. With 2 sportsbooks (free tier), expect a handful per day across all sports. With 15+ books (Pro plan), you'll see 5-20x more because more books means more pricing disagreements.

Margins are thin. Most arbs fall in the 0.5-3% range. On $1,000 that's $5-$30 per opportunity. The profit is guaranteed — but it's not a get-rich-quick scheme. Professional arb bettors make money through volume and speed.

Speed is everything. Arb windows can close in seconds as books adjust lines. SSE streaming catches opportunities that polling misses entirely. This is why I built SharpAPI on SSE instead of REST polling — the architecture post explains the tradeoffs.


What's Next: Beyond Basic Arbitrage

Once your scanner works, here are three ways to level up — all supported by the API:

+EV (Positive Expected Value) Detection

Instead of guaranteed profit on a single bet, +EV finds bets where the odds are better than the "true" probability. SharpAPI calculates this using Pinnacle's sharp lines as the reference:

odds = client.odds("nba", include="ev")

for game in odds:
    for book in game["bookmakers"]:
        for market in book["markets"]:
            for outcome in market["outcomes"]:
                if outcome.get("edge_pct", 0) > 2.0:
                    print(f"+EV: {outcome['name']} on {book['name']}")
                    print(f"  Edge: {outcome['edge_pct']}%")
Enter fullscreen mode Exit fullscreen mode

Over hundreds of +EV bets, the math works in your favor — like being the house.

Server-Side Arbitrage Detection

Skip the client-side math entirely and let the API find arbs for you:

odds = client.odds("nba", include="arb")

for game in odds:
    if game.get("arb_pct"):
        print(f"Arb: {game['arb_pct']}% - "
              f"{game['away_team']} @ {game['home_team']}")
Enter fullscreen mode Exit fullscreen mode

This checks all sportsbook combinations server-side, including books outside your current tier.

More Sportsbooks = More Arbs

The free tier includes 2 books. The Hobby plan ($79/mo, 3-day free trial) unlocks 5 books with real-time data. The math is simple: more books, more disagreements, more arbs.


Wrapping Up

In ~200 lines of Python, you now have a scanner that:

  1. Pulls live odds from multiple sportsbooks via one API call
  2. Detects arbitrage using implied probability math
  3. Calculates optimal stakes for guaranteed profit
  4. Alerts you in real time via Telegram or Discord

The hard part — aggregating odds from dozens of sportsbooks, normalizing formats, keeping data fresh in real time — is handled by the API. You focus on the strategy.

Full docs: docs.sharpapi.io
Free API key: sharpapi.io (no credit card)
Discord: Join the community


This is Part 2 of my "Building with SharpAPI" series. Part 1 covers how I built the API itself.

Top comments (0)