Build a Live Odds Line-Shopping Bot in Python
If you've ever placed a bet at -110 only to find the same side at -105 somewhere else, you already understand why a live odds line-shopping bot in Python is worth building. That half-point or five-cent juice difference compounds fast. Over a 500-game season it can be the difference between a winning record and a breakeven grind.
This tutorial walks you through a complete, runnable bot that hits the MoneyLine API, pulls current odds across multiple books for a target event, ranks them by implied probability, and fires a console alert (easily wired to Discord or Slack) when a book is offering a materially better line than the consensus. No fluff — just the code and the reasoning behind each step.
Why Line Shopping Still Matters in 2026
Sharp players have known this forever, but recreational bettors keep sleeping on it: sportsbooks are not synchronized. Books shade lines based on their own liability exposure, their user base, and how fast their traders move. That asymmetry is your edge even before you touch EV calculations or arbitrage detection.
A few concrete reasons to automate this:
- Speed. Lines move. By the time you manually tab between five books, the best price has usually tightened.
- Coverage. You probably can't watch 15 books at once for 30 simultaneous markets. The bot can.
- Record-keeping. Logging every line snapshot gives you a dataset you can use to study book-specific biases over time.
The MoneyLine API aggregates live odds from sharp and recreational books in a single endpoint, which is what makes this kind of tooling practical at the indie-dev level.
What We're Building
The bot does four things:
- Polls
/v1/eventsto find the event ID for a target game. - Polls
/v1/oddsfor that event on a configurable interval. - Computes the best available price per side and flags any book that beats the consensus by more than a threshold you set.
- Prints a structured alert — you swap
printfor a Discord webhook or SMS with three lines of code.
We'll use Python 3.11+ with only httpx and rich as external dependencies. No async gymnastics — this is a synchronous polling loop you can understand, modify, and ship.
The Code
Dependencies
pip install httpx rich
Full Bot — line_shopper.py
"""
line_shopper.py — Live odds line-shopping bot using the MoneyLine API.
Usage:
MLAPI_KEY=your_key_here python line_shopper.py \
--sport MLB \
--team "Yankees" \
--interval 30 \
--threshold 3
Arguments:
--sport Sport slug (MLB, NBA, NFL, NCAAB, etc.)
--team Partial team/event name to match (case-insensitive)
--interval Polling interval in seconds (default: 30)
--threshold Minimum edge in implied-prob percentage points to alert (default: 3)
"""
import argparse
import os
import sys
import time
from datetime import datetime, timezone
import httpx
from rich.console import Console
from rich.table import Table
BASE_URL = "https://mlapi.bet"
console = Console()
def american_to_implied(american: int | float) -> float:
"""Convert American odds to implied probability (0–1)."""
if american >= 100:
return 100 / (american + 100)
else:
return abs(american) / (abs(american) + 100)
def get_headers(api_key: str) -> dict:
return {
"Authorization": f"Bearer {api_key}",
"Accept": "application/json",
}
def fetch_events(api_key: str, sport: str) -> list[dict]:
"""Fetch upcoming events for a sport."""
url = f"{BASE_URL}/v1/events"
params = {"sport": sport, "status": "upcoming"}
r = httpx.get(url, headers=get_headers(api_key), params=params, timeout=10)
r.raise_for_status()
return r.json().get("events", [])
def find_event(events: list[dict], team_query: str) -> dict | None:
"""Return the first event whose name contains the query string."""
q = team_query.lower()
for ev in events:
if q in ev.get("name", "").lower():
return ev
return None
def fetch_odds(api_key: str, event_id: str) -> dict:
"""Fetch odds for a specific event across all available books."""
url = f"{BASE_URL}/v1/odds"
params = {"event_id": event_id}
r = httpx.get(url, headers=get_headers(api_key), params=params, timeout=10)
r.raise_for_status()
return r.json()
def parse_moneyline_odds(odds_payload: dict) -> dict[str, dict[str, dict]]:
"""
Parse the odds payload into a structure:
{ side_label: { book_key: { price, implied } } }
Assumes the payload has a `markets` list where market_type == "h2h".
Adjust field names to match whatever the API actually returns for your sport.
"""
result: dict[str, dict] = {}
markets = odds_payload.get("markets", [])
for market in markets:
if market.get("market_type") != "h2h":
continue
for outcome in market.get("outcomes", []):
side = outcome.get("name", "Unknown")
book = outcome.get("book_key", "unknown_book")
price = outcome.get("price") # American odds integer
if price is None:
continue
if side not in result:
result[side] = {}
result[side][book] = {
"price": price,
"implied": american_to_implied(price),
}
return result
def analyze_and_alert(
event_name: str,
parsed: dict[str, dict[str, dict]],
threshold_pct: float,
) -> None:
"""
For each side, find the best price and compare every book to the
consensus (average implied probability). Alert if a book beats
consensus by >= threshold_pct percentage points.
"""
timestamp = datetime.now(timezone.utc).strftime("%H:%M:%S UTC")
for side, books in parsed.items():
if not books:
continue
# Best price = lowest implied probability = longest odds = most value
best_book = min(books, key=lambda b: books[b]["implied"])
best_price = books[best_book]["price"]
best_implied = books[best_book]["implied"]
# Consensus = average implied across all books
all_implied = [v["implied"] for v in books.values()]
consensus_implied = sum(all_implied) / len(all_implied)
edge_pct = (consensus_implied - best_implied) * 100 # positive = value
# Build a Rich table for this side
table = Table(
title=f"[bold]{event_name}[/bold] | Side: [cyan]{side}[/cyan] | {timestamp}",
show_header=True,
header_style="bold magenta",
)
table.add_column("Book", style="dim", width=20)
table.add_column("Odds (American)", justify="right")
table.add_column("Implied %", justify="right")
table.add_column("vs Consensus", justify="right")
for book, data in sorted(books.items(), key=lambda x: x[1]["implied"]):
diff = (consensus_implied - data["implied"]) * 100
diff_str = f"+{diff:.1f}pp" if diff >= 0 else f"{diff:.1f}pp"
diff_color = "green" if diff >= threshold_pct else "white"
price_str = f"+{data['price']}" if data["price"] > 0 else str(data["price"])
table.add_row(
book,
price_str,
f"{data['implied'] * 100:.2f}%",
f"[{diff_color}]{diff_str}[/{diff_color}]",
)
console.print(table)
if edge_pct >= threshold_pct:
price_display = f"+{best_price}" if best_price > 0 else str(best_price)
console.print(
f" 🚨 [bold green]ALERT:[/bold green] {best_book} offers {side} at "
f"[bold]{price_display}[/bold] — "
f"{edge_pct:.1f}pp better than consensus implied ({consensus_implied * 100:.2f}%)\n"
)
else:
console.print(
f" ✓ No actionable gap found for {side} (best edge: {edge_pct:.1f}pp < {threshold_pct}pp threshold)\n"
)
def run(
api_key: str,
sport: str,
team_query: str,
interval: int,
threshold: float,
) -> None:
console.rule(f"[bold blue]Line Shopper — {sport} / '{team_query}'[/bold blue]")
# Step 1: Resolve the event once (re-resolve every 10 polls in case of rain delays etc.)
poll_count = 0
event: dict | None = None
while True:
if poll_count % 10 == 0:
console.log("Fetching event list...")
events = fetch_events(api_key, sport)
event = find_event(events, team_query)
if not event:
console.log(
f"[yellow]No event found matching '{team_query}' in {sport}. "
f"Retrying in {interval}s...[/yellow]"
)
time.sleep(interval)
poll_count += 1
continue
console.log(f"Locked on event: [bold]{event['name']}[/bold] (id: {event['id']})")
# Step 2: Fetch odds
try:
odds_payload = fetch_odds(api_key, event["id"])
except httpx.HTTPStatusError as exc:
console.log(f"[red]API error {exc.response.status_code}: {exc.response.text}[/red]")
time.sleep(interval)
poll_count += 1
continue
# Step 3: Parse and analyze
parsed = parse_moneyline_odds(odds_payload)
if not parsed:
console.log("[yellow]No h2h odds returned for this event.[/yellow]")
else:
analyze_and_alert(event["name"], parsed, threshold_pct=threshold)
poll_count += 1
console.log(f"[dim]Sleeping {interval}s... (poll #{poll_count})[/dim]\n")
time.sleep(interval)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Live odds line-shopping bot")
parser.add_argument("--sport", default="MLB")
parser.add_argument("--team", default="Yankees", dest="team")
parser.add_argument("--interval", type=int, default=30)
parser.add_argument("--threshold", type=float, default=3.0)
args = parser.parse_args()
key = os.environ.get("MLAPI_KEY", "")
if not key:
console.print("[red]Set MLAPI_KEY environment variable.[/red]")
sys.exit(1)
run(
api_key=key,
sport=args.sport,
team_query=args.team,
interval=args.interval,
threshold=args.threshold,
)
Reading the Output
When you run:
MLAPI_KEY=sk_live_xxxx python line_shopper.py \
--sport MLB \
--team "Yankees" \
--interval 30 \
--threshold 3
You'll see a table per side refreshed every 30 seconds. Green highlighted rows are books offering implied probability more than 3 percentage points below consensus — meaning you're getting longer odds than the market average. That's the line to bet.
The threshold flag is your noise filter. At 2pp you'll get a lot of alerts, many of which are stale lines that haven't been bet into yet. At 5pp you're waiting for genuinely juicy outliers. Start at 3 and tune from there based on your win rate.
Wiring Alerts to Discord
Swap the console.print alert block for a webhook call:
import httpx
DISCORD_WEBHOOK = os.environ.get("DISCORD_WEBHOOK_URL", "")
def send_discord_alert(message: str) -> None:
if not DISCORD_WEBHOOK:
return
httpx.post(DISCORD_WEBHOOK, json={"content": message}, timeout=5)
Then replace the 🚨 ALERT print line with:
send_discord_alert(
f"🚨 **{best_book}** | {event_name} | {side} @ {price_display} "
f"| {edge_pct:.1f}pp below consensus"
)
Done. You now have a live Discord feed of line-shopping opportunities. This is the same foundation you'd use to build a full EV scanner — the main difference is layering in no-vig fair values from /v1/edge instead of consensus implied.
Extending to Spreads and Totals
The bot currently filters for market_type == "h2h". To cover spreads, change the filter to "spreads" and add a point field comparison — you want to group outcomes by point value before comparing prices, otherwise you're comparing -110 on Yankees -1.5 against +120 on Yankees -2.5, which is meaningless.
# Group by (side, point) tuple for spreads
key = (outcome.get("name"), outcome.get("point"))
For totals it's the same pattern: group by (Over/Under, total_line). Check the MoneyLine API docs for the exact field names in each market type response.
FAQ
What API endpoints does this bot use?
It uses /v1/events to look up the event ID and /v1/odds to pull multi-book odds for that event. Both endpoints are available on the free tier of the MoneyLine API (1,000 credits/month).
How often should I poll?
For pre-game markets, 30–60 seconds is fine and won't burn credits fast. For live in-game markets where lines move in seconds, drop to 5–10 seconds and watch your credit usage. The free tier is generous for pre-game research but live polling at scale requires a paid plan.
What's the difference between this and an EV scanner?
A line-shopping bot finds the best available price across books. An EV scanner computes whether that price represents positive expected value against a no-vig fair line. They complement each other — the bot finds where to bet, the EV check tells you whether to bet at all. See the /v1/edge endpoint for pre-computed no-vig edges.
Can I run this for multiple sports simultaneously?
Yes. Spawn a thread or separate process per sport, each with its own polling loop and a shared Discord webhook. Python's threading.Thread or just launching multiple terminal processes with different --sport flags works fine at this scale.
How do I handle rate limits?
The API returns standard 429 status codes. Add an exponential backoff: on a 429, sleep 2 ** retry_count seconds before retrying. The code above raises HTTPStatusError on non-2xx responses, so wrap the fetch_odds call in a retry loop with that logic.
Wrapping Up
The bot above is about 150 lines and genuinely useful out of the box. It's also the skeleton for more sophisticated tooling: add /v1/edge calls to layer in EV filtering, persist line snapshots to SQLite to study book-specific opening-line tendencies, or push to a mobile app. The hard part — reliable multi-book odds in a single API response — is handled by the MoneyLine API, so you spend your time on the logic that's actually specific to your edge, not scraping and normalizing data from fifteen different books.
If you're comparing API options before committing, the The Odds API vs MoneyLine comparison breaks down latency, coverage, and pricing in detail.