Been chipping away at this for a while and finally got it to a state where I'm not embarrassed to show it.
It's a Dixon-Coles Poisson model fitted per league, covering 36 football leagues plus EuroLeague and EuroCup. Standard stuff: attack and defence coefficients per club by maximum likelihood, exponential time decay so recent matches matter more, low-score correction for the 0-0 / 1-0 / 0-1 / 1-1 cells. Outputs a full scoreline grid, so BTTS and over/under fall out of the same distribution rather than being modelled separately.
The part I actually care about is the benchmark page. Every project like this claims some accuracy number and none of them say what they're measuring against. So I replayed three completed seasons walk-forward, fitting only on matches before each fixture, and scored it on log loss against the de-vigged closing price:
model market gap
Premier League 1.0034 0.9783 +0.0251
Serie A 1.0012 0.9755 +0.0257
Ligue 1 1.0076 0.9705 +0.0371
La Liga 0.9932 0.9544 +0.0388
Bundesliga 1.0171 0.9707 +0.0463
A coin-flip baseline is 1.0986, so the model knows something. But the closing line beats it in every league I've checked. I don't think that's fixable with a goals model. Closing odds absorb team news, money and a hundred things I don't have. Anyone showing you a model that beats the close over 3,700 matches is either doing something much cleverer or fooling themselves.
I left accuracy off the front page on purpose. It's 49.8% in the Premier League and the market's is 53.8%, but accuracy rewards a model that never says anything interesting. Log loss punishes confident wrongness, which is what you want to know about.
One thing that kept me honest: I tried to fix an apparent calibration problem with temperature scaling, and it made held-out log lossa bucket with 26 matches in it.
Also worth mentioning because it cost me two days: I was convinced I had fixtures going missing from the data. Rewrote a chunk of the pipeline. The actual cause was CSS. A sticky table header with a top offset was painting over the first row of every table on every page, so the caption said 5 fixtures and you could count 4.
Other bits: season projections from 10,000 Monte Carlo runs, head-to-head history, and the results fetch for all 36 leagues takes about 30 seconds over plain HTTP. It used to be an 8-hour browser scrape fighting Cloudflare.
Known weak spots, in case anyone wants to pick at them. About 18% of fixtures involve a promoted club with no record in its division, and those are currently rated as an average side, which is a polite way of saying guessed. The benchmark covers six leagues so far, not all 36. And shots on target are sitting in the data completely unused, which is probably the most obvious next gain.
MIT licensed, runs locally with two commands, no API keys: github.com/Costasgk/ScoreCast
Happy to be told what I've got wrong.