Live at bundespredict.vercel.app
A Bundesliga match predictor with two layers that stay deliberately separate:
- A Dixon–Coles / Poisson model (hand-written weighted MLE, time decay, promoted-team shrinkage, temperature-scaled calibration) produces the probabilities.
- An LLM agent reads plain-English match context — "their striker is out and it's going to storm" — and turns it into small, bounded, typed adjustments to the model's expected-goals inputs, re-runs the model, and explains what moved and why.
Why that combination matters. Bookmakers price breaking news into a single number within minutes — but they won't tell you why the line moved, how much of it was the injury, or let you ask "and what if his backup also sits?" Static prediction sites don't react at all. Here the reaction is interactive and auditable: you state the context, the agent grounds it against real squad data (who the player is, which team he actually plays for, how much he matters by market value — scraped from Transfermarkt, snapshot date included), applies a bounded, sourced expected-goals adjustment, and shows baseline vs adjusted side by side. The point isn't that it knows about injuries — it's that you can run the counterfactual yourself and see the work.
The model does the maths, the LLM does the words. There is deliberately no tool through which the LLM can set a probability: it can only request expected-goals adjustments, every one of which is clamped server-side (±0.6 xG) and logged, so each answer is auditable back to the exact fitted parameters and the exact adjustments applied.
┌──────────────┐ SSE ┌─────────────────────────────────┐
│ Next.js chat │◄──────►│ Django (DRF) │
│ prob bars │ │ ┌───────────────────────────┐ │
│ heatmap │ │ │ Agent (tool-calling loop) │ │
│ adjustments │ │ └────────────┬──────────────┘ │
└──────────────┘ │ tools: │ predict_match │
│ │ predict_with_… │
│ │ get_team_form │
│ │ lookup_player │
│ │ get_weather │
│ ┌────────────▼──────────────┐ │
│ │ Model engine (pure) │ │
│ │ Dixon–Coles → score │ │
│ │ matrix → every market │ │
│ └────────────┬──────────────┘ │
└───────────────┼─────────────────┘
│ reads fitted params
┌───────────────▼─────────────────┐
│ Postgres: matches, model_runs, │
│ team_params, predictions (audit) │
└───────────────▲─────────────────┘
│
manage.py refit (offline job:
ingest → fit → persist params)
The boundaries are the point. packages/model/src/bundespredict/model/ is pure: no I/O, no
DB, no LLM; given parameters and adjustments it returns a distribution, which is
what makes it unit-testable (parameter recovery, invariants, monotonicity,
clamping). Serving never fits: the API loads the latest persisted run, and
training happens offline in the refit management command.
One score matrix P(home=i, away=j) drives everything — 1X2, over/under 2.5,
both-teams-to-score, correct scores are all slices of the same distribution, so
the markets can never disagree with each other.
Backtested walk-forward (refit every gameweek, each fit sees only results
strictly before that round's kickoff) over five seasons, 1,827 out-of-sample
predictions — full details in reports/backtest_report.md:
- RPS 0.2045 vs 0.1967 for the de-vigged closing market consensus. The model is behind the market, which is the expected result: the closing line aggregates more information than any goals-only model. Landing within a hundredth of it means the core is sound, not that it prints money.
- Well calibrated: ECE ≈ 0.01; temperature scaling landed at T = 1.139, i.e. the raw probabilities were already close to honest.
- CLV +0.24% on a value-betting sim (ROI is variance-dominated noise at this sample size; closing-line value is the signal worth reading).
- Market blend: an honest null. A log-opinion-pool blend of the calibrated model with the de-vigged opening odds, weight chosen walk-forward, gave the market full weight (w = 1.0) — out-of-sample log-likelihood rises monotonically toward the pure open. A goals-only Dixon-Coles carries no signal the opening line hasn't already priced in; the blend machinery stays so that claim gets re-tested automatically whenever the base model improves.
- Pre-match xG: also an honest null. Rolling pre-match expected goals (from
Understat, xG-minus-goals gap, decayed, strictly pre-kickoff) added to log-λ as
a fitted global coefficient improves in-sample fit (coefficient ≈ +0.7) but
worsens out-of-sample RPS (0.2069 vs 0.2046 goals-only). The
finishing-reversion signal is real inside the training window and doesn't
generalise, so it is not adopted into serving — the full pipeline (scrape →
ingest → engine offset → backtest
use_xg) stays so the null is reproducible and a better xG formulation can be re-measured the same way.
The adjustment layer makes no accuracy claims at all. Contextual magnitudes
("first-choice striker out ≈ −0.35 xG") can't be backtested — there's no labeled
history of them — so they live in a knowledge base where every range carries
either a citation or an explicit heuristic label, and the feature is framed as
what it is: an auditable, bounded what-if with an explanation, not an edge.
packages/model/ the pure model/agent/eval/data library (import name: bundespredict)
apps/backend/ Django + DRF project (config/, one folder per app, tests/)
apps/web/ Next.js frontend
infra/ Dockerfiles and docker-compose.yml
scripts/ check_backend_gate.sh and the recorder for the web's mock streams
tests/ tests for the library (model, agent, eval, data parsers)
The Python code is a uv workspace (packages/model, apps/backend)
with a single uv.lock at the root; ruff, mypy and pytest are configured there too.
The library never imports Django; the backend imports it.
Copy the env template into a local .env (gitignored):
POSTGRES_USER=bundespredict
POSTGRES_PASSWORD=bundespredict
POSTGRES_DB=bundespredict
DATABASE_URL=postgresql://bundespredict:bundespredict@localhost:5433/bundespredict
ANTHROPIC_API_KEY=sk-ant-... # the agent; endpoints 503 without it
# AGENT_MODEL=claude-sonnet-4-6 # optional; defaults to Haiku for cheap devThen the whole stack:
docker compose -f infra/docker-compose.yml up --build- Web: http://localhost:3000 · Django: http://localhost:8001/api/v1/health
- Postgres publishes host port 5433 (
POSTGRES_HOST_PORTto change)
Or without Docker (Postgres still via compose):
docker compose -f infra/docker-compose.yml up -d db
uv sync # installs the whole workspace into .venv
uv run apps/backend/manage.py migrate # create the schema
uv run apps/backend/manage.py runserver 8001 # Django on :8001
cd apps/web && npm install && npm run dev # web on :3000The web dev server needs API_BASE_URL=http://localhost:8001 and PROXY_SECRET=dev-only-proxy-secret in apps/web/.env.local.
uv run apps/backend/manage.py download_seasons --start 2019 --end 2025 # CSVs -> data/raw/
uv run apps/backend/manage.py ingest_results # upsert CSVs into Postgres (idempotent)
uv run apps/backend/manage.py scrape_transfermarkt # squads + market values (polite, cached)
uv run apps/backend/manage.py refit --skip-download # fit + persist serving parameters
uv run apps/backend/manage.py download_fixtures # upcoming schedule (OpenLigaDB)Each command prints a one-line summary, and a second run over the same data changes no rows.
refit is also the recurring job.
Run it after a matchday and it re-pulls the current season, re-ingests, refits on the full history and persists a new versioned run, which serving picks up on the next request.
refit --as-of 2025-06-01 trains only on matches played before that date.
download_fixtures keeps the upcoming schedule mirrored (it's how "predict Dortmund's next game" resolves the opponent), so run it alongside.
.github/workflows/refit.yml runs both on the schedule.
scrape_transfermarkt fills two things from Transfermarkt (identifying
User-Agent, ~1 request per 3s, raw pages cached in data/tm_cache/ so a
blocked scrape just keeps serving the last snapshot): the players table that
backs the agent's lookup_player tool — any contracted player, with a
market-value-derived importance and the snapshot date — and per-season squad
values, which the refit uses as shrinkage targets so a promoted club with an
expensive squad isn't priced like an average newcomer. Run it locally now and then
(market values move slowly, mostly around transfer windows); the daily refit uses
whatever values are stored. Transfermarkt's WAF answers datacenter IPs (GitHub's
runners included) with a bot challenge, which the scraper reports as ScrapeBlocked
instead of caching an empty page; --skip-fetch ingests from the cache alone. Historical league pages are fetched once and never again.
Per-match expected goals (for the pre-match xG experiment) come from Understat via
uv run apps/backend/manage.py scrape_understat. Understat sits behind Cloudflare and only serves its
data to a real browser, so the scraper drives a local Chromium through Playwright
(uv pip install playwright && uv run playwright install chromium) and caches each season's
JSON to data/understat_cache/; a second pass backfills matches.home_xg/away_xg.
It's an offline analysis step, not part of serving or the CI refit — the xG
feature was measured and left out (see the honest-results section), so nothing on
the request path depends on it.
The backtest + calibration report is regenerated with
uv run --with matplotlib apps/backend/manage.py run_backtest --report-dir reports; it also
runs the pre-match xG variant so the report carries the goals-only vs xG
comparison.
eval_promoted_prior scores the squad-value shrinkage prior against league-mean shrinkage on promoted teams' first matches back up.
uv run ruff check . && uv run ruff format --check . && uv run mypy && uv run pytest
cd apps/web && npx tsc --noEmit && npm run lintTests worth calling out: parameter recovery (simulate from known parameters, refit, assert recovery — if that passes, the MLE is almost certainly right), the ρ=0 reduction to independent Poisson, score-matrix/market invariants, clamping and the λ-floor, and agent-loop tests that run against recorded transcripts so CI never touches the live API.
- Web → Vercel (project root
apps/web;API_BASE_URLpoints at the backend). - Backend → Railway service
backend, built frominfra/Dockerfile.backendand configured in.railway/railway.ts. It runsmanage.py migratebefore each deploy and health-checks/api/v1/health. It needsDJANGO_SECRET_KEY,DJANGO_ALLOWED_HOSTS,PROXY_SECRETandDATABASE_URLto boot; the agent also needsANTHROPIC_API_KEY, andAGENT_MODELpicks the production model. - Postgres → Neon. Django migrations own the schema; seed a fresh database with
migrate→download_seasons→ingest_results→refit→download_fixtures. - Retraining → a GitHub Actions workflow (
.github/workflows/refit.yml) refits on the latest results and refreshes the fixture schedule every Monday, writing a new versioned parameter run that serving picks up on the next request — the model never fits inside a request, in production either.

