BettingLab

Build a Real-Time Line-Shopping Bot in Python

Marcus Hale
Marcus Hale

Build a Real-Time Line-Shopping Bot in Python

If you're still manually tabbing between DraftKings, FanDuel, and three offshore books to find the best price on a spread, you're leaving money on the table — and burning time doing it. A line-shopping bot in Python fixes both problems. You write it once, you run it whenever there's action, and it does the grunt work while you focus on whether the edge is worth taking.

This tutorial walks through a complete, runnable implementation using the MoneyLine API. We'll hit /v1/odds to pull live lines across books, parse the response, rank by best price per side, and output a clean comparison table to your terminal. The whole thing is under 120 lines of Python. You can extend it to Discord alerts, a Postgres write, or a Streamlit dashboard with another hour of work.


Why Line Shopping Beats Handicapping Alone

The average sharp bettor will tell you: game selection and line value are two separate skills, and most recreational bettors only practice one. You can have the best model in the room and still get wrecked by consistently betting into juice when a better number exists two clicks away.

A 0.5-run difference on an MLB runline or a half-point on an NFL spread sounds trivial. At scale, that difference is worth roughly 1–2% expected value per bet. If you're making 500 bets a year at average $200 a pop, that's $1,000–$2,000 in recaptured value from shopping alone — before your actual edge does anything.

The mechanics of line shopping are simple:

  1. Pull the current price for a market across all available books.
  2. Find the book offering the highest price (least juice) for the side you want.
  3. Bet there instead of your default book.

The hard part used to be step 1. The MoneyLine API's /v1/odds endpoint aggregates live pricing from dozens of books in one call. That turns a painful scraping project into a 10-line function.


Project Structure and Setup

We're keeping this self-contained: one script, one dependency beyond the standard library (the requests library), and a .env file for your API key.

line_shopper/
├── line_shopper.py
├── .env
└── requirements.txt

requirements.txt

requests>=2.31.0
python-dotenv>=1.0.0

Install with:

pip install -r requirements.txt

Get your API key from the free tier at https://www.moneylineapp.com — 1k credits/month, enough for serious development and light production use.

.env

MONEYLINE_API_KEY=your_api_key_here

The Core Bot: Fetching and Ranking Odds

Here's the full script. I'll break down the key sections below.

"""
line_shopper.py
Real-time line-shopping bot using the MoneyLine API.
Fetches odds across books for a given sport and surfaces the best price per side.
"""

import os
import sys
import requests
from dotenv import load_dotenv
from typing import Optional

load_dotenv()

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

HEADERS = {
    "Authorization": f"Bearer {API_KEY}",
    "Accept": "application/json",
}


def fetch_events(sport: str) -> list[dict]:
    """Fetch upcoming events for a given sport slug (e.g. 'baseball_mlb')."""
    resp = requests.get(
        f"{API_BASE}/v1/events",
        headers=HEADERS,
        params={"sport": sport, "status": "upcoming"},
        timeout=10,
    )
    resp.raise_for_status()
    return resp.json().get("data", [])


def fetch_odds(event_id: str, market: str = "h2h") -> dict:
    """
    Fetch odds for a specific event across all available books.
    market options: 'h2h' (moneyline), 'spreads', 'totals'
    """
    resp = requests.get(
        f"{API_BASE}/v1/odds",
        headers=HEADERS,
        params={"event_id": event_id, "market": market},
        timeout=10,
    )
    resp.raise_for_status()
    return resp.json()


def american_to_implied(american: int) -> float:
    """Convert American odds to implied probability (no-vig is handled externally)."""
    if american > 0:
        return 100 / (american + 100)
    else:
        return abs(american) / (abs(american) + 100)


def best_price_per_side(odds_data: dict) -> dict:
    """
    Given the /v1/odds response, return the best (highest) American odds
    available for each outcome across all books.
    Returns: { outcome_name: { "best_odds": int, "book": str, "implied_prob": float } }
    """
    best: dict = {}

    books = odds_data.get("data", {}).get("bookmakers", [])
    for book in books:
        book_name = book.get("key", "unknown")
        for market in book.get("markets", []):
            for outcome in market.get("outcomes", []):
                name = outcome["name"]
                price = outcome["price"]  # American odds as int

                if name not in best or price > best[name]["best_odds"]:
                    best[name] = {
                        "best_odds": price,
                        "book": book_name,
                        "implied_prob": round(american_to_implied(price) * 100, 2),
                    }

    return best


def display_best_lines(event: dict, best: dict, market: str) -> None:
    """Print a clean comparison table for an event."""
    home = event.get("home_team", "Home")
    away = event.get("away_team", "Away")
    commence = event.get("commence_time", "TBD")

    print(f"\n{'='*60}")
    print(f"  {away} @ {home}")
    print(f"  {commence}  |  Market: {market.upper()}")
    print(f"{'='*60}")
    print(f"  {'Outcome':<25} {'Best Odds':>10} {'Book':<20} {'Implied%':>8}")
    print(f"  {'-'*57}")

    for outcome_name, info in best.items():
        odds_str = (
            f"+{info['best_odds']}" if info["best_odds"] > 0 else str(info["best_odds"])
        )
        print(
            f"  {outcome_name:<25} {odds_str:>10} {info['book']:<20} {info['implied_prob']:>7}%"
        )

    # Sanity check: if implied probs sum close to 100, market is efficient
    total_implied = sum(v["implied_prob"] for v in best.values())
    print(f"\n  Total implied probability (best prices): {total_implied:.2f}%")
    if total_implied < 100:
        print(f"  ⚡ Potential arbitrage gap: {100 - total_implied:.2f}% — check /v1/edge")


def run(sport: str = "baseball_mlb", market: str = "h2h", limit: int = 5) -> None:
    print(f"\nFetching top {limit} upcoming {sport} events...")
    events = fetch_events(sport)

    if not events:
        print("No upcoming events found. Try a different sport slug.")
        sys.exit(0)

    for event in events[:limit]:
        event_id = event["id"]
        try:
            odds_data = fetch_odds(event_id, market=market)
            best = best_price_per_side(odds_data)
            if best:
                display_best_lines(event, best, market)
        except requests.HTTPError as e:
            print(f"  [WARN] Could not fetch odds for {event_id}: {e}")


if __name__ == "__main__":
    # Configurable via CLI args: python line_shopper.py baseball_mlb spreads 10
    sport_arg = sys.argv[1] if len(sys.argv) > 1 else "baseball_mlb"
    market_arg = sys.argv[2] if len(sys.argv) > 2 else "h2h"
    limit_arg = int(sys.argv[3]) if len(sys.argv) > 3 else 5
    run(sport=sport_arg, market=market_arg, limit=limit_arg)

Running It

# Default: MLB moneylines, top 5 games
python line_shopper.py

# NFL spreads, top 10 games
python line_shopper.py americanfootball_nfl spreads 10

# NBA totals, top 3 games
python line_shopper.py basketball_nba totals 3

Sample Output

Fetching top 5 upcoming baseball_mlb events...

============================================================
  New York Yankees @ Boston Red Sox
  2026-07-30T18:10:00Z  |  Market: H2H
============================================================
  Outcome                   Best Odds   Book                 Implied%
  ---------------------------------------------------------
  New York Yankees               +118   draftkings              45.87%
  Boston Red Sox                 -108   betmgm                  51.92%

  Total implied probability (best prices): 97.79%
  ⚡ Potential arbitrage gap: 2.21% — check /v1/edge

When the total implied probability across the best available prices drops below 100%, you have an arbitrage opportunity — or at least a signal worth investigating. The bot flags it automatically and points you toward /v1/edge for a deeper look.


Extending the Bot: Discord Alerts

The terminal output is fine for manual sessions. For a production workflow, you want push notifications when a meaningful gap appears. Here's a drop-in function that posts to a Discord webhook when the implied total falls below your threshold:

import requests as req

DISCORD_WEBHOOK = os.getenv("DISCORD_WEBHOOK_URL")

def alert_discord(event: dict, best: dict, gap: float) -> None:
    if not DISCORD_WEBHOOK:
        return

    home = event.get("home_team", "Home")
    away = event.get("away_team", "Away")
    lines = "\n".join(
        f"**{k}**: {'+' if v['best_odds'] > 0 else ''}{v['best_odds']} @ {v['book']}"
        for k, v in best.items()
    )
    payload = {
        "content": (
            f"🚨 **Line Shop Alert** — {away} @ {home}\n"
            f"{lines}\n"
            f"Implied gap: **{gap:.2f}%** — check `/v1/edge` for arb confirmation"
        )
    }
    req.post(DISCORD_WEBHOOK, json=payload, timeout=5)

Call it inside display_best_lines after the gap calculation. Pair this with a cron job running every 5 minutes during game windows and you've got a lightweight arb alert system for under $0 in infra costs.

For a deeper look at how the /v1/edge endpoint surfaces pre-calculated EV, check out the EV betting guide.


What to Build Next

This bot is intentionally minimal. A few obvious extensions:

Persist to SQLite or Postgres. Write each snapshot of best prices to a table with a timestamp. Now you can query for line movement — if the best price on Team A moved from +110 to +125 in 20 minutes, something is happening.

Add /v1/edge calls. After flagging a gap, hit /v1/edge with the event ID to get MoneyLine's pre-computed edge scores. This saves you from implementing your own no-vig model.

Filter by minimum edge threshold. Don't alert on every gap — set a floor (e.g., 1.5% implied gap) to avoid noise from rounding artifacts or illiquid books.

Port to async. If you're scanning 50+ events at once, swap requests for httpx with asyncio and run the fetch_odds calls concurrently. You'll cut runtime from 15+ seconds to under 3.

For a broader look at what the API surfaces and how credits are consumed per endpoint, see the MoneyLine API documentation.


Frequently Asked Questions

What sport slugs does the MoneyLine API accept for /v1/events? Common slugs include baseball_mlb, americanfootball_nfl, basketball_nba, icehockey_nhl, and soccer_mls. The API documentation at /build/api lists the full set. Passing an invalid slug returns an empty data array, not an error, so validate your input if you're taking it from user input.

How many API credits does a full scan consume? Each call to /v1/events or /v1/odds consumes credits proportional to the number of books and markets returned. On the free tier (1k credits/month), scanning 5 events with full book coverage uses roughly 10–20 credits. Budget accordingly if you're running continuous polling — aggressive polling of 50 events every 2 minutes will blow through a free tier quickly.

Why does the implied probability sum sometimes exceed 100% even after shopping? You're seeing the residual vig from the best available prices. No single book removes all juice, and the "best price" per side may come from different books. The sum represents the theoretical minimum vig you'd pay if you could hold accounts at every book simultaneously. Anything below 100% is a genuine arbitrage.

Can I use this to bet automatically? The bot identifies the best price; actual bet placement requires integrations with exchange APIs (like Novig or Kalshi) that offer programmatic order submission. MoneyLine's API is a data/analytics layer — it surfaces the edge, you decide how to act on it. That separation is intentional and is part of what makes it useful for quants who want to model first.

How do I handle stale odds between the API call and my bet? Lines move fast. Add a fetched_at timestamp to every record you store, and treat anything older than 60 seconds as potentially stale. In practice, for pre-game markets, 60-second windows are fine. For live in-game lines, you need near-real-time polling and should accept that a small percentage of your "best price" clicks will land on a revised number.

Build with the same data we use.

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