BettingLab

Build Closing Line Value CLV Calculator Python Sports API

Marcus Hale
Marcus Hale

Building a closing line value CLV calculator is the gold standard for measuring bet quality in professional sports betting. Unlike traditional win/loss tracking, CLV measures how your bet price compares to the sharp closing number — the most efficient price discovery mechanism in sports betting.

Sharp bettors obsess over CLV because it's predictive: positive CLV correlates with long-term profitability, even when short-term variance makes individual bets look bad. A losing bet at +150 when the closing line was +120 is a good bet. A winning bet at +120 when the line closed at +150 is lucky noise.

This tutorial builds a production-grade CLV calculator in Python using live odds APIs, storing historical bet data, and computing rolling CLV metrics across your entire betting portfolio.

Why Closing Line Value Matters More Than Win Rate

Traditional bettors track wins and losses. Sharp bettors track closing line value. Here's why CLV is the superior metric:

CLV removes variance noise. A 60% win rate sounds impressive until you realize you're betting heavy favorites at -200. Your CLV might be negative, indicating you're systematically betting into efficient lines with no edge.

CLV predicts future performance. Academic research shows consistent positive CLV correlates with long-term profitability across thousands of bets. It's the closest thing to a "skill metric" in sports betting.

CLV exposes market timing. If you're consistently betting lines that move against you before close, you're either getting bad numbers or betting into sharp money. CLV quantifies this leak.

The math is straightforward. For a bet placed at odds O_bet with closing odds O_close:

CLV = (Implied_Close - Implied_Bet) / Implied_Bet

Where implied probability = 1 / decimal_odds. Positive CLV means you got better than closing odds. Negative CLV means the market moved away from your position.

Setting Up the CLV Calculation Engine

Our CLV calculator needs three components: odds data ingestion, bet storage, and CLV computation. We'll use the MoneyLine API for real-time and historical odds data.

import requests
import sqlite3
import pandas as pd
from datetime import datetime, timedelta
from typing import Dict, List, Optional
import json

class CLVCalculator:
    def __init__(self, api_key: str, db_path: str = "bets.db"):
        self.api_key = api_key
        self.db_path = db_path
        self.base_url = "https://mlapi.bet/v1"
        self.init_database()
    
    def init_database(self):
        """Initialize SQLite database for bet tracking"""
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()
        
        cursor.execute("""
            CREATE TABLE IF NOT EXISTS bets (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                game_id TEXT NOT NULL,
                market_type TEXT NOT NULL,
                selection TEXT NOT NULL,
                odds_decimal REAL NOT NULL,
                stake REAL NOT NULL,
                bet_timestamp TEXT NOT NULL,
                closing_odds REAL,
                clv REAL,
                result TEXT,
                profit_loss REAL
            )
        """)
        
        cursor.execute("""
            CREATE TABLE IF NOT EXISTS closing_odds (
                game_id TEXT NOT NULL,
                market_type TEXT NOT NULL,
                selection TEXT NOT NULL,
                closing_odds REAL NOT NULL,
                closing_timestamp TEXT NOT NULL,
                PRIMARY KEY (game_id, market_type, selection)
            )
        """)
        
        conn.commit()
        conn.close()
    
    def decimal_to_implied(self, decimal_odds: float) -> float:
        """Convert decimal odds to implied probability"""
        return 1.0 / decimal_odds
    
    def calculate_clv(self, bet_odds: float, closing_odds: float) -> float:
        """Calculate closing line value as percentage"""
        implied_bet = self.decimal_to_implied(bet_odds)
        implied_close = self.decimal_to_implied(closing_odds)
        return (implied_close - implied_bet) / implied_bet * 100

The database schema stores individual bets with their metadata and links to closing odds data. The game_id comes from the odds API and ensures we can match bets to their closing numbers accurately.

Fetching Sharp Closing Odds Data

Sharp closing odds come from books like Pinnacle, which welcome professional bettors and have the most efficient lines. We'll pull closing odds from multiple sharp books and use consensus pricing.

    def get_closing_odds(self, game_id: str, market_type: str) -> Dict:
        """Fetch closing odds for a specific game and market"""
        headers = {"X-API-Key": self.api_key}
        
        # Get odds history for the specific game
        url = f"{self.base_url}/odds/{game_id}/history"
        params = {
            "market": market_type,
            "books": "pinnacle,betcris,heritage",  # Sharp books only
            "period": "close"
        }
        
        response = requests.get(url, headers=headers, params=params)
        
        if response.status_code != 200:
            raise Exception(f"API error: {response.status_code}")
        
        data = response.json()
        
        # Extract closing odds from sharp books
        closing_data = {}
        for book_data in data.get("odds", []):
            book_name = book_data["book"]
            if book_name in ["pinnacle", "betcris", "heritage"]:
                for selection in book_data["selections"]:
                    selection_key = selection["selection"]
                    if selection_key not in closing_data:
                        closing_data[selection_key] = []
                    closing_data[selection_key].append(selection["odds"])
        
        # Use median of sharp book odds as closing line
        consensus_closing = {}
        for selection, odds_list in closing_data.items():
            if odds_list:
                consensus_closing[selection] = sorted(odds_list)[len(odds_list)//2]
        
        return consensus_closing
    
    def store_closing_odds(self, game_id: str, market_type: str, 
                          closing_odds: Dict[str, float]):
        """Store closing odds in database"""
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()
        
        timestamp = datetime.now().isoformat()
        
        for selection, odds in closing_odds.items():
            cursor.execute("""
                INSERT OR REPLACE INTO closing_odds 
                (game_id, market_type, selection, closing_odds, closing_timestamp)
                VALUES (?, ?, ?, ?, ?)
            """, (game_id, market_type, selection, odds, timestamp))
        
        conn.commit()
        conn.close()

We prioritize Pinnacle, BetCRIS, and Heritage because they're the sharpest books that don't limit winners. Their closing odds represent the most efficient price discovery in the market.

The median approach handles cases where one book might have stale odds or be off-market. If Pinnacle shows 1.95, BetCRIS shows 1.98, and Heritage shows 1.96, we use 1.96 as our closing line.

Recording Bets and Computing CLV Metrics

When you place a bet, the calculator stores it immediately and computes CLV once closing odds become available. This separation is crucial because games might not close for hours or days.

    def record_bet(self, game_id: str, market_type: str, selection: str,
                   odds_decimal: float, stake: float) -> int:
        """Record a new bet in the database"""
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()
        
        timestamp = datetime.now().isoformat()
        
        cursor.execute("""
            INSERT INTO bets 
            (game_id, market_type, selection, odds_decimal, stake, bet_timestamp)
            VALUES (?, ?, ?, ?, ?, ?)
        """, (game_id, market_type, selection, odds_decimal, stake, timestamp))
        
        bet_id = cursor.lastrowid
        conn.commit()
        conn.close()
        
        return bet_id
    
    def update_bet_clv(self, bet_id: int) -> Optional[float]:
        """Update a bet's CLV once closing odds are available"""
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()
        
        # Get bet details
        cursor.execute("""
            SELECT game_id, market_type, selection, odds_decimal 
            FROM bets WHERE id = ?
        """, (bet_id,))
        
        bet_data = cursor.fetchone()
        if not bet_data:
            conn.close()
            return None
        
        game_id, market_type, selection, bet_odds = bet_data
        
        # Get closing odds
        cursor.execute("""
            SELECT closing_odds FROM closing_odds 
            WHERE game_id = ? AND market_type = ? AND selection = ?
        """, (game_id, market_type, selection))
        
        closing_data = cursor.fetchone()
        if not closing_data:
            conn.close()
            return None
        
        closing_odds = closing_data[0]
        clv = self.calculate_clv(bet_odds, closing_odds)
        
        # Update bet with CLV
        cursor.execute("""
            UPDATE bets SET closing_odds = ?, clv = ? WHERE id = ?
        """, (closing_odds, clv, bet_id))
        
        conn.commit()
        conn.close()
        
        return clv
    
    def get_portfolio_clv(self, days: int = 30) -> Dict:
        """Calculate CLV metrics for recent betting portfolio"""
        conn = sqlite3.connect(self.db_path)
        
        cutoff_date = (datetime.now() - timedelta(days=days)).isoformat()
        
        query = """
            SELECT clv, stake FROM bets 
            WHERE bet_timestamp > ? AND clv IS NOT NULL
        """
        
        df = pd.read_sql_query(query, conn, params=[cutoff_date])
        conn.close()
        
        if df.empty:
            return {"error": "No bets with CLV data in specified period"}
        
        # Calculate weighted and unweighted CLV
        total_clv = df['clv'].mean()
        weighted_clv = (df['clv'] * df['stake']).sum() / df['stake'].sum()
        
        positive_clv_rate = (df['clv'] > 0).mean() * 100
        total_bets = len(df)
        
        return {
            "total_bets": total_bets,
            "average_clv": round(total_clv, 2),
            "weighted_clv": round(weighted_clv, 2),
            "positive_clv_rate": round(positive_clv_rate, 1),
            "clv_std": round(df['clv'].std(), 2)
        }

The portfolio analysis separates unweighted CLV (treats all bets equally) from weighted CLV (larger bets have more impact). Professional bettors care more about weighted CLV since it reflects actual capital allocation decisions.

Automated CLV Updates and Market Analysis

The final piece automates closing odds collection and provides market movement analysis. This runs after games finish to compute CLV for all pending bets.

    def update_all_pending_clv(self) -> Dict:
        """Update CLV for all bets missing closing odds"""
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()
        
        # Find bets without CLV that are likely finished
        cutoff = (datetime.now() - timedelta(hours=6)).isoformat()
        cursor.execute("""
            SELECT DISTINCT game_id, market_type 
            FROM bets 
            WHERE clv IS NULL AND bet_timestamp < ?
        """, (cutoff,))
        
        pending_games = cursor.fetchall()
        conn.close()
        
        updated_count = 0
        errors = []
        
        for game_id, market_type in pending_games:
            try:
                # Fetch and store closing odds
                closing_odds = self.get_closing_odds(game_id, market_type)
                if closing_odds:
                    self.store_closing_odds(game_id, market_type, closing_odds)
                    
                    # Update all bets for this game/market
                    conn = sqlite3.connect(self.db_path)
                    cursor = conn.cursor()
                    
                    cursor.execute("""
                        SELECT id FROM bets 
                        WHERE game_id = ? AND market_type = ? AND clv IS NULL
                    """, (game_id, market_type))
                    
                    bet_ids = [row[0] for row in cursor.fetchall()]
                    conn.close()
                    
                    for bet_id in bet_ids:
                        if self.update_bet_clv(bet_id) is not None:
                            updated_count += 1
                            
            except Exception as e:
                errors.append(f"Game {game_id}: {str(e)}")
        
        return {
            "updated_bets": updated_count,
            "errors": errors
        }

    def analyze_market_movement(self, game_id: str, market_type: str) -> Dict:
        """Analyze how a market moved from opening to closing"""
        headers = {"X-API-Key": self.api_key}
        
        url = f"{self.base_url}/odds/{game_id}/movement"
        params = {"market": market_type, "books": "pinnacle"}
        
        response = requests.get(url, headers=headers, params=params)
        
        if response.status_code != 200:
            return {"error": f"API error: {response.status_code}"}
        
        data = response.json()
        movement_data = {}
        
        for selection_data in data.get("movement", []):
            selection = selection_data["selection"]
            history = selection_data["history"]
            
            if len(history) >= 2:
                opening_odds = history[0]["odds"]
                closing_odds = history[-1]["odds"]
                
                movement_pct = ((closing_odds - opening_odds) / opening_odds) * 100
                
                movement_data[selection] = {
                    "opening_odds": opening_odds,
                    "closing_odds": closing_odds,
                    "movement_pct": round(movement_pct, 2),
                    "total_moves": len(history)
                }
        
        return movement_data

# Example usage
if __name__ == "__main__":
    calculator = CLVCalculator("your_api_key_here")
    
    # Record a bet
    bet_id = calculator.record_bet(
        game_id="nfl_2026_week_1_chiefs_bills",
        market_type="spread",
        selection="chiefs_-3",
        odds_decimal=1.91,
        stake=100.00
    )
    
    print(f"Recorded bet {bet_id}")
    
    # Later, after game closes, update CLV
    clv = calculator.update_bet_clv(bet_id)
    if clv:
        print(f"Bet CLV: {clv:.2f}%")
    
    # Get portfolio performance
    portfolio = calculator.get_portfolio_clv(days=30)
    print(f"30-day portfolio CLV: {portfolio}")

This automated update system can run daily via cron job or cloud scheduler to keep CLV metrics current. The MoneyLine API provides the historical odds data needed for accurate CLV calculation across all major sportsbooks.

Advanced CLV Analytics and Interpretation

Raw CLV numbers need context. A +2% CLV is excellent for NFL spreads but might be below expectation for obscure tennis futures. Market-specific benchmarks help interpret your performance.

Sport-specific CLV expectations:

Sample size requirements matter. CLV stabilizes around 100-200 bets per market. Earlier samples have high variance and shouldn't drive major strategy changes.

Time decay effects exist. Bets placed early in the week often show different CLV patterns than those placed close to game time. Steam moves and injury news create systematic differences.

The calculator can segment CLV by these factors:

def segment_clv_analysis(self, days: int = 90) -> Dict:
    """Analyze CLV by sport, market, and timing factors"""
    conn = sqlite3.connect(self.db_path)
    
    query = """
        SELECT 
            game_id,
            market_type,
            clv,
            stake,
            bet_timestamp,
            CASE 
                WHEN game_id LIKE 'nfl_%' THEN 'nfl'
                WHEN game_id LIKE 'nba_%' THEN 'nba'
                WHEN game_id LIKE 'mlb_%' THEN 'mlb'
                ELSE 'other'
            END as sport
        FROM bets 
        WHERE clv IS NOT NULL 
        AND bet_timestamp > date('now', '-{} days')
    """.format(days)
    
    df = pd.read_sql_query(query, conn)
    conn.close()
    
    # Segment analysis
    sport_clv = df.groupby('sport')['clv'].agg(['mean', 'count', 'std']).round(2)
    market_clv = df.groupby('market_type')['clv'].agg(['mean', 'count', 'std']).round(2)
    
    return {
        "by_sport": sport_clv.to_dict(),
        "by_market": market_clv.to_dict()
    }

For more advanced bet tracking strategies, check out our guide on building EV scanners and arbitrage detection systems.

Frequently Asked Questions

Q: What's considered good CLV across different sports? A: CLV expectations vary by sport and market efficiency. NFL spreads: +1-2% is good. NBA totals: +1.5% is solid. Tennis futures: +5% might be break-even due to higher holds. College sports often offer higher CLV opportunities than pros.

Q: Should I include all sportsbooks in my closing line calculation? A: No. Use only sharp books like Pinnacle, BetCRIS, Heritage, and CRIS for closing lines. Recreational books like DraftKings often have stale or inefficient closing numbers that don't represent true market consensus.

Q: How many bets do I need for meaningful CLV analysis? A: CLV stabilizes around 100-200 bets per market type. You can track CLV from bet one, but don't make major strategy changes until you have sufficient sample size. Weekly CLV swings are mostly noise.

Q: Can positive CLV come from bad betting decisions? A: Yes. If you consistently bet early in soft markets that sharpen later, you might show positive CLV while still losing money due to poor game selection. CLV measures line value, not fundamental edge.

Q: How often should I update closing odds data? A: Update closing odds 2-6 hours after games end to ensure all books have settled. Some books update odds post-game, so immediate closing odds might not be final. Daily batch updates work well for most portfolios.

Build with the same data we use.

MoneyLine API powers BettingLab's edge calculations. Free tier, 1k credits/month.