Build a Live Arbitrage Detector in Python
If you've been building in this space for more than a week, you already know the painful loop: open four tabs, manually compare lines across books, do the implied probability math in your head, and by the time you've confirmed the arb exists, it's gone. A live arbitrage detector in Python solves that — and with the MoneyLine API feeding you clean, normalized, multi-book odds on one endpoint, it's maybe 150 lines of code to ship something real.
This post walks through the full build: pulling odds from /v1/odds, computing implied probabilities, identifying arb windows across books, and printing actionable alerts to your terminal (or piping them to a Discord webhook, a Telegram bot, whatever your stack is).
If you want background on what arb betting actually is and why it works, the arbitrage fundamentals piece on BettingLab is worth a read first. If you're here to build, let's get into it.
The Math You Need (And It's Not Much)
Arbitrage in sports betting exists when the combined implied probability of all outcomes across two or more books sums to less than 100%. The books have disagreed enough that you can cover both sides and lock in a guaranteed profit regardless of outcome.
For a two-outcome market (moneyline, spread, etc.):
American → Implied Probability:
- If odds are positive:
prob = 100 / (odds + 100) - If odds are negative:
prob = abs(odds) / (abs(odds) + 100)
Arb condition:
implied_prob_side_A + implied_prob_side_B < 1.0
Profit % (given equal-stake normalized betting):
arb_pct = 1 - (prob_A + prob_B)
A positive arb_pct means free money, modulo execution risk and account health. The bigger the number, the more aggressive you want to be.
The EV betting reference on BettingLab covers related Kelly-sizing logic if you want to go deeper on stake allocation.
What the MoneyLine API Gives You
The MoneyLine API serves normalized odds across 20+ sportsbooks on a single endpoint. No scraping, no per-book auth dance, no schema normalization work on your side. You hit /v1/odds, filter by sport and market type, and you get back a structured list of events with all available books and their current lines.
Key fields we'll use:
event_id— links outcomes togetherhome_team/away_teammarket— moneyline, spread, total, etc.outcomes[]— array of{ book, side, odds_american }
The free tier gives you 1,000 credits/month, and a /v1/odds call costs 1 credit. If you're polling every 30 seconds across a handful of sports, do the math — you'll want to be surgical about which markets you watch.
The Full Python Script
This is production-adjacent code, not pseudocode. It runs. You need httpx and python-dotenv installed (pip install httpx python-dotenv).
# arb_detector.py
# Polls MoneyLine API /v1/odds, detects two-way arbitrage opportunities,
# and prints actionable alerts. Extend the alert() function for Discord/Telegram.
import httpx
import time
import os
from dotenv import load_dotenv
from itertools import combinations
load_dotenv()
API_KEY = os.getenv("MONEYLINE_API_KEY")
BASE_URL = "https://mlapi.bet"
POLL_INTERVAL = 30 # seconds between polls
MIN_ARB_PCT = 0.003 # only alert on arbs >= 0.3%
HEADERS = {
"Authorization": f"Bearer {API_KEY}",
"Accept": "application/json",
}
def american_to_implied(odds: int) -> float:
"""Convert American odds to implied probability (no vig)."""
if odds > 0:
return 100 / (odds + 100)
else:
return abs(odds) / (abs(odds) + 100)
def fetch_odds(sport: str = "baseball_mlb", market: str = "h2h") -> list[dict]:
"""Fetch current odds from MoneyLine API for a given sport + market."""
url = f"{BASE_URL}/v1/odds"
params = {
"sport": sport,
"market": market,
"regions": "us",
}
try:
resp = httpx.get(url, headers=HEADERS, params=params, timeout=10)
resp.raise_for_status()
return resp.json().get("data", [])
except httpx.HTTPStatusError as e:
print(f"[ERROR] API returned {e.response.status_code}: {e.response.text}")
return []
except httpx.RequestError as e:
print(f"[ERROR] Request failed: {e}")
return []
def find_arbs(events: list[dict]) -> list[dict]:
"""
For each event, group outcomes by side (home/away or over/under).
Find the best available odds for each side across all books,
then check if the combination creates an arb.
"""
arbs = []
for event in events:
event_id = event.get("event_id")
home = event.get("home_team", "Home")
away = event.get("away_team", "Away")
outcomes = event.get("outcomes", [])
if not outcomes:
continue
# Group by side label
side_map: dict[str, list[dict]] = {}
for o in outcomes:
side = o.get("side")
if side not in side_map:
side_map[side] = []
side_map[side].append(o)
sides = list(side_map.keys())
# Only handle two-outcome markets for now
if len(sides) != 2:
continue
side_a_label, side_b_label = sides[0], sides[1]
# Best (highest) odds for each side
best_a = max(side_map[side_a_label], key=lambda x: x["odds_american"])
best_b = max(side_map[side_b_label], key=lambda x: x["odds_american"])
prob_a = american_to_implied(best_a["odds_american"])
prob_b = american_to_implied(best_b["odds_american"])
total_prob = prob_a + prob_b
if total_prob < 1.0:
arb_pct = 1.0 - total_prob
if arb_pct >= MIN_ARB_PCT:
arbs.append({
"event_id": event_id,
"matchup": f"{away} @ {home}",
"side_a": {
"label": side_a_label,
"book": best_a.get("book"),
"odds": best_a["odds_american"],
"implied": round(prob_a, 4),
},
"side_b": {
"label": side_b_label,
"book": best_b.get("book"),
"odds": best_b["odds_american"],
"implied": round(prob_b, 4),
},
"total_implied": round(total_prob, 4),
"arb_pct": round(arb_pct * 100, 3),
})
return arbs
def calculate_stakes(arb: dict, total_bankroll: float = 1000.0) -> dict:
"""
Compute optimal stakes for each side to guarantee equal profit.
stake_A / stake_B = implied_B / implied_A
"""
prob_a = arb["side_a"]["implied"]
prob_b = arb["side_b"]["implied"]
weight_a = prob_b / (prob_a + prob_b)
weight_b = prob_a / (prob_a + prob_b)
stake_a = total_bankroll * weight_a
stake_b = total_bankroll * weight_b
# Guaranteed profit on side A win
odds_a = arb["side_a"]["odds"]
if odds_a > 0:
payout_a = stake_a + stake_a * (odds_a / 100)
else:
payout_a = stake_a + stake_a * (100 / abs(odds_a))
guaranteed_profit = payout_a - total_bankroll
return {
"stake_a": round(stake_a, 2),
"stake_b": round(stake_b, 2),
"guaranteed_profit": round(guaranteed_profit, 2),
}
def alert(arb: dict, stakes: dict):
"""Print an arb alert. Replace or extend this for Discord/Slack/Telegram."""
print("\n" + "=" * 60)
print(f"🔔 ARB DETECTED — {arb['matchup']}")
print(f" Arb %: {arb['arb_pct']}%")
print(f" Side A: {arb['side_a']['label']} @ {arb['side_a']['book']} "
f"({arb['side_a']['odds']:+d}) — implied {arb['side_a']['implied']:.1%}")
print(f" Side B: {arb['side_b']['label']} @ {arb['side_b']['book']} "
f"({arb['side_b']['odds']:+d}) — implied {arb['side_b']['implied']:.1%}")
print(f" Total implied: {arb['total_implied']:.1%}")
print(f" Stake A: ${stakes['stake_a']} | Stake B: ${stakes['stake_b']}")
print(f" Guaranteed profit on $1000 bankroll: ${stakes['guaranteed_profit']}")
print("=" * 60)
def run(sport: str = "baseball_mlb", market: str = "h2h", bankroll: float = 1000.0):
print(f"[arb_detector] Scanning {sport} / {market} every {POLL_INTERVAL}s...")
seen_arbs: set[str] = set()
while True:
events = fetch_odds(sport=sport, market=market)
arbs = find_arbs(events)
for arb in arbs:
# Deduplicate within a session so you're not spammed on the same arb
arb_key = f"{arb['event_id']}_{arb['side_a']['book']}_{arb['side_b']['book']}"
if arb_key not in seen_arbs:
stakes = calculate_stakes(arb, total_bankroll=bankroll)
alert(arb, stakes)
seen_arbs.add(arb_key)
if not arbs:
print(f"[{time.strftime('%H:%M:%S')}] No arbs found. Next scan in {POLL_INTERVAL}s.")
time.sleep(POLL_INTERVAL)
if __name__ == "__main__":
run(sport="baseball_mlb", market="h2h", bankroll=1000.0)
Set MONEYLINE_API_KEY=your_key_here in a .env file alongside the script, run python arb_detector.py, and you're live.
What Good Output Looks Like
When an arb clears the MIN_ARB_PCT threshold, you'll see something like:
============================================================
🔔 ARB DETECTED — Rangers @ Yankees
Arb %: 1.24%
Side A: New York Yankees @ DraftKings (+118) — implied 45.9%
Side B: Texas Rangers @ FanDuel (+112) — implied 47.2%
Total implied: 93.1%
Stake A: $506.85 | Stake B: $493.15
Guaranteed profit on $1000 bankroll: $12.40
============================================================
A 1.24% arb on $1,000 means $12.40 risk-free. That's modest but compounding. Serious arbers are running this across 10+ sports simultaneously, sizing stakes into the mid-four-figures per side, and clearing six figures annually with minimal downside — if they can keep their accounts healthy. That last part is the operational problem, not the math.
Extending This: What to Build Next
Discord Webhook Alerts
Replace the alert() function body:
import httpx
DISCORD_WEBHOOK = os.getenv("DISCORD_WEBHOOK_URL")
def alert(arb: dict, stakes: dict):
msg = (
f"**ARB DETECTED — {arb['matchup']}** ({arb['arb_pct']}%)\n"
f"Side A: **{arb['side_a']['label']}** @ {arb['side_a']['book']} "
f"`{arb['side_a']['odds']:+d}`\n"
f"Side B: **{arb['side_b']['label']}** @ {arb['side_b']['book']} "
f"`{arb['side_b']['odds']:+d}`\n"
f"Stake split: ${stakes['stake_a']} / ${stakes['stake_b']} → "
f"**+${stakes['guaranteed_profit']} guaranteed**"
)
httpx.post(DISCORD_WEBHOOK, json={"content": msg})
Multi-Sport Coverage
Change the run() call or wrap it in a thread pool:
import threading
sports = [
("baseball_mlb", "h2h"),
("basketball_nba", "h2h"),
("soccer_usa_mls", "h2h"),
]
threads = [
threading.Thread(target=run, kwargs={"sport": s, "market": m}, daemon=True)
for s, m in sports
]
for t in threads:
t.start()
for t in threads:
t.join()
Three-Way Markets
Soccer draws require a len(sides) == 3 branch with a slightly different arb formula. The condition becomes prob_A + prob_B + prob_Draw < 1.0. The stake calculation generalizes the same way. I'll cover that in a follow-up post.
You can also check out the MoneyLine API developer docs for a full breakdown of which market types are available per sport, which matters a lot when you're trying to cover draws, totals, and spreads in the same loop.
Frequently Asked Questions
Q: How fast does the MoneyLine API update odds?
The /v1/odds endpoint reflects near-real-time odds from books — typically within seconds of a book posting a line move. For arb detection, this matters enormously. A 30-second polling interval is a reasonable starting point; tighten it if your credit budget allows and the sport is moving fast (live betting windows, for instance).
Q: Is arbitrage betting legal?
Yes, in every US state where sports betting is legal. Books can't stop you from holding accounts at multiple sportsbooks simultaneously. They can limit or close accounts they deem unprofitable, which is the real operational risk — not legal exposure.
Q: Why filter by MIN_ARB_PCT = 0.003?
Sub-0.3% arbs often evaporate before you can execute both sides, especially in fast markets. The gap between detecting an arb and actually placing both bets (including login latency, bet slip confirmation, etc.) can easily eat a thin margin. Set this floor based on your actual execution speed.
Q: Can I use this for live in-game betting?
In principle, yes — but live odds move fast, sometimes repricing 5-10 times per minute. You'd need sub-10-second polling (watch your credit usage), and you'd want to add a staleness check on the API response timestamps. The MoneyLine API includes last_updated timestamps on each outcome object; filter out anything older than 15-20 seconds before computing arbs.
Q: What's the difference between this and the /v1/edge endpoint?
/v1/edge gives you MoneyLine's pre-computed EV scores relative to sharp-book consensus — it's a faster path to "is this line off-market" signals. /v1/odds gives you the raw multi-book price grid, which is what you need if you want to compute your own arb logic across specific book pairs. For an arb detector, you want /v1/odds. For an EV scanner, start with /v1/edge.
Shipping This
The code above is a working foundation, not a toy. Add your API key, adjust MIN_ARB_PCT to match your execution speed, hook up a Discord webhook, and you have a monitoring system that will catch real gaps when they appear.
The practical ceiling here isn't the code — it's account management. Sportsbooks actively model profitable customers and restrict them. Use the detector to learn the market first, understand which books move early vs. late, and size stakes conservatively until you have a read on your account health at each book.
The MoneyLine API free tier gives you 1,000 credits/month to validate the setup before you commit to a paid plan. At 30-second polling for a single sport, that's roughly 48 polls/day × 30 days = 1,440 calls/month — just over the free limit. One sport at 45-second intervals fits cleanly in free tier. Good enough to build confidence before you scale.
Go ship it.