Sharp Betting Concepts

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.

By theFounder·SharpAPI

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 moves

Step 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 steam

4. 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?.

Frequently Asked Questions

What is line movement in sports betting?+
Line movement is the change in a betting line from when it opens to when it closes. A -3 point spread that opens and closes at -6 moved 3 points. Sharp bettors watch line movement to identify where professional ("sharp") money is being placed.
What causes betting lines to move?+
Lines move primarily due to sharp money (professional bettors placing large, well-researched bets), steam moves (coordinated sharp action across multiple books), and public betting volume (recreational money). Sportsbooks adjust lines to balance liability and respond to sharp signals.
What is a steam move?+
A steam move is rapid, coordinated betting action that causes a line to move significantly at multiple sportsbooks within seconds or minutes. It's typically the result of professional betting syndicates acting simultaneously. Steam moves are among the clearest signals of sharp action.
What is reverse line movement (RLM)?+
Reverse line movement occurs when the majority of public bets are on one side, but the line moves in the opposite direction. This indicates sharp (professional) money is on the minority side, overriding the public. RLM is a classic sharp indicator.
How do I track line movement programmatically?+
Poll an odds API on a regular schedule (every 1–5 minutes) and store each snapshot with a timestamp. Compare consecutive snapshots to detect changes. For real-time detection with sub-second granularity, use SSE streaming from a provider like SharpAPI.
What is closing line value (CLV)?+
CLV measures whether the price you bet was better or worse than the closing no-vig line. Getting +CLV consistently (betting better prices than where the line closes) is the strongest indicator of long-term betting skill.

Related Resources

Ready to Build?
Start free. Scale when you're ready. No credit card required.

No credit card required