Lines move for a reason. Reverse line movement signals sharp money; steam moves signal coordinated sharp attacks. Decompose odds movement into trend, seasonal, and noise components, then use ARIMA or exponential smoothing to forecast where lines are headed. Agents that time entries based on line movement signals capture more closing line value than agents that bet at arbitrary times.

Why This Matters for Agents

An autonomous betting agent that places bets without analyzing line movement is leaving money on the table. The line itself is a signal — it encodes the collective information of every sharp bettor, syndicate, and model that has acted on the market. An agent that can read that signal, decompose it, and predict where it’s heading gains a structural timing advantage over agents that treat odds as static inputs.

This is Layer 3 — Trading. Line movement analysis sits between the agent’s Layer 4 intelligence module (which generates probability estimates) and its execution engine (which places bets). The intelligence module says “the true probability is 58%.” The line movement module says “the line is currently at 55% and moving toward 58% — enter now before it corrects.” The difference between betting at 55% and betting at 57.5% is the difference between a 3% edge and a 0.5% edge. Over thousands of bets, that timing gap compounds into the difference between profitability and break-even. The Agent Betting Stack places this squarely in the trading layer’s execution logic.

The Math

Line Movement Fundamentals

A sportsbook line is a price. When the line moves, the price changes. Line movement has three causes:

  1. Balanced action — the book moves the line to equalize liability on both sides.
  2. Sharp action — professional bettors with proven track records bet one side, and the book respects their opinion by adjusting.
  3. Information arrival — injury reports, weather changes, lineup announcements cause the book’s own models to update.

The critical insight: sportsbooks do not move lines proportionally to bet count. They move lines proportionally to liability-weighted opinion, which overweights sharp accounts. A single $50,000 bet from a sharp account at Bookmaker moves the line more than 500 $100 public bets on the other side.

Reverse Line Movement (RLM)

Reverse line movement occurs when the line moves opposite to the direction that public betting percentages would predict.

Define:

  • P_pub = fraction of total bets on side A (public betting percentage)
  • L_t0 = opening line
  • L_t1 = current line
  • delta_L = L_t1 - L_t0 (positive = line moved toward side A)

RLM exists when:

sign(P_pub - 0.5) != sign(delta_L)

In plain English: the public is on side A (P_pub > 0.5), but the line moved toward side B (delta_L < 0). The book is respecting sharp money on side B over public money on side A.

RLM Strength Score:

RLM_score = |P_pub - 0.5| × |delta_L| × sign_mismatch

where sign_mismatch = 1 if signs disagree, 0 otherwise

Higher scores indicate stronger RLM signals. An RLM_score > 0.15 (e.g., 80% public on one side with a 1-point line move the other way) is a high-confidence sharp signal.

Steam Moves

A steam move is a rapid, coordinated line movement across multiple sportsbooks triggered by a syndicate or sharp group hitting the same side simultaneously. The defining characteristics:

  1. Velocity: Line moves 0.5+ points within 30-120 seconds at the originating book
  2. Cascade: Other books follow within 1-10 minutes
  3. Origination: Typically starts at sharp-friendly books (Pinnacle, CRIS, Bookmaker)

The detection algorithm tracks cross-book line synchronization:

Steam Score = (books_moved / books_tracked) × (1 / time_to_cascade_minutes) × |avg_move_size|

Where:

  • books_moved = number of books that moved in the same direction within the window
  • books_tracked = total books monitored
  • time_to_cascade_minutes = time from first move to last move
  • avg_move_size = average absolute line change across moved books

A Steam Score > 1.0 with 3+ books moving within 5 minutes is a confirmed steam move.

Time Series Decomposition of Odds Movement

An odds time series for a single market can be decomposed into three additive components:

L(t) = T(t) + S(t) + N(t)

where:
  L(t) = line at time t
  T(t) = trend component (market consensus shift)
  S(t) = seasonal component (day-of-week and time-to-game effects)
  N(t) = noise component (random fluctuation)

Trend T(t): The directional drift from opening line to closing line. On average, lines move toward the true probability as information accumulates. For NFL markets, the trend component accounts for 60-70% of total line variance from Tuesday open to Sunday close.

Seasonal S(t): Predictable periodic effects. NFL lines move most on:

  • Sunday morning (6-10 AM ET) — late sharp action before 1 PM kickoffs
  • Wednesday afternoon — initial sharp response to opening lines
  • Friday evening — recreational bettors after payday

For NBA, the seasonal cycle compresses to same-day: lines open around 9 AM ET and the sharpest movement occurs 30-90 minutes before tip.

Noise N(t): Random fluctuation from recreational bettors, small-dollar action, and book-specific adjustments. This component is non-informative and should be filtered out before making entry decisions.

ARIMA Modeling for Line Forecasting

The ARIMA(p, d, q) model applies directly to odds time series:

ARIMA(p, d, q):
  p = autoregressive order (how many past values predict the next)
  d = differencing order (1 for non-stationary odds series)
  q = moving average order (how many past errors predict the next)

Differenced series: Y(t) = L(t) - L(t-1)

Model: Y(t) = c + phi_1*Y(t-1) + ... + phi_p*Y(t-p) + theta_1*e(t-1) + ... + theta_q*e(t-q) + e(t)

where:
  phi_i = autoregressive coefficients
  theta_j = moving average coefficients
  e(t) = white noise error term
  c = constant

For NFL point spreads sampled hourly from open to close, ARIMA(1,1,1) typically fits well:

  • d=1 because raw odds are non-stationary (they drift toward the closing line)
  • p=1 captures the momentum effect (a line that just moved is more likely to continue moving)
  • q=1 captures the mean-reversion in forecast errors

Opening Line Value (OLV)

Opening lines are set by sportsbook traders using power ratings, models, and limited early-week information. As the week progresses, sharp bettors and syndicates correct mispriced lines. The closing line reflects the market’s final, most efficient estimate.

The OLV premium is the expected edge available in opening lines versus closing lines:

OLV = |L_open - L_close| / L_close

Typical OLV by sport:
  NFL point spread: 0.5-1.5 points (1-3% in probability terms)
  NBA point spread: 0.3-1.0 points
  MLB moneyline:    5-15 cents (in American odds terms)

An agent that bets Tuesday NFL openers at BetOnline and consistently beats the closing line by 1+ points is capturing OLV — the same edge that defines professional bettors. This connects directly to closing line value (CLV), the gold-standard performance metric for sharp bettors.

Worked Examples

Example 1: Detecting Reverse Line Movement

Market: Lakers vs. Celtics, NBA spread, Thursday game.

Opening line (9 AM ET):    Lakers -3.5 (-110) at BetOnline
Current line (5 PM ET):    Lakers -3 (-110) at BetOnline
Public betting:            78% of bets on Lakers -3.5

RLM Analysis:
  P_pub = 0.78 (public heavily on Lakers)
  delta_L = -3 - (-3.5) = +0.5 (line moved TOWARD Celtics)
  sign(P_pub - 0.5) = positive (public on Lakers)
  sign(delta_L) = positive (line moved away from Lakers)

  Wait — the line moved from -3.5 to -3. That means the Lakers are
  now favored by LESS. The line moved toward the Celtics.

  Public is on Lakers (positive direction).
  Line moved toward Celtics (negative direction for Lakers backers).

  RLM_score = |0.78 - 0.5| × |0.5| × 1 = 0.28 × 0.5 = 0.14

  Signal: Moderate RLM. Sharp money is on Celtics +3.

The agent’s decision: if the agent’s own model agrees with the sharp signal (Celtics probability higher than the market implies), this confirms the entry. If the model disagrees, the RLM signal alone is insufficient — it’s a feature, not a complete strategy.

Example 2: Steam Move Detection

Monitoring NFL Week 12 lines on Sunday morning:

Time     Book         Line              Move
8:01 AM  Pinnacle     Chiefs -6.5       → Chiefs -7 (-120)
8:02 AM  Bookmaker    Chiefs -6.5       → Chiefs -7 (-110)
8:03 AM  BetOnline    Chiefs -6.5       (no move yet)
8:04 AM  5Dimes       Chiefs -6.5       → Chiefs -7 (-110)
8:07 AM  BetOnline    Chiefs -6.5       → Chiefs -7 (-110)
8:09 AM  Bovada       Chiefs -6.5       → Chiefs -7 (-115)
8:12 AM  MyBookie     Chiefs -6.5       → Chiefs -7 (-110)

Steam Score:
  books_moved = 6
  books_tracked = 6
  time_to_cascade = 11 minutes (8:01 to 8:12)
  avg_move_size = 0.5 points

  Steam Score = (6/6) × (1/11) × 0.5 = 0.045

  Hmm — that's low. Let's recalculate with the fast cascade:
  First 4 books moved in 3 minutes:
  Fast Steam Score = (4/6) × (1/3) × 0.5 = 0.111

  With all 6 in 11 min: confirmed steam but moderate velocity.

The agent’s play: if the agent caught the steam early and BetOnline was still at Chiefs -6.5 at 8:03 AM, betting Chiefs -6.5 captures 0.5 points of CLV before BetOnline adjusts at 8:07. This is stale-line exploitation — one of the highest-edge strategies available to automated agents monitoring the offshore sportsbook API ecosystem.

Example 3: ARIMA Forecast for Entry Timing

NFL spread for Bills vs. Dolphins, sampled hourly from Tuesday open:

Hour   Line (Bills spread)
  0    -2.5   (Tue 12 PM — opening)
  1    -2.5
  2    -3.0
  3    -3.0
  4    -3.0
  5    -2.5   (Wed AM)
  6    -2.5
  7    -3.0
  8    -3.0
  9    -3.5   (Thu AM — sharp move)
 10    -3.5
 11    -3.5

Agent's model says Bills true line should be -4.5.
Current line: -3.5. Edge = 1.0 point.

ARIMA(1,1,1) forecast for hours 12-14:
  Hour 12 forecast: -3.7
  Hour 13 forecast: -3.9
  Hour 14 forecast: -4.0

The line is trending toward the agent's estimate.
Decision: bet now at -3.5 before the line reaches -4.0+.
Captured CLV: ~0.5 points vs. waiting until closing.

Implementation

"""
Line Movement Analysis Module for Betting Agents.

Requires:
  pip install pandas numpy statsmodels requests

Integrates with The Odds API for real-time odds data.
"""

import numpy as np
import pandas as pd
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Optional
from statsmodels.tsa.arima.model import ARIMA
from statsmodels.tsa.holtwinters import ExponentialSmoothing
import requests


# ── Data Structures ────────────────────────────────────────────

@dataclass
class OddsSnapshot:
    """Single odds observation from one sportsbook."""
    timestamp: datetime
    book: str
    spread: float
    price: int  # American odds, e.g., -110
    sport: str
    event: str


@dataclass
class RLMSignal:
    """Reverse line movement detection result."""
    event: str
    public_pct: float
    opening_line: float
    current_line: float
    line_delta: float
    rlm_detected: bool
    rlm_score: float
    sharp_side: str


@dataclass
class SteamSignal:
    """Steam move detection result."""
    event: str
    direction: str
    books_moved: int
    books_tracked: int
    cascade_seconds: float
    avg_move_size: float
    steam_score: float
    originating_book: str
    timestamp: datetime


# ── Reverse Line Movement Detection ───────────────────────────

def detect_rlm(
    opening_line: float,
    current_line: float,
    public_pct_side_a: float,
    event: str = "",
    side_a_name: str = "Home",
    side_b_name: str = "Away"
) -> RLMSignal:
    """
    Detect reverse line movement.

    Args:
        opening_line: Opening spread (negative = side A favored).
        current_line: Current spread.
        public_pct_side_a: Fraction of bets on side A (0.0 to 1.0).
        event: Event description for logging.
        side_a_name: Name of side A.
        side_b_name: Name of side B.

    Returns:
        RLMSignal with detection result and score.
    """
    line_delta = current_line - opening_line
    # Positive delta means line moved toward side A (more negative spread)
    # For spreads: opening -3.5, current -4.0, delta = -0.5 (moved toward A)

    public_direction = 1 if public_pct_side_a > 0.5 else -1
    line_direction = -1 if line_delta < 0 else (1 if line_delta > 0 else 0)

    # RLM: public on one side, line moves the other
    rlm_detected = (
        line_direction != 0
        and public_direction != line_direction
    )

    rlm_score = abs(public_pct_side_a - 0.5) * abs(line_delta) * (1 if rlm_detected else 0)

    sharp_side = ""
    if rlm_detected:
        sharp_side = side_a_name if line_direction < 0 else side_b_name

    return RLMSignal(
        event=event,
        public_pct=public_pct_side_a,
        opening_line=opening_line,
        current_line=current_line,
        line_delta=line_delta,
        rlm_detected=rlm_detected,
        rlm_score=rlm_score,
        sharp_side=sharp_side,
    )


# ── Steam Move Detection ──────────────────────────────────────

def detect_steam(
    snapshots: list[OddsSnapshot],
    window_minutes: float = 15.0,
    min_books: int = 3,
    min_move_size: float = 0.5
) -> Optional[SteamSignal]:
    """
    Detect steam moves from a time-ordered list of odds snapshots.

    Groups snapshots by book, detects synchronized moves within
    the time window, and scores the steam signal.

    Args:
        snapshots: Time-ordered odds snapshots across multiple books.
        window_minutes: Max time window for cascade detection.
        min_books: Minimum books that must move to qualify.
        min_move_size: Minimum point move to count.

    Returns:
        SteamSignal if detected, None otherwise.
    """
    if len(snapshots) < 2:
        return None

    df = pd.DataFrame([
        {
            "timestamp": s.timestamp,
            "book": s.book,
            "spread": s.spread,
            "event": s.event,
        }
        for s in snapshots
    ]).sort_values("timestamp")

    # Get first and last snapshot per book
    book_moves = []
    for book, group in df.groupby("book"):
        if len(group) < 2:
            continue
        first = group.iloc[0]
        last = group.iloc[-1]
        move = last["spread"] - first["spread"]
        if abs(move) >= min_move_size:
            book_moves.append({
                "book": book,
                "move": move,
                "first_time": first["timestamp"],
                "move_time": last["timestamp"],
            })

    if len(book_moves) < min_books:
        return None

    moves_df = pd.DataFrame(book_moves)

    # Check direction consensus
    directions = np.sign(moves_df["move"].values)
    dominant_direction = np.sign(np.sum(directions))
    aligned = moves_df[np.sign(moves_df["move"]) == dominant_direction]

    if len(aligned) < min_books:
        return None

    first_move_time = aligned["move_time"].min()
    last_move_time = aligned["move_time"].max()
    cascade_seconds = (last_move_time - first_move_time).total_seconds()

    if cascade_seconds > window_minutes * 60:
        return None

    originator = aligned.loc[aligned["move_time"].idxmin(), "book"]
    avg_move = aligned["move"].abs().mean()
    cascade_min = max(cascade_seconds / 60, 0.5)  # floor at 30 sec

    steam_score = (
        (len(aligned) / df["book"].nunique())
        * (1 / cascade_min)
        * avg_move
    )

    return SteamSignal(
        event=snapshots[0].event,
        direction="favorite" if dominant_direction < 0 else "underdog",
        books_moved=len(aligned),
        books_tracked=df["book"].nunique(),
        cascade_seconds=cascade_seconds,
        avg_move_size=avg_move,
        steam_score=steam_score,
        originating_book=originator,
        timestamp=first_move_time,
    )


# ── Time Series Decomposition ─────────────────────────────────

def decompose_line_movement(
    timestamps: list[datetime],
    lines: list[float],
    period: int = 24
) -> dict[str, np.ndarray]:
    """
    Decompose a line movement time series into trend, seasonal,
    and noise components using STL-like additive decomposition.

    Args:
        timestamps: Observation timestamps.
        lines: Corresponding line values.
        period: Seasonal period in observation units (24 = daily for hourly data).

    Returns:
        Dict with 'trend', 'seasonal', 'noise' arrays.
    """
    series = pd.Series(lines, index=pd.DatetimeIndex(timestamps))
    series = series.asfreq("h", method="ffill")

    # Trend: rolling mean with window = period
    trend = series.rolling(window=period, center=True, min_periods=1).mean()

    # Detrended
    detrended = series - trend

    # Seasonal: average detrended value for each hour-of-day
    seasonal_avg = detrended.groupby(detrended.index.hour).transform("mean")

    # Noise: residual
    noise = series - trend - seasonal_avg

    return {
        "observed": series.values,
        "trend": trend.values,
        "seasonal": seasonal_avg.values,
        "noise": noise.values,
        "timestamps": series.index,
    }


# ── ARIMA Forecasting ─────────────────────────────────────────

def forecast_line_arima(
    lines: list[float],
    steps: int = 3,
    order: tuple[int, int, int] = (1, 1, 1)
) -> dict:
    """
    Forecast future line values using ARIMA.

    Args:
        lines: Historical line values (evenly spaced).
        steps: Number of steps to forecast.
        order: ARIMA(p, d, q) order.

    Returns:
        Dict with 'forecast' values and 'confidence_intervals'.
    """
    series = pd.Series(lines, dtype=float)

    model = ARIMA(series, order=order)
    fitted = model.fit()

    forecast_result = fitted.get_forecast(steps=steps)
    forecast_values = forecast_result.predicted_mean.values
    conf_int = forecast_result.conf_int(alpha=0.05)

    return {
        "forecast": forecast_values,
        "lower_ci": conf_int.iloc[:, 0].values,
        "upper_ci": conf_int.iloc[:, 1].values,
        "aic": fitted.aic,
        "bic": fitted.bic,
    }


def forecast_line_ets(
    lines: list[float],
    steps: int = 3,
    seasonal_period: int = 24
) -> np.ndarray:
    """
    Forecast line movement using Exponential Smoothing (Holt-Winters).

    Lighter weight than ARIMA. Good for agents that need fast forecasts
    with minimal computation.

    Args:
        lines: Historical line values.
        steps: Steps ahead to forecast.
        seasonal_period: Seasonal cycle length.

    Returns:
        Array of forecasted values.
    """
    series = pd.Series(lines, dtype=float)

    if len(series) < 2 * seasonal_period:
        # Not enough data for seasonal model — use simple exponential smoothing
        model = ExponentialSmoothing(
            series, trend="add", seasonal=None
        )
    else:
        model = ExponentialSmoothing(
            series, trend="add", seasonal="add",
            seasonal_periods=seasonal_period
        )

    fitted = model.fit(optimized=True)
    return fitted.forecast(steps).values


# ── Opening Line Value Calculator ──────────────────────────────

def calculate_olv(
    opening_lines: list[float],
    closing_lines: list[float],
    bet_lines: list[float]
) -> dict:
    """
    Calculate Opening Line Value metrics across a sample of bets.

    Args:
        opening_lines: Opening spread for each event.
        closing_lines: Closing spread for each event.
        bet_lines: The spread at which the agent actually bet.

    Returns:
        Dict with OLV statistics.
    """
    opening = np.array(opening_lines)
    closing = np.array(closing_lines)
    bet = np.array(bet_lines)

    # Line movement from open to close
    total_movement = closing - opening
    avg_movement = np.mean(np.abs(total_movement))

    # CLV: how much better the agent's line was vs. closing
    clv = closing - bet  # positive = agent beat the close
    avg_clv = np.mean(clv)
    pct_beat_close = np.mean(clv > 0) * 100

    # OLV: how much the opening line differed from closing
    olv = np.abs(opening - closing)
    avg_olv = np.mean(olv)

    # What fraction of OLV did the agent capture?
    olv_captured = np.where(
        olv > 0,
        np.clip((closing - bet) / (closing - opening), 0, 1),
        0
    )
    avg_olv_captured = np.mean(olv_captured) * 100

    return {
        "avg_line_movement": avg_movement,
        "avg_clv": avg_clv,
        "pct_beat_closing_line": pct_beat_close,
        "avg_olv_points": avg_olv,
        "pct_olv_captured": avg_olv_captured,
        "sample_size": len(opening),
    }


# ── The Odds API Integration ──────────────────────────────────

def fetch_odds_history(
    api_key: str,
    sport: str = "americanfootball_nfl",
    regions: str = "us",
    markets: str = "spreads",
    days_back: int = 3
) -> pd.DataFrame:
    """
    Fetch historical odds snapshots from The Odds API.

    Args:
        api_key: The Odds API key.
        sport: Sport key (e.g., 'americanfootball_nfl').
        regions: Comma-separated regions.
        markets: Market type ('spreads', 'totals', 'h2h').
        days_back: How many days of history to fetch.

    Returns:
        DataFrame with columns: timestamp, event, book, spread, price.
    """
    base_url = "https://api.the-odds-api.com/v4/sports"
    date_from = (datetime.utcnow() - timedelta(days=days_back)).isoformat() + "Z"

    resp = requests.get(
        f"{base_url}/{sport}/odds-history",
        params={
            "apiKey": api_key,
            "regions": regions,
            "markets": markets,
            "dateFormat": "iso",
            "date": date_from,
        },
    )
    resp.raise_for_status()
    data = resp.json()

    rows = []
    for event in data.get("data", []):
        event_name = f"{event['away_team']} @ {event['home_team']}"
        for bookmaker in event.get("bookmakers", []):
            book = bookmaker["key"]
            for market in bookmaker.get("markets", []):
                if market["key"] == "spreads":
                    for outcome in market["outcomes"]:
                        rows.append({
                            "timestamp": pd.Timestamp(bookmaker["last_update"]),
                            "event": event_name,
                            "book": book,
                            "team": outcome["name"],
                            "spread": outcome.get("point", 0),
                            "price": outcome["price"],
                        })

    return pd.DataFrame(rows)


# ── Volume-Weighted Line Movement Scoring ──────────────────────

def sharp_vs_public_score(
    line_moves: list[float],
    volumes: list[float],
    bet_counts: list[int]
) -> dict:
    """
    Score whether line movement is driven by sharp money or public money.

    Sharp money: large average bet size, line moves with the money.
    Public money: small average bet size, high bet count.

    Args:
        line_moves: Signed line movements (positive = toward favorite).
        volumes: Dollar volume behind each move.
        bet_counts: Number of individual bets behind each move.

    Returns:
        Dict with sharp_score and classification.
    """
    moves = np.array(line_moves)
    vols = np.array(volumes)
    counts = np.array(bet_counts)

    avg_bet_size = vols / np.maximum(counts, 1)

    # Volume-weighted average move direction
    vol_weighted_direction = np.sum(moves * vols) / np.sum(vols)

    # Count-weighted average move direction
    count_weighted_direction = np.sum(moves * counts) / np.sum(counts)

    # If volume says one thing and count says another → sharp vs public split
    divergence = vol_weighted_direction - count_weighted_direction

    # Sharp score: normalized divergence × average bet size factor
    median_bet = np.median(avg_bet_size)
    sharp_score = divergence * (median_bet / 1000)  # normalize to ~$1000 baseline

    if sharp_score > 0.1:
        classification = "sharp_dominant"
    elif sharp_score < -0.1:
        classification = "public_dominant"
    else:
        classification = "mixed"

    return {
        "sharp_score": sharp_score,
        "classification": classification,
        "vol_weighted_direction": vol_weighted_direction,
        "count_weighted_direction": count_weighted_direction,
        "avg_bet_size": float(np.mean(avg_bet_size)),
    }


# ── Agent Entry Timing ─────────────────────────────────────────

def optimal_entry_signal(
    current_line: float,
    agent_fair_line: float,
    arima_forecast: list[float],
    rlm_signal: Optional[RLMSignal] = None,
    steam_signal: Optional[SteamSignal] = None,
) -> dict:
    """
    Combine line movement signals to generate an entry timing decision.

    Args:
        current_line: Current market spread.
        agent_fair_line: Agent's model estimate of the true spread.
        arima_forecast: Forecasted line values for next N periods.
        rlm_signal: Optional RLM detection result.
        steam_signal: Optional steam detection result.

    Returns:
        Dict with entry recommendation and confidence.
    """
    edge = abs(agent_fair_line - current_line)
    forecasted_close = arima_forecast[-1] if arima_forecast else current_line

    # Will the line move toward or away from agent's position?
    line_moving_toward_agent = (
        abs(agent_fair_line - forecasted_close) < abs(agent_fair_line - current_line)
    )

    confidence_factors = []

    # Factor 1: Raw edge
    if edge >= 1.5:
        confidence_factors.append(("large_edge", 0.3))
    elif edge >= 0.5:
        confidence_factors.append(("moderate_edge", 0.15))

    # Factor 2: Line moving toward fair value (bet now before it corrects)
    if line_moving_toward_agent:
        confidence_factors.append(("line_converging", 0.25))

    # Factor 3: RLM confirms agent's side
    if rlm_signal and rlm_signal.rlm_detected and rlm_signal.rlm_score > 0.1:
        confidence_factors.append(("rlm_confirmation", 0.2))

    # Factor 4: Steam on agent's side (or stale line opportunity)
    if steam_signal and steam_signal.steam_score > 0.5:
        confidence_factors.append(("steam_active", 0.25))

    total_confidence = sum(f[1] for f in confidence_factors)

    if total_confidence >= 0.5 and edge >= 0.5:
        action = "bet_now"
    elif total_confidence >= 0.3 and edge >= 1.0:
        action = "bet_now"
    elif line_moving_toward_agent and edge > 0:
        action = "bet_now"
    else:
        action = "wait"

    return {
        "action": action,
        "edge_points": edge,
        "current_line": current_line,
        "fair_line": agent_fair_line,
        "forecasted_close": forecasted_close,
        "confidence": total_confidence,
        "factors": confidence_factors,
    }

Limitations and Edge Cases

Stale data kills the model. Line movement analysis requires near-real-time odds data. The Odds API updates every 30-60 seconds for major sports, but during steam moves the line can move and settle within that polling interval. An agent relying on 60-second polling will miss the stale-line window at slower books. For sub-second detection, direct API connections to individual sportsbooks via the offshore sportsbook API infrastructure are necessary.

Public betting percentages are unreliable. No public data source provides accurate dollar-weighted handle data. Sites reporting “80% of bets on the Lakers” are tracking bet count from their own user base, not actual sportsbook handle. Volume-weighted RLM requires either book-specific handle data (unavailable to the public) or inference from line movement magnitude. Treat public betting percentages as directional signals with a large error bar.

ARIMA assumes stationarity after differencing. Odds series for events with sudden information shocks (injury announcements, lineup changes) exhibit structural breaks that violate ARIMA’s stationarity assumption. The model will produce poor forecasts immediately after a structural break. An agent should reset its ARIMA model whenever it detects a line movement exceeding 2 standard deviations from the rolling mean.

Small sample sizes per event. Each NFL game generates roughly 120-168 hourly observations from Tuesday open to Sunday close. That is a tiny sample for ARIMA fitting. The practical solution: fit ARIMA parameters on pooled historical data across many games of the same sport and market type, then apply those parameters to the current game’s series. Sport-specific parameter estimates are far more robust than single-game fits.

Prediction markets behave differently from sportsbooks. Polymarket and Kalshi lines move via orderbook dynamics, not book-side adjustments. There is no “sharp account” concept — all orders are anonymous. Steam move detection does not apply to prediction markets. Line movement analysis for prediction markets requires orderbook depth analysis and trade flow decomposition, not the sportsbook-centric methods in this guide. The Prediction Market Microstructure guide covers those techniques.

Sunday morning NFL lines are a special case. The 6-10 AM ET window before 1 PM kickoffs is the highest-volatility, highest-edge period in all of sports betting. Lines can move 2+ points in 30 minutes. ARIMA models trained on Tuesday-Saturday data will underestimate Sunday morning variance. Agents should use a separate, wider-confidence-interval model for this window.

FAQ

What is reverse line movement in sports betting?

Reverse line movement (RLM) occurs when the majority of public bets are on one side but the line moves in the opposite direction. For example, if 75% of bets are on the Lakers -3.5 but the line moves from -3.5 to -3, sharp money on the Celtics is pushing the line against the public. Sportsbooks move lines based on liability, not bet count — a single $50,000 sharp bet outweighs five hundred $100 public bets.

How do you detect a steam move in sports betting?

A steam move is a coordinated sharp attack that moves lines across multiple sportsbooks within 30-120 seconds. Detection requires polling odds from 3+ books simultaneously via The Odds API and flagging when lines at sharp-originating books (Pinnacle, Bookmaker, CRIS) move by 0.5+ points, followed by cascading moves at slower books within minutes. The velocity and synchronization across books distinguishes steam from organic line drift.

What is opening line value in sports betting?

Opening line value (OLV) measures the edge available in early lines before the market sharpens them. Opening lines are set by sportsbook traders using models and limited information; closing lines incorporate all sharp action and public money. Studies show 2-4% more exploitable inefficiency in opening lines versus closing lines, making early-week betting a core strategy for sharp bettors and autonomous agents.

How can betting agents use ARIMA for line movement forecasting?

Agents apply ARIMA(p,d,q) models to historical odds time series to forecast short-term line movements. The differencing parameter d=1 handles non-stationarity in odds data. An agent fits ARIMA on historical line movement patterns for a given sport and market type, generates a 1-3 step forecast, and times entries when the current line deviates from the forecasted trajectory. This works best for NFL and NBA markets with regular weekly patterns.

How does line movement analysis connect to closing line value?

Line movement analysis and closing line value (CLV) are deeply connected. CLV measures whether you beat the closing line — the most efficient price. Line movement analysis tells you when and why lines move, helping agents enter positions early when the line will move in their favor. Consistently capturing positive CLV is the strongest predictor of long-run profitability. See the Closing Line Value guide for the full CLV framework.

What’s Next

Line movement analysis gives agents the when to complement the what from their models. The natural next step is turning these signals into features for machine learning models.

  • Next in the series: Feature Engineering for Sports Prediction — how to encode line movement signals, RLM scores, and steam indicators as model features for prediction pipelines.
  • Prerequisite math: Closing Line Value (CLV) — the performance metric that validates whether your line movement timing is working.
  • Edge detection at scale: The Odds API Edge Detection Pipeline covers the full end-to-end pipeline from API polling to bet execution.
  • Sharp betting deep dive: The sharp betting hub covers the broader ecosystem of professional betting strategies that generate the line movements analyzed here.
  • Track vig across books: The AgentBets Vig Index shows real-time overround by sportsbook — essential context for understanding which books’ line movements carry the most signal.