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 the closest thing to free information in sports betting. When sharp money hits a number at Pinnacle, Circa, or Bookmaker and the line shifts hard in under three minutes, that's not noise — that's signal. The problem is that most bettors find out about steam after the line has already moved at the books they actually use. By then, the edge is gone.

The fix is building a detect steam moves system that polls sharp-book consensus lines continuously and flags divergences before the soft books catch up. This post walks through exactly how to do that using real data from the MoneyLine API, with Python code you can run today.


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

Steam is overused. Half the Twitter accounts screaming "STEAM MOVE" are just watching a line drift two cents over four hours. That's not steam. Steam is a fast, coordinated line move caused by sharp bettors (often syndicates) hammering the same side simultaneously across multiple books.

The operational definition I use:

That lag window — sometimes 2 minutes, sometimes 20 — is your trading opportunity. You're trying to bet the new fair price at the old soft-book number before they adjust.

Sharp Books vs. Soft Books

For this system, "sharp" means books that set rather than follow lines. In practice: Pinnacle, Circa, Bookmaker, Heritage, and to a lesser extent BetOnline. "Soft" means books that copy sharp lines with a delay and hold higher margins: DraftKings, FanDuel, BetMGM, PointsBet, Caesars.

The MoneyLine API /v1/odds endpoint returns data across both categories. The key is tagging your sources correctly so your steam detector knows which direction causality runs.


The Data Pipeline: Polling Sharp Consensus Lines

Here's the architecture:

  1. Poll /v1/odds every 60 seconds for markets you're monitoring
  2. Maintain a rolling line history for each sharp book per market
  3. Compute a sharp consensus line (weighted average across sharp books)
  4. Detect significant deviations in the consensus using a sliding window
  5. Compare sharp consensus to soft-book current lines to measure lag
  6. Alert when lag exceeds your threshold

Let's build it.

import httpx
import time
import statistics
from collections import defaultdict, deque
from datetime import datetime, timezone

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

SHARP_BOOKS = {"pinnacle", "circa", "bookmaker", "heritage"}
SOFT_BOOKS = {"draftkings", "fanduel", "betmgm", "caesars", "pointsbet"}

# Rolling window: last 10 polls per book per market
line_history = defaultdict(lambda: defaultdict(lambda: deque(maxlen=10)))

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

def decimal_to_implied(decimal: float) -> float:
    return 1.0 / decimal

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

def compute_sharp_consensus(odds_data: dict, side: str) -> float | None:
    """
    Weighted average of sharp-book implied probabilities for a given side.
    Returns the consensus fair probability (no-vig not applied here — 
    we're measuring relative movement, not fair value).
    """
    sharp_probs = []
    for book in odds_data.get("bookmakers", []):
        if book["key"].lower() not in SHARP_BOOKS:
            continue
        for outcome in book.get("markets", [{}])[0].get("outcomes", []):
            if outcome["name"].lower() == side.lower():
                dec = american_to_decimal(outcome["price"])
                sharp_probs.append(decimal_to_implied(dec))
    if not sharp_probs:
        return None
    return statistics.mean(sharp_probs)

def detect_steam(event_id: str, side: str, window_polls: int = 3) -> dict | None:
    """
    Returns a steam alert dict if:
    - Sharp consensus has moved >= 3 probability points in `window_polls` polls
    - At least 2 sharp books are aligned on the direction
    - Soft books have NOT moved (lag detected)
    """
    history = line_history[event_id][side]
    if len(history) < window_polls:
        return None

    recent = list(history)[-window_polls:]
    move = recent[-1]["sharp_consensus"] - recent[0]["sharp_consensus"]

    if abs(move) < 0.03:  # less than 3pp move — ignore
        return None

    # Check soft-book lag
    soft_moves = [
        abs(h["soft_consensus"] - recent[0]["soft_consensus"])
        for h in recent[1:]
    ]
    max_soft_move = max(soft_moves) if soft_moves else 0

    if max_soft_move > 0.015:  # soft books already moved — too late
        return None

    direction = "UP" if move > 0 else "DOWN"
    return {
        "event_id": event_id,
        "side": side,
        "direction": direction,
        "sharp_move_pp": round(move * 100, 2),
        "soft_lag_pp": round((abs(move) - max_soft_move) * 100, 2),
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "sharp_consensus_now": round(recent[-1]["sharp_consensus"], 4),
        "soft_consensus_now": round(recent[-1]["soft_consensus"], 4),
    }

def monitor_events(event_ids: list[str], poll_interval: int = 60):
    print(f"Monitoring {len(event_ids)} events. Poll interval: {poll_interval}s")
    while True:
        for event_id in event_ids:
            try:
                data = fetch_odds(event_id)
                teams = [
                    o["name"]
                    for o in data["bookmakers"][0]["markets"][0]["outcomes"]
                ]
                for side in teams:
                    sharp_c = compute_sharp_consensus(data, side)
                    # compute soft consensus similarly
                    soft_probs = []
                    for book in data.get("bookmakers", []):
                        if book["key"].lower() not in SOFT_BOOKS:
                            continue
                        for outcome in book["markets"][0]["outcomes"]:
                            if outcome["name"].lower() == side.lower():
                                soft_probs.append(
                                    decimal_to_implied(american_to_decimal(outcome["price"]))
                                )
                    soft_c = statistics.mean(soft_probs) if soft_probs else None

                    if sharp_c and soft_c:
                        line_history[event_id][side].append({
                            "sharp_consensus": sharp_c,
                            "soft_consensus": soft_c,
                            "polled_at": time.time(),
                        })
                        alert = detect_steam(event_id, side)
                        if alert:
                            print(f"\n🔥 STEAM ALERT: {alert}")

            except Exception as e:
                print(f"Error fetching {event_id}: {e}")

        time.sleep(poll_interval)

# Kick it off — fetch active event IDs from /v1/events first
if __name__ == "__main__":
    events_resp = httpx.get(
        f"{API_BASE}/v1/events",
        headers={"Authorization": f"Bearer {API_KEY}"},
        params={"sport": "baseball_mlb", "status": "upcoming"},
        timeout=10,
    )
    event_ids = [e["id"] for e in events_resp.json()["events"][:20]]
    monitor_events(event_ids, poll_interval=60)

This is a blocking loop for clarity. In production you'd run this async with asyncio and httpx.AsyncClient, or push it to a task queue per event.


The Math: Sizing Into a Steam Move

Finding steam is half the problem. The other half is knowing how much to bet.

When you catch a lag window, you have a temporary edge because you're getting the old soft-book price on a market that has already repriced at the sharp books. Your edge is roughly:

Edge = sharp_consensus_prob - soft_book_implied_prob

Where soft_book_implied_prob includes the soft book's margin (juice). You need to strip that out to get the fair probability comparison.

For a two-way market with sides A and B:

vig_factor = implied_A + implied_B
fair_A = implied_A / vig_factor
fair_B = implied_B / vig_factor

Then EV on the soft-book price:

EV = (fair_prob × (decimal_odds - 1)) - ((1 - fair_prob) × 1)

For Kelly sizing:

Kelly fraction = (b × p - q) / b

Where:
  b = decimal odds - 1   (net payout per unit)
  p = fair probability
  q = 1 - p

A full-Kelly bet on a steam move is aggressive — the lag window can close mid-bet. I'd recommend quarter-Kelly to half-Kelly on these. Soft books also limit steam chasers aggressively, so the practical ceiling is often the book's bet cap on live lines, not Kelly.

You can find pre-computed edge values via the /v1/edge endpoint, which saves you from running the no-vig math yourself on every poll cycle. See the EV betting guide for how those edge numbers are calculated and what confidence intervals to put around them.


Tuning the Detector: Avoiding False Positives

A naive detector fires on every 3pp move and you'll be flooded with alerts that are mostly line correction noise. A few tuning levers:

Time-of-Day Weighting

Lines move more in the 2 hours before game time as public money flows in. A 3pp move at -240 minutes is more significant than the same move at -10 minutes, where casual bettors are piling in. Consider applying a confidence multiplier based on time_to_event.

Volume Confirmation

The MoneyLine API /v1/odds endpoint includes lastUpdate timestamps per bookmaker. If Pinnacle and Bookmaker update within 90 seconds of each other on the same side, that's corroborating evidence of coordinated sharp action vs. a single book correcting a stale line.

Market Depth Filtering

Steam moves matter more in liquid markets (NFL game lines, major soccer match odds) than thin markets (WNBA props, minor league futures). Thin markets have high natural variance. Restrict your monitor to events and markets where multiple sharp books are actively quoting — filter out any event where fewer than three sharp books appear in the response.

Cooldown Logic

Once a steam alert fires on a market, suppress subsequent alerts for that market for 15 minutes. You don't want to chase a move that's already halfway repriced at soft books.

For a comparison of how different API providers handle multi-book line data, check out the MoneyLine API vs. The Odds API comparison — the refresh rate difference matters significantly for steam detection latency.


Operationalizing: From Alert to Bet

The hardest part of steam detection isn't the code — it's acting on alerts fast enough to matter. Some practical notes:

Automate the bet placement or accept that you'll miss most windows. Human reaction time plus app navigation plus confirmation screens often exceeds the lag window. If you're in a jurisdiction where API-based bet placement is available (exchanges, some international books), wire the alert directly to a placement function.

Track your hit rate. Log every alert with the sharp consensus at alert time, the soft-book price at alert time, and the final closing line. If your alerts are consistently pointing in the same direction as the line's eventual close, your detector is working. If it's 50/50, you're detecting noise.

Watch for reverse steam. Occasionally sharp books move in one direction and then snap back hard. This happens when a large one-sided bet temporarily distorts the line and the book corrects. Your cooldown logic and multi-book confirmation requirement help filter these, but not perfectly.

The MoneyLine API free tier gives you 1,000 credits/month — enough to monitor 3-4 active events at 60-second polling for a full day. Plenty for testing your detector before committing to a paid tier.


FAQ

What's the difference between steam and a reverse line move? A steam move is sharp money driving a line in one direction, usually correlated with heavy action. A reverse line move (RLM) is when the line moves against the public betting percentage — i.e., the majority of bettors are on Team A but the line moves toward Team A getting more points. Both are sharp signals, but they're different mechanics. Steam is about speed and cross-book coordination; RLM is about line direction vs. public split.

How fast do soft books typically reprice after a sharp-book steam move? It varies. In liquid NFL markets, large soft books often reprice within 2-5 minutes. In MLB or NBA, it can be 5-15 minutes depending on the book and time of day. Smaller regional books can lag 20+ minutes. This is why monitoring multiple soft books simultaneously matters — some lag longer than others and may offer better prices even after the fastest books have caught up.

Do I need to worry about API rate limits when polling every 60 seconds? At 60-second intervals across 20 events, you're making ~1,440 requests per day per sport. Check your tier's rate limits and credit costs per call. The MoneyLine API /v1/odds endpoint is designed for polling use cases — the free tier is functional for development and small-scale monitoring. Production steam detection across multiple sports will require a paid tier.

Can I detect steam moves on player props, not just game lines? Yes, but the signal is weaker. Fewer sharp books quote props actively, so cross-book confirmation is harder to get. Pinnacle props are the gold standard input. If you're seeing line movement in props, the /v1/odds endpoint accepts a market parameter where you can specify prop market types. Filter aggressively for sharp-book coverage before acting on a prop steam alert.

What sports have the best steam move opportunities? NFL and NBA have the highest sharp-book liquidity and the most active steam activity. MLB is good intraday. Soccer (especially European leagues) has excellent Pinnacle liquidity. College football and college basketball have steam activity but sharp books are sometimes slower to post lines. Avoid low-liquidity markets for steam detection — the signal-to-noise ratio is too low to be actionable.

Build with the same data we use.

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