Premier League match predictions: a FastAPI backend, a React frontend, and one scheduled sync job that keeps everything current.
ESPN scoreboard API ──┐ ┌── GET /api/matches (one call per page)
football-data.co.uk ──┤→ backend/sync.py → DB ──┼── GET /api/teams, /api/forecast, ...
(fallback results) │ (cron, idempotent) └── React frontend
Understat (xG) ───────┤
data/teams.json ──────┘
- One source of match truth.
backend/sources/espn.pyreads ESPN's public scoreboard JSON: a season's fixtures, kickoffs, statuses, scores and club crests in one pass, no scraping library. If ESPN is down, results still come in from football-data.co.uk. - Matches, not days. The
matchestable has one row per fixture, keyedseason-home-away(e.g.2026-arsenal-chelsea). In a double round-robin each ordered pairing meets once a season, so the key survives reschedules and feed renames. The fixture, the model's call and the result all live on the same row, so there are no stale duplicates and no name matching to pair a result with its prediction. - Frozen calls. Predictions refresh daily until kickoff, then never
change. That frozen call is what the Records page judges. The one
exception: a played match whose stored call is a flat 33/34/33 placeholder
(a bug before 24 Sep 2026 froze those onto matchweeks 4 and 5) is rebuilt
by the sync from pre-match Elo and form only, tagged
+rebuiltand marked on its card. - One team registry.
data/teams.jsonholds every club's canonical name, aliases, URL slug, ESPN id, Premier League id and colour. Badges are 500px ESPN crests with the Premier League crest as fallback, then initials. Crest URLs synced from the feed are stored in theteamstable. A newly promoted club gets its crest automatically, even before it is added to the registry. - One storage layer.
backend/db.py(SQLAlchemy Core) runs on Postgres in production and SQLite everywhere else. No parallel JSON-file mode.
pip install -r requirements-dev.txt # runtime + pytest
python -m backend.sync # pull the season, predict, forecast
python -m backend.server # API on :8000
cd frontend && npm install && npm run dev # UI on :5173Without POSTGRES_URL the app uses SQLite at data/football.db.
| Variable | Purpose |
|---|---|
POSTGRES_URL / DATABASE_URL |
Postgres connection (Vercel sets POSTGRES_URL). Unset → SQLite. |
CRON_SECRET |
Bearer token required by /api/jobs/sync (Vercel Cron sends it). |
CORS_ORIGINS |
Comma-separated origins for local cross-origin dev (default: localhost:5173, :3000). |
- The only write path is
/api/jobs/sync, guarded byCRON_SECRET(constant-time compare; no secret configured means no access). - Every other endpoint is a public, read-only view of public match data. SQL goes through SQLAlchemy Core with bound parameters.
vercel.jsonsends a strict Content-Security-Policy (scripts from the site only), HSTS,nosniff, no framing and a restrictive Permissions-Policy.- The bundled
training_data.pklis loaded with joblib (pickle): only ever replace it with a file you built yourself.
All reads are served from the database and cached at the edge
(s-maxage=300, stale-while-revalidate); data only changes when the sync runs.
| Endpoint | Description |
|---|---|
GET /api/health |
Status, database dialect, last sync run |
GET /api/matches?season=&team= |
Every match of a season: fixture, prediction, result, verdict, matchweek, team info |
GET /api/teams?season= |
The season's clubs with slug, crest and colour |
GET /api/teams/{slug-or-name} |
Club profile, form, Elo history, upcoming fixtures |
GET /api/teams/{team}/h2h?vs= |
Head-to-head record |
GET /api/forecast |
Latest Monte Carlo season forecast |
GET /api/calibration |
Accuracy, Brier score and calibration bins over settled calls |
GET /api/predict?home=&away= |
Ad-hoc prediction for any pairing (not stored) |
GET/POST /api/jobs/sync?forecast=true |
Run the sync (cron secret required) |
python -m backend.sync (or the cron hitting /api/jobs/sync at 06:00 and
22:30 UTC):
- pulls the current season from ESPN and upserts clubs and matches;
- drops unplayed fixtures the feed no longer lists (only after a complete pull);
- predicts every unplayed match kicking off in the next 28 days;
- rebuilds the season forecast on Mondays, when new results arrived, or on
?forecast=true.
It is idempotent: a missed run is caught up by the next one. Each run is
logged in job_runs and reported by /api/health.
A time-weighted Dixon-Coles model (backend/dixon_coles.py): every club gets
an attack and a defence strength, fitted by maximum likelihood to every result
since 2020-21, with a match five months old counting half as much as one played
this week. Each side's count is three parts xG to one part goals where xG is
known: chance quality says more about a team than a few finishes. A fixture's
expected goals feed a Poisson scoreline grid with the Dixon-Coles low-score
correction, which gives the home / draw / away odds and the likeliest score.
xG comes from Understat (backend/sources/understat.py): the sync stores it
next to each result; until it arrives the model uses the goals. The bundled
training_data.pkl holds the 2020-21 to February 2026 history with xG. If the
fit cannot run, predictions fall back to an Elo-only Poisson model, tagged
model_version = "elo-poisson". Elo is computed in-house (backend/elo.py)
from every result since 1995/96.
The model was chosen, and its two settings tuned, with a walk-forward backtest: every week is predicted from earlier matches only.
python -m backend.backtest # 1,010 matches from Aug 2023
python -m backend.backtest --xi 0.003 0.0045 0.006| Model (Aug 2023 to Feb 2026) | RPS (lower is better) | Accuracy |
|---|---|---|
| Dixon-Coles, 75% xG (production) | 0.1967 | 54.6% |
| Dixon-Coles, goals only | 0.1995 | 53.3% |
| Elo only | 0.2006 | 54.6% |
| Random forest + Poisson (previous model) | 0.2051 | 52.7% |
| Base rates | 0.2336 | 43.6% |
A call for a match already played (when the sync rebuilds a placeholder) is fitted only on results from before that match day.
Nothing to run by hand. On first start against a database holding the old
predictions / results / fixtures / forecast_cache tables, the app copies
them into the new schema: stale twins collapse, and each result attaches to
the prediction it settles. The old teams table gets its new columns. The old
tables are otherwise left in place and can be dropped once you are happy.
python -m pytest # SQLite
TEST_DATABASE_URL=postgresql://... python -m pytest # against a throwaway Postgres (schema is wiped!)
cd frontend && npm test