NFL prediction platform

This is an NFL prediction platform I built as a hobby project: a Postgres data warehouse, an API over it, prediction models that score themselves against results, and a command-line tool and browser board that read it through the API. It is a data engineering and forecasting exercise: how well can a model predict games, measured honestly.

The problem

It started as a thin script that printed odds from one paid API. It had no storage, no history, no model and no record of what it had predicted and whether that was right. Without that record, a system can only feel like it is improving. The other trap is leakage: if a backtest is allowed to see end-of-season stats, its accuracy looks excellent and means nothing.

What I built

  • A warehouse in four layers: raw data as it arrived, cleaned core tables, the features the models read, and the predictions.
  • Loaders for free community data (nflverse: schedules, scores, play-by-play, rosters, depth charts), hourly market lines at nine sportsbooks, hourly injury designations, daily-fantasy salaries and stadium weather. Six seasons, 2020 to 2025, are loaded.
  • A point-in-time feature store of team ratings, season-to-date profiles and market columns, at team-week and player-week grain.
  • Fourteen registered models across three game markets (who wins, the point spread, the total) and one player market (fantasy points): Elo ratings, logistic and ridge regressions, a naive baseline, and three market benchmarks based on the closing line with the bookmaker’s margin removed.
  • A FastAPI service, a command-line tool and a read-only browser board.
  • Twelve scheduled jobs that run the week, plus restore-tested nightly database dumps.

How it works

How a prediction is made and scored: loaders into the four warehouse layers, the API and its clients on their own cloud machine, and the week of Tuesday cutoff, Wednesday predictions and settlement after the games

Every loader works the same way: one database transaction, an audit row, quality checks that stop the load when the data looks wrong, and a dry-run mode that rolls everything back. Only the ingest container writes to the warehouse. One check fails small: if so many of my fantasy league’s waiver-wire players go unmatched that only a broken match could explain it, only that list rolls back; the morning’s rosters stay and the run is recorded as failed.

On Tuesday a job refreshes last week’s results, then rebuilds the features. Tuesday at 8 a.m. is the cutoff for the coming week: a row for week 5 is built only from what was known before it. A gate enforces this and rolls back a run that breaks it, and each build ends by re-deriving a sample from raw data to prove no future data leaked in. Predictions are written on Wednesday, before kickoff, with a fixed timestamp and model version.

After the games, each prediction is settled. Winners are scored on hit rate, on Brier score (which punishes confident wrong answers more than hesitant ones), on calibration (does “70%” come true 70% of the time?) and against the closing line. Spreads and totals are scored by the points they missed by.

Production runs on its own cloud machine, api, behind Cloudflare Access, and the API requires a bearer token. A Grafana dashboard on my home server reads the warehouse read-only over WireGuard.

Decisions and trade-offs

  • Postgres over Cloudflare D1. D1’s size limits strain play-by-play data, and it has no Python runtime for the models. Postgres gives window functions and plain dump backups.
  • An append-only prediction log, and no automatic model choice. A re-run adds a new generation and never overwrites an old one, so every prediction stays scoreable. The board shows every model, and a person chooses.
  • Measured pairs instead of assumed improvements. The starting-quarterback adjustment to Elo runs as its own model beside the plain one, so its value is measured. It costs about a point of accuracy; keeping or dropping it is an open decision.
  • The market as the bar. Nothing in the current set beats the closing market, which is expected. When a regression given the market scored worse than the market alone, I raised the bar for the next generation from “beat the logistic baseline” to “beat the Tuesday market on Brier score in a walk-forward backtest.”

What’s next

Gradient-boosted models with calibration, which ship only if they clear that bar. A daily pull of practice-participation injury reports. A near-kickoff model and a line-movement feature wait for the 2026 season’s hourly snapshots, since past seasons hold no intermediate lines to train on. Errors I found in upstream player data are on Open source.

Built with

Python, Postgres 18, FastAPI, Typer, Alembic, SQLAlchemy, nflverse data, Docker, systemd timers, Cloudflare Access, WireGuard, Grafana, Uptime Kuma.