BettingLab

Build a Real-Time Odds Movement Alert Bot in Python

Marcus Hale
Marcus Hale

Build a Real-Time Odds Movement Alert Bot in Python

If you've spent any time handicapping seriously, you know the drill: you set a line in your head, walk away for twenty minutes, come back, and the book has already moved two dimes against you. The sharp money hit while you were making coffee. Building an odds movement alert bot in Python is one of the highest-ROI projects you can ship as a bettor-developer — not because bots are magic, but because line movement is information, and information you see before the square public sees it has value.

This tutorial wires up the MoneyLine API to poll /v1/odds on a configurable interval, compare current lines against a baseline snapshot, and fire alerts when movement crosses a threshold you care about. By the end you'll have a working script you can deploy to a VPS or run locally, with Discord webhook support so alerts land wherever you actually live.

No fluff. Let's build.

Why Line Movement Matters (The 30-Second Version)

Sharp bettors move lines. When a respected syndicate hammers one side, books adjust — sometimes immediately. The sequence you want to detect:

  1. Steam move — rapid, coordinated action forces a quick line adjustment across multiple books simultaneously.
  2. Reverse line movement (RLM) — the public loads one side, but the line moves the other way. Sharps are on the other side.
  3. Stale line — one book is slow to update and you get a window.

A polling bot can't perfectly distinguish steam from sharp action, but it can surface the anomaly fast enough for you to decide. Pair it with the EV scanner concepts from the /bet/ev section and you're making decisions from data, not gut.


Architecture: Keep It Simple

Here's the shape of what we're building:

Cron / sleep loop
    └─ GET /v1/odds  (MoneyLine API)
           └─ Compare to in-memory baseline
                  └─ Threshold crossed?
                         ├─ Yes → POST to Discord webhook + log
                         └─ No  → Update baseline, sleep

No database required for a first version. We store the baseline in a Python dict keyed by event_id + market + book. The script runs continuously with a configurable poll interval (I use 60 seconds for most markets, 20 seconds in the hour before tip-off).

Dependencies: httpx for async HTTP, python-dotenv for config, rich for readable terminal output. All available via pip.


The Code

Install deps first:

pip install httpx python-dotenv rich

Create a .env file:

MONEYLINE_API_KEY=your_key_here
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/your/webhook
SPORT=basketball_nba
MARKETS=h2h,spreads
MOVEMENT_THRESHOLD_CENTS=10
POLL_INTERVAL_SECONDS=60

Now the main script — odds_alert_bot.py:

"""
odds_alert_bot.py
Real-time odds movement alert bot using MoneyLine API + Discord webhooks.
Marcus Hale / BettingLab — 2026-06-24

Usage:
    python odds_alert_bot.py
"""

import asyncio
import os
import time
from datetime import datetime, timezone
from typing import Any

import httpx
from dotenv import load_dotenv
from rich.console import Console
from rich.table import Table

load_dotenv()

console = Console()

# ── Config ──────────────────────────────────────────────────────────────────
API_KEY          = os.environ["MONEYLINE_API_KEY"]
DISCORD_WEBHOOK  = os.getenv("DISCORD_WEBHOOK_URL")
BASE_URL         = "https://mlapi.bet"
SPORT            = os.getenv("SPORT", "basketball_nba")
MARKETS          = os.getenv("MARKETS", "h2h,spreads")
THRESHOLD_CENTS  = int(os.getenv("MOVEMENT_THRESHOLD_CENTS", "10"))
POLL_INTERVAL    = int(os.getenv("POLL_INTERVAL_SECONDS", "60"))

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


# ── Helpers ──────────────────────────────────────────────────────────────────

def american_to_cents(american: int) -> int:
    """Return implied probability in basis points (for delta comparison)."""
    if american > 0:
        return round(10000 * 100 / (american + 100))
    else:
        return round(10000 * (-american) / (-american + 100))


def describe_move(old: int, new: int) -> str:
    """Human-readable description of an odds movement."""
    direction = "shortened" if new < old else "lengthened"
    delta = abs(new - old)
    return f"moved {direction} by {delta} cents (american: {old:+d} → {new:+d})"


async def fetch_odds(client: httpx.AsyncClient) -> list[dict[str, Any]]:
    """Pull current odds from MoneyLine API /v1/odds endpoint."""
    params = {
        "sport":   SPORT,
        "markets": MARKETS,
        "regions": "us",
        "oddsFormat": "american",
    }
    resp = await client.get(
        f"{BASE_URL}/v1/odds",
        headers=HEADERS,
        params=params,
        timeout=15.0,
    )
    resp.raise_for_status()
    return resp.json().get("data", [])


async def post_discord_alert(client: httpx.AsyncClient, message: str) -> None:
    """Fire a Discord webhook alert."""
    if not DISCORD_WEBHOOK:
        return
    payload = {"content": message, "username": "OddsBot"}
    try:
        await client.post(DISCORD_WEBHOOK, json=payload, timeout=5.0)
    except Exception as exc:
        console.print(f"[red]Discord webhook failed:[/red] {exc}")


def build_baseline(events: list[dict]) -> dict[str, int]:
    """
    Flatten the nested API response into a dict:
        key  = "{event_id}|{market}|{book}|{outcome}"
        value = american odds (int)
    """
    baseline: dict[str, int] = {}
    for event in events:
        eid = event.get("id", "")
        for bm in event.get("bookmakers", []):
            book = bm.get("key", "")
            for market in bm.get("markets", []):
                mkey = market.get("key", "")
                for outcome in market.get("outcomes", []):
                    name  = outcome.get("name", "")
                    price = outcome.get("price")
                    if price is not None:
                        k = f"{eid}|{mkey}|{book}|{name}"
                        baseline[k] = int(price)
    return baseline


def detect_movements(
    old_baseline: dict[str, int],
    new_baseline:  dict[str, int],
    events: list[dict],
) -> list[dict]:
    """
    Compare two baselines, return list of movement records above threshold.
    """
    event_meta: dict[str, dict] = {e["id"]: e for e in events}
    moves = []

    for key, new_price in new_baseline.items():
        if key not in old_baseline:
            continue  # new market — skip first appearance

        old_price = old_baseline[key]
        if new_price == old_price:
            continue

        old_impl = american_to_cents(old_price)
        new_impl = american_to_cents(new_price)
        delta    = abs(new_impl - old_impl)

        if delta < THRESHOLD_CENTS:
            continue

        eid, mkey, book, outcome = key.split("|")
        meta = event_meta.get(eid, {})
        home = meta.get("home_team", "")
        away = meta.get("away_team", "")

        moves.append({
            "event":   f"{away} @ {home}",
            "commence": meta.get("commence_time", ""),
            "market":  mkey,
            "book":    book,
            "outcome": outcome,
            "old":     old_price,
            "new":     new_price,
            "delta":   delta,
            "direction": "shorter" if new_impl > old_impl else "longer",
        })

    # Sort largest move first
    moves.sort(key=lambda m: m["delta"], reverse=True)
    return moves


def render_moves_table(moves: list[dict]) -> None:
    """Print a rich table of detected line movements."""
    table = Table(title="Line Movements Detected", show_lines=True)
    table.add_column("Event",    style="cyan")
    table.add_column("Market",   style="white")
    table.add_column("Book",     style="yellow")
    table.add_column("Outcome",  style="white")
    table.add_column("Move",     style="green")
    table.add_column("Δ (cents)", style="magenta", justify="right")

    for m in moves:
        table.add_row(
            m["event"],
            m["market"],
            m["book"],
            m["outcome"],
            f"{m['old']:+d} → {m['new']:+d}",
            str(m["delta"]),
        )

    console.print(table)


def format_discord_message(moves: list[dict]) -> str:
    lines = [f"🚨 **{len(moves)} line move(s) detected** — {datetime.now(timezone.utc).strftime('%H:%M UTC')}"]
    for m in moves[:5]:  # cap at 5 to avoid Discord truncation
        lines.append(
            f"• **{m['event']}** | {m['market']} | {m['book']} | "
            f"{m['outcome']}: `{m['old']:+d}` → `{m['new']:+d}` "
            f"({m['delta']} cent {'⬆️' if m['direction'] == 'shorter' else '⬇️'})"
        )
    return "\n".join(lines)


# ── Main loop ────────────────────────────────────────────────────────────────

async def main() -> None:
    console.print(f"[bold green]OddsBot starting[/bold green] | sport={SPORT} | "
                  f"threshold={THRESHOLD_CENTS}¢ | interval={POLL_INTERVAL}s")

    async with httpx.AsyncClient() as client:
        # Seed baseline on first run
        console.print("Seeding baseline...")
        events   = await fetch_odds(client)
        baseline = build_baseline(events)
        console.print(f"Baseline seeded: {len(baseline)} outcome-book pairs tracked.")

        while True:
            await asyncio.sleep(POLL_INTERVAL)
            ts = datetime.now(timezone.utc).strftime("%H:%M:%S")

            try:
                events      = await fetch_odds(client)
                new_baseline = build_baseline(events)
                moves        = detect_movements(baseline, new_baseline, events)

                if moves:
                    render_moves_table(moves)
                    msg = format_discord_message(moves)
                    await post_discord_alert(client, msg)
                else:
                    console.print(f"[dim]{ts} — No moves above {THRESHOLD_CENTS}¢ threshold.[/dim]")

                baseline = new_baseline  # roll forward

            except httpx.HTTPStatusError as exc:
                console.print(f"[red]API error {exc.response.status_code}:[/red] {exc}")
            except Exception as exc:
                console.print(f"[red]Unexpected error:[/red] {exc}")


if __name__ == "__main__":
    asyncio.run(main())

Reading the Output

When the bot fires, you'll see something like this in your terminal (and in Discord):

┌─────────────────────────────────────────────────────────────────────────┐
│                    Line Movements Detected                              │
├──────────────────┬─────────┬──────────┬──────────┬───────────┬─────────┤
│ Event            │ Market  │ Book     │ Outcome  │ Move      │ Δ(cents)│
├──────────────────┼─────────┼──────────┼──────────┼───────────┼─────────┤
│ Celtics @ Lakers │ spreads │ draftkings│ Lakers   │ -4 → -6  │   14    │
│ Celtics @ Lakers │ h2h     │ fanduel  │ Lakers   │ -180→-195│   11    │
└──────────────────┴─────────┴──────────┴──────────┴───────────┴─────────┘

Two books tightening on Lakers simultaneously is textbook steam. That's your signal to decide: are you chasing the move (betting with the sharp action before more books update) or fading it (if you think it's overreaction)?

The bot doesn't tell you what to bet. It tells you something moved. The decision layer stays with you — which is how it should be.

Tuning the Threshold

MOVEMENT_THRESHOLD_CENTS is in implied probability basis points (1 cent = 0.01%). A threshold of 10 on an NBA spread is meaningful. On a futures market with wide vig, you probably want 20-25 to filter noise. Start conservative, look at what fires over 48 hours, and calibrate from there.

Filtering by Book

Some books move slow and don't tell you much. You might want to filter your detection loop to only watch Pinnacle, DraftKings, and FanDuel — the three books that other books use as reference lines. Add a WATCHED_BOOKS env var and filter inside build_baseline with a set membership check.


Deploying This Thing

Local dev is fine for testing, but for 24/7 operation you want a VPS. A $6/month Hetzner or DigitalOcean instance handles this comfortably. Keep it simple:

# Run in background with nohup, log to file
nohup python odds_alert_bot.py >> bot.log 2>&1 &

Or drop a systemd unit file if you're running multiple bots and need process supervision. For Discord, you can also swap the webhook for a bot token and push to specific channels per sport — that's a 20-line extension.

Credit usage: at 60-second polling across two markets, you'll burn roughly 1,440 API calls per day. The MoneyLine API free tier gives you 1,000 credits/month, which is enough for testing and weekend sessions. If you're running this continuously across 3+ sports, you'll want a paid plan.

For a different take on what to do with the lines you detect — including how to find EV edges after a move — see the arbitrage detection guide.


Frequently Asked Questions

Q: Why poll instead of using a WebSocket?

A: Most sports odds APIs, including MoneyLine, expose REST endpoints rather than persistent WebSocket streams at the standard tier. Polling at 60-second intervals is sufficient for pregame markets where lines move in minutes, not milliseconds. If you're trying to scalp last-second in-play markets, you need infrastructure that costs far more than API credits.

Q: How do I avoid burning all my credits on one sport?

A: Set POLL_INTERVAL_SECONDS to 120 or 300 for lower-value markets (NCAAB regular season, minor soccer leagues). Reserve your tight 30-60 second polling for markets you actively bet — NFL, NBA, and whatever sport is in season right now. You can also run multiple instances of this script with different env files targeting different sports.

Q: Can this detect reverse line movement automatically?

A: Partially. True RLM detection requires combining public betting percentage data (which side the public is on) with the direction of the line move. This bot detects the line move. If you can find a source for public percentages, add it as a second API call and cross-reference inside detect_movements. The logic is straightforward once you have the data.

Q: What's the difference between this and an EV scanner?

A: An EV scanner looks at a snapshot in time and calculates whether a price is mispriced versus a no-vig fair line. This bot tracks price change over time. They're complementary: a line moving toward a position you already calculated has positive EV is a double confirmation. They're designed to be used together.

Q: Is this legal?

A: Polling a public API and sending yourself Discord alerts is legal everywhere. Automated bet placement is a different question entirely, depends on your jurisdiction and the sportsbook's terms of service. This bot does not place bets — it surfaces information. What you do with that information is on you.

Build with the same data we use.

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