When 75% of bets land on Team A but the line moves toward Team B, something is happening behind the scenes. The public is pushing one direction and the book is moving the other way. That’s reverse line movement — one of the strongest observable indicators of sharp action in sports betting. It means the sportsbook is reacting to money that matters more than the ticket count, and you can detect it programmatically.
This guide builds an RLM detector from scratch. You’ll understand the theory, set up the data pipeline, implement the detection algorithm in Python, and integrate RLM signals into your betting strategy.
What Is Reverse Line Movement?
Normal Line Movement
In a functioning market, public money pushes lines toward the popular side. If 70% of bets come in on the Eagles -3, the sportsbook adjusts the line to Eagles -3.5 or -4 to balance their liability. More bets on one side means the book moves the number to attract action on the other side. This is expected, mechanical, and boring.
When the Line Moves Backward
Reverse line movement breaks that pattern. The public is heavy on the Eagles -3, but instead of the line moving to Eagles -3.5, it drops to Eagles -2.5. The book moved toward the side getting fewer tickets.
Why would a sportsbook do this? Because not all money is created equal. A thousand $50 public bets on the Eagles ($50,000 total) can be completely offset by a handful of sharp bettors wagering $200,000 on the opponent. The book doesn’t care about ticket count — it cares about dollar exposure, and it weights sharp dollars far more heavily than public dollars because sharps have a demonstrated track record of beating the closing line.
When you see the line move against the public, the book is telling you: “Smart money is on the other side, and we trust it more than the crowd.”
A Concrete Example
NFL Week 8. Chiefs -6.5 opens on Sunday morning. By noon, 73% of tickets are on the Chiefs — the public loves a dominant home favorite. Normal market mechanics would push the line toward Chiefs -7 or -7.5.
Instead, the line drops to Chiefs -6. Then Chiefs -5.5.
What happened: a syndicate group identified value on the underdog and placed $400,000 across multiple books. The books adjusted to the sharp side despite the public ticket imbalance. The line moved opposite to where public volume should have pushed it. That’s textbook RLM.
Data Requirements
Building an RLM detector requires two independent data streams, and neither is trivial.
Line Movement Data
You need historical odds snapshots — the line for each market at regular time intervals. The Odds API provides multi-book odds via REST. Poll every 10-15 minutes for standard monitoring, increasing to every 2-5 minutes as game time approaches. Store every snapshot with a timestamp so you can reconstruct the full trajectory of a line.
The critical fields per snapshot: event ID, book, market type, side, line/odds value, and timestamp. Without timestamped snapshots, you can’t measure movement — you only know the current number, not where it came from.
Public Betting Percentages
This is the harder dataset. You need to know what percentage of bets (or dollars) are on each side of a market. Sources:
- Action Network — The most widely used source. Provides ticket percentages and money percentages. API access is available on paid tiers.
- VegasInsider — Publishes consensus betting percentages for major sports. Free but requires scraping.
- DonBest — Professional-grade data used by sportsbooks themselves. Expensive but accurate.
A critical caveat: no source has perfect public betting data. These percentages are estimates based on samples from reporting books. The exact numbers differ across providers. For RLM detection, you don’t need decimal precision — you need directional accuracy. If Action Network says 72% on the Chiefs and VegasInsider says 68%, both agree the public is heavily on the Chiefs. That’s good enough.
What “Good Enough” Looks Like
Your RLM detector needs the public side to be clearly identifiable (above 60%) and the line movement direction to be unambiguous (at least 0.5 points opposite). If the public split is 52/48 or the line moved by 0.1 points, there’s no meaningful signal. Filter aggressively for clarity.
Detection Algorithm
Core Logic
The fundamental check is simple:
if public_pct(Side A) > threshold AND line_moved_toward(Side B):
flag as RLM
If the public is heavily on one side and the line moved toward the other side, something is overriding the public pressure. That something is sharp money.
Parameters
Three parameters control your detector’s sensitivity:
| Parameter | Description | Recommended Starting Value |
|---|---|---|
public_threshold | Minimum public percentage on one side to consider it “the public side” | 65% |
min_movement | Minimum line movement (in points) against the public side | 0.5 points |
time_window | Period over which to measure line movement | 4–12 hours |
Start with these defaults and adjust based on your sport and data quality. NFL markets are the most RLM-friendly because they have the deepest public betting data and the clearest line movement patterns. NBA and MLB work but require tighter filters.
Strength Scoring
Not all RLM signals are equal. A 0.5-point move against 66% public action is a weaker signal than a 2-point move against 80% public action. Score your RLM signals by combining the two dimensions:
rlm_strength = (public_pct - 0.50) * abs(line_movement)
A public percentage of 75% and a 1.5-point move yields a strength score of 0.25 * 1.5 = 0.375. A public percentage of 65% and a 0.5-point move yields 0.15 * 0.5 = 0.075. The first signal is five times stronger. Use this score to prioritize which RLM alerts deserve action.
False Positive Management
RLM detection is prone to false positives from a few sources:
- Bad public data: If your public percentage source is wrong about which side the public is on, your “RLM” is just normal line movement. Cross-reference multiple sources when possible.
- Line corrections: Sometimes a line moves because the opening number was simply wrong, not because of sharp action. If a line opened at an outlier number and corrected toward market consensus, that’s not RLM.
- Stale data: If your public betting data updates less frequently than your odds data, you can get phantom RLM — the public side may have shifted since the last update.
Log every alert and track whether the line continued moving in the RLM direction through the close. Aim for 55%+ directional accuracy before deploying capital on RLM signals.
Building the RLM Detector
Here’s the RLMDetector class. It ingests line snapshots and public betting data, then identifies markets where the line has moved against the public.
from dataclasses import dataclass
from datetime import datetime, timedelta
from collections import defaultdict
@dataclass
class LineSnapshot:
event_id: str
market: str
side: str
line: float
timestamp: datetime
@dataclass
class PublicBetting:
event_id: str
market: str
side_a: str
side_b: str
pct_a: float # 0.0–1.0
updated_at: datetime
@dataclass
class RLMSignal:
event_id: str
market: str
public_side: str
sharp_side: str
public_pct: float
line_movement: float
strength: float
detected_at: datetime
class RLMDetector:
def __init__(
self,
public_threshold: float = 0.65,
min_movement: float = 0.5,
time_window_hours: int = 8,
):
self.public_threshold = public_threshold
self.min_movement = min_movement
self.time_window = timedelta(hours=time_window_hours)
self.snapshots: dict[str, list[LineSnapshot]] = defaultdict(list)
self.public_data: dict[str, PublicBetting] = {}
def _key(self, event_id: str, market: str, side: str) -> str:
return f"{event_id}:{market}:{side}"
def ingest_line(self, snap: LineSnapshot) -> None:
key = self._key(snap.event_id, snap.market, snap.side)
self.snapshots[key].append(snap)
def ingest_public(self, data: PublicBetting) -> None:
pub_key = f"{data.event_id}:{data.market}"
self.public_data[pub_key] = data
def _net_movement(self, key: str, now: datetime) -> float | None:
cutoff = now - self.time_window
history = [s for s in self.snapshots[key] if s.timestamp >= cutoff]
if len(history) < 2:
return None
history.sort(key=lambda s: s.timestamp)
return history[-1].line - history[0].line
def detect(self, event_id: str, market: str) -> RLMSignal | None:
pub_key = f"{event_id}:{market}"
pub = self.public_data.get(pub_key)
if pub is None:
return None
if pub.pct_a >= self.public_threshold:
public_side, sharp_side = pub.side_a, pub.side_b
public_pct = pub.pct_a
elif (1 - pub.pct_a) >= self.public_threshold:
public_side, sharp_side = pub.side_b, pub.side_a
public_pct = 1 - pub.pct_a
else:
return None
now = datetime.utcnow()
key_public = self._key(event_id, market, public_side)
movement = self._net_movement(key_public, now)
if movement is None:
return None
# RLM: public is on this side, but the line is moving
# to make this side more attractive (line dropping for
# a spread favorite means the book is shading away)
if movement > 0:
return None
if abs(movement) < self.min_movement:
return None
strength = (public_pct - 0.50) * abs(movement)
return RLMSignal(
event_id=event_id,
market=market,
public_side=public_side,
sharp_side=sharp_side,
public_pct=round(public_pct, 3),
line_movement=round(movement, 2),
strength=round(strength, 4),
detected_at=now,
)
def scan_all(self) -> list[RLMSignal]:
"""Run detection across all markets with public data."""
signals = []
for pub_key, pub in self.public_data.items():
event_id, market = pub_key.split(":", 1)
signal = self.detect(event_id, market)
if signal:
signals.append(signal)
signals.sort(key=lambda s: s.strength, reverse=True)
return signals
Wiring It Together
Feed the detector with odds snapshots from The Odds API and public betting data from your chosen source:
import time
import requests
from datetime import datetime
API_KEY = "your-odds-api-key"
SPORT = "americanfootball_nfl"
POLL_INTERVAL = 600 # 10 minutes
detector = RLMDetector(public_threshold=0.65, min_movement=0.5, time_window_hours=8)
while True:
resp = requests.get(
"https://api.the-odds-api.com/v4/sports/{}/odds".format(SPORT),
params={"apiKey": API_KEY, "regions": "us,us2", "markets": "spreads"},
)
for event in resp.json():
for bookmaker in event.get("bookmakers", []):
for market in bookmaker.get("markets", []):
for outcome in market.get("outcomes", []):
snap = LineSnapshot(
event_id=event["id"],
market=market["key"],
side=outcome["name"],
line=outcome.get("point", outcome.get("price", 0)),
timestamp=datetime.utcnow(),
)
detector.ingest_line(snap)
# Ingest public betting data from your source here
# detector.ingest_public(PublicBetting(...))
signals = detector.scan_all()
for sig in signals:
print(
f"RLM: {sig.sharp_side} (sharp) vs {sig.public_side} "
f"({sig.public_pct:.0%} public) | "
f"movement: {sig.line_movement} pts | strength: {sig.strength:.3f}"
)
time.sleep(POLL_INTERVAL)
Replace the public data ingestion comment with your actual data source — Action Network API call, VegasInsider scraper, or whatever feeds your pipeline.
Using RLM in Your Strategy
RLM as a Confirmation Signal
RLM is most powerful when it confirms other sharp indicators. If a steam move fires on the same side your RLM detector flagged, that’s a high-confidence signal — both real-time coordinated action and sustained line movement are pointing the same direction. Stack it with CLV analysis: if you’ve historically seen positive closing line value on RLM-flagged bets, your detector is calibrated correctly.
Don’t treat RLM as a standalone trigger for automated execution. Use it as a filter or confirmation layer in a composite signal model. A bet that passes RLM + steam + positive expected value is far more reliable than RLM alone.
Fading the Public
The core premise of RLM is that the public is wrong and the sharps are right. This holds statistically in markets with deep liquidity and sophisticated sharp action — mainly NFL and NCAA football. The reasoning: public bettors overweight narratives, recency bias, and media coverage. Sharps rely on models, closing line analysis, and information the public hasn’t processed.
Fading the public works best when the public imbalance is extreme (70%+ on one side) and the line movement is clearly opposite. At moderate splits (55-60%), there’s not enough separation to distinguish RLM from normal market noise.
Sport-Specific Patterns
NFL is the gold standard for RLM detection. Massive public handle creates clear ticket imbalances, and the week-long betting window gives lines time to move visibly. Sunday morning RLM (lines moving against the public between Saturday night and Sunday morning) is the classic pattern.
NBA produces usable RLM signals, particularly for high-profile matchups where public bias is strongest. The shorter betting window (lines posted the day before) compresses the detection period.
MLB is noisy for RLM. Public betting percentages are less reliable because the daily volume is spread across 15 games, and line movement in moneyline markets is harder to interpret than spread movement. Filter aggressively if you apply RLM to baseball.
Timing
In NFL markets, the most meaningful RLM occurs between Friday evening and Sunday morning. That’s when the sharpest money enters — syndicate groups wait for the market to settle during the week, then hit lines once they’ve identified value against the public consensus. Early-week line movement is more likely driven by look-ahead adjustments and opening-line corrections than genuine RLM.
For same-day sports (NBA, NHL), look for RLM in the 2-6 hours before game time. That’s the window where sharp money concentrates, and public betting data has had time to stabilize.
What’s Next
RLM detection is one piece of the sharp signal toolkit. Combine it with these related guides for a complete edge detection pipeline:
- Steam Move Detection Bot — Catch real-time coordinated sharp action across multiple books
- Closing Line Value API — Measure whether your RLM bets actually beat the closing line
- Kelly Criterion Betting Bot — Size your RLM-confirmed bets using optimal bankroll allocation
- Sharp Betting Concepts — The full framework for automated edge detection
- Offshore Sportsbook APIs — Get the multi-book odds data that powers RLM detection