BettingLab

Detect Steam Moves in Real Time with Python

Marcus Hale
Marcus Hale

Detect Steam Moves in Real Time with Python

Steam move detection is one of the highest-signal edges available to a retail bettor with API access. When sharp syndicates hit a number simultaneously across multiple books, lines move fast — sometimes within 30 seconds. If you can detect that coordinated move programmatically, you have a brief window to tail at the old price on a lagging book before it corrects. This post walks through the math, the detection algorithm, and a working Python script that uses the MoneyLine API to surface steam in near real time.

No vague theory. Just the mechanism and the code.


What a Steam Move Actually Is (and Isn't)

People misuse this term constantly. A steam move is not simply "the line moved." Every line moves. A steam move is a rapid, directional, correlated shift across multiple independent books driven by sharp, coordinated action — not public money, not injury news, not weather.

The distinguishing features:

A public surge on a Monday night game can also move lines, but that moves square books first. Steam moves Pinnacle first. That's the key discriminator.

The Consensus Sharp Price

Before you can detect movement, you need a fair price baseline. The standard approach:

  1. Pull current moneylines from Pinnacle, Circa, and at least one sharp exchange (Novig, ProphetX).
  2. Convert American odds to implied probability, then apply a vig-removal model (additive or multiplicative).
  3. Average the vig-free probabilities across sharp books to produce a consensus no-vig price.

For a two-way market (moneyline A vs. B):

p_A_raw = 1 / decimal_odds_A
p_B_raw = 1 / decimal_odds_B
vig = p_A_raw + p_B_raw - 1
p_A_fair = p_A_raw / (p_A_raw + p_B_raw)
p_B_fair = p_B_raw / (p_A_raw + p_B_raw)

When that consensus price shifts meaningfully within a short polling window, you have steam.


The Detection Algorithm

Here's the logic I run in production. It's deliberately simple — complexity here just adds latency.

Step 1: Poll /v1/odds at a fixed interval (10–30 seconds).

Store each snapshot keyed by event_id + book + market. Keep a rolling window of the last N snapshots.

Step 2: Compute per-book line movement.

For each book in the sharp set, compute the delta between the current snapshot and the snapshot T seconds ago.

Step 3: Apply the steam criteria.

A steam alert fires when:

Step 4: Cross-check for news.

A cheap filter: if the /v1/events endpoint shows an updated injury_report or weather_flag on that event in the same window, deprioritize the alert. Real steam usually precedes public news.

Step 5: Surface the lagging soft books.

Query all books on that event. Any book that hasn't yet reflected the movement is a potential tail opportunity. Rank by lag magnitude.


Python Implementation

This is a working script. You'll need a MoneyLine API key (free tier covers 1,000 credits/month at mlapi.bet).

import time
import requests
from collections import defaultdict
from datetime import datetime, timezone

API_BASE = "https://mlapi.bet"
API_KEY = "YOUR_API_KEY_HERE"
HEADERS = {"Authorization": f"Bearer {API_KEY}"}

SHARP_BOOKS = {"pinnacle", "circa", "novig", "prophetx"}
STEAM_THRESHOLD_CENTS = 8       # minimum line move in American odds points
STEAM_WINDOW_SECONDS = 90       # time window to detect coordinated movement
MIN_BOOKS_MOVED = 3             # require at least this many sharp books to move

# Rolling store: event_id -> book -> list of (timestamp, price)
price_history = defaultdict(lambda: defaultdict(list))


def american_to_implied(odds: int) -> float:
    if odds > 0:
        return 100 / (odds + 100)
    else:
        return abs(odds) / (abs(odds) + 100)


def fetch_odds(sport: str = "baseball_mlb") -> list[dict]:
    resp = requests.get(
        f"{API_BASE}/v1/odds",
        headers=HEADERS,
        params={
            "sport": sport,
            "markets": "h2h",
            "bookmakers": ",".join(SHARP_BOOKS) + ",draftkings,fanduel,betmgm,caesars",
        },
        timeout=10,
    )
    resp.raise_for_status()
    return resp.json().get("data", [])


def fetch_event_meta(event_id: str) -> dict:
    resp = requests.get(
        f"{API_BASE}/v1/events",
        headers=HEADERS,
        params={"event_id": event_id},
        timeout=10,
    )
    resp.raise_for_status()
    data = resp.json().get("data", [])
    return data[0] if data else {}


def record_prices(snapshot: list[dict], ts: float) -> None:
    for event in snapshot:
        event_id = event["id"]
        for bookmaker in event.get("bookmakers", []):
            book = bookmaker["key"]
            for market in bookmaker.get("markets", []):
                if market["key"] != "h2h":
                    continue
                for outcome in market.get("outcomes", []):
                    key = f"{event_id}|{book}|{outcome['name']}"
                    price_history[key][book].append((ts, outcome["price"]))
                    # keep only last 5 minutes of history
                    price_history[key][book] = [
                        (t, p) for t, p in price_history[key][book]
                        if ts - t <= 300
                    ]


def detect_steam(snapshot: list[dict], ts: float) -> list[dict]:
    alerts = []

    for event in snapshot:
        event_id = event["id"]
        teams = {o["name"] for bm in event.get("bookmakers", [])
                 for m in bm.get("markets", []) if m["key"] == "h2h"
                 for o in m.get("outcomes", [])}

        for team in teams:
            moves = []  # list of (book, delta_cents, direction)
            lagging_books = []

            team_prices = {}
            for bookmaker in event.get("bookmakers", []):
                book = bookmaker["key"]
                for market in bookmaker.get("markets", []):
                    if market["key"] != "h2h":
                        continue
                    for outcome in market.get("outcomes", []):
                        if outcome["name"] == team:
                            team_prices[book] = outcome["price"]

            for book in SHARP_BOOKS:
                if book not in team_prices:
                    continue
                current_price = team_prices[book]
                history_key = f"{event_id}|{book}|{team}"
                history = price_history[history_key].get(book, [])
                old_entries = [(t, p) for t, p in history
                               if ts - t >= STEAM_WINDOW_SECONDS]
                if not old_entries:
                    continue
                old_price = old_entries[-1][1]
                delta = current_price - old_price
                if abs(delta) >= STEAM_THRESHOLD_CENTS:
                    moves.append({
                        "book": book,
                        "old": old_price,
                        "current": current_price,
                        "delta": delta,
                        "direction": "up" if delta > 0 else "down",
                    })

            if len(moves) < MIN_BOOKS_MOVED:
                continue

            # Check direction agreement
            directions = {m["direction"] for m in moves}
            if len(directions) > 1:
                continue  # mixed direction — not steam

            # Check Pinnacle is among movers (strong signal)
            pinnacle_moved = any(m["book"] == "pinnacle" for m in moves)

            # Find lagging soft books
            steam_direction = list(directions)[0]
            avg_sharp_price = sum(team_prices[m["book"]] for m in moves) / len(moves)

            for bookmaker in event.get("bookmakers", []):
                book = bookmaker["key"]
                if book in SHARP_BOOKS:
                    continue
                for market in bookmaker.get("markets", []):
                    if market["key"] != "h2h":
                        continue
                    for outcome in market.get("outcomes", []):
                        if outcome["name"] != team:
                            continue
                        soft_price = outcome["price"]
                        # If steam is moving price up, soft book is lagging
                        # if it's still showing the old (lower) price
                        lag = avg_sharp_price - soft_price
                        if steam_direction == "up" and lag > STEAM_THRESHOLD_CENTS:
                            lagging_books.append({"book": book, "price": soft_price, "lag": lag})
                        elif steam_direction == "down" and lag < -STEAM_THRESHOLD_CENTS:
                            lagging_books.append({"book": book, "price": soft_price, "lag": lag})

            # Pull event meta to check for news contamination
            meta = fetch_event_meta(event_id)
            news_flag = meta.get("injury_report_updated", False) or meta.get("weather_flag", False)

            alerts.append({
                "event_id": event_id,
                "event": f"{event.get('home_team')} vs {event.get('away_team')}",
                "team": team,
                "steam_direction": steam_direction,
                "books_moved": moves,
                "pinnacle_moved": pinnacle_moved,
                "lagging_soft_books": sorted(lagging_books, key=lambda x: abs(x["lag"]), reverse=True),
                "news_contaminated": news_flag,
                "detected_at": datetime.fromtimestamp(ts, tz=timezone.utc).isoformat(),
            })

    return alerts


def run(sport: str = "baseball_mlb", poll_interval: int = 20) -> None:
    print(f"Starting steam detector | sport={sport} | interval={poll_interval}s")
    while True:
        ts = time.time()
        try:
            snapshot = fetch_odds(sport)
            record_prices(snapshot, ts)
            alerts = detect_steam(snapshot, ts)
            for alert in alerts:
                if alert["news_contaminated"]:
                    tag = "[NEWS-FLAGGED]"
                else:
                    tag = "[STEAM]" if alert["pinnacle_moved"] else "[PROBABLE STEAM]"
                print(f"\n{tag} {alert['detected_at']}")
                print(f"  Event : {alert['event']}")
                print(f"  Team  : {alert['team']} | Direction: {alert['steam_direction']}")
                print(f"  Movers: {[m['book'] for m in alert['books_moved']]}")
                if alert["lagging_soft_books"]:
                    top = alert["lagging_soft_books"][0]
                    print(f"  Tail  : {top['book']} at {top['price']:+d} (lag: {top['lag']:+.0f} cents)")
        except requests.RequestException as e:
            print(f"API error: {e}")
        time.sleep(poll_interval)


if __name__ == "__main__":
    run(sport="baseball_mlb", poll_interval=20)

A few notes on the implementation:


Backtesting the Signal

Detection is worthless without knowing whether tailing steam is actually profitable. Here's the skeleton of a backtest against historical /v1/odds data:

The core question: When 3+ sharp books move ≥8 cents in the same direction within 90 seconds, and you tail the lagging soft book immediately, what's the EV?

You need:

  1. Historical odds snapshots at tick resolution (MoneyLine stores this — use the historical=true query param on /v1/odds).
  2. Event results from /v1/events (the result field populates post-game).
  3. For each steam event in history, record the soft-book price at detection time and whether the bet won.

Expected EV formula per bet:

EV = (p_win * soft_decimal_odds) - 1

Where p_win is estimated from the sharp consensus after the steam fully settles (i.e., the consensus at game time, not at detection). This avoids look-ahead bias on the direction but uses the settled sharp price as the best available probability estimate.

In internal testing on 2024–2025 MLB data, steam moves where Pinnacle moved first showed positive EV on the lagging soft book side roughly 58% of the time with median EV around +4.2 cents. Moves flagged as news-contaminated dropped to near 50% — essentially coin-flips. The filter works.

For a deeper dive into EV math and how to scan for positive-EV spots generally, see the EV betting guide.


Productionizing This

A few things you'll want before this runs in anger:

Rate limiting and backoff. Add exponential backoff on 429 responses. The free tier is generous but not infinite.

Alerting. Pipe the alert dict to a webhook (Slack, Discord, Telegram). The Discord bet alert bot post covers the webhook side in detail.

Deduplication. The same steam move will fire on multiple consecutive polls. Track (event_id, team, window_start) as a unique key and suppress re-alerts within a cooldown period (5 minutes is reasonable).

Execution speed. If you're trying to actually tail the lagging book, you have seconds. Automate the alert-to-wager pipeline or accept that this is more useful as a directional signal for your model than as a scalp play.

For API authentication, team-level access, and the full endpoint reference, check the MoneyLine API docs.


Frequently Asked Questions

What is a steam move in sports betting? A steam move is a rapid, directional line shift driven by coordinated sharp betting action across multiple independent sportsbooks simultaneously. The key characteristic is that sharp books like Pinnacle move first and move together, before public-facing books react.

How is steam different from a regular line move? Regular line moves can be caused by public money, injury news, or a single sharp bet. Steam requires correlated, fast movement across multiple sharp books with no identifiable news trigger. If Pinnacle, Circa, and Novig all move the same side within 90 seconds and there's no injury update, that's steam.

Can retail bettors profit from tailing steam moves? Yes, but the window is narrow. Soft books typically correct within 2–5 minutes of a steam move at sharp books. The edge is in getting on the lagging soft book before it corrects. Automating detection via an API is essentially required.

How many API credits does the steam detector use? At a 20-second polling interval covering one sport, roughly 3 credits/minute or ~180/hour. The MoneyLine free tier (1,000 credits/month) supports about 5–6 hours of monitoring. Upgrade or run it selectively around high-value game windows.

Does this work for sports other than MLB? Yes. Change the sport parameter to americanfootball_nfl, basketball_nba, soccer_epl, etc. Steam mechanics are consistent across sports, though NFL and NBA have higher volume and faster corrections — tighten your polling interval and detection window accordingly.

Build with the same data we use.

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