Closing line value is the gold standard metric for evaluating betting skill. Not ROI, not win rate, not your best week — CLV. If you consistently get better odds than the closing line, you have edge. If you don’t, you’re donating to the market and running on luck.
The problem: most bettors — even those running automated agents — never actually measure CLV. They track wins and losses, maybe ROI, and call it analysis. That’s like evaluating a stock trader by whether they made money last Tuesday instead of whether they systematically buy below fair value.
This guide builds a complete CLV tracking system in Python. You’ll capture odds snapshots from The Odds API, log your bet placements, fetch closing lines, and calculate whether your agent is actually beating the market.
What Is Closing Line Value?
The closing line is the final set of odds a sportsbook offers before a game starts. By game time, the line has been shaped by every sharp bettor, syndicate, model, and recreational gambler in the market. The result is the most efficient price the market can produce — a consensus probability backed by real money.
CLV is the difference between the odds you locked in when you placed your bet and that closing line. If you bet the Lakers at +150 and the line closes at +130, you got a better price than the market’s final assessment. That’s positive CLV.
Why CLV Beats Every Other Metric
Win rate is noisy. You can win 60% of your bets over a month and still be a losing bettor if you’re taking -200 favorites. ROI is better but takes thousands of bets to stabilize. CLV stabilizes faster and predicts future profitability with far fewer data points.
The logic: if you consistently get better prices than the close, you’re consistently finding value the market hasn’t fully priced in. Over thousands of bets, that edge compounds into profit. The market might be wrong about any single game, but the closing line’s accuracy in aggregate is extremely well-established.
The Math
CLV is typically expressed as a percentage using implied probabilities.
Decimal odds example: You bet at 2.50 (implied probability 40%). The line closes at 2.20 (implied 45.5%). Your CLV:
CLV = (closing_implied - bet_implied) / bet_implied
CLV = (0.455 - 0.400) / 0.400 = +13.7%
You locked in a 40% implied probability on something the market ultimately priced at 45.5%. That’s a significant edge.
American odds example: You bet at +150 (implied 40%). The line closes at +130 (implied 43.5%):
Implied probability (+150) = 100 / (150 + 100) = 0.400
Implied probability (+130) = 100 / (130 + 100) = 0.435
CLV = (0.435 - 0.400) / 0.400 = +8.7%
Positive CLV means you beat the market. Negative CLV means the market had a better price than you, and you’re paying a premium to gamble.
The Data You Need
Building a CLV tracker requires three data streams:
1. Odds Snapshots Over Time
You need periodic snapshots of odds from the time a market opens until game start. This gives you the full line history — useful for understanding when your agent is capturing value (early line? steam move? closing window?).
The Odds API provides multi-book odds via a simple REST endpoint. Poll it every 5-15 minutes for standard tracking, or every 1-5 minutes as game time approaches.
2. Bet Placement Records
Every bet your agent places must be logged with:
- Event ID (to match against odds snapshots)
- The exact odds at placement time
- Timestamp of placement
- Book where the bet was placed
- Stake and market type (spread, moneyline, total)
Without precise bet records, you can’t compute CLV. This is the data most setups miss — they track the bet but not the exact odds at the moment of execution.
3. Closing Lines
The final odds snapshot before game start. You capture this by scheduling an API call 1-5 minutes before kickoff/tip-off/first pitch. The closer to game time, the more accurate your closing line — but you risk the market being suspended if you cut it too close.
Building the CLV Tracker
Here’s a Python implementation that ties together odds snapshotting, bet logging, and CLV calculation.
import json
import time
import sqlite3
import requests
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
@dataclass
class BetRecord:
event_id: str
book: str
market: str # "spreads", "h2h", "totals"
side: str # team name or "Over"/"Under"
odds: float # decimal odds at placement
stake: float
placed_at: str # ISO timestamp
@dataclass
class CLVResult:
bet: BetRecord
closing_odds: float
bet_implied: float
closing_implied: float
clv_pct: float
no_vig_closing: float | None = None
clv_vs_no_vig: float | None = None
class CLVTracker:
"""Tracks odds snapshots, records bets, and calculates CLV."""
def __init__(self, api_key: str, db_path: str = "clv_tracker.db"):
self.api_key = api_key
self.db_path = db_path
self._init_db()
def _init_db(self):
conn = sqlite3.connect(self.db_path)
conn.executescript("""
CREATE TABLE IF NOT EXISTS odds_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id TEXT, book TEXT, market TEXT,
side TEXT, odds REAL, fetched_at TEXT
);
CREATE TABLE IF NOT EXISTS bets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id TEXT, book TEXT, market TEXT,
side TEXT, odds REAL, stake REAL, placed_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_snap_event
ON odds_snapshots(event_id, fetched_at);
""")
conn.close()
def snapshot_odds(self, sport: str, regions: str = "us,eu") -> int:
"""Fetch current odds and store a timestamped snapshot."""
url = "https://api.the-odds-api.com/v4/sports/{}/odds".format(sport)
resp = requests.get(url, params={
"apiKey": self.api_key,
"regions": regions,
"markets": "h2h,spreads,totals",
"oddsFormat": "decimal",
})
resp.raise_for_status()
events = resp.json()
now = datetime.now(timezone.utc).isoformat()
conn = sqlite3.connect(self.db_path)
rows_inserted = 0
for event in events:
event_id = event["id"]
for bookmaker in event.get("bookmakers", []):
book = bookmaker["key"]
for market in bookmaker.get("markets", []):
market_key = market["key"]
for outcome in market.get("outcomes", []):
conn.execute(
"INSERT INTO odds_snapshots "
"(event_id, book, market, side, odds, fetched_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(event_id, book, market_key,
outcome["name"], outcome["price"], now),
)
rows_inserted += 1
conn.commit()
conn.close()
return rows_inserted
def record_bet(self, bet: BetRecord):
"""Log a bet placement for later CLV comparison."""
conn = sqlite3.connect(self.db_path)
conn.execute(
"INSERT INTO bets "
"(event_id, book, market, side, odds, stake, placed_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(bet.event_id, bet.book, bet.market,
bet.side, bet.odds, bet.stake, bet.placed_at),
)
conn.commit()
conn.close()
def get_closing_line(self, event_id: str, market: str,
side: str, book: str | None = None) -> float | None:
"""Return the last recorded odds for an event/market/side."""
conn = sqlite3.connect(self.db_path)
query = (
"SELECT odds FROM odds_snapshots "
"WHERE event_id = ? AND market = ? AND side = ? "
)
params: list = [event_id, market, side]
if book:
query += "AND book = ? "
params.append(book)
query += "ORDER BY fetched_at DESC LIMIT 1"
row = conn.execute(query, params).fetchone()
conn.close()
return row[0] if row else None
@staticmethod
def implied_prob(decimal_odds: float) -> float:
return 1.0 / decimal_odds
@staticmethod
def strip_vig(odds_a: float, odds_b: float) -> tuple[float, float]:
"""Return no-vig fair odds for both sides of a two-way market."""
imp_a = 1.0 / odds_a
imp_b = 1.0 / odds_b
total = imp_a + imp_b
fair_a = imp_a / total
fair_b = imp_b / total
return (1.0 / fair_a, 1.0 / fair_b)
def calculate_clv(self, bet: BetRecord) -> CLVResult | None:
"""Compute CLV for a single bet against the closing line."""
closing = self.get_closing_line(
bet.event_id, bet.market, bet.side, bet.book
)
if closing is None:
return None
bet_imp = self.implied_prob(bet.odds)
close_imp = self.implied_prob(closing)
clv_pct = (close_imp - bet_imp) / bet_imp
return CLVResult(
bet=bet,
closing_odds=closing,
bet_implied=round(bet_imp, 4),
closing_implied=round(close_imp, 4),
clv_pct=round(clv_pct, 4),
)
def clv_report(self) -> list[CLVResult]:
"""Generate CLV results for all recorded bets."""
conn = sqlite3.connect(self.db_path)
rows = conn.execute("SELECT * FROM bets").fetchall()
conn.close()
results = []
for row in rows:
bet = BetRecord(
event_id=row[1], book=row[2], market=row[3],
side=row[4], odds=row[5], stake=row[6], placed_at=row[7],
)
result = self.calculate_clv(bet)
if result:
results.append(result)
return results
Usage
tracker = CLVTracker(api_key="your-odds-api-key")
# Snapshot odds every 10 minutes (run via cron or scheduler)
tracker.snapshot_odds("basketball_nba")
# When your agent places a bet, log it
tracker.record_bet(BetRecord(
event_id="abc123",
book="draftkings",
market="spreads",
side="Los Angeles Lakers",
odds=1.91,
stake=100.0,
placed_at=datetime.now(timezone.utc).isoformat(),
))
# After game start, generate your CLV report
for result in tracker.clv_report():
direction = "+" if result.clv_pct > 0 else ""
print(f"{result.bet.side}: {direction}{result.clv_pct:.2%} CLV")
Interpreting CLV Results
Raw CLV numbers mean nothing without context. Here’s how to read them.
Positive CLV = You’re Beating the Market
If your average CLV across 200+ bets is positive, your agent is finding value. The magnitude tells you how much:
| Average CLV | Interpretation |
|---|---|
| +3% or higher | Extremely sharp — you’re exploiting significant mispricings |
| +1% to +3% | Strong edge — sustainable long-term profitability |
| +0.5% to +1% | Moderate edge — profitable after accounting for vig |
| 0% to +0.5% | Marginal — may not survive vig on high-juice books |
| Negative | You’re paying a premium to the market |
Always Use No-Vig Closing Lines
The CLV calculation above uses raw book odds, which include vig. For a fair comparison, strip the vig from the closing line to get the “true” market probability.
Use the strip_vig method from the tracker class. Pass both sides of the closing market, normalize to 100%, and compare your bet odds against the fair price. This prevents you from inflating your CLV just because you bet at a low-vig book.
CLV by Segment
Don’t just look at overall CLV. Break it down:
- By sport — Your agent might crush NBA totals but bleed on NFL sides
- By market type — Spreads, moneylines, and totals have different dynamics
- By timing — Bets placed at open vs. 1 hour before close vs. 5 minutes before close
- By book — Some books are softer than others; CLV will confirm which
Segmented CLV analysis is how you turn raw data into strategy adjustments.
Visualizing Trends
Use pandas to aggregate and plot CLV over time:
import pandas as pd
results = tracker.clv_report()
df = pd.DataFrame([{
"placed_at": r.bet.placed_at,
"clv_pct": r.clv_pct,
"market": r.bet.market,
"stake": r.bet.stake,
} for r in results])
df["placed_at"] = pd.to_datetime(df["placed_at"])
df["cumulative_clv"] = df["clv_pct"].expanding().mean()
# Rolling 50-bet CLV average
df["rolling_clv"] = df["clv_pct"].rolling(50).mean()
A rising cumulative CLV line means your agent is improving. A declining one means the market is adjusting to your patterns or your model is degrading. Either way, you can see it before your bankroll feels it.
Operationalizing CLV in Your Agent
CLV isn’t just a reporting metric — it’s a feedback signal your agent should act on.
The Feedback Loop
Agent places bets → CLV tracker measures results → Agent adjusts strategy
↑ │
└────────────────────────────────────────────────────┘
Your agent should read its own CLV data and adapt:
Threshold tuning — If CLV is consistently high, your agent’s edge threshold might be too conservative. Lower it to capture more volume at slightly less edge. If CLV is declining toward zero, tighten the threshold.
Capital allocation — Route more capital to sport/market segments with the highest CLV. If NBA totals show +2.5% CLV but NFL spreads show +0.3%, the agent should size NBA totals more aggressively.
Book selection — CLV by book tells you which sportsbooks are softest for your strategy. Double down on books where you show the highest CLV; reduce exposure to books where you’re barely breaking even.
Timing optimization — If your CLV is highest on bets placed 2-4 hours before game time, your agent should concentrate execution in that window. If early-line bets show the best CLV, shift toward opening-line strategies.
When to Scale Up
Scale your agent’s bankroll allocation when:
- Rolling 100-bet CLV is consistently above +1%
- CLV is positive across at least two independent market types
- You’ve accumulated 500+ tracked bets with stable results
When to Pull Back
Reduce exposure when:
- Rolling CLV drops below +0.5% for 100+ bets
- CLV turns negative in a segment that was previously profitable
- A sportsbook limits your account (they’ve noticed your CLV too)
The point of building this tracking system isn’t just measurement — it’s giving your agent the data it needs to self-optimize. An agent that tracks and reacts to its own CLV will outperform one that blindly follows static rules.
What’s Next
CLV tracking is one piece of the sharp betting intelligence stack. Build out the rest:
- Steam Move Detection Bot — Catch line cascades and time your bets for maximum CLV
- +EV Betting Bot — Use Pinnacle’s no-vig line as your true-probability benchmark
- Sharp Betting Concepts — The full framework for building edge detection into your agent
- Offshore Sportsbook APIs — Access the soft lines where CLV is easiest to capture