DEV Community

Michael Garcia
Michael Garcia

Posted on

Python Automation That Earned $3,500: Building a Sports Betting Analysis System

Python Automation That Earned $3,500: Building a Sports Betting Analysis System

My bankroll started at $2,000. Six weeks later, the system had turned it into $5,500. Not from luck — from a Python script running at 6 AM every morning, scanning 47 games, filtering through four verification layers, and flagging exactly the spots where the bookmakers had mispriced the line.

This isn't a "get rich quick" story. The system lost 38% of its bets. It won because the winning bets were sized correctly and the edge was real. That distinction — between winning percentage and expected value — is exactly what the automation enforced that a human brain simply won't, consistently, at scale.

Here's how it was built.

The Problem with Manual Sports Betting Analysis

Anyone who's tried to beat sports betting manually runs into the same wall. You identify a team with a strong recent form, decent motivation, and favorable matchup. You bet. You lose. You look back and realize you missed that their starting quarterback was listed as questionable on Wednesday's injury report, which you would have seen if you'd checked four different websites instead of two.

The information exists. The edge exists. The human execution doesn't.

The specific failure modes I kept hitting:

  • Missing injury report updates that dropped between 4 PM and 7 PM
  • Betting on teams with strong form but ignoring home/away splits
  • Sizing flat across bets when the edge varied massively
  • No record-keeping that actually fed back into the model

The solution was a pipeline: pull odds every morning, score each game across four analytical layers, filter by minimum edge threshold, calculate Kelly-sized stakes, and write everything to a database with outcomes tracked automatically.

System Architecture Overview

Before the code: here's what the full pipeline looks like.

Odds API → Game Normalization → 4-Layer Scoring → EV Filter → Kelly Sizing → Output
Enter fullscreen mode Exit fullscreen mode

The four layers are:

  1. Injury Report — weighted scoring based on position and injury severity
  2. Motivation Index — playoff implications, rivalry games, revenge spots
  3. Form Analysis — rolling 5-game window with recency weighting
  4. Head-to-Head — last 8 meetings, home/away filtered

Each layer produces a score between -1.0 and 1.0. A composite score above 0.3 in favor of one team, combined with an implied probability gap of at least 4% against the market line, triggers a bet flag.

Layer 1: Odds API Integration and Game Scanning

The system uses The Odds API (free tier gets you 500 requests/month, which is tight but workable for one sport). Here's the actual scanner that runs at 6:15 AM daily via cron:

import requests
import json
import sqlite3
from datetime import datetime, timezone
from typing import Optional
import time

API_KEY = "your_api_key_here"
BASE_URL = "https://api.the-odds-api.com/v4"

SPORTS = ["americanfootball_nfl", "basketball_nba"]
BOOKMAKERS = ["fanduel", "draftkings", "betmgm", "pinnacle"]

def fetch_odds(sport: str, regions: str = "us", markets: str = "h2h") -> Optional[list]:
    """
    Fetch current odds for a sport. Returns list of game dicts or None on failure.
    Pinnacle is included as the sharp line reference — their odds are the market signal.
    """
    url = f"{BASE_URL}/sports/{sport}/odds"
    params = {
        "apiKey": API_KEY,
        "regions": regions,
        "markets": markets,
        "bookmakers": ",".join(BOOKMAKERS),
        "oddsFormat": "decimal",
    }

    try:
        resp = requests.get(url, params=params, timeout=10)
        resp.raise_for_status()

        # Log remaining API calls — we burn through these fast
        remaining = resp.headers.get("x-requests-remaining", "unknown")
        used = resp.headers.get("x-requests-used", "unknown")
        print(f"[{datetime.now().isoformat()}] API calls used: {used} | remaining: {remaining}")

        return resp.json()

    except requests.exceptions.HTTPError as e:
        if resp.status_code == 422:
            print(f"Sport {sport} not currently available: {e}")
        elif resp.status_code == 429:
            print("Rate limit hit — sleeping 60s")
            time.sleep(60)
        else:
            print(f"HTTP error {resp.status_code}: {e}")
        return None

    except requests.exceptions.ConnectionError:
        print("Connection failed. Check network.")
        return None


def american_to_decimal(american: int) -> float:
    """Convert American odds to decimal for uniform probability math."""
    if american > 0:
        return (american / 100) + 1
    else:
        return (100 / abs(american)) + 1


def extract_probabilities(game: dict) -> dict:
    """
    Pull implied probabilities from each bookmaker.
    Returns dict keyed by bookmaker with home/away implied probs.
    Pinnacle's number is used as the 'true' market reference.
    """
    result = {
        "game_id": game["id"],
        "home_team": game["home_team"],
        "away_team": game["away_team"],
        "commence_time": game["commence_time"],
        "bookmakers": {}
    }

    for bm in game.get("bookmakers", []):
        bm_key = bm["key"]
        for market in bm.get("markets", []):
            if market["key"] != "h2h":
                continue

            outcomes = {o["name"]: o["price"] for o in market["outcomes"]}
            home_dec = outcomes.get(game["home_team"], None)
            away_dec = outcomes.get(game["away_team"], None)

            if not home_dec or not away_dec:
                continue

            # Strip the overround to get implied probability
            home_implied = 1 / home_dec
            away_implied = 1 / away_dec
            overround = home_implied + away_implied

            result["bookmakers"][bm_key] = {
                "home_implied": home_implied / overround,
                "away_implied": away_implied / overround,
                "home_decimal": home_dec,
                "away_decimal": away_dec,
                "overround": overround
            }

    return result


def scan_all_games() -> list:
    """Main scanning function. Returns all games with normalized probability data."""
    all_games = []

    for sport in SPORTS:
        raw = fetch_odds(sport)
        if not raw:
            continue

        for game in raw:
            # Only process games within the next 36 hours
            game_time = datetime.fromisoformat(
                game["commence_time"].replace("Z", "+00:00")
            )
            hours_until = (game_time - datetime.now(timezone.utc)).total_seconds() / 3600

            if hours_until < 1 or hours_until > 36:
                continue

            prob_data = extract_probabilities(game)
            prob_data["sport"] = sport
            prob_data["hours_until_game"] = round(hours_until, 1)
            all_games.append(prob_data)

    print(f"Scanned {len(all_games)} upcoming games")
    return all_games
Enter fullscreen mode Exit fullscreen mode

When this ran on a typical NFL Sunday morning, the output looked like this:

[2024-01-14T06:15:23.441] API calls used: 12 | remaining: 488
[2024-01-14T06:15:24.892] API calls used: 13 | remaining: 487
Scanned 14 upcoming games
Enter fullscreen mode Exit fullscreen mode

Layer 4: The Probability Filtering and EV Calculation

The four scoring layers all funnel into one final function that calculates expected value. Here's where the actual bet selection happens:


python
from dataclasses import dataclass
from typing import Literal

@dataclass
class BetCandidate:
    game_id: str
    sport: str
    home_team: str
    away_team: str
    side: Literal["home", "away"]
    our_probability: float
    market_probability: float
    best_decimal_odds: float
    bookmaker: str
    edge: float
    kelly_fraction: float
    recommended_stake: float
    composite_score: float
    hours_until_game: float


def calculate_composite_score(
    injury_score: float,
    motivation_score: float,
    form_score: float,
    h2h_score: float,
    weights: dict = None
) -> float:
    """
    Combine four layer scores into single composite.
    Weights are tunable — these were calibrated after 200 historical games.
    Scores range from -1.0 (strongly against) to +1.0 (strongly for).
    """
    if weights is None:
        weights = {
            "injury": 0.35,      # Highest weight — injuries are often mispriced
            "motivation": 0.20,
            "form": 0.25,
            "h2h": 0.20
        }

    composite = (
        injury_score * weights["injury"] +
        motivation_score * weights["motivation"] +
        form_score * weights["form"] +
        h2h_score * weights["h2h"]
    )
    return round(max(-1.0, min(1.0, composite)), 4)


def score_to_probability_adjustment(composite: float) -> float:
    """
    Convert composite score to a probability adjustment.
    A composite of +0.5 shifts our fair probability by ~8% above market.
    Calibrated empirically — don't trust theoretical adjustments here.
    """
    # Sigmoid-scaled adjustment, capped at ±15%
    import math
    raw_adjustment = 0.15 * (2 / (1 + math.exp(-3 * composite)) - 1)
    return round(raw_adjustment, 4)


def calculate_kelly(our_prob: float, decimal_odds: float, fraction: float = 0.25) -> float:
    """
    Fractional Kelly criterion. Full Kelly is too aggressive for correlated sports bets.
    Using quarter Kelly here — still grows bankroll but won't blow up on bad runs.

    Formula: f = (bp - q) / b
    Where b = decimal_odds - 1, p = our probability, q = 1 - p
    """
    b = decimal_odds - 1
    p = our_prob
    q = 1 - p

    full_kelly = (b * p - q) / b

    if full_kelly <= 0:
        return 0.0  # Negative edge — no bet

    return round(full_kelly * fraction, 4)


def find_best_odds(game_probs: dict, side: str) -> tuple[float, str]:
    """Find the bookmaker offering the best odds for our side."""
    best_decimal = 0.0
    best_bm = ""

    side_key = f"{side}_decimal"

    for bm_name, bm_data in game_probs["bookmakers"].items():
        if bm_data.get(side_key, 0) > best_decimal:
            best_decimal = bm_data[side_key]
            best_bm = bm_name

    return best_decimal, best_bm


def evaluate_game(
    game_probs: dict,
    injury_scores: tuple[float, float],    # (home, away)
    motivation_scores: tuple[float, float],
    form_scores: tuple[float, float],
    h2h_scores: tuple[float, float],
    bankroll: float,
    min_edge: float = 0.04,
    min_composite: float = 0.25
) -> list[BetCandidate]:
    """
    Full evaluation for one game. Returns list of BetCandidates (usually 0 or 1).
    A game rarely presents edge on both sides — if it does, something's wrong with your data.
    """
    candidates = []
    pinnacle_data = game_probs["bookmakers"].get("pinnacle")

    if not pinnacle_data:
        # No sharp line available — skip. Recreational books without Pinnacle
        # are too easy to have stale lines and too hard to trust for reference.
        print(f"No Pinnacle line for {game_probs['home_team']} vs {game_probs['away_team']} — skipping")
        return []

    for side_idx, side in enumerate(["home", "away"]):
        # Build composite score from perspective of this side
        composite = calculate_composite_score(
            injury_score=injury_scores[side_idx],
            motivation_score=motivation_scores[side_idx],
            form_score=form_scores[side_idx],
            h2h_score=h2h_scores[side_idx]
        )

        if abs(composite) < min_composite:
            continue  # Not enough signal either direction

        # Start with Pinnacle's implied probability as the market baseline
        market_prob = pinnacle_data[f"{side}_implied"]

        # Adjust based on our four-layer analysis
        adjustment = score_to_probability_adjustment(composite)
        our_prob = max(0.05, min(0.95, market_prob + adjustment))

        # Find best available odds across all bookmakers
        best_decimal, best_bm = find_best_odds(game_probs, side)

        if best_decimal <= 1.0:
            continue

        # Calculate edge: how much better are our odds than our model says they should be?
        fair_decimal = 1 / our_prob
        edge = (best_decimal - fair_decimal) / fair_decimal

        if edge < min_edge:
            continue  # Below threshold — not worth the variance

        kelly_frac = calculate_kelly(our_prob, best_decimal)
        if kelly_frac <= 0:
            continue

        stake = round(bankroll * kelly_frac, 2)
        stake = max(10.0, min(stake, bankroll * 0.05))  # Hard cap at 5% of bankroll

        candidates.append(BetCandidate(
            game_id=game_probs["game_id"],
            sport=game_probs["sport"],
            home_team=game_probs["home_team"],
            away_team=game_probs["away_team"],
            side=side,
            our_probability=round(our_prob, 4),
            market_probability=round(market_prob, 4),
            best_decimal_odds=best_decimal,
            bookmaker=best_bm,
            edge=round(edge, 4),
            kelly_fraction=kelly_frac,
            recommended_stake=stake,
            composite_score=composite,
            hours_until_game=game_probs["hours_until_game"]
        ))

    return candidates


def run_daily_evaluation(bankroll: float, game_scores: list[dict]) -> list[BetCandidate]:
    """
    Orchestrates the full daily pass. game_scores is a list of dicts containing
    game_probs and all four layer scores pre-computed by their respective modules.
    """
    all_candidates = []

    for gs in game_scores:
        candidates = evaluate_game(
            game_probs=gs["game_probs"],
            injury_scores=gs["injury_scores"],
            motivation_scores=gs["motivation_scores"],
            form_scores=gs["form_scores"],
            h2h_scores=gs["h2h_scores"],
            bankroll=bankroll
        )
        all_candidates.extend(candidates)

    # Sort by edge descending — highest conviction plays first
    all_candidates.sort(key=lambda x: x.edge, reverse=True)

    print(f"\n{'='*60}")
    print(f"Daily scan complete. {len(all_candidates)} bet candidates found.")
    for c in all_candidates:
        print(
            f"  {'HOME' if c.side == 'home' else 'AWAY'}: "
            f"{c.home_team} vs {c.away_team} | "
            f"Edge: {c.edge:.1%} | "
            f"Stake: ${c.recommended_stake:.2f} | "
            f"Book: {c.bookmaker}"
        )
    print(f"{'='*60}\n")

    return

---

**Need automation built?** I build Python bots, Telegram systems, and trading automation.

[View my Fiverr gigs →](https://www.fiverr.com/mikog7998) — Starting at $75. Delivered in 24 hours.
Enter fullscreen mode Exit fullscreen mode

Top comments (0)