Steam Move Detector: Build a Line Movement Alert Bot with Python (Free API)
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.