BettingLab

Build a Real-Time Arbitrage Detector in Python

Marcus Hale
Marcus Hale

Build a Real-Time Arbitrage Detector in Python

If you're searching for a sports betting arbitrage detector in Python, you've probably already run the math by hand a few times and decided it doesn't scale. You're right — it doesn't. Manually checking two or three books for arb windows is a fool's errand. Markets move in seconds. By the time you open three tabs and type in the odds, the window is gone.

This tutorial walks you through a real, runnable arbitrage scanner that polls the MoneyLine API for live odds across books, computes arb percentages for two-outcome markets, and logs any positive-EV guaranteed-profit opportunities to your terminal (or a downstream alert layer). We'll use Python 3.11+, httpx for async requests, and keep dependencies minimal.


What "Arbitrage" Actually Means in This Context

Quick primer before we write a line of code, because I see a lot of amateur implementations that get this wrong.

Arbitrage in sports betting means placing proportional bets on all outcomes of an event across different books such that you profit regardless of result. The math is:

arb_pct = (1 / odds_A_decimal) + (1 / odds_B_decimal)

If arb_pct < 1.0, you have an arb. The implied profit margin is (1 - arb_pct) * 100%. A value of 0.97 means you lock in ~3% guaranteed return.

Most arb detectors stop there. Ours will also surface the stake split — how much to put on each side — so you can act immediately. For a deeper breakdown of EV vs. arb concepts, see the EV betting primer we published separately.

Two caveats I want to name upfront:

  1. Books limit arbers aggressively. Rotate accounts, use low-profile sizing.
  2. True arb windows on mainline markets (full-game moneyline, spread) last under 60 seconds at sharp books. Props and alternate lines survive longer.

This scanner is designed to find them fast. Speed is the product.


Architecture Overview

The scanner does four things in a loop:

  1. Poll /v1/odds — fetch live two-way odds across multiple books for a set of event IDs.
  2. Compute arb percentage — for each event, find the best price on each side across all books.
  3. Filter — keep only events where arb_pct < 1.0.
  4. Print / alert — emit structured output you can pipe to Slack, Discord, or a database.

We'll also pull from /v1/events to seed the event ID list dynamically, so you're not hardcoding games.

Everything runs in an async loop with configurable poll interval. I'd run this at 15–30 second intervals to avoid hammering rate limits on the free tier (1k credits/month) or staging costs on paid tiers.


The Full Python Implementation

Install dependencies first:

pip install httpx python-dotenv

Create a .env:

MONEYLINE_API_KEY=your_key_here

Now the scanner:

# arb_scanner.py
# Real-time arbitrage detector using MoneyLine API
# Target: two-outcome markets (moneyline, spreads)
# Author: Marcus Hale / BettingLab

import asyncio
import os
from datetime import datetime, timezone
from typing import Optional

import httpx
from dotenv import load_dotenv

load_dotenv()

API_KEY = os.getenv("MONEYLINE_API_KEY")
BASE_URL = "https://mlapi.bet"
HEADERS = {"Authorization": f"Bearer {API_KEY}", "Accept": "application/json"}

# Config
SPORTS = ["baseball_mlb", "basketball_nba", "soccer_usa_mls"]
POLL_INTERVAL_SECONDS = 20
MIN_ARB_MARGIN = 0.0  # surface anything < 1.0; raise to 0.01 for 1%+ only
STAKE = 1000.0        # hypothetical total stake for sizing output


def american_to_decimal(american: int) -> float:
    """Convert American odds to decimal."""
    if american > 0:
        return (american / 100) + 1.0
    else:
        return (100 / abs(american)) + 1.0


def compute_arb(dec_a: float, dec_b: float) -> float:
    """Return implied arb percentage. < 1.0 = profitable."""
    return (1 / dec_a) + (1 / dec_b)


def compute_stakes(dec_a: float, dec_b: float, total_stake: float) -> tuple[float, float]:
    """Return optimal stake allocation for each side."""
    inv_a = 1 / dec_a
    inv_b = 1 / dec_b
    total_inv = inv_a + inv_b
    stake_a = (inv_a / total_inv) * total_stake
    stake_b = (inv_b / total_inv) * total_stake
    return round(stake_a, 2), round(stake_b, 2)


async def fetch_events(client: httpx.AsyncClient, sport: str) -> list[dict]:
    """Pull live/upcoming events for a sport."""
    try:
        resp = await client.get(
            f"{BASE_URL}/v1/events",
            headers=HEADERS,
            params={"sport": sport, "status": "upcoming", "limit": 20},
            timeout=10.0,
        )
        resp.raise_for_status()
        return resp.json().get("events", [])
    except Exception as e:
        print(f"[WARN] fetch_events failed for {sport}: {e}")
        return []


async def fetch_odds(client: httpx.AsyncClient, event_id: str) -> dict:
    """Fetch multi-book odds for a single event."""
    try:
        resp = await client.get(
            f"{BASE_URL}/v1/odds",
            headers=HEADERS,
            params={"event_id": event_id, "market": "h2h"},
            timeout=10.0,
        )
        resp.raise_for_status()
        return resp.json()
    except Exception as e:
        print(f"[WARN] fetch_odds failed for event {event_id}: {e}")
        return {}


def find_best_prices(odds_data: dict) -> Optional[dict]:
    """
    Walk the odds payload, find best available price per outcome across books.
    Returns a dict keyed by outcome name -> {price_decimal, book}.
    """
    bookmakers = odds_data.get("bookmakers", [])
    best: dict[str, dict] = {}

    for bm in bookmakers:
        book_name = bm.get("key", "unknown")
        for market in bm.get("markets", []):
            if market.get("key") != "h2h":
                continue
            for outcome in market.get("outcomes", []):
                name = outcome.get("name")
                price = outcome.get("price")  # decimal from API
                if name is None or price is None:
                    continue
                if name not in best or price > best[name]["decimal"]:
                    best[name] = {"decimal": price, "book": book_name}

    return best if len(best) >= 2 else None


def detect_arb(event: dict, odds_data: dict) -> Optional[dict]:
    """Return arb opportunity dict if one exists, else None."""
    best_prices = find_best_prices(odds_data)
    if not best_prices:
        return None

    outcomes = list(best_prices.items())
    if len(outcomes) != 2:
        # Skip 3-way markets for now (draws require separate logic)
        return None

    name_a, info_a = outcomes[0]
    name_b, info_b = outcomes[1]

    dec_a = info_a["decimal"]
    dec_b = info_b["decimal"]
    arb_pct = compute_arb(dec_a, dec_b)

    if arb_pct >= 1.0 - MIN_ARB_MARGIN:
        return None  # No arb

    profit_margin = round((1.0 - arb_pct) * 100, 3)
    stake_a, stake_b = compute_stakes(dec_a, dec_b, STAKE)
    guaranteed_profit = round(STAKE * (1 / arb_pct) - STAKE, 2)

    return {
        "event_id": event.get("id"),
        "event": f"{event.get('home_team')} vs {event.get('away_team')}",
        "sport": event.get("sport_key"),
        "commence_time": event.get("commence_time"),
        "arb_pct": round(arb_pct, 5),
        "profit_margin_pct": profit_margin,
        "guaranteed_profit_usd": guaranteed_profit,
        "side_a": {
            "outcome": name_a,
            "decimal_odds": dec_a,
            "book": info_a["book"],
            "stake": stake_a,
        },
        "side_b": {
            "outcome": name_b,
            "decimal_odds": dec_b,
            "book": info_b["book"],
            "stake": stake_b,
        },
    }


def print_arb(arb: dict) -> None:
    ts = datetime.now(timezone.utc).strftime("%H:%M:%S UTC")
    print(f"\n{'='*60}")
    print(f"[{ts}] 🔒 ARB DETECTED — {arb['profit_margin_pct']}% guaranteed")
    print(f"  Event   : {arb['event']}")
    print(f"  Sport   : {arb['sport']}")
    print(f"  Arb pct : {arb['arb_pct']} (< 1.0 = profitable)")
    print(f"  Profit  : ${arb['guaranteed_profit_usd']} on ${STAKE:.0f} total stake")
    print(f"  Side A  : {arb['side_a']['outcome']} @ {arb['side_a']['decimal_odds']}"
          f" on {arb['side_a']['book']} → stake ${arb['side_a']['stake']}")
    print(f"  Side B  : {arb['side_b']['outcome']} @ {arb['side_b']['decimal_odds']}"
          f" on {arb['side_b']['book']} → stake ${arb['side_b']['stake']}")
    print(f"{'='*60}")


async def scan_once(client: httpx.AsyncClient) -> list[dict]:
    """One full scan pass across all configured sports."""
    found = []

    for sport in SPORTS:
        events = await fetch_events(client, sport)
        tasks = [fetch_odds(client, e["id"]) for e in events if e.get("id")]
        odds_results = await asyncio.gather(*tasks)

        for event, odds_data in zip(events, odds_results):
            if not odds_data:
                continue
            arb = detect_arb(event, odds_data)
            if arb:
                print_arb(arb)
                found.append(arb)

    if not found:
        ts = datetime.now(timezone.utc).strftime("%H:%M:%S UTC")
        print(f"[{ts}] No arbs found this pass. Waiting {POLL_INTERVAL_SECONDS}s...")

    return found


async def main():
    print(f"BettingLab Arb Scanner — polling every {POLL_INTERVAL_SECONDS}s")
    print(f"Sports: {', '.join(SPORTS)}")
    print(f"Hypothetical stake: ${STAKE:.0f}\n")

    async with httpx.AsyncClient() as client:
        while True:
            await scan_once(client)
            await asyncio.sleep(POLL_INTERVAL_SECONDS)


if __name__ == "__main__":
    asyncio.run(main())

Sample Output

When an arb is found, you'll see something like:

============================================================
[14:32:07 UTC] 🔒 ARB DETECTED — 2.14% guaranteed
  Event   : Toronto Blue Jays vs Baltimore Orioles
  Sport   : baseball_mlb
  Arb pct : 0.97898 (< 1.0 = profitable)
  Profit  : $21.86 on $1000 total stake
  Side A  : Toronto Blue Jays @ 2.91 on BetOpenly → stake $524.32
  Side B  : Baltimore Orioles @ 1.61 on Novig → stake $475.68
============================================================

Extending the Scanner: Discord and Filtering

The print layer is intentionally dumb. You'd swap print_arb for a POST to a Discord webhook, a database insert, or a Slack message. Here's a minimal webhook shim:

import httpx

DISCORD_WEBHOOK = "https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_TOKEN"

async def alert_discord(client: httpx.AsyncClient, arb: dict) -> None:
    msg = (
        f"**ARB DETECTED** — {arb['profit_margin_pct']}% guaranteed\n"
        f"`{arb['event']}` | {arb['sport']}\n"
        f"Side A: **{arb['side_a']['outcome']}** @ {arb['side_a']['decimal_odds']} "
        f"on `{arb['side_a']['book']}` — stake ${arb['side_a']['stake']}\n"
        f"Side B: **{arb['side_b']['outcome']}** @ {arb['side_b']['decimal_odds']} "
        f"on `{arb['side_b']['book']}` — stake ${arb['side_b']['stake']}\n"
        f"Guaranteed profit: **${arb['guaranteed_profit_usd']}**"
    )
    await client.post(DISCORD_WEBHOOK, json={"content": msg})

Replace the print_arb(arb) call with await alert_discord(client, arb) and you have a live Discord alert bot. We built the full version of this pattern in the GPT-4o line move explainer Discord bot post if you want a more production-grade async webhook setup.

You'll also want to add deduplication — store a set of (event_id, book_a, book_b) tuples so you don't ping yourself 40 times in a row on the same window.


Connecting to /v1/edge for Pre-Filtered Opportunities

If you don't want to roll your own arb math from raw odds, the MoneyLine API also exposes /v1/edge, which returns pre-scored edges including arbitrage flags. This is useful if you want to combine EV plays and arb plays in a single dashboard. The /v1/edge endpoint returns a type field that can be "arbitrage", "positive_ev", or "sharp_fade". You can use it as a fast pre-filter:

async def fetch_edges(client: httpx.AsyncClient, sport: str) -> list[dict]:
    resp = await client.get(
        f"{BASE_URL}/v1/edge",
        headers=HEADERS,
        params={"sport": sport, "type": "arbitrage", "limit": 10},
        timeout=10.0,
    )
    resp.raise_for_status()
    return resp.json().get("edges", [])

This is lower latency than building from raw /v1/odds because the server has already done the cross-book comparison for you. The trade-off: less control over the math. For a production system, I'd use /v1/edge as a fast notification layer and /v1/odds for verification and stake sizing. See the API reference for field-level docs on both endpoints.


Frequently Asked Questions

Q: How often should I poll the API to catch arb windows before they close?

A: Realistically, 10–20 seconds is the sweet spot for the MoneyLine free tier (1k credits/month). Sharp books close mainline arbs in under 60 seconds. Props and alt lines are slower — sometimes 5-10 minutes. Poll faster on mainlines during live-game hours, slower on pre-game props. On paid tiers you can push toward 5-second intervals.

Q: Why does the scanner skip three-way markets?

A: Three-way markets (soccer with a draw outcome) require distributing stake across three legs, and the no-vig calculations shift slightly. The math isn't hard — extend compute_arb to sum three reciprocals — but I wanted the tutorial code to be readable first. For draw-inclusive arbitrage, see the arbitrage betting strategy guide.

Q: What does arb_pct of 0.978 mean practically?

A: It means your combined implied probability across both sides is 97.8%, not 100%+. The 2.2% gap is your locked-in profit margin before juice. On a $1,000 stake you'd clear approximately $22 regardless of outcome, assuming both bets get filled at the quoted prices.

Q: Can I use this on live (in-play) markets?

A: Yes, but latency requirements tighten dramatically. You'd need to reduce POLL_INTERVAL_SECONDS to 5 or less, add WebSocket support if the API exposes it, and wire your stake submission directly to book APIs. In-play arbs are real but they require sub-5 second execution chains. This scanner gives you the detection layer; execution is on you.

Q: Will books limit me for arbing?

A: Aggressively, yes. Pinnacle and exchange-style books (Novig, ProphetX) are tolerant. Square books (DraftKings, FanDuel, BetMGM) will stake-limit or ban arbers within weeks if you're consistent. The standard advice: keep stakes small, vary amounts, don't bet round numbers, and don't always arb the same pair of books.


Where to Go From Here

This scanner is a working foundation. The production path looks like:

  1. Deduplication store — Redis set of seen (event_id, book_a, book_b) tuples.
  2. Stake sizing — Kelly-adjacent, not flat $1,000. You need bankroll state.
  3. Execution hooks — POST to book APIs or Betfair Exchange API for auto-bet.
  4. Logging — every opportunity to Postgres so you can backtest discovery latency vs. close latency.

The free tier at MoneyLine API is 1k credits/month — enough for development and initial testing. At 20-second polling intervals across three sports with 20 events each, you're burning roughly 3 credits per minute. Do the math and set your POLL_INTERVAL_SECONDS accordingly before you run this overnight.

Build with the same data we use.

MoneyLine API powers BettingLab's edge calculations. Free tier, 1k credits/month.