BettingLab

Build Steam Move Detection System with Sports Odds API

Marcus Hale
Marcus Hale

Sharp money moves fast. By the time you see a 3-point line move on ESPN, the value is gone. Professional bettors need steam move detection systems that catch these movements in real-time, identifying when coordinated betting action signals an information edge.

Building a steam move detection system requires tracking odds movements across 100+ sportsbooks simultaneously, filtering noise from signal, and alerting when movement patterns indicate sharp action. The math isn't trivial—you need to distinguish between random variance and coordinated betting that suggests someone knows something the market doesn't.

Let's build a production-ready steam detection system that processes live odds feeds, calculates movement velocity, and identifies the patterns that separate recreational line drift from professional betting signals.

Understanding Steam Move Mathematics

Steam moves aren't just large line movements—they're specific patterns of coordinated betting that indicate sharp money. The key metrics we track:

Movement Velocity: Rate of line change over time intervals Cross-Book Correlation: How many books move simultaneously
Volume-Weighted Movement: Price changes relative to betting limits Reverse Line Movement: When lines move against public betting percentages

The mathematical foundation starts with measuring odds movement velocity across time windows:

def calculate_movement_velocity(price_history, time_window_minutes=15):
    """Calculate odds movement velocity over specified time window"""
    current_time = datetime.now()
    window_start = current_time - timedelta(minutes=time_window_minutes)
    
    relevant_prices = [
        p for p in price_history 
        if p['timestamp'] >= window_start
    ]
    
    if len(relevant_prices) < 2:
        return 0
    
    initial_price = relevant_prices[0]['price']
    final_price = relevant_prices[-1]['price']
    time_elapsed = (relevant_prices[-1]['timestamp'] - relevant_prices[0]['timestamp']).seconds / 60
    
    # Convert American odds to decimal for velocity calculation
    initial_decimal = american_to_decimal(initial_price)
    final_decimal = american_to_decimal(final_price)
    
    movement_magnitude = abs(final_decimal - initial_decimal)
    velocity = movement_magnitude / time_elapsed if time_elapsed > 0 else 0
    
    return velocity

Cross-book correlation measures how many books move in the same direction within a time window. Sharp action typically triggers movements across 8-12 books within minutes:

def calculate_cross_book_correlation(book_movements, correlation_threshold=0.7):
    """Identify correlated movements across multiple books"""
    movement_vectors = []
    
    for book, movements in book_movements.items():
        if len(movements) >= 2:
            direction = 1 if movements[-1]['price'] > movements[0]['price'] else -1
            magnitude = abs(movements[-1]['price'] - movements[0]['price'])
            movement_vectors.append((direction, magnitude))
    
    if len(movement_vectors) < 3:
        return False, 0
    
    # Calculate correlation of movement directions
    directions = [m[0] for m in movement_vectors]
    same_direction_count = max(directions.count(1), directions.count(-1))
    correlation = same_direction_count / len(directions)
    
    return correlation >= correlation_threshold, correlation

Building the Real-Time Detection Engine

Our steam detection system monitors live odds feeds from the MoneyLine API /v1/odds endpoint, maintaining price history for each book and calculating movement metrics continuously.

The core detection engine processes incoming odds updates and evaluates whether movement patterns indicate steam:

import asyncio
import aiohttp
from datetime import datetime, timedelta
from collections import defaultdict, deque

class SteamDetector:
    def __init__(self, api_key, lookback_minutes=30):
        self.api_key = api_key
        self.lookback_minutes = lookback_minutes
        self.price_history = defaultdict(lambda: deque(maxlen=1000))
        self.steam_alerts = []
        
    async def fetch_live_odds(self, event_id):
        """Fetch current odds for an event from MoneyLine API"""
        url = f"https://mlapi.bet/v1/odds"
        params = {
            'event_id': event_id,
            'include_all_books': True
        }
        headers = {'Authorization': f'Bearer {self.api_key}'}
        
        async with aiohttp.ClientSession() as session:
            async with session.get(url, params=params, headers=headers) as response:
                return await response.json()
    
    def update_price_history(self, event_id, odds_data):
        """Update price history with new odds data"""
        timestamp = datetime.now()
        
        for book_data in odds_data.get('books', []):
            book_name = book_data['name']
            
            for market in book_data.get('markets', []):
                if market['type'] == 'spread':  # Focus on spread markets for steam detection
                    for outcome in market['outcomes']:
                        key = f"{event_id}:{book_name}:{market['type']}:{outcome['name']}"
                        
                        self.price_history[key].append({
                            'timestamp': timestamp,
                            'price': outcome['price'],
                            'point': outcome.get('point', 0)
                        })
    
    def detect_steam_movement(self, event_id, market_type='spread'):
        """Analyze recent price movements for steam patterns"""
        current_time = datetime.now()
        cutoff_time = current_time - timedelta(minutes=self.lookback_minutes)
        
        # Group price histories by outcome
        outcome_movements = defaultdict(list)
        
        for key, history in self.price_history.items():
            if f"{event_id}:" in key and f":{market_type}:" in key:
                recent_prices = [
                    p for p in history 
                    if p['timestamp'] >= cutoff_time
                ]
                
                if len(recent_prices) >= 3:  # Need sufficient data points
                    parts = key.split(':')
                    outcome = parts[-1]
                    book = parts[1]
                    
                    outcome_movements[outcome].append({
                        'book': book,
                        'prices': recent_prices,
                        'velocity': calculate_movement_velocity(recent_prices)
                    })
        
        # Analyze each outcome for steam patterns
        steam_signals = []
        
        for outcome, movements in outcome_movements.items():
            if len(movements) < 5:  # Need movements across multiple books
                continue
            
            # Calculate aggregate movement metrics
            velocities = [m['velocity'] for m in movements if m['velocity'] > 0]
            avg_velocity = sum(velocities) / len(velocities) if velocities else 0
            
            # Check for synchronized movements
            movement_directions = []
            for movement in movements:
                if len(movement['prices']) >= 2:
                    initial = movement['prices'][0]['price']
                    final = movement['prices'][-1]['price']
                    direction = 1 if final > initial else -1
                    movement_directions.append(direction)
            
            if len(movement_directions) >= 5:
                same_direction = max(movement_directions.count(1), movement_directions.count(-1))
                sync_ratio = same_direction / len(movement_directions)
                
                # Steam detection criteria
                if (avg_velocity > 0.15 and  # Sufficient movement speed
                    sync_ratio > 0.7 and    # High synchronization
                    len(movements) >= 6):    # Multiple books moving
                    
                    steam_signals.append({
                        'outcome': outcome,
                        'velocity': avg_velocity,
                        'sync_ratio': sync_ratio,
                        'book_count': len(movements),
                        'timestamp': current_time
                    })
        
        return steam_signals

Advanced Pattern Recognition

Professional steam detection goes beyond simple line movements. We need to identify specific patterns that indicate different types of sharp action:

Reverse Line Movement: When lines move opposite to public betting percentages, indicating sharp money on the unpopular side. This requires correlating line movements with betting handle data.

Closing Line Value: Steam that persists until game time, indicating sustained sharp interest rather than temporary arbitrage.

Book Hierarchy Movements: Professional books like Pinnacle and Circa typically move first, followed by recreational books. Tracking this sequence helps validate steam authenticity.

def analyze_reverse_line_movement(self, event_id, public_betting_data):
    """Detect reverse line movement against public betting trends"""
    steam_signals = self.detect_steam_movement(event_id)
    reverse_movements = []
    
    for signal in steam_signals:
        outcome = signal['outcome']
        
        # Get public betting percentage for this outcome
        public_pct = public_betting_data.get(outcome, 50)  # Default to 50% if unknown
        
        # Determine if line moved against public
        if len(self.price_history) > 0:
            recent_movement = self.get_recent_movement_direction(event_id, outcome)
            
            # If public is on this side but line moved against it
            if ((public_pct > 60 and recent_movement < 0) or  # Public backing, line dropping
                (public_pct < 40 and recent_movement > 0)):   # Public fading, line rising
                
                reverse_movements.append({
                    **signal,
                    'public_percentage': public_pct,
                    'reverse_indicator': True,
                    'confidence': min(abs(public_pct - 50) / 10, 1.0)  # Higher confidence with more extreme public %
                })
    
    return reverse_movements

def track_book_hierarchy_movements(self, event_id, sharp_books=['Pinnacle', 'Circa Sports']):
    """Analyze movement patterns across book hierarchy"""
    all_movements = defaultdict(list)
    
    # Collect recent movements by book type
    for key, history in self.price_history.items():
        if f"{event_id}:" in key:
            parts = key.split(':')
            book = parts[1]
            outcome = parts[-1]
            
            if len(history) >= 2:
                recent_change = history[-1]['price'] - history[-2]['price']
                timestamp = history[-1]['timestamp']
                
                book_type = 'sharp' if book in sharp_books else 'recreational'
                all_movements[outcome].append({
                    'book': book,
                    'book_type': book_type,
                    'change': recent_change,
                    'timestamp': timestamp
                })
    
    # Identify patterns where sharp books moved first
    hierarchy_signals = []
    
    for outcome, movements in all_movements.items():
        sharp_moves = [m for m in movements if m['book_type'] == 'sharp']
        rec_moves = [m for m in movements if m['book_type'] == 'recreational']
        
        if len(sharp_moves) >= 1 and len(rec_moves) >= 3:
            # Check if sharp books moved before recreational books
            earliest_sharp = min(sharp_moves, key=lambda x: x['timestamp'])
            avg_rec_time = sum([m['timestamp'].timestamp() for m in rec_moves]) / len(rec_moves)
            
            if earliest_sharp['timestamp'].timestamp() < avg_rec_time - 300:  # 5 minutes earlier
                hierarchy_signals.append({
                    'outcome': outcome,
                    'sharp_book_leader': earliest_sharp['book'],
                    'time_advantage_seconds': avg_rec_time - earliest_sharp['timestamp'].timestamp(),
                    'following_books': len(rec_moves),
                    'pattern_strength': min(len(rec_moves) / 5, 1.0)
                })
    
    return hierarchy_signals

Historical Backtesting Framework

To validate our steam detection algorithms, we need robust backtesting against historical data. This helps calibrate sensitivity thresholds and measure prediction accuracy.

The backtesting framework processes historical odds data from the MoneyLine API's /v1/odds endpoint with time-series parameters, simulating real-time detection:

class SteamBacktester:
    def __init__(self, api_key):
        self.api_key = api_key
        self.detector = SteamDetector(api_key)
        
    async def fetch_historical_odds(self, event_id, start_date, end_date):
        """Fetch historical odds data for backtesting"""
        url = f"https://mlapi.bet/v1/odds"
        params = {
            'event_id': event_id,
            'start_date': start_date.isoformat(),
            'end_date': end_date.isoformat(),
            'include_all_books': True,
            'granularity': 'minute'  # Minute-by-minute data for steam detection
        }
        headers = {'Authorization': f'Bearer {self.api_key}'}
        
        async with aiohttp.ClientSession() as session:
            async with session.get(url, params=params, headers=headers) as response:
                return await response.json()
    
    def simulate_real_time_detection(self, historical_data):
        """Simulate steam detection as if running in real-time"""
        results = []
        
        # Sort all price updates chronologically
        all_updates = []
        for timestamp_str, odds_snapshot in historical_data.items():
            timestamp = datetime.fromisoformat(timestamp_str)
            all_updates.append((timestamp, odds_snapshot))
        
        all_updates.sort(key=lambda x: x[0])
        
        # Process updates sequentially to simulate real-time
        for timestamp, odds_data in all_updates:
            # Update detector's price history
            event_id = odds_data.get('event_id')
            self.detector.update_price_history(event_id, odds_data)
            
            # Run steam detection
            steam_signals = self.detector.detect_steam_movement(event_id)
            
            if steam_signals:
                results.append({
                    'timestamp': timestamp,
                    'signals': steam_signals,
                    'odds_snapshot': odds_data
                })
        
        return results
    
    def calculate_prediction_accuracy(self, detection_results, actual_outcomes):
        """Measure how often steam detection predicted correct outcomes"""
        correct_predictions = 0
        total_predictions = 0
        
        for result in detection_results:
            for signal in result['signals']:
                total_predictions += 1
                outcome = signal['outcome']
                
                # Check if steamed side was correct
                actual_winner = actual_outcomes.get(result['timestamp'].date())
                if actual_winner and outcome == actual_winner:
                    correct_predictions += 1
        
        accuracy = correct_predictions / total_predictions if total_predictions > 0 else 0
        return {
            'accuracy': accuracy,
            'total_predictions': total_predictions,
            'correct_predictions': correct_predictions
        }

Production Deployment Architecture

A production steam detection system needs to handle high-frequency data streams, maintain low latency, and scale across thousands of concurrent events. Here's the architecture that handles real-world requirements:

WebSocket Connections: Maintain persistent connections to the MoneyLine API's real-time odds feed for sub-second latency.

Redis Price Cache: Store recent price history in Redis for fast lookups and cross-process sharing.

Message Queue: Use Redis Pub/Sub or AWS SQS for distributing steam alerts to downstream systems.

The deployment handles the complexity of processing thousands of odds updates per second while maintaining detection accuracy. Memory management becomes critical—we use sliding windows and periodic cleanup to prevent unbounded growth.

For serious applications, consider running multiple detector instances across different geographic regions to minimize latency to odds sources. The build section covers additional scaling considerations for high-frequency betting systems.

FAQ

How accurate is steam detection compared to manual line watching?

Automated steam detection typically achieves 70-85% accuracy when properly calibrated, compared to 60-70% for manual observation. The automation advantage comes from processing more books simultaneously and detecting subtle patterns humans miss. However, experienced line watchers still excel at contextual judgment—knowing when a steam move represents sharp injury information versus routine market making.

Which sportsbooks provide the most reliable steam signals?

Sharp books like Pinnacle, Circa Sports, and CRIS provide the highest quality steam signals because their customer base includes more professional bettors. Recreational books like DraftKings and FanDuel often lag by 5-10 minutes, making them better for confirmation rather than initial detection. The key is tracking the sequence—sharp books move first, recreational books follow.

What minimum bankroll is needed for steam move betting?

Steam move betting requires significant bankroll depth due to high variance and rapid-fire betting. Professional steam bettors typically operate with 500-1000 unit bankrolls and bet 1-2% per play. With average steam bets around +100 to +120 odds, you need sufficient capital to weather 10-15 consecutive losses, which can occur even with positive expected value.

How do you filter false steam signals from real sharp action?

False signals usually show one or more of these characteristics: movement on only 2-3 books, no reverse line movement pattern, occurs during low-liquidity periods, or reverses within 30 minutes. Real steam typically involves 6+ books moving within 15 minutes, persists for at least an hour, and often accelerates rather than reverses. Volume analysis helps—true steam shows increasing bet sizes, not just price movement.

Can steam detection work for player props and alternative markets?

Steam detection works best on high-liquidity markets like spreads, totals, and moneylines where multiple books offer consistent pricing. Player props have limited steam potential due to lower limits and fewer participating books. Alternative spreads and totals can show steam, but require adjusting sensitivity thresholds since these markets naturally have higher variance and wider spreads between books.

Build with the same data we use.

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