Steam Move Detector: Build a Line Movement Alert Bot with Python (Free API)

Steam Move Detector - OddsPapi API Blog
How To Guides May 15, 2026

What Is a Steam Move, and Why Should You Care?

A steam move is a sudden, sharp line movement at a professional bookmaker — usually Pinnacle — triggered by informed money. When Pinnacle shortens odds on an outcome, it means sharp bettors have hammered that side. Soft bookmakers (Bet365, DraftKings, FanDuel) take 30 seconds to several minutes to react. That lag window is where the edge lives.

Tools like OddsJam charge $199+/month for their “Sharp Money” screen. Sports Insights keeps their detection logic proprietary. Both are black boxes — you pay, you trust, you hope the signals are real.

This tutorial builds an open-source steam move detector in Python that uses OddsPapi’s API to track Pinnacle line movements across 350+ bookmakers and alert you before soft books correct. Every line of code is tested against real data. You own the logic, you tune the parameters, and you can run it on the free tier.

How Steam Moves Work (The 90-Second Explainer)

Here’s a concrete example. Chelsea vs Manchester City, Full Time Result market:

Time Event Pinnacle (Chelsea Win) Bet365 (Chelsea Win)
T = 0 Market open 3.84 3.60
T = 30s Sharp money hits Pinnacle 3.55 3.60 (stale)
T = 2min Bet365 corrects 3.55 3.50

At T = 30 seconds, Pinnacle dropped from 3.84 to 3.55 — a 0.29 move. Bet365 is still sitting at 3.60. That’s a steam move signal: the sharp book moved, the soft book hasn’t followed. You have a ~90-second window where Bet365’s price is stale relative to the true market price.

Two signals matter: (1) the magnitude of the Pinnacle move, and (2) the lag before soft books follow. Bigger moves with longer lags = stronger signals.

Old Way vs OddsPapi

Feature Scraping / Manual OddsJam ($199+/mo) OddsPapi
Bookmakers 2-3 you scrape ~100 (US-focused) 350+
Pinnacle included No (blocked) No (US books only) Yes
Detection logic Your guess Proprietary black box Open source (this tutorial)
Alert delivery None In-app only You own it (Discord/Telegram)
changedAt timestamp No No Every outcome, every book
Historical backtest No Separate $79/mo Free tier included
Cost Your time + IP bans $199-999/mo Free tier: 250 req/mo

The key technical differentiator: OddsPapi returns a changedAt timestamp on every outcome for every bookmaker. This tells you exactly when a bookmaker last updated a price — without it, you’d need to poll continuously and diff prices yourself. With it, you can detect staleness in a single API call.

Build the Steam Move Detector: Step by Step

Step 1: Setup and Configuration

import requests
import time
from datetime import datetime, timedelta, timezone

API_KEY = "YOUR_API_KEY"
BASE_URL = "https://api.oddspapi.io/v4"

# --- Detection parameters ---
SHARP_BOOK = "pinnacle"
SOFT_BOOKS = ["bet365", "1xbet", "draftkings", "fanduel", "betmgm", "betway"]
SPORT_ID = 10              # Soccer (change to 11 for basketball, 14 for NFL, etc.)
MARKET_ID = 101            # Full Time Result (1X2)
STEAM_THRESHOLD = 0.05     # Minimum price drop to flag as steam
POLL_INTERVAL = 90         # Seconds between polling cycles
OUTCOME_NAMES = {"101": "Home", "102": "Draw", "103": "Away"}

def api_get(endpoint, params=None):
    """Authenticated API call with rate-limit safety."""
    if params is None:
        params = {}
    params["apiKey"] = API_KEY
    resp = requests.get(f"{BASE_URL}/{endpoint}", params=params)
    resp.raise_for_status()
    time.sleep(0.2)
    return resp.json()

STEAM_THRESHOLD is the minimum price drop at Pinnacle to count as a steam move. A drop from 3.84 to 3.55 = 0.29, well above the 0.05 default. Tune this based on your risk tolerance — tighter thresholds mean fewer but stronger signals.

Step 2: Fetch Upcoming Fixtures

def get_fixtures(sport_id, days_ahead=3):
    """Fetch upcoming fixtures with odds available."""
    today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
    to_date = (datetime.now(timezone.utc) + timedelta(days=days_ahead)).strftime("%Y-%m-%d")

    fixtures = api_get("fixtures", {
        "sportId": sport_id,
        "from": today,
        "to": to_date
    })

    with_odds = [f for f in fixtures if f.get("hasOdds")]
    print(f"Found {len(with_odds)} fixtures with odds (out of {len(fixtures)} total)")
    return with_odds

Only fixtures with hasOdds: true will return a bookmakerOdds payload from the /odds endpoint. The /fixtures endpoint supports a max 10-day range, but 3 days is enough for most use cases.

Step 3: Snapshot Builder — Capture Current Odds State

This is the core data-capture function. For each fixture, it grabs the current price and changedAt timestamp from Pinnacle plus your list of soft books.

def snapshot_odds(fixture_id, bookmakers):
    """
    Capture price + changedAt for every outcome across bookmakers.
    Returns: {slug: {outcome_id: {"price": float, "changedAt": datetime}}}
    """
    snapshot = {}
    all_books = list(bookmakers)

    # Batch requests (API accepts comma-separated bookmaker slugs)
    data = api_get("odds", {
        "fixtureId": fixture_id,
        "bookmakers": ",".join(all_books)
    })

    for slug, book_data in data.get("bookmakerOdds", {}).items():
        market = book_data.get("markets", {}).get(str(MARKET_ID), {})
        snapshot[slug] = {}

        for oid, outcome in market.get("outcomes", {}).items():
            player = outcome.get("players", {}).get("0", {})
            if not isinstance(player, dict) or "price" not in player:
                continue

            changed_raw = player.get("changedAt", "")
            changed_dt = None
            if changed_raw:
                changed_dt = datetime.fromisoformat(changed_raw)

            snapshot[slug][oid] = {
                "price": player["price"],
                "changedAt": changed_dt
            }

    return snapshot

The changedAt field is an ISO 8601 UTC timestamp on every outcome — it tells you the last time that specific bookmaker updated that specific price. This is what makes the entire detector work: you compare changedAt across sharp and soft books to detect staleness without maintaining your own price history.

Step 4: Steam Detection Engine

This is the algorithm. Compare two consecutive snapshots for the sharp book. If Pinnacle’s price dropped by more than the threshold, check each soft book: if the soft book’s changedAt is older than Pinnacle’s changedAt, it hasn’t reacted yet. That’s the steam signal.

def detect_steam(prev_snapshot, curr_snapshot, fixture_meta):
    """
    Compare two snapshots. Detect where Pinnacle moved but soft books haven't.
    Returns list of signal dicts.
    """
    signals = []
    sharp = SHARP_BOOK

    if sharp not in prev_snapshot or sharp not in curr_snapshot:
        return signals

    for oid in curr_snapshot.get(sharp, {}):
        prev_sharp = prev_snapshot[sharp].get(oid)
        curr_sharp = curr_snapshot[sharp].get(oid)
        if not prev_sharp or not curr_sharp:
            continue

        # Price drop = shortening odds = sharp money on this side
        price_move = prev_sharp["price"] - curr_sharp["price"]
        if price_move < STEAM_THRESHOLD:
            continue

        # Pinnacle moved. Check each soft book for staleness.
        for soft_slug in SOFT_BOOKS:
            if soft_slug not in curr_snapshot:
                continue
            soft_data = curr_snapshot[soft_slug].get(oid)
            if not soft_data:
                continue

            sharp_moved_at = curr_sharp["changedAt"]
            soft_moved_at = soft_data["changedAt"]

            if not sharp_moved_at or not soft_moved_at:
                continue

            # Soft book's last update is OLDER than Pinnacle's = stale
            if soft_moved_at < sharp_moved_at:
                lag_seconds = (sharp_moved_at - soft_moved_at).total_seconds()
                edge_pct = ((soft_data["price"] / curr_sharp["price"]) - 1) * 100

                signals.append({
                    "fixture": fixture_meta,
                    "outcome_id": oid,
                    "sharp_prev": prev_sharp["price"],
                    "sharp_now": curr_sharp["price"],
                    "sharp_move": round(price_move, 3),
                    "soft_book": soft_slug,
                    "soft_price": soft_data["price"],
                    "soft_stale_sec": int(lag_seconds),
                    "edge_vs_sharp": round(edge_pct, 2),
                    "detected_at": datetime.now(timezone.utc).isoformat()
                })

    return signals

The edge_vs_sharp field tells you how much value remains. If Pinnacle moved Chelsea Win from 3.84 to 3.55 and Bet365 is still at 3.60, the edge is (3.60 / 3.55) - 1 = +1.41%. That's the EV of betting Bet365's stale price against Pinnacle's new "true" price.

Step 5: The Polling Loop

def run_detector(fixtures):
    """Main loop: snapshot -> compare -> alert -> repeat."""
    all_books = [SHARP_BOOK] + SOFT_BOOKS
    prev_snapshots = {}

    print(f"Steam Detector running | {len(fixtures)} fixtures | Poll every {POLL_INTERVAL}s")
    print(f"Sharp: {SHARP_BOOK} | Soft: {', '.join(SOFT_BOOKS)}")
    print(f"Threshold: {STEAM_THRESHOLD} price drop")
    print("=" * 60)

    while True:
        for f in fixtures:
            fid = f["fixtureId"]
            match_name = f"{f['participant1Name']} vs {f['participant2Name']}"
            meta = {"fixtureId": fid, "match": match_name, "kickoff": f["startTime"]}

            curr = snapshot_odds(fid, all_books)

            if fid in prev_snapshots:
                signals = detect_steam(prev_snapshots[fid], curr, meta)
                for s in signals:
                    label = OUTCOME_NAMES.get(s["outcome_id"], s["outcome_id"])
                    print(f"\n{'=' * 60}")
                    print(f"  STEAM MOVE DETECTED")
                    print(f"  {s['fixture']['match']}")
                    print(f"  Outcome: {label}")
                    print(f"  Pinnacle: {s['sharp_prev']} -> {s['sharp_now']} (moved {s['sharp_move']})")
                    print(f"  {s['soft_book']}: still at {s['soft_price']} (stale {s['soft_stale_sec']}s)")
                    print(f"  Edge vs sharp: +{s['edge_vs_sharp']}%")
                    print(f"  Time: {s['detected_at'][:19]} UTC")
                    print(f"{'=' * 60}")

            prev_snapshots[fid] = curr
            time.sleep(1)  # Rate limit between fixtures

        ts = datetime.now(timezone.utc).strftime("%H:%M:%S")
        print(f"[{ts}] Poll complete. Next in {POLL_INTERVAL}s...")
        time.sleep(POLL_INTERVAL)

Run it:

# Fetch fixtures and start detecting
fixtures = get_fixtures(SPORT_ID, days_ahead=1)
run_detector(fixtures[:5])  # Monitor 5 fixtures to stay within rate limits

When the detector catches a steam move, the output looks like this:

============================================================
  STEAM MOVE DETECTED
  Chelsea FC vs Manchester City
  Outcome: Home
  Pinnacle: 3.84 -> 3.55 (moved 0.29)
  bet365: still at 3.60 (stale 87s)
  Edge vs sharp: +1.41%
  Time: 2026-04-12T16:32:05 UTC
============================================================

Step 6: Add Discord or Telegram Alerts

Console output is fine for testing. For live monitoring, push signals to a Discord channel or Telegram chat.

def send_discord_alert(signal, webhook_url):
    """Push a steam move signal to Discord via webhook."""
    label = OUTCOME_NAMES.get(signal["outcome_id"], signal["outcome_id"])
    msg = {
        "content": (
            f"**STEAM MOVE** | {signal['fixture']['match']}\n"
            f"Outcome: {label}\n"
            f"Pinnacle: {signal['sharp_prev']} -> {signal['sharp_now']} "
            f"(moved {signal['sharp_move']})\n"
            f"{signal['soft_book']}: {signal['soft_price']} "
            f"(stale {signal['soft_stale_sec']}s)\n"
            f"Edge: +{signal['edge_vs_sharp']}%"
        )
    }
    requests.post(webhook_url, json=msg)

To use it, create a Discord webhook in your channel settings and drop the URL into the polling loop:

DISCORD_WEBHOOK = "https://discord.com/api/webhooks/YOUR_WEBHOOK_URL"

# Inside the polling loop, after detecting signals:
for s in signals:
    send_discord_alert(s, DISCORD_WEBHOOK)

For Telegram, swap in a call to the Bot API (sendMessage to your chat ID). The detection logic stays the same — only the delivery changes.

How to Tune the Detector

Parameter Default Tighter Looser
STEAM_THRESHOLD 0.05 Fewer signals, higher confidence More signals, more noise
POLL_INTERVAL 90s Faster detection, more API calls Fewer calls, may miss fast windows
SOFT_BOOKS 6 books Focus on slowest movers More edge opportunities
MARKET_ID 101 (1X2) Single market focus Scan multiple markets (loop IDs)

Free Tier Budget Math

OddsPapi's free tier gives you 250 requests per month. Each call to /odds is 1 request. If you monitor 5 fixtures every 90 seconds, that's ~5 requests per poll cycle. At 250 req/month, you get roughly 50 poll cycles — enough to cover a Saturday afternoon of matches or a midweek Champions League slate.

To stretch the budget: reduce the fixture count, increase the poll interval, or poll only during kickoff windows (when lines move most). For all-day monitoring across multiple sports, upgrade to a paid plan.

Backtest It: Did the Steam Moves Actually Win?

Detecting steam moves is one thing. Proving they're profitable is another. OddsPapi's /historical-odds endpoint — free on every tier — lets you pull the full price history for any past fixture and check whether Pinnacle's move predicted the result.

def check_closing_line(fixture_id, bookmaker="pinnacle"):
    """Pull historical prices and compare opening vs closing line."""
    data = api_get("historical-odds", {
        "fixtureId": fixture_id,
        "bookmakers": bookmaker
    })

    book = data.get("bookmakers", {}).get(bookmaker, {})
    market = book.get("markets", {}).get(str(MARKET_ID), {})

    for oid, outcome in market.get("outcomes", {}).items():
        snaps = outcome.get("players", {}).get("0", [])
        if not snaps:
            continue
        opening = snaps[0]["price"]
        closing = snaps[-1]["price"]
        move = opening - closing
        label = OUTCOME_NAMES.get(oid, oid)
        print(f"  {label}: opened {opening} -> closed {closing} (moved {move:+.3f})")

If the closing line consistently moved in the same direction as the steam signal, your detector is capturing real information. For a full backtesting framework, see our guide to backtesting betting models with free historical data.

Why OddsPapi for Steam Detection

Requirement Why It Matters OddsPapi
350+ bookmakers More soft books = more stale prices to catch 350+ books including regional and crypto
Sharp benchmarks Need Pinnacle (or Singbet/SBOBet) as the signal source All three available on free tier
changedAt timestamp Detect staleness without continuous polling On every outcome, every bookmaker
Free historical data Backtest whether steam signals actually predict outcomes Full price history, free tier
Simple auth No OAuth, no session tokens Single query parameter: ?apiKey=KEY

Most competing odds APIs either don't carry Pinnacle at all (The Odds API, OddsJam's standard tier) or don't expose the changedAt timestamp that makes staleness detection possible. Without both, you're stuck polling every second and maintaining your own price history — or paying $200/month for someone else to do it.

Frequently Asked Questions

What is a steam move in sports betting?

A steam move is a sudden, sharp line movement caused by professional bettors placing large wagers at a sharp bookmaker like Pinnacle. When Pinnacle's odds shorten rapidly on one side, it signals that informed money has identified value. Soft bookmakers typically follow 30 seconds to several minutes later — that lag is the window to act.

How accurate are steam moves at predicting outcomes?

Pinnacle's line movements are the single most predictive signal in sports betting markets. Academic research shows that Pinnacle's closing line closely approximates true outcome probabilities. Steam moves that shorten odds by 5+ cents have historically high follow-through rates, but no signal is 100% — proper bankroll management is still essential.

Can I detect steam moves with a free API?

Yes. OddsPapi's free tier includes 250 requests per month with access to Pinnacle and 350+ other bookmakers. The detector in this tutorial batches bookmakers into single requests and uses 90-second polling intervals to maximize coverage within the free tier. For continuous monitoring across multiple sports, a paid plan removes the cap.

What is the difference between a steam move and reverse line movement?

A steam move is driven by sharp bookmaker price changes — Pinnacle moves, soft books follow. Reverse line movement (RLM) is when the line moves opposite to public betting percentages, suggesting sharp money is on the unpopular side. Both indicate sharp action, but steam moves are directly observable through odds data, while RLM requires access to public betting percentages that most APIs don't provide.

Why do soft bookmakers lag behind Pinnacle?

Soft bookmakers like Bet365, DraftKings, and FanDuel set odds through a mix of internal models and shadow-copying sharp books. Their automated feeds and manual traders take 30 seconds to several minutes to process Pinnacle's movements. Regional books and crypto sportsbooks are often even slower, creating wider windows for steam detection.

Stop Paying $200/mo for a Black Box

You just built a steam move detector that does what OddsJam and Sports Insights charge hundreds for — in ~100 lines of Python, with fully transparent logic you can inspect, tune, and extend.

OddsPapi gives you the two things no other free API offers: Pinnacle odds and changedAt timestamps on every outcome from 350+ bookmakers. That's everything you need to detect steam moves, quantify edges, and backtest whether they convert.

Get your free API key and start detecting steam moves today.

Related Tutorials