Detect Steam Moves with Sharp-Book Consensus API
Steam move detection is one of the oldest edges in sports betting — and one of the most abused terms in the industry. Every tout with a Telegram channel claims to send "steam alerts." Most of them are watching a single book's line move and calling it steam. That's not steam. That's noise.
Real detect steam moves sharp-book consensus work requires polling multiple sharp-accepting books simultaneously, identifying coordinated line movement that exceeds what you'd expect from random walk, and doing it fast enough that you can actually act before the soft books catch up. That's what this post is about.
We'll build a working Python pipeline using the MoneyLine API that polls sharp-side consensus, flags genuine steam, and timestamps the soft-book lag so you can quantify your action window.
What Actually Constitutes a Steam Move
Before the code, the definition. A steam move is a coordinated, rapid line shift across multiple independent sharp books — not just one. The signal is the correlation of movement, not the magnitude.
The Math Behind Steam Detection
Define the line at time t for book b on market m as L(b, m, t). We track this in decimal odds for cleaner math.
The sharp-consensus fair price at time t is the no-vig midpoint across a defined set of sharp books S:
FairOdds(m, t) = harmonic_mean(L(b, m, t) for b in S)
We use harmonic mean because we're averaging implied probabilities, then converting back. The no-vig probability for each side is:
p_sharp(b, m, t) = 1 / L(b, m, t)
no_vig_prob(m, t) = avg(p_sharp) / sum(avg(p_sharp) for all sides)
A steam trigger fires when all three of these conditions hold simultaneously:
- Velocity:
|L(b, m, t) - L(b, m, t-Δt)| > thresholdfor at least 2 sharp books - Directionality: All qualifying shifts are in the same direction
- Correlation timing: The shifts on independent books occur within a short window (we use 90 seconds)
The velocity threshold is book-specific — Pinnacle moving 3 cents on a -110 spread is meaningful; Betsson moving 3 cents is less so. You need baseline volatility estimates per book to set sensible thresholds.
Why One Book Isn't Enough
If only Pinnacle moves, that could be:
- A large sharp bet getting absorbed
- Bettor error / misread market they're correcting
- Internal model update not triggered by external action
If Pinnacle, Circa, and Bookmaker all move within 90 seconds in the same direction, the probability that this is random noise is extremely low. That's a steam move.
Setting Up the MoneyLine API Polling Loop
The /v1/odds endpoint returns current odds across all available books for a given event. For steam detection we need to poll this on a tight interval and maintain our own time-series buffer in memory.
First, grab your API key from the API docs and install dependencies:
pip install httpx pandas numpy asyncio
Here's the core polling engine:
import asyncio
import httpx
import time
from collections import defaultdict
from statistics import harmonic_mean
API_BASE = "https://mlapi.bet"
API_KEY = "YOUR_API_KEY_HERE"
# Books we consider "sharp" for consensus
SHARP_BOOKS = {"pinnacle", "circa", "bookmaker", "heritage", "bet105"}
# Minimum number of sharp books that must move together
STEAM_QUORUM = 2
# Movement threshold in decimal odds
MOVEMENT_THRESHOLD = 0.015 # ~1.5 cents on a -110 line
# Time window for correlated moves (seconds)
STEAM_WINDOW = 90
# Polling interval (seconds)
POLL_INTERVAL = 10
class SteamDetector:
def __init__(self):
# {event_id: {book: {market: (odds_home, odds_away, timestamp)}}}
self.snapshots = defaultdict(lambda: defaultdict(dict))
self.steam_log = []
async def fetch_odds(self, session: httpx.AsyncClient, event_id: str) -> dict:
resp = await session.get(
f"{API_BASE}/v1/odds",
params={"event_id": event_id, "market": "h2h"},
headers={"Authorization": f"Bearer {API_KEY}"},
)
resp.raise_for_status()
return resp.json()
def extract_sharp_lines(self, odds_data: dict) -> dict:
"""Returns {book: (home_decimal, away_decimal)} for sharp books only."""
lines = {}
for book_entry in odds_data.get("books", []):
book = book_entry["book"].lower()
if book not in SHARP_BOOKS:
continue
markets = book_entry.get("markets", {})
h2h = markets.get("h2h", {})
if h2h.get("home") and h2h.get("away"):
lines[book] = (float(h2h["home"]), float(h2h["away"]))
return lines
def compute_fair_line(self, lines: dict) -> tuple[float, float]:
"""No-vig fair line from sharp consensus."""
if not lines:
return None, None
home_probs = [1 / odds[0] for odds in lines.values()]
away_probs = [1 / odds[1] for odds in lines.values()]
avg_home = sum(home_probs) / len(home_probs)
avg_away = sum(away_probs) / len(away_probs)
total = avg_home + avg_away
return avg_home / total, avg_away / total
def check_steam(self, event_id: str, current_lines: dict, ts: float) -> list[dict]:
"""Compare current snapshot to previous. Return list of steam alerts."""
prev = self.snapshots[event_id]
alerts = []
home_moves = {} # book -> delta in decimal odds
away_moves = {}
for book, (home_now, away_now) in current_lines.items():
if book not in prev:
continue
(home_prev, away_prev, ts_prev) = prev[book]
# Only look at moves within our steam window
if ts - ts_prev > STEAM_WINDOW:
continue
home_delta = home_now - home_prev
away_delta = away_now - away_prev
if abs(home_delta) >= MOVEMENT_THRESHOLD:
home_moves[book] = home_delta
if abs(away_delta) >= MOVEMENT_THRESHOLD:
away_moves[book] = away_delta
# Check for directional quorum
for side_label, moves in [("home", home_moves), ("away", away_moves)]:
if len(moves) < STEAM_QUORUM:
continue
directions = [1 if v > 0 else -1 for v in moves.values()]
if len(set(directions)) == 1: # all same direction
fair_home, fair_away = self.compute_fair_line(current_lines)
alerts.append({
"event_id": event_id,
"side": side_label,
"direction": "lengthening" if directions[0] > 0 else "shortening",
"books_moved": list(moves.keys()),
"delta_avg": sum(moves.values()) / len(moves),
"fair_home_prob": round(fair_home, 4) if fair_home else None,
"fair_away_prob": round(fair_away, 4) if fair_away else None,
"timestamp": ts,
})
return alerts
async def poll_event(self, session: httpx.AsyncClient, event_id: str):
ts = time.time()
try:
data = await self.fetch_odds(session, event_id)
lines = self.extract_sharp_lines(data)
alerts = self.check_steam(event_id, lines, ts)
if alerts:
for alert in alerts:
print(f"[STEAM] {alert}")
self.steam_log.append(alert)
# Update snapshot
for book, (home, away) in lines.items():
self.snapshots[event_id][book] = (home, away, ts)
except Exception as e:
print(f"Error polling {event_id}: {e}")
async def run(self, event_ids: list[str]):
async with httpx.AsyncClient(timeout=8.0) as session:
while True:
tasks = [self.poll_event(session, eid) for eid in event_ids]
await asyncio.gather(*tasks)
await asyncio.sleep(POLL_INTERVAL)
if __name__ == "__main__":
# Replace with real event IDs from /v1/events
event_ids = ["evt_nba_gsw_bos_20260717", "evt_mlb_nyy_bos_20260717"]
detector = SteamDetector()
asyncio.run(detector.run(event_ids))
This gives you a live steam detection loop you can run against any set of event IDs fetched from /v1/events.
Finding Soft-Book Lag After a Steam Move
The actionable part of steam detection isn't just knowing a move happened — it's knowing which soft books haven't adjusted yet, and by how much.
After a steam alert fires, query all books via /v1/odds and compare each book's current line against your updated sharp consensus fair price. The EV formula on a detected steam line is:
EV = (p_fair × odds_soft) - 1
Where p_fair is from your sharp consensus calculation and odds_soft is the decimal odds at the slow book.
async def find_soft_book_lag(
session: httpx.AsyncClient,
event_id: str,
fair_home: float,
fair_away: float,
):
"""After a steam alert, find soft books still showing stale lines."""
resp = await session.get(
f"{API_BASE}/v1/odds",
params={"event_id": event_id, "market": "h2h"},
headers={"Authorization": f"Bearer {API_KEY}"},
)
data = resp.json()
opportunities = []
for book_entry in data.get("books", []):
book = book_entry["book"].lower()
if book in SHARP_BOOKS:
continue # We want soft books here
markets = book_entry.get("markets", {})
h2h = markets.get("h2h", {})
home_odds = h2h.get("home")
away_odds = h2h.get("away")
if not home_odds or not away_odds:
continue
ev_home = (fair_home * float(home_odds)) - 1
ev_away = (fair_away * float(away_odds)) - 1
if ev_home > 0.02: # 2% EV minimum threshold
opportunities.append({
"book": book,
"side": "home",
"ev": round(ev_home, 4),
"odds": home_odds,
})
if ev_away > 0.02:
opportunities.append({
"book": book,
"side": "away",
"ev": round(ev_away, 4),
"odds": away_odds,
})
return sorted(opportunities, key=lambda x: x["ev"], reverse=True)
Pair this with the /v1/edge endpoint to cross-check your computed EV against the API's own edge calculations. The two should align — if they don't, debug your no-vig math first.
Backtesting Your Steam Thresholds
The MOVEMENT_THRESHOLD and STEAM_QUORUM parameters above aren't magic numbers. You need to tune them against historical data or you'll fire too many false positives (random noise) or too few true positives (missing real steam).
The key metrics to track in backtest:
| Metric | What It Tells You | |--------|------------------| | Alert precision | % of steam alerts where soft-book EV existed | | Median EV at alert | How much edge is available after detection | | Soft-book lag (seconds) | How long you have to act | | False positive rate | Alerts with no corresponding soft-book edge |
For backtesting, request historical odds snapshots from /v1/odds with a timestamp parameter (if your tier supports historical data). Then replay your detection logic across the saved snapshots.
A rough rule of thumb from running this on MLB and NBA data: the optimal quorum is 2-3 sharp books depending on the sport, and thresholds between 0.012 and 0.020 decimal odds minimize false positives while catching most real steam. NFL and NBA tend to move faster; MLB markets are slower and give you a longer window.
For a deeper dive on EV-positive strategy and bankroll sizing once you have steam-flagged bets, see the EV betting methodology page — it covers Kelly sizing in the context of edge estimates, which matters when your edge is time-decaying.
Operationalizing: From Alert to Bet
Detection is table stakes. What matters is the execution loop. Here's the rough operational stack I'd recommend:
- Detection layer — The polling loop above, running on a lightweight VPS or Lambda function
- Alert layer — Write steam alerts to a Redis stream or push to a webhook. You can wire this to a Discord bot (see the Discord odds alert bot post for the plumbing)
- Opportunity layer —
find_soft_book_lag()runs immediately after each steam alert - Sizing layer — Kelly fraction based on edge estimate; cap fractional Kelly at 0.25× for steam plays since your p_fair estimate has some error
- Execution layer — Manual or semi-automated depending on your jurisdiction and book relationships
The window between a sharp-consensus steam move and soft-book adjustment has been shrinking. Three years ago it was 3-5 minutes on average. On liquid markets today it's often under 60 seconds. This means your detection loop needs to be tight — 10-second polling is the minimum, 5 seconds is better.
You also want to track your historical steam alerts and their outcomes. If your precision is below 55% on a 2% EV threshold, something is wrong with your sharp book list or threshold calibration. If it's above 75%, you might be undersizing.
If you want to compare how the MoneyLine API's book coverage stacks up against other data providers before committing, check the comparison pages for context on what sharp-book data is actually available at each tier.
Frequently Asked Questions
What is a steam move in sports betting? A steam move is a rapid, coordinated line shift across multiple independent sharp-side books, indicating that sophisticated bettors have moved on a market simultaneously. The key is correlation across books — one book moving is just noise; three sharp books moving in the same direction within 90 seconds is a steam move.
How many books do I need to detect steam accurately?
You need at least 2-3 sharp-accepting books in your monitoring set. Pinnacle, Circa, and Bookmaker are commonly used. One book is insufficient — you'll misclassify random line adjustments as steam. The code in this post uses a configurable STEAM_QUORUM parameter to require agreement across a minimum number of sharp books.
How long do I have to act on a steam move before soft books adjust? On liquid markets (NFL, NBA, MLB primetime), soft books typically adjust within 30-90 seconds of a confirmed steam move. On less liquid markets, you can get 2-5 minutes. This is why real-time API polling at 5-10 second intervals is essential — anything slower and you're consistently late.
What endpoints does the MoneyLine API expose for steam detection?
The primary endpoints are /v1/odds (current odds across all books for an event), /v1/events (to get event IDs to monitor), and /v1/edge (pre-computed edge estimates you can use to validate your own EV calculations). Historical snapshot data for backtesting is available on paid tiers.
Why use harmonic mean for no-vig fair price instead of a simple average? Because we're averaging probabilities, not odds. When you average decimal odds directly you get a biased estimate — harmonic mean of odds is equivalent to averaging the implied probabilities and then converting back, which is the mathematically correct approach. On a typical -110/-110 market the difference is small, but on lopsided prices (e.g., -250/+200) the error from naive averaging is meaningful.