BettingLab

Build a Real-Time Arbitrage Detector in Python

Marcus Hale
Marcus Hale

Build a Real-Time Arbitrage Detector in Python

If you've ever tried to build a real-time arbitrage detector in Python, you already know the two hard parts: getting clean, synchronized odds across books, and doing the math fast enough to matter. The arbitrage window is usually measured in minutes — sometimes seconds after a sharp book moves. By the time you're scraping HTML, it's gone.

This post walks through a working Python script that hits the MoneyLine API /v1/odds endpoint, pulls multi-book odds for a slate of games, and flags any opportunity where the implied probabilities sum to less than 1.0 — a true arb. You'll get runnable code, the exact math, and a structure you can extend into a full alert system.

No fluff. Let's build.


What Arbitrage Actually Means (Fast Math Refresher)

Before we touch code, get the math locked in. An arbitrage exists when you can bet all outcomes of an event across different books such that your combined implied probability is below 100%. The gap is your guaranteed profit.

For a two-outcome market (moneyline):

implied_prob(American odds) =
  odds < 0  →  (-odds) / (-odds + 100)
  odds > 0  →  100 / (odds + 100)

If p1 + p2 < 1.0, arb exists. The profit margin is 1 - (p1 + p2).

Stake allocation to guarantee equal profit on both sides:

stake_side_A = total_stake * (1/odds_decimal_A) / (1/odds_decimal_A + 1/odds_decimal_B)
stake_side_B = total_stake - stake_side_A

That's the whole model. Everything else is data plumbing.

For a deeper look at the EV side of this — arb is just a special case of +EV where the edge is locked — see our EV betting explainer.


Setting Up: Dependencies and API Key

You need Python 3.10+, httpx for async requests, and nothing else exotic.

pip install httpx python-dotenv

Store your key:

# .env
MONEYLINE_API_KEY=your_key_here

Get your key from the MoneyLine API dashboard — free tier gives you 1,000 credits/month, which is plenty for scanning a daily slate.


Fetching Multi-Book Odds from /v1/odds

The /v1/odds endpoint returns current odds for an event across all books MoneyLine tracks. One call, one event, all books. Here's a minimal fetch:

import httpx
import os
from dotenv import load_dotenv

load_dotenv()

API_KEY = os.getenv("MONEYLINE_API_KEY")
BASE_URL = "https://mlapi.bet"

def fetch_odds(event_id: str) -> dict:
    url = f"{BASE_URL}/v1/odds"
    headers = {"Authorization": f"Bearer {API_KEY}"}
    params = {"event_id": event_id}
    response = httpx.get(url, headers=headers, params=params, timeout=10)
    response.raise_for_status()
    return response.json()

The response shape looks roughly like this (simplified):

{
  "event_id": "mlb-2026-nyy-bos-0803",
  "sport": "baseball_mlb",
  "home_team": "New York Yankees",
  "away_team": "Boston Red Sox",
  "markets": {
    "h2h": [
      { "book": "DraftKings",  "home_odds": -145, "away_odds": 125 },
      { "book": "FanDuel",     "home_odds": -140, "away_odds": 118 },
      { "book": "Novig",       "home_odds": -132, "away_odds": 122 },
      { "book": "BetOpenly",   "home_odds": -128, "away_odds": 130 }
    ]
  }
}

Now we have everything we need to scan.


The Arbitrage Detection Logic

Here's the core scanner. It checks every pair of books for a given event and reports any combination where the arb margin is positive.

from itertools import permutations
from typing import Optional

def american_to_decimal(odds: int) -> float:
    if odds > 0:
        return (odds / 100) + 1.0
    else:
        return (100 / abs(odds)) + 1.0

def implied_prob(odds: int) -> float:
    d = american_to_decimal(odds)
    return 1.0 / d

def find_arbs(odds_data: dict, total_stake: float = 100.0) -> list[dict]:
    """
    Scan all book pairs in a moneyline market for arbitrage opportunities.
    Returns a list of arb dicts, sorted by margin descending.
    """
    markets = odds_data.get("markets", {})
    h2h = markets.get("h2h", [])

    if not h2h:
        return []

    arbs = []

    # Check every home-book / away-book combination
    for home_entry in h2h:
        for away_entry in h2h:
            if home_entry["book"] == away_entry["book"]:
                continue  # Same book, not a true arb

            p_home = implied_prob(home_entry["home_odds"])
            p_away = implied_prob(away_entry["away_odds"])
            arb_sum = p_home + p_away

            if arb_sum < 1.0:
                margin = round((1.0 - arb_sum) * 100, 4)  # as %

                dec_home = american_to_decimal(home_entry["home_odds"])
                dec_away = american_to_decimal(away_entry["away_odds"])

                # Optimal stake split
                stake_home = total_stake * (1 / dec_home) / (1 / dec_home + 1 / dec_away)
                stake_away = total_stake - stake_home

                profit = round((stake_home * dec_home) - total_stake, 2)

                arbs.append({
                    "event":      odds_data.get("home_team", "?") + " vs " + odds_data.get("away_team", "?"),
                    "home_book":  home_entry["book"],
                    "home_odds":  home_entry["home_odds"],
                    "away_book":  away_entry["book"],
                    "away_odds":  away_entry["away_odds"],
                    "arb_sum":    round(arb_sum, 6),
                    "margin_pct": margin,
                    "stake_home": round(stake_home, 2),
                    "stake_away": round(stake_away, 2),
                    "profit":     profit,
                })

    arbs.sort(key=lambda x: x["margin_pct"], reverse=True)
    return arbs

Wiring It Together: Full Scanner Script

Now pull the event list from /v1/events, loop, and print any arbs found.

import httpx
import os
import json
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}"}

def american_to_decimal(odds: int) -> float:
    return (odds / 100 + 1.0) if odds > 0 else (100 / abs(odds) + 1.0)

def implied_prob(odds: int) -> float:
    return 1.0 / american_to_decimal(odds)

def find_arbs(odds_data: dict, total_stake: float = 100.0) -> list[dict]:
    h2h = odds_data.get("markets", {}).get("h2h", [])
    arbs = []

    for home_entry in h2h:
        for away_entry in h2h:
            if home_entry["book"] == away_entry["book"]:
                continue
            p_home = implied_prob(home_entry["home_odds"])
            p_away = implied_prob(away_entry["away_odds"])
            arb_sum = p_home + p_away

            if arb_sum < 1.0:
                dec_home = american_to_decimal(home_entry["home_odds"])
                dec_away = american_to_decimal(away_entry["away_odds"])
                stake_home = total_stake * (1 / dec_home) / (1 / dec_home + 1 / dec_away)
                stake_away = total_stake - stake_home

                arbs.append({
                    "event":      f"{odds_data['home_team']} vs {odds_data['away_team']}",
                    "home_book":  home_entry["book"],
                    "home_odds":  home_entry["home_odds"],
                    "away_book":  away_entry["book"],
                    "away_odds":  away_entry["away_odds"],
                    "margin_pct": round((1.0 - arb_sum) * 100, 4),
                    "stake_home": round(stake_home, 2),
                    "stake_away": round(stake_away, 2),
                    "profit":     round(stake_home * dec_home - total_stake, 2),
                })

    return sorted(arbs, key=lambda x: x["margin_pct"], reverse=True)

def fetch_events(sport: str = "baseball_mlb") -> list[dict]:
    r = httpx.get(f"{BASE_URL}/v1/events", headers=HEADERS,
                  params={"sport": sport}, timeout=10)
    r.raise_for_status()
    return r.json().get("events", [])

def fetch_odds(event_id: str) -> dict:
    r = httpx.get(f"{BASE_URL}/v1/odds", headers=HEADERS,
                  params={"event_id": event_id}, timeout=10)
    r.raise_for_status()
    return r.json()

def scan_sport(sport: str = "baseball_mlb", stake: float = 500.0):
    print(f"\n=== Scanning {sport} for arbs (stake: ${stake}) ===\n")
    events = fetch_events(sport)
    print(f"Found {len(events)} events.\n")

    all_arbs = []

    for event in events:
        event_id = event.get("event_id")
        try:
            odds_data = fetch_odds(event_id)
            arbs = find_arbs(odds_data, total_stake=stake)
            all_arbs.extend(arbs)
        except Exception as e:
            print(f"  [WARN] {event_id}: {e}")

    if not all_arbs:
        print("No arbs found. Market is efficient right now.")
        return

    all_arbs.sort(key=lambda x: x["margin_pct"], reverse=True)

    for arb in all_arbs:
        print(
            f"  ARB FOUND  {arb['margin_pct']}%  |  {arb['event']}\n"
            f"    Bet HOME  {arb['home_odds']:+d} on {arb['home_book']}  →  ${arb['stake_home']}\n"
            f"    Bet AWAY  {arb['away_odds']:+d} on {arb['away_book']}  →  ${arb['stake_away']}\n"
            f"    Guaranteed profit: ${arb['profit']}\n"
        )

if __name__ == "__main__":
    scan_sport("baseball_mlb", stake=500.0)

Example Output

=== Scanning baseball_mlb for arbs (stake: $500.0) ===

Found 14 events.

  ARB FOUND  1.8300%  |  New York Yankees vs Boston Red Sox
    Bet HOME  -128 on BetOpenly  →  $259.34
    Bet AWAY  +125 on DraftKings  →  $240.66
    Guaranteed profit: $9.15

  ARB FOUND  0.6100%  |  Los Angeles Dodgers vs San Francisco Giants
    Bet HOME  -112 on Novig  →  $247.88
    Bet AWAY  +110 on FanDuel  →  $252.12
    Guaranteed profit: $3.05

Extending This: Alerts, Filtering, and Edge Cases

Once the core loop works, here's how to harden it for production use:

Rate limiting. Free tier is 1k credits/month. A full MLB slate is ~14 games; at 1 credit per /v1/odds call, one full scan costs 14 credits. Poll every 3 minutes and you'll burn ~200 credits/day. Fine for daily scanning, tight for live market monitoring. Upgrade or cache aggressively.

Discord/Slack alerts. Drop an httpx.post to a webhook when margin_pct > 0.5. Ten lines of code. Your phone buzzes, you place the bets. See how other builders are wiring up alert pipelines for patterns.

Filtering by book. Not every book in the dataset accepts sharp action or processes withdrawals fast. Hard-code a TRUSTED_BOOKS set and skip entries not in it. Arb only works if you can actually get both bets down.

Three-way markets. Soccer moneylines have a draw outcome. The sum threshold becomes p_home + p_away + p_draw < 1.0. Same logic, one more loop dimension.

Stale odds. Check the last_updated timestamp on each book's entry. If a quote is more than 90 seconds old, treat it as unreliable. Opening a position against a stale line and getting middled is worse than missing the arb.

For the conceptual framework on why arbs close so fast and what sharp books do differently, our arbitrage betting primer is worth the ten minutes.


FAQ

How many credits does scanning a full MLB slate cost? One /v1/odds call per event. A full 15-game MLB slate costs 15 credits. Add /v1/events to pull the list — that's 1 more. So roughly 16 credits per scan pass. The free tier's 1,000 credits/month supports ~60 full-slate scans before you need to upgrade.

What sports have the most arbitrage opportunities? MLB and NBA moneylines generate the most arbs because there are many competing books with different juice structures (especially the no-vig books like Novig vs. traditional books). Soccer draws are another sweet spot — the three-way market creates more surface area for discrepancies.

Why does arb_sum < 1.0 not guarantee I make money in practice? Three reasons: (1) limits — books cap stakes, so you may not get the full optimal size on; (2) bet rejection — sharp books sometimes cancel or void arb bets post-placement; (3) timing — by the time you place both legs manually, one line has moved. Automation and speed matter.

Can I use /v1/edge instead of /v1/odds for this? /v1/edge is designed for EV scanning — it already bakes in a no-vig fair line and returns the edge versus a target book. For arb detection you want the raw multi-book odds from /v1/odds so you can compare book A vs. book B directly without the API's EV model in the way.

Is arbitrage betting legal? In every US jurisdiction where sports betting is legal, arbitrage betting is legal. Books can limit or close accounts for arbing, but it is not illegal. It's a business relationship issue, not a legal one. Know your books' tolerance before depositing large.


The script above is production-ready for a solo operator running a daily scan. Wire it to a cron job, point it at a Discord webhook, and you've shipped a real tool in an afternoon. The edge is in the data freshness — that's exactly what the MoneyLine API is built to provide.

Build with the same data we use.

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