BettingLab

Detect Steam Moves with a Sharp-Book Consensus Line

Marcus Hale
Marcus Hale

Steam moves are one of the most actionable signals in sports betting. When sharp money hits a line at a respected book — Pinnacle, Circa, Bookmaker — and the number moves fast, you have roughly a 90-second window before every square book in the ecosystem reprices. If you can detect steam moves programmatically before that repricing cascade completes, you can pick off stale prices at slower books and bank genuine positive EV without building a predictive model at all.

This post walks through the exact workflow I use: constructing a sharp-book consensus fair line from the MoneyLine API, comparing it against a universe of soft books in real time, and flagging deviations that signal an in-progress steam move. All code is Python. All data comes from MoneyLine API.


Why Sharp-Book Consensus Beats Any Single Reference Line

The naive approach is to use Pinnacle as your single source of truth. Pinnacle is excellent — low hold, professionally bet market, prices reflect sharp action faster than anywhere else. But Pinnacle alone has two failure modes:

  1. Pinnacle is sometimes the target of the steam, not the source. When a syndicate hits Pinnacle first, the line moves there before it moves anywhere else. You're reading the signal after it's already priced.
  2. Pinnacle has occasional hold blips on less-liquid markets (niche props, early-week totals) where the market is thin.

A consensus of Pinnacle + Circa + Bookmaker + (where available) Kambi/Betsson gives you a much more stable no-vig midpoint. When all four of these sharp books agree on a range, that's your fair line. When one of them breaks away from the others while soft books haven't moved yet — that's your steam signal.

The Math: Constructing a No-Vig Consensus Price

Take a two-outcome market (moneyline). Each book quotes two American odds: home H and away A. Convert to implied probability:

p_home = 1 / decimal(H)
p_away = 1 / decimal(A)
hold   = p_home + p_away - 1

Strip the vig by normalizing:

fair_home = p_home / (p_home + p_away)
fair_away = p_away / (p_home + p_away)

Do this for each sharp book, then take the simple average across books:

consensus_fair_home = mean([fair_home_book1, fair_home_book2, ...])

Convert back to American odds for human readability:

def prob_to_american(p: float) -> float:
    if p >= 0.5:
        return -(p / (1 - p)) * 100
    else:
        return ((1 - p) / p) * 100

Now you have a single consensus fair line. Any book quoting worse than this (from the bettor's perspective) by more than its typical hold is either stale or has been steamed and hasn't repriced yet.


Pulling the Data: MoneyLine API Endpoints

You need two endpoints for this workflow.

/v1/odds — returns current odds across all books for a given event or market. You'll filter this to your sharp-book list and your soft-book targets.

/v1/edge — returns pre-computed no-vig lines and EV estimates. Useful as a sanity check against your own consensus math, and it surfaces the sharp-consensus line directly so you don't have to roll your own if you'd rather not.

Here's the full Python implementation. It polls both endpoints on a configurable interval, computes the consensus fair line, then flags any soft book line that's more than threshold_cents off the consensus.

import time
import requests
from statistics import mean

API_BASE = "https://mlapi.bet"
API_KEY = "YOUR_API_KEY"  # free tier: 1k credits/month

SHARP_BOOKS = {"pinnacle", "circa", "bookmaker", "betsson"}
SOFT_BOOKS  = {"draftkings", "fanduel", "betmgm", "caesars", "bet365"}

POLL_INTERVAL = 15      # seconds between polls
THRESHOLD_CENTS = 8     # flag if soft book is 8+ cents worse than consensus fair

HEADERS = {"Authorization": f"Bearer {API_KEY}"}


def american_to_decimal(american: float) -> float:
    if american > 0:
        return (american / 100) + 1
    return (100 / abs(american)) + 1


def decimal_to_prob(dec: float) -> float:
    return 1 / dec


def prob_to_american(p: float) -> float:
    if p <= 0 or p >= 1:
        raise ValueError(f"Invalid probability: {p}")
    if p >= 0.5:
        return -round((p / (1 - p)) * 100, 1)
    return round(((1 - p) / p) * 100, 1)


def strip_vig(p_home: float, p_away: float) -> tuple[float, float]:
    total = p_home + p_away
    return p_home / total, p_away / total


def get_odds(event_id: str) -> dict:
    r = requests.get(
        f"{API_BASE}/v1/odds",
        headers=HEADERS,
        params={"event_id": event_id, "market": "moneyline"},
        timeout=10,
    )
    r.raise_for_status()
    return r.json()


def get_edge(event_id: str) -> dict:
    r = requests.get(
        f"{API_BASE}/v1/edge",
        headers=HEADERS,
        params={"event_id": event_id, "market": "moneyline"},
        timeout=10,
    )
    r.raise_for_status()
    return r.json()


def build_consensus(odds_data: dict) -> dict[str, float]:
    """
    Returns {"home": fair_prob, "away": fair_prob} from sharp-book consensus.
    """
    home_probs, away_probs = [], []

    for book_entry in odds_data.get("books", []):
        book = book_entry["book"].lower()
        if book not in SHARP_BOOKS:
            continue
        try:
            h_dec = american_to_decimal(book_entry["odds"]["home"])
            a_dec = american_to_decimal(book_entry["odds"]["away"])
            fh, fa = strip_vig(decimal_to_prob(h_dec), decimal_to_prob(a_dec))
            home_probs.append(fh)
            away_probs.append(fa)
        except (KeyError, ZeroDivisionError):
            continue

    if not home_probs:
        return {}

    return {
        "home": mean(home_probs),
        "away": mean(away_probs),
    }


def scan_soft_books(odds_data: dict, consensus: dict[str, float]) -> list[dict]:
    """
    Returns a list of flagged lines where a soft book is >THRESHOLD_CENTS
    worse (for the bettor) than the consensus fair line.
    """
    flags = []
    for book_entry in odds_data.get("books", []):
        book = book_entry["book"].lower()
        if book not in SOFT_BOOKS:
            continue
        for side in ("home", "away"):
            try:
                soft_american = book_entry["odds"][side]
                soft_dec = american_to_decimal(soft_american)
                soft_prob = decimal_to_prob(soft_dec)

                consensus_american = prob_to_american(consensus[side])
                # cents difference: positive = soft book is BETTER than fair (rare)
                # negative = soft book is WORSE than fair (stale / steamed)
                delta = soft_american - consensus_american

                if delta < -THRESHOLD_CENTS:
                    flags.append({
                        "book": book,
                        "side": side,
                        "soft_line": soft_american,
                        "consensus_fair": round(consensus_american, 1),
                        "delta_cents": round(delta, 1),
                        "edge_pct": round(
                            (consensus[side] - soft_prob) / soft_prob * 100, 2
                        ),
                    })
            except (KeyError, ZeroDivisionError, ValueError):
                continue
    return flags


def run_steam_scanner(event_id: str):
    print(f"Starting steam scanner for event {event_id}")
    seen_flags = set()

    while True:
        try:
            odds_data = get_odds(event_id)
            consensus = build_consensus(odds_data)

            if not consensus:
                print("No sharp-book data available yet.")
                time.sleep(POLL_INTERVAL)
                continue

            flags = scan_soft_books(odds_data, consensus)

            for flag in flags:
                key = (flag["book"], flag["side"], int(flag["soft_line"]))
                if key not in seen_flags:
                    seen_flags.add(key)
                    print(
                        f"[STEAM FLAG] {flag['book'].upper()} | {flag['side'].upper()} "
                        f"| Soft: {flag['soft_line']:+.0f} | "
                        f"Consensus: {flag['consensus_fair']:+.1f} | "
                        f"Delta: {flag['delta_cents']:+.1f}¢ | "
                        f"Edge: {flag['edge_pct']:+.2f}%"
                    )

        except requests.RequestException as e:
            print(f"API error: {e}")

        time.sleep(POLL_INTERVAL)


if __name__ == "__main__":
    # Replace with a live event_id from /v1/events
    run_steam_scanner("event_mlb_20260627_nyy_bos")

A few notes on the implementation:


Interpreting Steam Move Signals in Practice

Not every flag is a steam move. Here's how to triage:

True Steam vs. Opening Line Lag

When a book first posts a line, it might simply be behind the market. That looks identical to steam in the data. The distinguishing feature: directional consistency across multiple soft books simultaneously.

If DraftKings, FanDuel, and BetMGM all flag on the same side within the same 30-second polling window — and Pinnacle just moved 5+ cents in the last two minutes — that's steam. If only one soft book flags and Pinnacle hasn't moved, you're probably looking at a slow opener.

Extend the scanner to log Pinnacle's last N prices and compare the current consensus against the rolling prior. A consensus shift of 4+ cents in under 2 minutes, followed by multi-book soft-side flags, is your steam confirmation signal.

Expected Value of Acting on Steam Flags

The edge from a steam window is real but decays fast. Industry data suggests the average soft-book repricing latency is 90–180 seconds after a sharp-book move. Your scanner at 15-second intervals leaves you a 1–2 cycle window.

For this reason, steam-hunting is most viable when you:

  1. Are pre-funded at the soft books (no deposit delay).
  2. Use the API data to pre-identify which books are historically slow to reprice for a given sport.
  3. Set alerts that hit a mobile push notification, not just a console log.

For more on building that alerting layer, see our post on building a real-time odds movement alert bot.


Backtesting the Signal

Before going live, you want to know whether this signal is actually profitable in your jurisdiction at the books you can access. The MoneyLine API provides historical odds snapshots — use /v1/odds with a timestamp parameter to reconstruct past line states and replay the algorithm.

Measure:

A backtest showing 60%+ of flagged lines closing 5+ cents tighter (in the direction of the consensus) is strong evidence the signal is real. Anything below 50% suggests your threshold is too loose or your sharp-book list needs trimming.

For a deep dive on building and evaluating EV strategies, see the EV betting methodology page.


FAQ

What is a steam move in sports betting?

A steam move is a sudden, sharp line movement at respected sportsbooks caused by coordinated sharp or syndicate action. It signals that informed bettors have identified significant value on one side. The cascade effect — where square books reprice to match the sharp books — creates a brief window where stale prices offer positive EV.

Which books count as "sharp" for consensus line calculation?

Pinnacle is the gold standard. Circa, Bookmaker, and Betsson/Kambi are also widely respected. The key criterion: these books accept large bets from winning players without restricting accounts, so their prices reflect actual sharp action rather than recreational volume.

How fast do soft books reprice after a steam move?

Historically, 90–180 seconds is typical for major moneylines at Tier-1 soft books. Niche props and secondary markets can lag 5–10 minutes. Your detection latency plus your bet-placement latency needs to fit inside that window.

What polling interval should I use with the MoneyLine API?

On the free tier (1k credits/month), 15–30 second polling is sustainable for a small number of events. If you're scanning 50+ events simultaneously you'll want a paid tier. The /v1/edge endpoint is more credit-efficient if you just need the pre-computed consensus line rather than raw book-by-book odds.

Is steam-move betting the same as arbitrage?

No. Arbitrage locks in a guaranteed profit by simultaneously betting both sides of a market at favorable prices. Steam-move betting is positive-EV betting on one side — you're betting that the soft book's price is wrong relative to the sharp consensus. It's not guaranteed profit, but it has positive expected value over a large sample.


The full workflow — consensus fair line construction, real-time soft-book scanning, and CLV-based backtesting — is tractable for any developer with a free API key. The MoneyLine API surfaces the sharp-book data you need across all of these steps without scraping individual books or negotiating data licenses. Start with the free tier, validate the signal on historical snapshots, then tighten the polling interval once you've confirmed the edge is real.

Build with the same data we use.

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