BettingLab

Build Arbitrage Scanner Python MoneyLine API

Marcus Hale
Marcus Hale

Build Arbitrage Scanner Python MoneyLine API

Building an arbitrage scanner Python system is the holy grail for sharp bettors. While sportsbooks hate arb players, the math doesn't lie โ€” when Book A prices one side higher than Book B prices the opposite side, there's guaranteed profit regardless of outcome.

Most builders think arbitrage detection requires complex statistical models. Wrong. It's pure arithmetic. The challenge isn't the math โ€” it's getting clean, fast odds data across multiple sportsbooks and building a system that spots opportunities before they vanish.

Today we'll build a production-ready arbitrage scanner that monitors live odds, calculates implied probabilities, and alerts you to guaranteed profit opportunities. We'll use the MoneyLine API for real-time odds data across 20+ sportsbooks.

Understanding Arbitrage Math

Before we write code, let's nail the fundamentals. Sports arbitrage exists when the sum of implied probabilities across all outcomes is less than 100%.

For a two-way market (moneyline, spread, total):

Example: Lakers vs Warriors

Best odds: Lakers +150 (40%) + Warriors +120 (45.5%) = 85.5% total probability Arbitrage margin: 14.5%

That's guaranteed profit.

Setting Up the Arbitrage Scanner

Let's build a scanner that monitors multiple sports and markets simultaneously. We'll structure it as a class-based system for easy extension.

import requests
import time
import json
from typing import Dict, List, Tuple, Optional
from dataclasses import dataclass
from datetime import datetime, timedelta
import logging

@dataclass
class ArbitrageOpportunity:
    sport: str
    event: str
    market_type: str
    side_a: Dict
    side_b: Dict
    profit_margin: float
    stakes: Dict
    timestamp: datetime

class ArbitrageScanner:
    def __init__(self, api_key: str, min_profit_margin: float = 0.02):
        self.api_key = api_key
        self.base_url = "https://mlapi.bet"
        self.min_profit_margin = min_profit_margin
        self.session = requests.Session()
        self.session.headers.update({
            'Authorization': f'Bearer {api_key}',
            'Content-Type': 'application/json'
        })
        
        # Configure logging
        logging.basicConfig(
            level=logging.INFO,
            format='%(asctime)s - %(levelname)s - %(message)s'
        )
        self.logger = logging.getLogger(__name__)

    def decimal_to_implied_prob(self, odds: float) -> float:
        """Convert decimal odds to implied probability."""
        if odds <= 0:
            return 1.0  # Invalid odds
        return 1.0 / odds

    def american_to_decimal(self, american_odds: int) -> float:
        """Convert American odds to decimal odds."""
        if american_odds > 0:
            return (american_odds / 100) + 1
        else:
            return (100 / abs(american_odds)) + 1

    def calculate_optimal_stakes(self, prob_a: float, prob_b: float, 
                               bankroll: float = 1000) -> Tuple[float, float]:
        """Calculate optimal stake distribution for arbitrage."""
        total_prob = prob_a + prob_b
        stake_a = bankroll * (prob_a / total_prob)
        stake_b = bankroll * (prob_b / total_prob)
        return stake_a, stake_b

    def fetch_live_odds(self, sport: str = "nba") -> List[Dict]:
        """Fetch live odds data from MoneyLine API."""
        try:
            # Get upcoming events
            events_response = self.session.get(
                f"{self.base_url}/v1/events",
                params={
                    "sport": sport,
                    "status": "upcoming",
                    "limit": 50
                }
            )
            events_response.raise_for_status()
            events = events_response.json().get('events', [])
            
            # Get odds for each event
            all_odds = []
            for event in events[:10]:  # Limit for demo
                odds_response = self.session.get(
                    f"{self.base_url}/v1/odds",
                    params={
                        "event_id": event['id'],
                        "markets": "moneyline,spread,total"
                    }
                )
                if odds_response.status_code == 200:
                    odds_data = odds_response.json()
                    all_odds.append({
                        'event': event,
                        'odds': odds_data
                    })
                time.sleep(0.1)  # Rate limiting
            
            return all_odds
            
        except Exception as e:
            self.logger.error(f"Error fetching odds: {e}")
            return []

    def find_arbitrage_opportunities(self, odds_data: List[Dict]) -> List[ArbitrageOpportunity]:
        """Scan odds data for arbitrage opportunities."""
        opportunities = []
        
        for item in odds_data:
            event = item['event']
            odds = item['odds']
            
            # Check moneyline arbitrage
            ml_arb = self.check_moneyline_arbitrage(event, odds)
            if ml_arb:
                opportunities.append(ml_arb)
            
            # Check spread arbitrage  
            spread_arbs = self.check_spread_arbitrage(event, odds)
            opportunities.extend(spread_arbs)
            
            # Check total arbitrage
            total_arbs = self.check_total_arbitrage(event, odds)
            opportunities.extend(total_arbs)
        
        return opportunities

    def check_moneyline_arbitrage(self, event: Dict, odds_data: Dict) -> Optional[ArbitrageOpportunity]:
        """Check for moneyline arbitrage opportunities."""
        if 'moneyline' not in odds_data.get('markets', {}):
            return None
            
        ml_odds = odds_data['markets']['moneyline']
        books = {}
        
        # Collect odds from all books
        for book, book_odds in ml_odds.items():
            if 'home' in book_odds and 'away' in book_odds:
                home_decimal = self.american_to_decimal(book_odds['home'])
                away_decimal = self.american_to_decimal(book_odds['away'])
                
                books[book] = {
                    'home': {'odds': home_decimal, 'prob': self.decimal_to_implied_prob(home_decimal)},
                    'away': {'odds': away_decimal, 'prob': self.decimal_to_implied_prob(away_decimal)}
                }
        
        if len(books) < 2:
            return None
        
        # Find best odds for each side
        best_home = min(books.items(), key=lambda x: x[1]['home']['prob'])
        best_away = min(books.items(), key=lambda x: x[1]['away']['prob'])
        
        total_prob = best_home[1]['home']['prob'] + best_away[1]['away']['prob']
        
        if total_prob < (1 - self.min_profit_margin):
            profit_margin = 1 - total_prob
            stake_home, stake_away = self.calculate_optimal_stakes(
                best_home[1]['home']['prob'], 
                best_away[1]['away']['prob']
            )
            
            return ArbitrageOpportunity(
                sport=event.get('sport', 'unknown'),
                event=f"{event.get('away_team', 'Team A')} @ {event.get('home_team', 'Team B')}",
                market_type='moneyline',
                side_a={
                    'book': best_home[0],
                    'selection': event.get('home_team', 'Home'),
                    'odds': best_home[1]['home']['odds'],
                    'stake': stake_home
                },
                side_b={
                    'book': best_away[0], 
                    'selection': event.get('away_team', 'Away'),
                    'odds': best_away[1]['away']['odds'],
                    'stake': stake_away
                },
                profit_margin=profit_margin,
                stakes={'side_a': stake_home, 'side_b': stake_away},
                timestamp=datetime.now()
            )
        
        return None

    def check_spread_arbitrage(self, event: Dict, odds_data: Dict) -> List[ArbitrageOpportunity]:
        """Check for spread arbitrage opportunities."""
        opportunities = []
        
        if 'spread' not in odds_data.get('markets', {}):
            return opportunities
            
        spread_odds = odds_data['markets']['spread']
        
        # Group by spread value
        spreads_by_line = {}
        for book, book_odds in spread_odds.items():
            if 'home' in book_odds and 'away' in book_odds:
                home_spread = book_odds['home'].get('spread', 0)
                away_spread = book_odds['away'].get('spread', 0)
                
                # Use home spread as the key (away should be opposite)
                line_key = home_spread
                if line_key not in spreads_by_line:
                    spreads_by_line[line_key] = {}
                
                spreads_by_line[line_key][book] = {
                    'home': {
                        'odds': self.american_to_decimal(book_odds['home']['odds']),
                        'spread': home_spread
                    },
                    'away': {
                        'odds': self.american_to_decimal(book_odds['away']['odds']),  
                        'spread': away_spread
                    }
                }
        
        # Check each spread line for arbitrage
        for line, books in spreads_by_line.items():
            if len(books) < 2:
                continue
                
            # Find best odds for each side
            best_home = min(books.items(), 
                          key=lambda x: self.decimal_to_implied_prob(x[1]['home']['odds']))
            best_away = min(books.items(),
                          key=lambda x: self.decimal_to_implied_prob(x[1]['away']['odds']))
            
            prob_home = self.decimal_to_implied_prob(best_home[1]['home']['odds'])
            prob_away = self.decimal_to_implied_prob(best_away[1]['away']['odds'])
            total_prob = prob_home + prob_away
            
            if total_prob < (1 - self.min_profit_margin):
                profit_margin = 1 - total_prob
                stake_home, stake_away = self.calculate_optimal_stakes(prob_home, prob_away)
                
                opportunities.append(ArbitrageOpportunity(
                    sport=event.get('sport', 'unknown'),
                    event=f"{event.get('away_team', 'Team A')} @ {event.get('home_team', 'Team B')}",
                    market_type=f'spread_{line}',
                    side_a={
                        'book': best_home[0],
                        'selection': f"{event.get('home_team', 'Home')} {line:+}",
                        'odds': best_home[1]['home']['odds'],
                        'stake': stake_home
                    },
                    side_b={
                        'book': best_away[0],
                        'selection': f"{event.get('away_team', 'Away')} {-line:+}",
                        'odds': best_away[1]['away']['odds'],
                        'stake': stake_away
                    },
                    profit_margin=profit_margin,
                    stakes={'side_a': stake_home, 'side_b': stake_away},
                    timestamp=datetime.now()
                ))
        
        return opportunities

    def check_total_arbitrage(self, event: Dict, odds_data: Dict) -> List[ArbitrageOpportunity]:
        """Check for totals arbitrage opportunities."""
        opportunities = []
        
        if 'total' not in odds_data.get('markets', {}):
            return opportunities
        
        total_odds = odds_data['markets']['total']
        
        # Group by total value
        totals_by_line = {}
        for book, book_odds in total_odds.items():
            if 'over' in book_odds and 'under' in book_odds:
                total_line = book_odds['over'].get('total', 0)
                line_key = total_line
                
                if line_key not in totals_by_line:
                    totals_by_line[line_key] = {}
                
                totals_by_line[line_key][book] = {
                    'over': {
                        'odds': self.american_to_decimal(book_odds['over']['odds']),
                        'total': total_line
                    },
                    'under': {
                        'odds': self.american_to_decimal(book_odds['under']['odds']),
                        'total': total_line  
                    }
                }
        
        # Check each total line for arbitrage
        for line, books in totals_by_line.items():
            if len(books) < 2:
                continue
                
            best_over = min(books.items(),
                          key=lambda x: self.decimal_to_implied_prob(x[1]['over']['odds']))
            best_under = min(books.items(), 
                           key=lambda x: self.decimal_to_implied_prob(x[1]['under']['odds']))
            
            prob_over = self.decimal_to_implied_prob(best_over[1]['over']['odds'])
            prob_under = self.decimal_to_implied_prob(best_under[1]['under']['odds'])
            total_prob = prob_over + prob_under
            
            if total_prob < (1 - self.min_profit_margin):
                profit_margin = 1 - total_prob
                stake_over, stake_under = self.calculate_optimal_stakes(prob_over, prob_under)
                
                opportunities.append(ArbitrageOpportunity(
                    sport=event.get('sport', 'unknown'),
                    event=f"{event.get('away_team', 'Team A')} @ {event.get('home_team', 'Team B')}",
                    market_type=f'total_{line}',
                    side_a={
                        'book': best_over[0],
                        'selection': f"Over {line}",
                        'odds': best_over[1]['over']['odds'],
                        'stake': stake_over
                    },
                    side_b={
                        'book': best_under[0],
                        'selection': f"Under {line}",
                        'odds': best_under[1]['under']['odds'], 
                        'stake': stake_under
                    },
                    profit_margin=profit_margin,
                    stakes={'side_a': stake_over, 'side_b': stake_under},
                    timestamp=datetime.now()
                ))
        
        return opportunities

    def print_opportunity(self, opp: ArbitrageOpportunity):
        """Print formatted arbitrage opportunity."""
        print(f"\n๐ŸŽฏ ARBITRAGE OPPORTUNITY - {opp.profit_margin:.2%} profit")
        print(f"Sport: {opp.sport}")
        print(f"Event: {opp.event}")
        print(f"Market: {opp.market_type}")
        print(f"Side A: {opp.side_a['selection']} @ {opp.side_a['odds']:.2f} ({opp.side_a['book']}) - Stake: ${opp.side_a['stake']:.2f}")
        print(f"Side B: {opp.side_b['selection']} @ {opp.side_b['odds']:.2f} ({opp.side_b['book']}) - Stake: ${opp.side_b['stake']:.2f}")
        print(f"Total Stake: ${opp.side_a['stake'] + opp.side_b['stake']:.2f}")
        print(f"Guaranteed Profit: ${(opp.side_a['stake'] + opp.side_b['stake']) * opp.profit_margin:.2f}")

    def run_scanner(self, sports: List[str] = ["nba", "nfl", "mlb"], 
                   scan_interval: int = 30):
        """Run the arbitrage scanner continuously."""
        self.logger.info(f"Starting arbitrage scanner for {sports}")
        self.logger.info(f"Minimum profit margin: {self.min_profit_margin:.1%}")
        
        while True:
            try:
                total_opportunities = 0
                
                for sport in sports:
                    self.logger.info(f"Scanning {sport.upper()} for arbitrage...")
                    odds_data = self.fetch_live_odds(sport)
                    
                    if odds_data:
                        opportunities = self.find_arbitrage_opportunities(odds_data)
                        
                        for opp in opportunities:
                            self.print_opportunity(opp)
                            total_opportunities += 1
                
                if total_opportunities == 0:
                    self.logger.info("No arbitrage opportunities found this scan")
                else:
                    self.logger.info(f"Found {total_opportunities} arbitrage opportunities!")
                
                self.logger.info(f"Next scan in {scan_interval} seconds...")
                time.sleep(scan_interval)
                
            except KeyboardInterrupt:
                self.logger.info("Scanner stopped by user")
                break
            except Exception as e:
                self.logger.error(f"Scanner error: {e}")
                time.sleep(10)  # Wait before retrying

# Usage example
if __name__ == "__main__":
    # Initialize scanner with your MoneyLine API key
    scanner = ArbitrageScanner(
        api_key="your_api_key_here",
        min_profit_margin=0.01  # 1% minimum profit
    )
    
    # Run scanner for NBA, NFL, and MLB
    scanner.run_scanner(
        sports=["nba", "nfl", "mlb"],
        scan_interval=60  # Scan every minute
    )

Running Your First Arbitrage Scan

The scanner above is production-ready but let's walk through a simple test to verify everything works:

# Quick test script
scanner = ArbitrageScanner("your_api_key_here", min_profit_margin=0.005)

# Single scan for NBA
odds_data = scanner.fetch_live_odds("nba")
print(f"Fetched odds for {len(odds_data)} NBA games")

# Find opportunities
opportunities = scanner.find_arbitrage_opportunities(odds_data)
print(f"Found {len(opportunities)} arbitrage opportunities")

# Print each opportunity
for opp in opportunities:
    scanner.print_opportunity(opp)

This will fetch current NBA odds, scan for arbitrage across moneylines, spreads, and totals, and print any opportunities above your minimum profit threshold.

Optimizing Scanner Performance

Real-time arbitrage detection requires speed. Opportunities vanish in seconds once other bots spot them. Here's how to optimize:

Parallel API Calls

import asyncio
import aiohttp

class AsyncArbitrageScanner(ArbitrageScanner):
    async def fetch_live_odds_async(self, sports: List[str]) -> Dict:
        """Fetch odds for multiple sports simultaneously."""
        async with aiohttp.ClientSession() as session:
            tasks = []
            for sport in sports:
                tasks.append(self.fetch_sport_odds(session, sport))
            
            results = await asyncio.gather(*tasks)
            return dict(zip(sports, results))
    
    async def fetch_sport_odds(self, session: aiohttp.ClientSession, sport: str):
        """Fetch odds for a single sport."""
        headers = {
            'Authorization': f'Bearer {self.api_key}',
            'Content-Type': 'application/json'
        }
        
        async with session.get(
            f"{self.base_url}/v1/events",
            params={"sport": sport, "status": "upcoming", "limit": 20},
            headers=headers
        ) as response:
            events = await response.json()
            
            odds_tasks = []
            for event in events.get('events', [])[:5]:
                odds_tasks.append(
                    self.fetch_event_odds(session, event['id'])
                )
            
            odds_results = await asyncio.gather(*odds_tasks)
            return list(zip(events.get('events', [])[:5], odds_results))

Smart Filtering

Not all games have arbitrage potential. Focus on:

def filter_high_potential_events(self, events: List[Dict]) -> List[Dict]:
    """Filter events most likely to have arbitrage."""
    filtered = []
    now = datetime.now()
    
    for event in events:
        start_time = datetime.fromisoformat(event.get('start_time', ''))
        hours_until_start = (start_time - now).total_seconds() / 3600
        
        # Focus on games starting within 24 hours
        if 0.5 <= hours_until_start <= 24:
            filtered.append(event)
    
    return filtered

Advanced Features and Alerts

Discord Notifications

Send arbitrage alerts directly to Discord:

import discord
from discord.ext import commands

class ArbitrageBot(commands.Bot):
    def __init__(self, scanner: ArbitrageScanner):
        intents = discord.Intents.default()
        super().__init__(command_prefix='!', intents=intents)
        self.scanner = scanner
        
    async def send_arbitrage_alert(self, opportunity: ArbitrageOpportunity, 
                                 channel_id: int):
        """Send formatted arbitrage alert to Discord channel."""
        channel = self.get_channel(channel_id)
        if not channel:
            return
            
        embed = discord.Embed(
            title="๐ŸŽฏ Arbitrage Opportunity",
            color=0x00ff00,
            timestamp=opportunity.timestamp
        )
        
        embed.add_field(
            name="Event",
            value=f"{opportunity.event} ({opportunity.sport})",
            inline=False
        )
        
        embed.add_field(
            name="Market",
            value=opportunity.market_type,
            inline=True
        )
        
        embed.add_field(
            name="Profit Margin", 
            value=f"{opportunity.profit_margin:.2%}",
            inline=True
        )
        
        embed.add_field(
            name="Side A",
            value=f"{opportunity.side_a['selection']}\n{opportunity.side_a['book']}: {opportunity.side_a['odds']:.2f}\nStake: ${opportunity.side_a['stake']:.2f}",
            inline=True
        )
        
        embed.add_field(
            name="Side B", 
            value=f"{opportunity.side_b['selection']}\n{opportunity.side_b['book']}: {opportunity.side_b['odds']:.2f}\nStake: ${opportunity.side_b['stake']:.2f}",
            inline=True
        )
        
        total_stake = opportunity.side_a['stake'] + opportunity.side_b['stake']
        guaranteed_profit = total_stake * opportunity.profit_margin
        
        embed.add_field(
            name="Profit",
            value=f"Total Stake: ${total_stake:.2f}\nGuaranteed Profit: ${guaranteed_profit:.2f}",
            inline=False
        )
        
        await channel.send(embed=embed)

Bankroll Management

Smart stake sizing based on Kelly criterion:

def kelly_optimal_stakes(self, prob_a: float, prob_b: float, 
                        odds_a: float, odds_b: float, 
                        bankroll: float, kelly_fraction: float = 0.25) -> Tuple[float, float]:
    """Calculate Kelly-optimal stakes for arbitrage."""
    # Expected value for each side
    ev_a = (odds_a - 1) * prob_a - (1 - prob_a) 
    ev_b = (odds_b - 1) * prob_b - (1 - prob_b)
    
    # Kelly fractions
    kelly_a = ev_a / (odds_a - 1) if odds_a > 1 else 0
    kelly_b = ev_b / (odds_b - 1) if odds_b > 1 else 0
    
    # Apply fractional Kelly for safety
    stake_a = bankroll * kelly_a * kelly_fraction
    stake_b = bankroll * kelly_b * kelly_fraction
    
    return max(0, stake_a), max(0, stake_b)

Monitoring and Risk Management

Successful arbitrage requires discipline and risk management:

Position Tracking

class PositionTracker:
    def __init__(self):
        self.positions = []
        
    def add_position(self, opportunity: ArbitrageOpportunity, 
                    bet_ids: Dict[str, str]):
        """Track a placed arbitrage position."""
        position = {
            'opportunity': opportunity,
            'bet_ids': bet_ids,
            'placed_at': datetime.now(),
            'status': 'active'
        }
        self.positions.append(position)
        
    def calculate_pnl(self) -> float:
        """Calculate total P&L across all positions."""
        total_pnl = 0
        for position in self.positions:
            if position['status'] == 'settled':
                # Calculate actual profit/loss
                pass
        return total_pnl

Account Limits Detection

Track bet rejections and adjust stakes:

def adjust_for_limits(self, stake: float, book: str, 
                     limit_history: Dict) -> float:
    """Adjust stake based on historical bet limits."""
    if book in limit_history:
        max_successful = limit_history[book].get('max_stake', stake)
        return min(stake, max_successful * 0.8)  # 20% buffer
    return stake

Frequently Asked Questions

How fast do arbitrage opportunities disappear?

Most arbitrage opportunities last 30 seconds to 2 minutes in popular markets. Sharp books adjust lines quickly when they spot discrepancies. Your scanner needs to run continuously with sub-minute refresh rates to catch opportunities.

What profit margins are realistic for arbitrage betting?

Expect 0.5-3% profit margins on most opportunities. Margins above 5% are rare and usually indicate data errors or books with slow line adjustments. Don't chase huge margins โ€” consistent small profits compound faster than occasional big scores.

Which sportsbooks are best for arbitrage betting?

You need accounts at books with different risk profiles. Sharp books (Pinnacle, Bet365) often have one side of an arbitrage, while recreational books (DraftKings, FanDuel) have the other. Maintain accounts at 8-10 books minimum for consistent opportunities.

How much bankroll do I need for arbitrage betting?

Start with $5,000-$10,000 minimum across all books. Smaller bankrolls face two problems: stakes too small for meaningful profit, and inability to capitalize on multiple simultaneous opportunities. Most profitable arbitrage players operate with $50,000+ across books.

What are the main risks in arbitrage betting?

The biggest risks are: bet cancellations (one side gets voided), account limitations (books restrict your betting), and execution delays (odds change between placing bets). Never assume arbitrage is "risk-free" โ€” it's lower risk, not no risk.

This arbitrage scanner gives you the foundation for systematic profit from sportsbook inefficiencies. The MoneyLine API provides the fast, accurate odds data you need to spot opportunities before they vanish. Remember โ€” in arbitrage betting, speed and discipline matter more than complex models. Build simple, fast, reliable systems that execute consistently.

For more advanced betting strategies, check out our guide on EV betting and explore building steam move detection systems for additional edge opportunities.

Build with the same data we use.

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