How to Track Line Movement in Sports Betting
A practical guide to reading line movement — what the different kinds of moves mean, how to detect steam moves and reverse line movement programmatically, and how to track closing line value (CLV) as a long-term skill benchmark.
Line movement is the change in a betting line from when it opens to when it closes. Sharp bettors watch line movement for sharp-money signals: multi-book simultaneous moves (steam), lines moving opposite to public betting percentage (reverse line movement), and large single-book moves triggered by a big bet. To track it programmatically, poll an odds API every 1–5 minutes and store snapshots — or use SSE streaming for sub-second granularity.
1. What Line Movement Tells You
Opening lines are a sportsbook's initial estimate of fair value. As bets come in — from recreational bettors and sharp professionals — books adjust their lines to reduce risk.
Line moves toward heavy public side
The book is balancing action — not a sharp signal.
Line moves away from heavy public side (RLM)
Sharp money opposing the public — actionable.
Rapid multi-book movement (steam)
Professional syndicate action — the clearest sharp signal.
Line moves then snaps back
Possible error or small sharp test — watch, don't follow blindly.
2. Types of Line Movement
Sharp (actionable)
- • Pinnacle moves first — market setter, others follow
- • Large bet triggers movement (5+ points in spread)
- • Multiple books move simultaneously (steam)
- • Line moves opposite to public betting %
Public / square (less actionable)
- • Books shading popular teams to attract opposite action
- • Small, gradual moves toward popular public side
- • Heavy public % matches line direction
3. Build a Line Movement Tracker in Python
Step 1 — Snapshot odds every 2 minutes
import time
import sqlite3
from datetime import datetime
from sharpapi import SharpAPI
client = SharpAPI(api_key="YOUR_KEY")
conn = sqlite3.connect("line_history.db")
conn.execute("""
CREATE TABLE IF NOT EXISTS snapshots (
id INTEGER PRIMARY KEY,
event_id TEXT,
home_team TEXT,
away_team TEXT,
sport TEXT,
sportsbook TEXT,
market TEXT,
outcome TEXT,
price REAL,
no_vig_price REAL,
ts TEXT
)
""")
def snapshot(sport: str):
odds = client.odds.list(sport=sport, markets=["h2h", "spreads"])
now = datetime.utcnow().isoformat()
rows = []
for event in odds.data:
for book in event.bookmakers:
for market in book.markets:
for outcome in market.outcomes:
rows.append((
event.id, event.home_team, event.away_team, sport,
book.title, market.key, outcome.name,
outcome.price,
getattr(outcome, 'no_vig_price', None),
now,
))
conn.executemany(
"INSERT INTO snapshots VALUES (NULL,?,?,?,?,?,?,?,?,?,?)", rows,
)
conn.commit()
return len(rows)
# Poll every 2 minutes
while True:
n = snapshot("basketball_nba")
print(f"[{datetime.utcnow().isoformat()}] Snapped {n} prices")
time.sleep(120)Step 2 — Detect line changes between snapshots
def detect_moves(event_id: str, sportsbook: str, market: str, outcome: str):
rows = conn.execute("""
SELECT price, ts FROM snapshots
WHERE event_id=? AND sportsbook=? AND market=? AND outcome=?
ORDER BY ts ASC
""", (event_id, sportsbook, market, outcome)).fetchall()
moves = []
for i in range(1, len(rows)):
prev_price, prev_ts = rows[i - 1]
curr_price, curr_ts = rows[i]
if curr_price != prev_price:
moves.append({
"from": prev_price,
"to": curr_price,
"change": curr_price - prev_price,
"ts": curr_ts,
})
return movesStep 3 — Alert on steam moves
from collections import defaultdict
def detect_steam(snapshots_before, snapshots_after, threshold: float = 3.0):
"""
Return events where 3+ books moved the same direction by >= threshold
points within the same snapshot window.
"""
steam = []
event_moves = defaultdict(list)
for row in snapshots_after:
event_id, book, market, outcome, price_after = row
price_before = next(
(r[4] for r in snapshots_before
if r[0] == event_id and r[1] == book
and r[2] == market and r[3] == outcome),
None,
)
if price_before and abs(price_after - price_before) >= threshold:
event_moves[event_id].append({
"book": book,
"market": market,
"outcome": outcome,
"change": price_after - price_before,
})
for event_id, moves in event_moves.items():
directions = [m["change"] > 0 for m in moves]
if len(moves) >= 3 and (all(directions) or not any(directions)):
steam.append({
"event_id": event_id,
"books": len(moves),
"moves": moves,
})
return steam4. Real-Time Line Movement with SSE Streaming
Polling every 2 minutes misses fast steam moves. SSE streaming gives you sub-second updates:
def handle_update(update: dict):
if update.get("type") != "odds_delta":
return
event_id = update["event_id"]
for change in update.get("changes", []):
sportsbook = change["sportsbook"]
market = change["market"]
outcome = change["outcome"]
old_price = change["old_price"]
new_price = change["new_price"]
delta = new_price - old_price
if abs(delta) >= 5:
direction = "UP" if delta > 0 else "DOWN"
print(f"SHARP MOVE {direction} | {event_id} | {sportsbook} | "
f"{market} {outcome}: {old_price:+d} -> {new_price:+d} "
f"({delta:+d})")
# Stream all sports simultaneously
for update in client.stream.odds(sports=["basketball_nba", "americanfootball_nfl"]):
handle_update(update)See also: Real-Time Odds Streaming and SSE vs WebSocket for Sports Data.
5. Reverse Line Movement Detection
To detect RLM you need betting percentages alongside line movement. SharpAPI exposes public betting splits (where available) via the /splits endpoint:
splits = client.splits.list(sport="basketball_nba")
for event in splits.data:
for market in event.markets:
if market.key == "h2h":
for outcome in market.outcomes:
pct = outcome.bet_percent # % of bets on this side
price_move = outcome.price_change_24h # line move last 24h
# Classic RLM: >60% public bets but line moving against them
if pct > 60 and price_move < -3:
print(f"RLM: {event.home_team} vs {event.away_team}")
print(f" {outcome.name}: {pct}% of bets, "
f"line moved {price_move:+d}")6. Closing Line Value (CLV) Tracking
CLV is your long-term edge measurement. To calculate it, log the price you bet at and compare to the closing no-vig line:
def calculate_clv(bet_price: float, closing_no_vig: float) -> float:
"""Return CLV in odds points (positive = you beat the closing line)."""
return bet_price - closing_no_vig
# Log a bet
my_bet = {
"event_id": "nba_lakers_celtics_0417",
"outcome": "Celtics",
"price": -105,
}
# At game time, fetch closing no-vig from Pinnacle
closing = conn.execute("""
SELECT no_vig_price FROM snapshots
WHERE event_id=? AND outcome=? AND sportsbook='pinnacle'
ORDER BY ts DESC LIMIT 1
""", (my_bet["event_id"], my_bet["outcome"])).fetchone()
if closing:
clv = calculate_clv(my_bet["price"], closing[0])
label = "Positive" if clv > 0 else "Negative"
print(f"CLV: {clv:+.1f} points ({label})")See also: What Are No-Vig Odds?.