Build a Live Arbitrage Detector in Python
If you've ever watched two books post wildly different lines on the same game and thought "there's money in that gap" — you're right, and you can automate the whole thing. This tutorial walks you through how to build a live arbitrage detector in Python using the MoneyLine API. We're not talking about a toy demo. We're talking about something you could actually run on a cron job, pipe into a Slack bot, or extend into a full alert system.
The arbitrage math is simple. The engineering is also simple. What's hard is getting clean, normalized odds across multiple books fast enough to act. That's the only real problem worth solving here, and the MoneyLine API handles it.
What Arbitrage Detection Actually Requires
Before we touch code, let's be precise about what we're building.
A two-outcome arbitrage (think moneyline, spread side) exists when:
(1 / implied_prob_A) + (1 / implied_prob_B) < 1.0
If the sum of implied probabilities is less than 100%, the combined margin is negative — meaning you can bet both sides, guarantee a profit, and the books are eating the loss.
In practice:
- You need odds from multiple books for the same event and market
- You need them normalized to the same format (implied probability)
- You need to detect the arb fast enough to matter — lines move in minutes
- You need to know which books have which side, so you can actually place
The MoneyLine API /v1/odds endpoint gives you exactly this: multi-book odds for a given event, normalized, pulled from a single authenticated call. No scraping, no rate-limit roulette across six different book APIs, no mismatched team name normalization.
Project Structure and Setup
Keep this tight. Here's the structure:
arb-detector/
├── main.py
├── arb.py
├── notifier.py
├── requirements.txt
└── .env
requirements.txt
httpx
python-dotenv
rich
Install:
pip install -r requirements.txt
.env
MONEYLINE_API_KEY=your_api_key_here
Get your key from the MoneyLine API docs and dashboard. Free tier gives you 1,000 credits/month — enough to run this scanner a few hundred times depending on the response size.
The Core Code
arb.py — Pure arbitrage math
# arb.py
def american_to_implied(odds: int) -> float:
"""Convert American odds to implied probability."""
if odds > 0:
return 100 / (odds + 100)
else:
return abs(odds) / (abs(odds) + 100)
def find_arbs(odds_data: list[dict]) -> list[dict]:
"""
Given a list of outcome dicts with multi-book odds,
find two-outcome arbitrage opportunities.
Expected structure per outcome:
{
"outcome": "TeamA",
"books": [
{"book": "DraftKings", "price": -110},
{"book": "BetOpenly", "price": +105},
]
}
Returns a list of arb opportunities with profit margin.
"""
arbs = []
# We need at least two outcomes per market
if len(odds_data) < 2:
return arbs
# For each pair of outcomes, find the best available price on each side
# across all books
best_by_outcome = {}
for outcome in odds_data:
name = outcome["outcome"]
best_price = None
best_book = None
for entry in outcome.get("books", []):
price = entry["price"]
if best_price is None or price > best_price:
best_price = price
best_book = entry["book"]
if best_price is not None:
best_by_outcome[name] = {"price": best_price, "book": best_book}
outcomes = list(best_by_outcome.items())
# Check all pairs (handles two-way and can extend to three-way)
for i in range(len(outcomes)):
for j in range(i + 1, len(outcomes)):
name_a, data_a = outcomes[i]
name_b, data_b = outcomes[j]
prob_a = american_to_implied(data_a["price"])
prob_b = american_to_implied(data_b["price"])
margin = prob_a + prob_b
if margin < 1.0:
profit_pct = round((1 - margin) * 100, 3)
arbs.append({
"side_a": name_a,
"book_a": data_a["book"],
"price_a": data_a["price"],
"side_b": name_b,
"book_b": data_b["book"],
"price_b": data_b["price"],
"margin": round(margin, 6),
"profit_pct": profit_pct,
})
return sorted(arbs, key=lambda x: x["profit_pct"], reverse=True)
main.py — Fetch odds and run detection
# main.py
import httpx
import os
import time
from dotenv import load_dotenv
from rich.console import Console
from rich.table import Table
from arb import find_arbs
load_dotenv()
API_KEY = os.getenv("MONEYLINE_API_KEY")
BASE_URL = "https://mlapi.bet"
SPORT = "baseball_mlb"
MARKETS = "h2h" # moneyline; add spreads/totals as needed
POLL_INTERVAL = 60 # seconds
console = Console()
def fetch_events() -> list[dict]:
url = f"{BASE_URL}/v1/events"
params = {"sport": SPORT, "apiKey": API_KEY}
r = httpx.get(url, params=params, timeout=10)
r.raise_for_status()
return r.json().get("data", [])
def fetch_odds(event_id: str) -> dict:
url = f"{BASE_URL}/v1/odds"
params = {
"eventId": event_id,
"markets": MARKETS,
"apiKey": API_KEY,
}
r = httpx.get(url, params=params, timeout=10)
r.raise_for_status()
return r.json()
def parse_outcomes(odds_payload: dict) -> list[dict]:
"""
Parse the MoneyLine API odds response into the structure
expected by find_arbs().
"""
outcomes_map: dict[str, list] = {}
markets = odds_payload.get("data", {}).get("markets", [])
for market in markets:
if market.get("key") != MARKETS:
continue
for book_data in market.get("bookmakers", []):
book_name = book_data["key"]
for outcome in book_data.get("outcomes", []):
name = outcome["name"]
price = outcome["price"]
if name not in outcomes_map:
outcomes_map[name] = []
outcomes_map[name].append({"book": book_name, "price": price})
return [{"outcome": k, "books": v} for k, v in outcomes_map.items()]
def display_arbs(event_name: str, arbs: list[dict]):
if not arbs:
return
table = Table(title=f"ARB: {event_name}", show_header=True, header_style="bold magenta")
table.add_column("Side A", style="cyan")
table.add_column("Book A")
table.add_column("Price A", justify="right")
table.add_column("Side B", style="green")
table.add_column("Book B")
table.add_column("Price B", justify="right")
table.add_column("Profit %", justify="right", style="bold yellow")
for arb in arbs:
price_a = f"+{arb['price_a']}" if arb["price_a"] > 0 else str(arb["price_a"])
price_b = f"+{arb['price_b']}" if arb["price_b"] > 0 else str(arb["price_b"])
table.add_row(
arb["side_a"], arb["book_a"], price_a,
arb["side_b"], arb["book_b"], price_b,
f"{arb['profit_pct']}%",
)
console.print(table)
def run():
console.print(f"[bold]Arb detector running — polling every {POLL_INTERVAL}s[/bold]")
while True:
try:
events = fetch_events()
console.print(f"[dim]Scanning {len(events)} events...[/dim]")
for event in events:
event_id = event["id"]
event_name = f"{event.get('home_team')} vs {event.get('away_team')}"
odds_payload = fetch_odds(event_id)
outcomes = parse_outcomes(odds_payload)
arbs = find_arbs(outcomes)
if arbs:
display_arbs(event_name, arbs)
except httpx.HTTPStatusError as e:
console.print(f"[red]HTTP error: {e.response.status_code}[/red]")
except Exception as e:
console.print(f"[red]Error: {e}[/red]")
time.sleep(POLL_INTERVAL)
if __name__ == "__main__":
run()
How the Output Looks
When an arb exists, you'll see a formatted table in your terminal:
ARB: Athletics vs Mariners
┌──────────────┬─────────────┬─────────┬──────────────┬──────────┬─────────┬──────────┐
│ Side A │ Book A │ Price A │ Side B │ Book B │ Price B │ Profit % │
├──────────────┼─────────────┼─────────┼──────────────┼──────────┼─────────┼──────────┤
│ Athletics │ BetOpenly │ +290 │ Mariners │ Novig │ -240 │ 1.23% │
└──────────────┴─────────────┴─────────┴──────────────┴──────────┴─────────┴──────────┘
A 1.23% edge on a two-way market is real. If you're betting $500 each side, that's ~$12.30 locked. Scale up or hunt bigger gaps and it compounds fast.
Extensions Worth Building
Stake sizing output. Add a kelly_stakes() function that computes the exact dollar amounts to wager on each side given a bankroll. The formula for two-outcome arbs is deterministic — no probability guessing needed.
Discord / Slack push. Wire notifier.py to POST to a webhook when profit_pct > 0.5. You don't need to stare at a terminal. See arbitrage alert patterns for webhook designs that don't spam you with noise.
Spread and totals coverage. Change MARKETS to "spreads,totals" and extend parse_outcomes() to handle point values in addition to team names. The outcome key structure changes slightly — spreads include a point field you'll want to bucket on.
EV overlaps. Pull /v1/edge alongside /v1/odds to flag arbs that also have positive EV on one side. That's not arbitrage anymore — that's a sharp line mispriced by a soft book. Those situations are rarer but far more scalable. Check how EV edges work on BettingLab for the conceptual grounding.
Persistence. Log every detected arb to SQLite with a timestamp, event ID, and whether the line moved before you could act. After 30 days you'll have a dataset that tells you which books are slowest to adjust — gold for tuning your polling strategy.
Deployment Notes
Don't run this on your laptop. Use a small VPS (a $4/month instance is plenty) in a data center close to the API's origin. Latency from a cloud instance is meaningfully lower than from your home connection on most arb plays.
Set up a simple systemd service or run inside a tmux session. If you're containerizing: the requirements.txt above is clean, python:3.12-slim works fine, and you can pass MONEYLINE_API_KEY as an environment variable at runtime.
Watch your credit burn rate. Each /v1/events call plus N /v1/odds calls per poll cycle can add up. On a 60-second poll over MLB season (say 15 live games), you're at ~30 API calls/minute. Profile your usage in the MoneyLine API dashboard before going sub-30-second polling.
Frequently Asked Questions
Q: How many books does the MoneyLine API cover for arb detection?
The feed includes a broad set of sharp and recreational books — enough that genuine pricing gaps appear regularly. The exact book list is in the API docs. For arb purposes, coverage of at least one sharp book (Pinnacle, Circa-style pricing) alongside soft American-facing books is what generates usable gaps.
Q: Is arbitrage still profitable in 2026?
Two-way arbs on major markets are thin — typically sub-2% — and soft books are faster to limit accounts than they were five years ago. The real play is combining arb detection with EV identification: find spots where the mispriced side is from a book that hasn't caught up to sharp consensus. That's more durable than pure arbitrage.
Q: Can I detect three-way arbs with this code?
The find_arbs() function in arb.py already iterates all pairs via the nested loop. For three-way markets (soccer 1X2), you'd extend it to check all triplets. The margin formula generalizes: sum of three implied probabilities must be less than 1.0. The combinatorics get slightly heavier but nothing that taxes Python.
Q: How often should I poll the odds endpoint?
For live in-game markets, you want 15-30 second intervals — but verify your credit allocation first. For pre-game markets, 60-120 seconds is usually sufficient. Lines don't typically move faster than that in the hours before a game unless sharp action hits.
Q: Will this get my sportsbook account limited?
The detector itself doesn't touch your book accounts — it only calls the MoneyLine API. Your book exposure comes from placing the bets. Accounts that consistently take arb positions (winning on both sides over time) do get flagged by soft books. Sharp books like BetOpenly and Novig generally don't limit winners. The limiting issue is a placement-strategy problem, not an API problem.