BettingLab

Detect Steam Moves with a Sharp-Book Consensus API

Marcus Hale
Marcus Hale

Detect Steam Moves with a Sharp-Book Consensus API

Steam moves are one of the most reliable signals in sports betting. When sharp money hits a number and forces coordinated line movement across books — that's steam. If you can detect it early, you can either follow the sharp side or fade the soft-book lag. Either way, the detect steam moves problem is really a data engineering problem: you need a continuous feed of consensus lines from sharp books, and you need to compute the delta fast enough to act.

This post walks through the full workflow: what steam actually is, how to build a sharp-book consensus line, how to measure divergence across 100+ books, and how to write a Python scanner that fires alerts when steam is in the air. Everything routes through the MoneyLine API — which aggregates odds across sharp and recreational books simultaneously, so you're not stitching together three separate data providers.


What a Steam Move Actually Is (And What It Isn't)

A lot of bettors confuse line movement with steam. They're not the same.

Line movement is just a price change. A book might move from -110 to -115 because their liability is lopsided on one side. That's risk management, not intelligence.

Steam is coordinated movement driven by sharp, informed bettors hitting the same side at multiple books within a short window. The telltale signature: Pinnacle (or another sharp book like Circa or Bookmaker) moves first, then soft books follow within minutes. The line-originating book has priced correctly; the follower books are adjusting to not get eaten alive.

The math is simple: if Pinnacle's no-vig price shifts by more than ~15 cents in either direction within a 10-minute window, and that move is echoed by at least two other sharp-leaning books, you have a steam signal worth logging.

Why Sharp-Book Consensus Beats Any Single Line

No single book is right all the time. But the weighted consensus of Pinnacle, Circa, and Bookmaker forms something close to an efficient price. When that consensus moves, the market has spoken. When soft books haven't caught up, you have an exploitable gap — at minimum for EV, at best for a live line shop.


Building the Sharp-Book Consensus Line

Let's define consensus formally. For a two-way market (moneyline), the no-vig (fair) probability for each book is:

P_implied = (american_to_decimal(price))^-1

For American odds, the conversion to decimal is:

Then to remove the vig from a two-sided market:

P_fair(A) = P_raw(A) / (P_raw(A) + P_raw(B))

For a consensus across N sharp books, weight each book by a sharpness coefficient w_i (Pinnacle gets 1.0, Circa 0.95, Bookmaker 0.9, etc.):

P_consensus = sum(w_i * P_fair_i(A)) / sum(w_i)

Back-convert to American:

This is your sharp consensus line. Now you track it over time. When it jumps by more than a threshold in a short window, you've got steam.


Pulling Multi-Book Odds with the MoneyLine API

The /v1/odds endpoint returns current odds across all available books for a given event. The /v1/events endpoint gives you the event list to iterate over. Here's how to fetch, filter to sharp books, and compute the consensus — all in Python.

import httpx
import time
from collections import defaultdict

API_BASE = "https://mlapi.bet"
API_KEY = "YOUR_API_KEY"

SHARP_BOOKS = {
    "pinnacle": 1.00,
    "circa": 0.95,
    "bookmaker": 0.90,
    "heritage": 0.85,
}

STEAM_THRESHOLD_CENTS = 15  # cents on implied probability (0.015)
WINDOW_SECONDS = 600         # 10-minute rolling window

def american_to_prob(american: float) -> float:
    if american >= 0:
        decimal = (american / 100) + 1
    else:
        decimal = (100 / abs(american)) + 1
    return 1 / decimal

def remove_vig(p_a: float, p_b: float) -> tuple[float, float]:
    total = p_a + p_b
    return p_a / total, p_b / total

def compute_sharp_consensus(odds_list: list[dict]) -> dict | None:
    weighted_p_home = 0.0
    weighted_p_away = 0.0
    total_weight = 0.0

    for entry in odds_list:
        book = entry.get("book", "").lower()
        weight = SHARP_BOOKS.get(book)
        if weight is None:
            continue

        home_price = entry.get("home_price")
        away_price = entry.get("away_price")
        if home_price is None or away_price is None:
            continue

        p_home_raw = american_to_prob(home_price)
        p_away_raw = american_to_prob(away_price)
        p_home_fair, p_away_fair = remove_vig(p_home_raw, p_away_raw)

        weighted_p_home += weight * p_home_fair
        weighted_p_away += weight * p_away_fair
        total_weight += weight

    if total_weight == 0:
        return None

    consensus_p_home = weighted_p_home / total_weight
    consensus_p_away = weighted_p_away / total_weight

    return {
        "consensus_p_home": consensus_p_home,
        "consensus_p_away": consensus_p_away,
        "sharp_book_count": sum(
            1 for e in odds_list if e.get("book", "").lower() in SHARP_BOOKS
        ),
    }

def fetch_events(sport: str = "baseball_mlb") -> list[dict]:
    resp = httpx.get(
        f"{API_BASE}/v1/events",
        headers={"Authorization": f"Bearer {API_KEY}"},
        params={"sport": sport, "status": "upcoming"},
        timeout=10,
    )
    resp.raise_for_status()
    return resp.json().get("events", [])

def fetch_odds(event_id: str) -> list[dict]:
    resp = httpx.get(
        f"{API_BASE}/v1/odds",
        headers={"Authorization": f"Bearer {API_KEY}"},
        params={"event_id": event_id, "market": "h2h"},
        timeout=10,
    )
    resp.raise_for_status()
    return resp.json().get("odds", [])

def run_steam_scanner(sport: str = "baseball_mlb", poll_interval: int = 60):
    """
    Polls every `poll_interval` seconds and detects steam moves.
    Stores rolling consensus snapshots per event_id.
    """
    history: dict[str, list[tuple[float, dict]]] = defaultdict(list)

    print(f"Starting steam scanner for {sport}...")

    while True:
        events = fetch_events(sport)
        now = time.time()

        for event in events:
            event_id = event["id"]
            home = event.get("home_team", "Home")
            away = event.get("away_team", "Away")

            odds_list = fetch_odds(event_id)
            consensus = compute_sharp_consensus(odds_list)

            if consensus is None or consensus["sharp_book_count"] < 2:
                continue

            # Store snapshot
            history[event_id].append((now, consensus))

            # Prune old snapshots outside the window
            history[event_id] = [
                (t, c) for t, c in history[event_id]
                if now - t <= WINDOW_SECONDS
            ]

            snapshots = history[event_id]
            if len(snapshots) < 2:
                continue

            oldest_t, oldest_c = snapshots[0]
            current_c = snapshots[-1][1]

            delta_home = abs(
                current_c["consensus_p_home"] - oldest_c["consensus_p_home"]
            )

            if delta_home >= STEAM_THRESHOLD_CENTS / 100:
                direction = (
                    "HOME" if current_c["consensus_p_home"]
                    > oldest_c["consensus_p_home"]
                    else "AWAY"
                )
                print(
                    f"🔥 STEAM DETECTED | {away} @ {home} | "
                    f"Direction: {direction} | "
                    f"Delta: {delta_home:.4f} over "
                    f"{(now - oldest_t)/60:.1f} min | "
                    f"Sharp books: {consensus['sharp_book_count']}"
                )

        time.sleep(poll_interval)

if __name__ == "__main__":
    run_steam_scanner(sport="baseball_mlb", poll_interval=60)

A few things to note here:

  1. The SHARP_BOOKS dict is your sharpness weighting. Adjust the weights based on your empirical observations. Pinnacle's closing line is the canonical benchmark, so it stays at 1.0.
  2. STEAM_THRESHOLD_CENTS = 15 is a starting point, not gospel. For MLB moneylines, 10 cents might catch more steam. For NFL totals, 20 cents is more appropriate given the vig spread.
  3. You're polling every 60 seconds. If you have a higher-tier API plan, drop this to 15-20 seconds. Steam moves can resolve in under 5 minutes on liquid markets.

Turning Steam Signals into EV Bets

Detecting steam isn't the endgame — exploiting the soft-book lag is. When your scanner fires a steam alert on the home side, here's the play:

  1. Pull the /v1/odds response again immediately.
  2. Compare every soft book's price against your fresh sharp consensus.
  3. Any soft book still offering the pre-steam price has a positive-EV line.

The EV formula:

EV = (P_consensus * soft_payout) - (1 - P_consensus) * 1

Where soft_payout is the decimal payout at the soft book minus your stake. If EV > 0 and the book hasn't moved yet, you bet it.

For a deeper look at how to structure this into a continuous scanner (not just a one-shot check), see the EV betting workflow breakdown and how it ties into API credit budgeting. If you're also looking at guaranteed-profit situations where both sides are out of sync, the arbitrage scanner guide covers that angle.

What to Do When You Get Flagged

Steam following at soft books is the fastest path to getting limited. A few operational notes:


Backtesting Your Steam Threshold on Historical Data

The /v1/edge endpoint exposes historical edge data you can use to backtest your parameters. Here's a minimal backtest skeleton:

def backtest_steam_signals(sport: str, from_date: str, to_date: str):
    resp = httpx.get(
        f"{API_BASE}/v1/edge",
        headers={"Authorization": f"Bearer {API_KEY}"},
        params={
            "sport": sport,
            "from": from_date,
            "to": to_date,
            "market": "h2h",
        },
        timeout=15,
    )
    resp.raise_for_status()
    edges = resp.json().get("edges", [])

    wins, losses, total_ev = 0, 0, 0.0

    for edge in edges:
        ev = edge.get("ev", 0)
        result = edge.get("result")  # "win" | "loss" | "push"

        if result == "win":
            wins += 1
        elif result == "loss":
            losses += 1

        total_ev += ev

    total = wins + losses
    win_rate = wins / total if total > 0 else 0
    print(f"Bets: {total} | Win rate: {win_rate:.2%} | Total EV: {total_ev:.2f} units")

backtest_steam_signals("baseball_mlb", "2025-04-01", "2025-09-30")

When you run this against a full MLB season, expect win rates around 52-55% on steam-following strategies. The edge isn't massive, but it's real and consistent — which is more than can be said for most approaches.


FAQ

What is a steam move in sports betting?

A steam move is rapid, coordinated line movement driven by sharp bettors hitting the same side at multiple sportsbooks in a short window, typically under 10 minutes. It usually originates at a sharp book like Pinnacle, then cascades to recreational books as they adjust to avoid being beaten by informed money.

How do I detect steam moves programmatically?

You poll a multi-book odds API (like the MoneyLine API /v1/odds endpoint) on a short interval, compute a weighted sharp-book consensus line, and compare snapshots over a rolling time window. When the consensus probability shifts by more than a threshold (typically 10-20 cents implied) within that window, you flag it as steam.

Which sportsbooks count as "sharp" for consensus purposes?

Pinnacle, Circa, Bookmaker, Heritage, and CRIS are the standard sharp-book benchmarks. Pinnacle's closing line in particular is widely considered the most efficient market in sports betting. Weight them by historical closing-line accuracy if you want to be precise.

How often should I poll the odds API to catch steam moves?

Every 30-60 seconds is sufficient for most markets. Liquid MLB and NFL markets can steam out in 3-5 minutes, so a 60-second poll will catch the tail end of most moves. For live betting or NBA (which moves faster), go to 15-20 seconds if your API plan allows it.

Can I follow steam moves at soft books without getting limited?

Steam following is the highest-risk strategy for account longevity at recreational books. Soft books track closing-line performance and will limit sharp accounts. Standard mitigation: spread action across multiple accounts and jurisdictions, stay under liability triggers, and prioritize books with slower-updating lines. The edge is real but your shelf life matters.

Build with the same data we use.

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