BlogModels and analytics

How to Build a Sports Betting Model, Step by Step in Python

To build a sports betting model, pick one sport and one market, collect past results and the prices that were available before each game, fit a simple rating that produces a probability for every game, and test those probabilities on later games against the no-vig market price. Only add complexity once the simple version is measured. The model is done when you can say, with a number, whether it beats the market on games it never saw.

This guide on how to build a sports betting model walks through that with an Elo-style rating for moneylines, in plain Python that doesn't call any API. It's a starting point, not a winning system. Most first models lose to the market, and finding that out cheaply is part of the work.

Step 1: choose a narrow target

Start with one sport, one market and one period. A full-game moneyline in a sport without draws is the easiest: two outcomes, one probability, and a clear result. Spreads, totals and props come later, because each needs its own distribution, not just a win chance.

Write down the question the model answers: "the chance the home team wins this game, as of the moment I'd bet." The last part matters more than it looks, and it comes back in step 5.

Step 2: collect results and honest prices

You need two tables:

  • Results: date, start time, home team, away team, winner. Drop or flag games that were postponed or decided in an unusual way.
  • Prices: for each game, the moneyline on both sides from a sharp book or a consensus, with the time each price was observed.

Keep the price you could have bet at decision time separately from the closing price. You'll use them for different things. Our guide to historical sports betting data covers what to check in a price archive before you trust it.

Step 3: write the rating

Elo gives every team a rating. The gap between two ratings, plus a home advantage, becomes a win chance. After each game, the winner takes points from the loser in proportion to how surprising the result was.

import math

def expected(r_home, r_away, home_adv):
    return 1 / (1 + 10 ** (-(r_home - r_away + home_adv) / 400))

def run_elo(games, k=20, home_adv=50, start=1500):
    """games: list of dicts sorted by start time, each with
    'home', 'away' and 'home_won' (1 or 0).
    Returns one prediction per game, made before its result is used."""
    ratings = {}
    preds = []
    for g in games:
        rh = ratings.get(g["home"], start)
        ra = ratings.get(g["away"], start)
        p = expected(rh, ra, home_adv)
        preds.append(p)                      # predict first
        change = k * (g["home_won"] - p)     # then learn from the result
        ratings[g["home"]] = rh + change
        ratings[g["away"]] = ra - change
    return preds

def log_loss(preds, outcomes):
    eps = 1e-12
    total = 0.0
    for p, y in zip(preds, outcomes):
        p = min(max(p, eps), 1 - eps)
        total += y * math.log(p) + (1 - y) * math.log(1 - p)
    return -total / len(preds)

The order inside the loop is the whole point. The prediction for a game is stored before that game's result touches the ratings. Swap those two lines and the model sees the future.

Step 4: a worked example

Say the home team is rated 1550, the away team 1500, and the home advantage is 50 points. The gap is 100 points, so:

  • Win chance: 1 / (1 + 10^(-100/400)) = 1 / (1 + 0.5623) = 64.0%.
  • Fair American odds: -(0.640 / 0.360) x 100, about -178.

The market has the home team at -150 and the away team at +130. Those imply 60.0% and 43.48%, which add up to 103.48%. Remove the margin and the home team's fair chance is 57.98%.

The model says 64.0%. The market says 58.0%. A six-point gap on a main moneyline is large, and the first thing to assume is that the model is missing something the market knows: an injury, a rest day, a starting lineup. A big disagreement is a question, not a bet.

If the home team wins, the update is 20 x (1 - 0.640) = 7.2 points: the home team goes to 1557.2, the away team to 1492.8. On this one game the model's log loss is 0.446 and the market's is 0.545. Had the away team won, the model would score 1.022 and the market 0.867. One game says nothing. The comparison only means something over hundreds.

Step 5: test in time order against the market

Split by date, never at random. Fit k and home_adv on earlier seasons, then run the model forward through a later season it has never seen and compare:

def no_vig_home(home_odds, away_odds):
    def implied(a):
        return -a / (-a + 100) if a < 0 else 100 / (a + 100)
    h, a = implied(home_odds), implied(away_odds)
    return h / (h + a)

# test_games sorted by start time, each with the prices
# available when you would have bet
market = [no_vig_home(g["home_ml"], g["away_ml"]) for g in test_games]
model = run_elo(train_games + test_games, k=best_k, home_adv=best_adv)[len(train_games):]
outcomes = [g["home_won"] for g in test_games]
print("model", log_loss(model, outcomes))
print("market", log_loss(market, outcomes))

Expect the market to win this comparison. If it does, the model isn't useless: it may still add something when blended with the market price. But on its own it's not a reason to bet. Our pillar guide to the sports betting model covers the other two checks, calibration and closing line value.

The backtesting mistakes that fake an edge

Look-ahead leakage. Any input that wasn't known before the game leaks the answer. Season-long averages, injury reports published after kickoff, and ratings updated with the game's own result all do it. The fix is the loop above: every input is dated, and nothing dated after the decision time is used.

Overfitting. Try fifty values of k and keep the best, and you've fitted the noise in your test set. Tune on one block of seasons, choose once, and test on a block you haven't touched. If you must tune again, hold back another block.

Betting at prices you couldn't get. A backtest that bets at the closing price is measuring something you couldn't have done, because you bet before the close. Use the price that was available at your decision time, and use the closing price only to measure closing line value. Check the timestamps: a "pre-game" price recorded after a big injury report isn't the one you'd have seen.

Forgetting the margin. Comparing a model's chance with the raw implied chance of one side, margin included, makes every price look worse than it is. Compare with the no-vig number.

Too few bets. A few hundred bets can show a profit by chance. A model with a real edge can show a loss over the same span. That's why the log loss and closing line checks matter more than early results.

What comes after the model

A probability isn't a bet yet. Deciding when a gap is big enough, how much to stake, and what to do when a price moves or a book limits you is the job of a sports betting algorithm. Even with a real edge you'll lose often, sometimes for long runs, so stake only what you can afford to lose.

For the price side, ZippyOddsAPI returns current prices from operators' own public data (publisher snapshots of some books are labelled raw-only and never scored), each with the time it was observed, and /v1/history returns a full-game moneyline, spread or total market's stored points for 30 days. ZippyOddsAPI also scores prices against a no-vig consensus of sharp books at /v1/ev, which is a useful market baseline to test your model against. The API docs explain each endpoint and how to ask the operator for a key, and the ZippyOddsAPI coverage page shows which books are feeding.