The whole thing ran on my laptop.
9,819 lines of Python and 2,588 lines of tests across 24 modules, on eight libraries. No GPU, no hosted inference, no database. The interesting parts are the pipeline shape and the gate, so that is what the rest of this page is about.
penaltyblogpandasnumpystatsmodelsscipyrequestsstreamlitpyarrow
TheStatsAPI ──> ingest.py ──> data/*.parquet ──> models/ ──> predictions/*.json
ESPN (free) ──> espn.py 880 lines 10 files 4 models stamped MODEL_VERSION
23,735 rows
│
v
Bet365 odds ──> paper.py ──> 3 ledgers ──> settle + CLV
odds.parquet 895 lines 173 bets │
│ Kalshi + Polymarket feed the
│ live desk in the dashboard only —
│ read-only quotes, never graded.
v
notebooks/backtest.py ──> data/market_gates.json
walk-forward + bootstrap the betting engine reads this file
GitHub Actions ran the chain and committed data/ back to the repo after every run.
Streamlit read from GitHub every 5 minutes.Four models, each fitted to what it predicts| Model | Technique | Lines |
|---|
| Match outcome & goals | Dixon-Coles bivariate Poisson, time-decayed weights, neutral-venue aware | 355 |
| Anytime scorer | Poisson thinning — team goal expectation split by npxG/90 × projected minutes, penalty duty explicit | 142 |
| Corners | Negative binomial GLM on pre-match possession priors | 101 |
| Cards | Poisson on shrunk team booking rates | 45 |
Two prediction layers
Layer 1 refits Dixon-Coles daily and stores a base prediction. Layer 2 runs ~75 minutes before kickoff, measures the confirmed XI's attacking strength against the pre-lineup projection, scales each team's goal expectation by that ratio and rebuilds the whole probability grid. A prediction made 48h out and one made after team news are different products, so they are stored and graded separately.
Flat data as the audit trail
Every run commits its parquets, predictions, stakes and settlements straight back into data/. There is no database and no mutable state — the git history is the record, so any figure traces to the commit that produced it and the state of the world at that moment. 444 commits.
When the provider vanished
The paid trial lapsed mid-tournament. espn.py replaced it in a day against a free no-auth feed: update-only writes that never overwrite a finished score, regulation and after-extra-time scores split by period from the scoring events, shootout kicks excluded. Parsers were written against captured fixtures, so the adapter is tested without a network.
What keeps it honest
MODEL_VERSION stamps every prediction. make test-regression re-runs the walk-forward and fails the build if the backtest degrades past its committed baseline, so a “harmless” refactor won't cost accuracy.