If you've spent any time seriously betting MLB, you know the line-shopping grind: open six tabs, squint at numbers, try to remember which book had the juice you wanted, and by the time you've decided, the sharp money already moved the number. Manually line-shopping is slow and error-prone. A build MLB line-shopping bot in Python project solves this in under 150 lines of code.
This tutorial walks through a real, runnable bot that:
- Pulls live odds for every MLB game from the MoneyLine API
- Compares moneylines and run-line prices across all available books
- Flags the best available price per side, per game
- Optionally fires a Discord webhook when a configurable price threshold is crossed
We'll use Python 3.11+, httpx for async HTTP, and rich for readable terminal output. No pandas, no Jupyter — this is a script you run in a cron job or a GitHub Action.
Why Line-Shopping Deserves Automation
The edge in retail sports betting is thin. A -110 versus -115 on a bet you're making 500 times a year is the difference between a winning season and a losing one. Most bettors intellectually understand this but don't act on it because the tooling is manual.
Automated line-shopping does a few things you can't do by hand:
- Catches stale lines. When a starting pitcher scratches or injury news breaks, one book will be slow to update. Your bot finds it before it closes.
- Surfaces book-specific quirks. Certain books consistently post better prices on road underdogs, favorites at certain run lines, or specific time slots. You can't see those patterns until you've scraped hundreds of games.
- Removes emotion. You stop rationalizating "close enough" and start systematically finding the best number.
If you're interested in taking this further into full EV analysis, see how EV betting works — the line-shopping layer feeds directly into that workflow.
Project Setup
Install the dependencies:
pip install httpx rich python-dotenv
Create a .env file:
MONEYLINE_API_KEY=your_api_key_here
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... # optional
PRICE_ALERT_THRESHOLD=+120 # alert when best price is >= this
You can get a free API key with 1,000 credits/month at moneylineapp.com. Each call to /v1/odds costs one credit and returns all books simultaneously, so you're not burning a credit per book.
The Full Bot
#!/usr/bin/env python3
"""
MLB Line-Shopping Bot
Uses MoneyLine API /v1/events + /v1/odds to find the best available
price per side across all books for every MLB game today.
"""
import asyncio
import os
import json
from datetime import date
from typing import Optional
import httpx
from rich.console import Console
from rich.table import Table
from dotenv import load_dotenv
load_dotenv()
API_BASE = "https://mlapi.bet"
API_KEY = os.environ["MONEYLINE_API_KEY"]
DISCORD_WEBHOOK = os.getenv("DISCORD_WEBHOOK_URL")
ALERT_THRESHOLD = int(os.getenv("PRICE_ALERT_THRESHOLD", "115"))
HEADERS = {
"Authorization": f"Bearer {API_KEY}",
"Accept": "application/json",
}
console = Console()
def american_to_decimal(american: int) -> float:
"""Convert American odds to decimal for comparison math."""
if american > 0:
return (american / 100) + 1.0
else:
return (100 / abs(american)) + 1.0
def decimal_to_implied(decimal: float) -> float:
"""Decimal odds → implied probability."""
return 1.0 / decimal
async def fetch_mlb_events(client: httpx.AsyncClient) -> list[dict]:
"""Pull today's MLB events from /v1/events."""
today = date.today().isoformat()
resp = await client.get(
f"{API_BASE}/v1/events",
headers=HEADERS,
params={
"sport": "baseball_mlb",
"date": today,
},
timeout=15.0,
)
resp.raise_for_status()
data = resp.json()
return data.get("events", [])
async def fetch_odds_for_event(
client: httpx.AsyncClient, event_id: str
) -> dict:
"""Pull full odds for a single event from /v1/odds."""
resp = await client.get(
f"{API_BASE}/v1/odds",
headers=HEADERS,
params={
"event_id": event_id,
"markets": "h2h,spreads", # moneyline + run line
},
timeout=15.0,
)
resp.raise_for_status()
return resp.json()
def parse_best_prices(odds_payload: dict) -> dict[str, dict]:
"""
For each market+outcome combination, find the book offering
the best (highest) American price.
Returns a dict keyed by "{market}|{outcome}" → {
"best_price": int,
"best_book": str,
"all_prices": [(book, price), ...],
"worst_price": int,
}
"""
results: dict[str, dict] = {}
for market in odds_payload.get("markets", []):
market_key = market["key"] # e.g. "h2h" or "spreads"
for outcome in market.get("outcomes", []):
label = outcome["name"] # team name or "Over"/"Under"
point = outcome.get("point") # run line spread, None for h2h
composite_key = f"{market_key}|{label}"
if point is not None:
composite_key += f"|{point:+.1f}"
price = outcome["price"] # American odds as int
book = outcome["bookmaker"]
if composite_key not in results:
results[composite_key] = {
"best_price": price,
"best_book": book,
"worst_price": price,
"all_prices": [],
}
results[composite_key]["all_prices"].append((book, price))
if american_to_decimal(price) > american_to_decimal(
results[composite_key]["best_price"]
):
results[composite_key]["best_price"] = price
results[composite_key]["best_book"] = book
if american_to_decimal(price) < american_to_decimal(
results[composite_key]["worst_price"]
):
results[composite_key]["worst_price"] = price
return results
def price_spread(best: int, worst: int) -> float:
"""
Implied probability spread between worst and best price.
Positive = you're getting a better deal by shopping.
"""
return (
decimal_to_implied(american_to_decimal(worst))
- decimal_to_implied(american_to_decimal(best))
) * 100
async def maybe_alert_discord(
event_name: str, composite_key: str, best_price: int, best_book: str
) -> None:
if not DISCORD_WEBHOOK:
return
if best_price < ALERT_THRESHOLD:
return
message = {
"content": (
f"🔔 **Line-Shop Alert**\n"
f"**Event:** {event_name}\n"
f"**Market/Side:** {composite_key}\n"
f"**Best Price:** {best_price:+d} @ {best_book}\n"
)
}
async with httpx.AsyncClient() as client:
await client.post(DISCORD_WEBHOOK, json=message, timeout=5.0)
def render_table(event_name: str, best_prices: dict[str, dict]) -> None:
table = Table(title=event_name, show_lines=True)
table.add_column("Market / Side", style="cyan", no_wrap=True)
table.add_column("Best Price", style="green", justify="right")
table.add_column("Best Book", style="yellow")
table.add_column("Worst Price", style="red", justify="right")
table.add_column("Shop Edge (pp)", justify="right")
for key, data in sorted(best_prices.items()):
bp = data["best_price"]
wp = data["worst_price"]
spread = price_spread(bp, wp)
table.add_row(
key,
f"{bp:+d}",
data["best_book"],
f"{wp:+d}",
f"{spread:.2f}",
)
console.print(table)
async def main() -> None:
console.rule("[bold blue]MLB Line-Shopping Bot[/bold blue]")
async with httpx.AsyncClient() as client:
events = await fetch_mlb_events(client)
if not events:
console.print("[yellow]No MLB events found for today.[/yellow]")
return
console.print(f"Found [bold]{len(events)}[/bold] MLB events.\n")
# Fetch all odds concurrently
tasks = [fetch_odds_for_event(client, e["id"]) for e in events]
all_odds = await asyncio.gather(*tasks)
for event, odds_payload in zip(events, all_odds):
event_name = f"{event['away_team']} @ {event['home_team']}"
best_prices = parse_best_prices(odds_payload)
if not best_prices:
continue
render_table(event_name, best_prices)
# Fire Discord alerts for qualifying prices
for key, data in best_prices.items():
await maybe_alert_discord(
event_name, key, data["best_price"], data["best_book"]
)
if __name__ == "__main__":
asyncio.run(main())
Reading the Output
When you run python bot.py, you'll get a table per game that looks roughly like this (terminal output rendered by rich):
NYY @ BOS
┌──────────────────┬───────────┬──────────┬─────────────┬───────────────┐
│ Market / Side │ Best Price│ Best Book│ Worst Price │ Shop Edge (pp)│
├──────────────────┼───────────┼──────────┼─────────────┼───────────────┤
│ h2h|New York │ +108 │ FanDuel │ +100 │ 0.38 │
│ h2h|Boston │ -115 │ DraftKings│ -125 │ 0.72 │
│ spreads|NYY|+1.5 │ -130 │ BetMGM │ -145 │ 0.91 │
│ spreads|BOS|-1.5 │ +110 │ Novig │ +105 │ 0.23 │
└──────────────────┴───────────┴──────────┴─────────────┴───────────────┘
Shop Edge (pp) is the difference in implied probability between the worst available price and the best available price — expressed in percentage points. A 0.72pp edge on Boston's moneyline means you're getting a meaningfully worse deal at the worst book. Over a season of bets, that compounds hard.
Extending the Bot
A few directions worth building toward:
Cron Scheduling
Run this every 5 minutes with a cron job or GitHub Actions scheduled workflow:
# .github/workflows/line-shop.yml
on:
schedule:
- cron: "*/5 12-23 * * *" # every 5 min during game hours (UTC)
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install httpx rich python-dotenv
- run: python bot.py
env:
MONEYLINE_API_KEY: ${{ secrets.MONEYLINE_API_KEY }}
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
Layering in EV Analysis
Right now the bot just finds the best available price. To make it a proper EV scanner, you need a fair-value estimate — a no-vig line or a model output — to compare against. The /v1/edge endpoint returns a pre-computed edge against the closing line, which you can plug directly into this pipeline. Check out the EV betting guide for context on how to interpret those edge numbers.
Arbitrage Detection
If you extend parse_best_prices to look at both sides of a market simultaneously, you can check whether the best prices on Side A and Side B across different books sum to less than 100% implied probability — that's an arb. We covered arbitrage mechanics in depth if you want the math.
FAQ
What does "line-shopping" mean in sports betting? Line-shopping means checking multiple sportsbooks to find the best available price (odds) on a given bet. Because different books shade their lines differently, the same bet can pay +105 at one book and -110 at another — a material difference over hundreds of bets.
How many API credits does this bot use per run?
One credit for the /v1/events call plus one credit per game fetched from /v1/odds. For a typical MLB slate of 14 games, that's 15 credits per run. At 5-minute intervals for 6 hours, that's ~1,080 credits/day — you'll want a paid tier for continuous monitoring, but the free tier (1k credits/month) is enough for manual or daily runs.
Can I use this for sports other than MLB?
Yes. Change the sport parameter in fetch_mlb_events to any sport the API supports — americanfootball_nfl, basketball_nba, soccer_epl, etc. The market keys and outcome formats are consistent across sports.
Why use httpx instead of requests?
httpx supports native async, which matters here because we fire all the per-game odds requests concurrently with asyncio.gather. For 14 games, async cuts latency from ~14 serial seconds down to roughly the latency of a single request. requests is synchronous and would block on each call.
How do I handle rate limits?
The MoneyLine API returns 429 status when you exceed your rate limit. Wrap resp.raise_for_status() in a try/except, back off with asyncio.sleep(2), and retry. For production use, add a semaphore to asyncio.gather to cap concurrent requests to 5-10 at a time.