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
The four layers are:
- Injury Report — weighted scoring based on position and injury severity
- Motivation Index — playoff implications, rivalry games, revenge spots
- Form Analysis — rolling 5-game window with recency weighting
- 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
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
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.
Top comments (0)