Statistical football predictions (1X2 and derived markets) powered by Dixon-Coles and Negative-Binomial models, presented with confidence bands. Informative, not betting advice.
A full-stack project that predicts football match outcomes from a statistical engine and shows them in a responsive web app:
- 6 leagues: 5 European (Premier League, La Liga, Serie A, Bundesliga, Ligue 1) + the Ecuadorian Liga Pro (EC1)
- ~20 derived markets per match: double chance, over/under, Asian handicap, HT and HT/FT, corners, bookings, shots on target, fouls, first goal/corner
- Confidence bands on every prediction (Safe, Likely, Tight, Uncertain) so you know how much to trust each pick
- Two model families: Dixon-Coles (bivariate Poisson for full-time and first-half scores) and Negative-Binomial for count markets, trained on 2014–2025 results with temporal decay and recent form (k=5)
ESPN (live fixtures) → server (Node/Express + SQLite) → ml-service (FastAPI models) → web (React)
The server syncs fixtures from ESPN on a cron schedule, resolves team names against the models, asks the ml-service for predictions, and persists everything in SQLite. The web app reads from /api — nothing else.
| Service | Stack | Role |
|---|---|---|
ml-service |
Python / FastAPI | Statistical models and markets (/predict, /teams, /health) |
server |
Node / Express + SQLite | ESPN orchestration, team resolution, predictions, tracking |
web |
React 19 / Vite / Tailwind v4 | Responsive SPA that only reads /api |
ESPN scoreboard (fixtures, -2..+14 days)
│ cron sync (SYNC_CRON, 06:00 local · TZ)
▼
server Node/Express + SQLite (:4000)
│ runSync → refreshFixtures → resolveTeam → predictFixture → persist
│ POST /predict
▼
ml-service FastAPI (:8001)
│ models loaded from artifacts/*.npz
│ (Dixon-Coles FT & HT, HT/FT conditional, Negative-Binomial counts)
▼
web React 19 + Vite (:5173)
│ reads /api only (Vite proxy in dev)
Key design points:
- One shared model for the 5 European leagues; Liga Pro (EC1) uses its own Dixon-Coles trained on ESPN history — EC1 data never touches the global model.
- Artifacts are reproducible:
ml-service/data/andml-service/artifacts/are gitignored and regenerated with the download scripts +scripts/train.py. - Confidence bands have a single source of truth: the ml-service serves
GET /bandsand the server caches it with a fallback. - Auto re-prediction: if the model is retrained (
trained_atchanges), the server force-repredicts pending fixtures.
ml-service/ # Python / FastAPI — statistical models
app/
api.py # /predict, /teams, /health, /models, /bands
data.py # loads historical CSVs (5 European leagues + EC1)
models/
dixon_coles.py # Dixon-Coles for FT and HT scores
count_model.py # Negative-Binomial for count markets (recent form, k=5)
markets.py # ~20 derived markets
scripts/ # download_data, download_espn_ecuador, train, validate, backtest
tests/ # pytest (37 tests)
requirements.lock # pinned Python dependencies
server/ # Node / Express + SQLite — orchestration
src/
index.ts # bootstrap, cron sync, health-check of ml-service
config.ts # env: PORT, ML_URL, SYNC_CRON, DB_PATH, REFRESH_TOKEN, CORS
db.ts # node:sqlite — fixtures, picks, stats, meta
teams.ts # resolves ESPN display names → model team names (fuzzy ≥ 0.8)
dates.ts # local dates (TZ) for the fixture window and filter
providers/espn.ts # ESPN scoreboard client
routes/api.ts # /api/leagues, /api/fixtures, /api/stats, /api/refresh
services/predict.ts # runSync, re-prediction by trained_at, backfill
data/teamOverrides.ts # ESPN → model aliases (single source)
lib/json.ts # safe JSON response helpers
package.json
web/ # React 19 / Vite / Tailwind v4 — frontend
src/
App.tsx # league tabs, silent auto-refresh every 60s
api.ts · bands.ts · heat.ts · utils.ts
components/ # MatchCard, Markets, ProbabilityBar, ConfidenceBadge, BandLegend,
# Countdown, MatchToolbar, SpotlightCard, Sidebar, Header,
# ThemeToggle, Tooltip, ErrorBoundary
components/ui/ # shadcn/ui primitives: badge, button, select, dropdown-menu, tooltip
hooks/useTheme.ts # light/dark theme
e2e/ # smoke (7 checks) + responsive (51 checks) via playwright-core
scripts/ # verify-* checks and screenshot helper
package.json
ml-service (Python)
| Package | Purpose |
|---|---|
| fastapi + uvicorn | API server |
| numpy · scipy · pandas | model math and data |
| pydantic | request/response validation |
| pytest · httpx | tests (httpx is required by FastAPI's TestClient) |
server (Node)
| Package | Purpose |
|---|---|
| express | HTTP API |
| node-cron | sync schedule |
| cors · dotenv | middleware and env |
node:sqlite (built-in) |
persistence |
| dev: tsx · typescript · vitest · eslint · prettier | running and tooling |
web (React)
| Package | Purpose |
|---|---|
| react · react-dom | UI |
| @base-ui/react + shadcn/ui | accessible primitives |
| lucide-react | icons |
| motion | animations |
| tailwindcss v4 (@tailwindcss/vite) | styling |
| dev: vite · vitest · testing-library · playwright-core · shadcn | build, tests, e2e |
# ml-service (Python)
cd ml-service && python -m ruff check app tests scripts && python -m ruff format --check . && python -m pytest tests -q # 37 tests
# server (Node)
cd server && npm run lint && npm test && npm run typecheck # 23 tests
# web (React)
cd web && npm run lint && npm test && npm run typecheck # 28 tests
# e2e (requires the three services running)
cd web && npm run test:e2e && npm run test:e2e:responsive # 7 + 51 checksGitHub Actions (.github/workflows/ci.yml) runs lint, typecheck, tests and pip-audit for all three services on every push.
# ml-service (models)
cd ml-service
pip install -r requirements.lock
uvicorn app.api:app --port 8001
# server (Express + SQLite + ESPN)
cd server
cp ../.env.example .env
npm install && npm run dev
# web (frontend)
cd web
npm install && npm run dev # http://localhost:5173Environment variables are documented in .env.example; API endpoints and the data/training pipeline are documented in the source of each service (server/, ml-service/, web/).
Models are validated walk-forward on out-of-sample seasons (2023–2025, n≈5.4k): log-loss ≈ 0.99, RPS ≈ 0.20, ~52% accuracy for the FT model. A flat-betting backtest of all 14 markets with synthetic SBOBET-style odds shows no market beats the 7% bookmaker margin — derived markets are informative, not a betting system.
- Band-level recalibration (deferred): only if the project is monetized.
- Player-level markets (deferred): needs a scorer-per-match data source.
MIT — see LICENSE.

