A steam move is when sharp bettors hit a line across multiple sportsbooks in rapid succession, causing synchronized odds movement that cascades through the market in seconds. If you’ve ever refreshed an odds page and watched three books move the same direction within a minute — that’s steam. Detecting it in real time is one of the most valuable edges in sports betting, and it’s a problem perfectly suited to automation.

This guide builds a steam move detection bot from scratch in Python. You’ll connect to a multi-book odds API, track line history, implement detection logic, and fire alerts when the market shows coordinated sharp action.


What Are Steam Moves?

A steam move is coordinated sharp action across multiple sportsbooks within minutes. A betting syndicate or sharp group identifies a mispriced line, then hits it simultaneously at every book offering value. The result: a rapid, synchronized line shift that ripples across the entire market.

Why They Matter

Steam moves predict closing line direction with high accuracy. When sharp money enters hard enough to move 4+ books in under two minutes, the market is telling you something material has changed. Studies of historical steam moves show they predict the direction of the closing line 75-85% of the time. That’s not a guarantee the bet wins — but consistently being on the right side of closing line movement is the definition of sharp betting.

Anatomy of a Steam Move

Every steam move follows the same lifecycle:

  1. Trigger book: Sharps identify value at a price-setting book — usually Pinnacle or Circa. They place large wagers. The book’s automated systems detect the sharp action and adjust the line.
  2. Copycat movement: Other sportsbooks monitor sharp-originating books (or receive the same information via commercial odds feeds). Within 30-120 seconds, they move their lines in the same direction. DraftKings, FanDuel, BetMGM — they all follow.
  3. Market equilibrium: After 2-5 minutes, most books have adjusted. The opportunity window closes. Trailing books that haven’t moved yet are the last chance for value.

A Real-World Example

NFL Sunday, 11:45 AM ET. Pinnacle moves Patriots -3 (-110) to Patriots -3.5 (-110). Within 45 seconds, Circa adjusts to -3.5. Within 90 seconds, DraftKings, FanDuel, and BetMGM all move to -3.5. BetOnline and Bovada are still sitting at Patriots -3. A steam detection bot catches this cascade at the 60-second mark and flags BetOnline and Bovada as stale — those are the books you bet before they catch up.

The entire window of opportunity lasted about two minutes. No human refreshing odds pages catches that consistently. A bot polling every 10 seconds catches it every time.


Detection Algorithm

The core logic is straightforward: monitor N books, flag when M or more books move in the same direction within T minutes by at least X cents or points.

Core Parameters

Your detector needs three tunable parameters:

ParameterDescriptionTypical Range
movement_thresholdMinimum line shift per book to count as a move0.5–1.5 points (spreads), 5–15 cents (moneylines)
time_windowMaximum seconds between first and last qualifying move60–180 seconds
min_booksMinimum number of books that must move to trigger3–4 (out of 8+ monitored)

Start conservative — a wide time window and low book count will generate false positives, but you’ll see what real steam looks like in your data. Then tighten parameters based on backtesting.

Distinguishing Steam from Noise

Not every synchronized movement is steam. Normal market forces cause correlated movement too — public betting patterns, news events, or simply books copying each other’s opening lines. Your detector needs to filter noise:

  • Magnitude filter: Require each book’s movement to exceed a minimum threshold. A 0.5-point spread move at 4 books is meaningful. A 0.1-point drift is not.
  • Direction filter: All qualifying moves must be in the same direction. If two books move the line up and two move it down, that’s market uncertainty, not steam.
  • Staleness filter: Ignore books that haven’t updated in over 5 minutes — their “movement” might just be a delayed data feed catching up.

False Positive Management

The biggest operational risk is false positives. A noisy detector that fires 50 alerts per hour is useless — you’ll either ignore it or blow your bankroll on phantom signals. Track your detector’s precision by logging every alert and checking whether the move continued in the flagged direction. Aim for 60%+ directional accuracy before trading on alerts.


Building the Bot

Here’s the SteamDetector class. It polls an odds API at configurable intervals, maintains line history per market per book, and detects synchronized movements that meet your steam criteria.

from dataclasses import dataclass, field
from datetime import datetime, timedelta
from collections import defaultdict


@dataclass
class OddsUpdate:
    book: str
    event_id: str
    market: str
    line: float
    timestamp: datetime


@dataclass
class SteamAlert:
    event_id: str
    market: str
    direction: str
    books_moved: list[str]
    magnitude: float
    window_seconds: float
    stale_books: list[str]
    detected_at: datetime


class SteamDetector:
    def __init__(
        self,
        movement_threshold: float = 0.5,
        time_window_sec: int = 120,
        min_books: int = 3,
    ):
        self.movement_threshold = movement_threshold
        self.time_window = timedelta(seconds=time_window_sec)
        self.min_books = min_books
        self.line_history: dict[str, list[OddsUpdate]] = defaultdict(list)

    def _market_key(self, event_id: str, market: str) -> str:
        return f"{event_id}:{market}"

    def ingest(self, update: OddsUpdate) -> None:
        key = self._market_key(update.event_id, update.market)
        self.line_history[key].append(update)

    def _recent_moves(
        self, key: str, now: datetime
    ) -> dict[str, tuple[float, datetime]]:
        """Return the net movement per book within the time window."""
        cutoff = now - self.time_window
        updates = self.line_history[key]
        books: dict[str, list[OddsUpdate]] = defaultdict(list)
        for u in updates:
            if u.timestamp >= cutoff:
                books[u.book].append(u)

        moves = {}
        for book, snapshots in books.items():
            if len(snapshots) < 2:
                continue
            snapshots.sort(key=lambda s: s.timestamp)
            delta = snapshots[-1].line - snapshots[0].line
            if abs(delta) >= self.movement_threshold:
                moves[book] = (delta, snapshots[-1].timestamp)
        return moves

    def detect(self, event_id: str, market: str, all_books: list[str]) -> SteamAlert | None:
        key = self._market_key(event_id, market)
        now = datetime.utcnow()
        moves = self._recent_moves(key, now)

        if len(moves) < self.min_books:
            return None

        up = {b: m for b, (m, _) in moves.items() if m > 0}
        down = {b: m for b, (m, _) in moves.items() if m < 0}

        dominant, direction = (up, "up") if len(up) >= len(down) else (down, "down")

        if len(dominant) < self.min_books:
            return None

        timestamps = [moves[b][1] for b in dominant]
        window = (max(timestamps) - min(timestamps)).total_seconds()
        magnitude = sum(abs(v) for v in dominant.values()) / len(dominant)
        moved_set = set(dominant.keys())
        stale = [b for b in all_books if b not in moved_set]

        return SteamAlert(
            event_id=event_id,
            market=market,
            direction=direction,
            books_moved=list(dominant.keys()),
            magnitude=round(magnitude, 2),
            window_seconds=round(window, 1),
            stale_books=stale,
            detected_at=now,
        )

    def prune(self, max_age_minutes: int = 30) -> None:
        """Remove stale history to prevent memory growth."""
        cutoff = datetime.utcnow() - timedelta(minutes=max_age_minutes)
        for key in list(self.line_history):
            self.line_history[key] = [
                u for u in self.line_history[key] if u.timestamp >= cutoff
            ]

Wiring It to an Odds API

The detector is API-agnostic. Feed it OddsUpdate objects from whatever source you use. Here’s the polling loop skeleton:

import time
import requests

API_KEY = "your-odds-api-key"
SPORT = "americanfootball_nfl"
BOOKS = ["pinnacle", "draftkings", "fanduel", "betmgm", "bovada", "betonline"]
POLL_INTERVAL = 10

detector = SteamDetector(movement_threshold=0.5, time_window_sec=120, min_books=3)

while True:
    resp = requests.get(
        "https://api.the-odds-api.com/v4/sports/{}/odds".format(SPORT),
        params={"apiKey": API_KEY, "regions": "us,us2", "markets": "spreads"},
    )
    for event in resp.json():
        for bookmaker in event.get("bookmakers", []):
            for market in bookmaker.get("markets", []):
                for outcome in market.get("outcomes", []):
                    update = OddsUpdate(
                        book=bookmaker["key"],
                        event_id=event["id"],
                        market=f"{market['key']}:{outcome['name']}",
                        line=outcome.get("point", outcome.get("price", 0)),
                        timestamp=datetime.utcnow(),
                    )
                    detector.ingest(update)

        alert = detector.detect(event["id"], "spreads:home", BOOKS)
        if alert:
            print(f"STEAM DETECTED: {alert}")

    detector.prune()
    time.sleep(POLL_INTERVAL)

Replace the print statement with whatever alert mechanism fits your stack — Telegram, Discord webhook, SMS via Twilio, or direct integration into your betting execution pipeline.


Which Books Move First?

Understanding the hierarchy of book reactivity is critical for a steam detection bot. Not all sportsbooks are equal — some set the market, and the rest follow.

The Price Setters

Pinnacle and Circa are the two books that matter most. Pinnacle accepts the largest sharp wagers globally and runs the lowest vig. Circa, the Las Vegas sharp book, takes six- and seven-figure bets on NFL sides. When either moves a line, the rest of the market treats it as a signal.

Your steam detector should weight moves at these books more heavily. A 1-point shift at Pinnacle is a stronger signal than a 1-point shift at DraftKings, because DraftKings might be reacting to Pinnacle rather than to independent sharp action.

The Fast Followers

US regulated books — DraftKings, FanDuel, BetMGM, Caesars — typically react within 30-90 seconds of a Pinnacle move. They use automated systems that monitor sharp-originating books and adjust accordingly. These books rarely originate steam — they respond to it.

The Trailing Books

Offshore books like Bovada and BetOnline tend to lag. Their line management is less automated, their monitoring of sharp books is slower, and they sometimes sit on stale numbers for 2-5 minutes after a steam event. This lag is your opportunity — and the reason your bot should specifically track which books haven’t moved yet. The stale_books field in the SteamAlert dataclass exists for exactly this purpose.


From Detection to Action

Detecting steam is the first half. Acting on it profitably is the second.

Strategy 1: Bet the Trailing Book

The simplest approach — when your bot detects steam, immediately bet the stale books that haven’t adjusted. If Pinnacle, DraftKings, and FanDuel all moved Patriots from -3 to -3.5, and BetOnline is still showing -3, bet BetOnline at -3 before they catch up.

This works because the trailing book’s line is now mispriced relative to the market consensus. You’re getting a number that’s 0.5 points better than where the sharp market has settled. That’s pure CLV.

Strategy 2: Steam as a +EV Signal

Instead of blindly chasing every steam move, use detection as one input in a composite signal model. Stack it with your +EV calculation — does the stale line also show positive expected value against a Pinnacle no-vig benchmark? If steam confirms what your model already flagged, you have a higher-confidence bet.

How Fast Do You Need to Be?

Fast. The median window between steam detection and trailing book adjustment is 90-180 seconds for NFL markets, shorter for NBA and live betting. If your bot detects steam at T+60 seconds and you can place a bet within 30 seconds, you’re operating in the profitable zone. If your workflow requires manual review and a phone call to your bookie, you’ll miss most of the window.

This is why steam detection is a natural fit for fully automated agents. The entire detect-decide-execute loop needs to complete in under a minute for reliable exploitation.

Validate with CLV

After every steam-triggered bet, track your closing line value. If your steam bets consistently beat the closing line, your detection parameters are calibrated correctly. If they don’t, your thresholds are too loose or you’re acting too slowly. CLV is the ground truth metric that tells you whether your steam detector is actually working.


Backtesting Your Parameters

Before trading real money on steam alerts, backtest your detection parameters against historical odds data. Log every odds snapshot your bot ingests to a database, then replay the data through your detector with different parameter combinations.

Key metrics to track during backtesting:

  • Alert frequency: How many steam events per day? (Typical: 5-15 per sport during peak hours)
  • Directional accuracy: What percentage of flagged moves continued in the same direction through the close?
  • CLV capture: If you had bet the trailing book at the moment of detection, what CLV would you have captured?
  • False positive rate: How many alerts were noise — moves that reversed within minutes?

Sweep across parameter ranges: time windows from 60 to 300 seconds, book counts from 2 to 5, and movement thresholds from 0.3 to 2.0 points. Plot the precision-recall tradeoff and pick parameters that match your risk tolerance.


What’s Next

Steam detection is one signal in the sharp betting toolkit. Combine it with these related concepts for a complete edge detection pipeline: