Understat: current-season npxG, a live squad list, and a chore that fetches it #30

Open
solvreven wants to merge 6 commits from feat/understat-current-npxg into main
Owner

Stacked on #29, which is stacked on #28. Merge in that order and each reduces to its own commits.

Answers the question "are we using Understat, and can it update itself after gameweeks?" — the answer to the first was yes, but narrowly, and the investigation turned up a defect bigger than the one it set out to fix.

The defect: the join resolved names against a five-week-old squad list

scripts/fetch_understat.py read FPL elements from data/backfill/bootstrap.json — a 25 July snapshot with 558 elements against 651 live. The 93 missing were all summer signings, and an unresolved player is skipped with a bare continue.

So the join was silently blindest on exactly the cohort the file exists to serve — its own docstring says "players whose PL rates are position anchors", which is to say new signings with no PL history.

Goretzka is in that gap. CLAUDE.md already records him as one of the five 2026-08-28 transfers a free source had right while our own snapshot did not. Same staleness, one artefact over, sitting there since July.

Measured on the tracked model input:

rows change
foreign_rates.json (2025/26) 281 → 320 +39 gained, 0 lost
understat_2026_27.json (new) 210 current-season npxG, which FPL never publishes

The gains are the right names — Di Gregorio, Maitland-Niles, Norton-Cuffy, Fernández-Pardo, all cross-league summer signings.

One surviving player's rates moved when zero should have. Chased rather than shrugged at: element 316 Emersonn, same understat_id 14082, minutes 1517→1607, games 27→28. Understat revised its own 2025/26 Toulouse data after the July pull. Not a wrong-identity match — and worth knowing that a "completed" season still moves.

Why Understat rather than FotMob, for xG

Checked both rather than trusting the 2026-08-02 audit note. Current-season xG/xA is already ingested free from the FPL API (expected_goals, expected_assists per gameweek, wired at pipeline.py and feeding the rate build), so scraping either site for that would duplicate what we have.

What Understat uniquely adds is npxG, which FPL does not publish and which split_open_play already consumes.

FotMob's api/data/leagues?id=47 does return 200 with 742KB — the "no public API" note is out of date — but stats.players is a 43-row leaderboard, not squad-wide, with no npxG and no shotmap, and the deep-stats endpoint 404s. It buys ratings and a transfers feed, not xG depth. That lands separately, in pre_deadline_steps, where squad freshness actually matters.

Season choice: two seasons of one source, never blended

Each row carries the season it was measured in, so which one won is visible in the row rather than inferred from which file it came out of. The current season only displaces last season once it clears 450 minutes.

Verified a true no-op today: 320 rows in, 320 out, byte-identical, because the current season's maximum is 270 minutes. A no-op by construction, not by luck — and the switchover therefore cannot fire on real data until ~GW6, so it is forced with a synthetic fixture.

No season is named in the code. Files are discovered by glob and ordered by the season field inside them. CLAUDE.md counts fifteen sites of the hardcoded-gameweek family, every guard for it too narrow by exactly the axis the next instance used — so the guard here is not a grep for a filename. test_the_filename_never_decides_the_season puts the data in understat_something_else_entirely.json and asserts the row still wins on its own field.

The chore

understat_watch, beside nt_watch, keyed on coverage — "does the banked file reach the round that has been played" — answered from the file, so a hand-run fetch counts.

Not folded into after_gameweek.py, measured rather than assumed: Understat had GW2 by 48h after that round's last kickoff, while after_gameweek fires the moment the round ends. Chaining it there would fetch before Understat published and leave us permanently one round behind.

The fetch is always followed by a rebuild — these files are read at cache-build time and nowhere else, so a fetch alone refreshes something no projection sees.

A bug that was live for about a minute, and the sharpest thing here. Coverage counted all five leagues. Ligue 1 had played 3 rounds against the PL's 2, so it returned 3, never fell below the denominator, and the watch would have been permanently satisfied without firing once. Counting one population while describing another — the "20 fixtures" coverage line in a different file.

Verification

  • full suite: 1575 passed, 0 failed (pytest exit 0)
  • 21 new tests; every mutation cut RED, including two on the WIRING rather than the mechanism — removing the planner hook goes red while every helper test stays green
  • one of my own tests was too weak and was replaced: it grepped for BOOTSTRAP_URL in the source and stayed GREEN when the constant was renamed, over code that would NameError. Now drives load_elements and reads back the URL actually requested.

🤖 Generated with Claude Code

https://claude.ai/code/session_014adnmRztkdAPwinWXnNQ7N

Stacked on **#29**, which is stacked on **#28**. Merge in that order and each reduces to its own commits. Answers the question "are we using Understat, and can it update itself after gameweeks?" — the answer to the first was *yes, but narrowly*, and the investigation turned up a defect bigger than the one it set out to fix. ## The defect: the join resolved names against a five-week-old squad list `scripts/fetch_understat.py` read FPL elements from `data/backfill/bootstrap.json` — a **25 July snapshot with 558 elements against 651 live**. The 93 missing were all summer signings, and an unresolved player is skipped with a bare `continue`. So the join was **silently blindest on exactly the cohort the file exists to serve** — its own docstring says "players whose PL rates are position anchors", which is to say new signings with no PL history. **Goretzka is in that gap.** CLAUDE.md already records him as one of the five 2026-08-28 transfers a free source had right while our own snapshot did not. Same staleness, one artefact over, sitting there since July. Measured on the tracked model input: | | rows | change | |---|---|---| | `foreign_rates.json` (2025/26) | 281 → **320** | +39 gained, **0 lost** | | `understat_2026_27.json` (new) | **210** | current-season npxG, which FPL never publishes | The gains are the right names — Di Gregorio, Maitland-Niles, Norton-Cuffy, Fernández-Pardo, all cross-league summer signings. One surviving player's rates moved when zero should have. Chased rather than shrugged at: element 316 Emersonn, **same `understat_id` 14082**, minutes 1517→1607, games 27→28. Understat revised its own 2025/26 Toulouse data after the July pull. Not a wrong-identity match — and worth knowing that a "completed" season still moves. ## Why Understat rather than FotMob, for xG Checked both rather than trusting the 2026-08-02 audit note. **Current-season xG/xA is already ingested free from the FPL API** (`expected_goals`, `expected_assists` per gameweek, wired at `pipeline.py` and feeding the rate build), so scraping either site for that would duplicate what we have. What Understat uniquely adds is **npxG**, which FPL does not publish and which `split_open_play` already consumes. FotMob's `api/data/leagues?id=47` does return 200 with 742KB — the "no public API" note is out of date — but `stats.players` is a **43-row leaderboard**, not squad-wide, with **no npxG and no shotmap**, and the deep-stats endpoint 404s. It buys ratings and a transfers feed, not xG depth. That lands separately, in `pre_deadline_steps`, where squad freshness actually matters. ## Season choice: two seasons of one source, never blended Each row carries the `season` it was measured in, so which one won is visible in the row rather than inferred from which file it came out of. The current season only displaces last season once it clears 450 minutes. **Verified a true no-op today**: 320 rows in, 320 out, byte-identical, because the current season's maximum is 270 minutes. A no-op by construction, not by luck — and the switchover therefore cannot fire on real data until ~GW6, so it is forced with a synthetic fixture. **No season is named in the code.** Files are discovered by glob and ordered by the `season` field inside them. CLAUDE.md counts fifteen sites of the hardcoded-gameweek family, every guard for it too narrow by exactly the axis the next instance used — so the guard here is not a grep for a filename. `test_the_filename_never_decides_the_season` puts the data in `understat_something_else_entirely.json` and asserts the row still wins on its own field. ## The chore `understat_watch`, beside `nt_watch`, keyed on **coverage** — "does the banked file reach the round that has been played" — answered from the file, so a hand-run fetch counts. **Not folded into `after_gameweek.py`**, measured rather than assumed: Understat had GW2 by 48h after that round's last kickoff, while `after_gameweek` fires the moment the round ends. Chaining it there would fetch before Understat published and leave us permanently one round behind. **The fetch is always followed by a rebuild** — these files are read at cache-build time and nowhere else, so a fetch alone refreshes something no projection sees. **A bug that was live for about a minute, and the sharpest thing here.** Coverage counted all five leagues. Ligue 1 had played 3 rounds against the PL's 2, so it returned 3, never fell below the denominator, and the watch would have been **permanently satisfied without firing once**. Counting one population while describing another — the "20 fixtures" coverage line in a different file. ## Verification - full suite: **1575 passed, 0 failed** (pytest exit 0) - 21 new tests; **every mutation cut RED**, including two on the WIRING rather than the mechanism — removing the planner hook goes red while every helper test stays green - one of my own tests was too weak and was replaced: it grepped for `BOOTSTRAP_URL` in the source and stayed GREEN when the constant was renamed, over code that would `NameError`. Now drives `load_elements` and reads back the URL actually requested. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_014adnmRztkdAPwinWXnNQ7N
fix: the asset guard hashed raw bytes, so it could only be green on one platform
All checks were successful
CI / syntax (pull_request) Successful in 8s
CI / tests (pull_request) Successful in 1m7s
CI / wheel (pull_request) Successful in 29s
CI / browser (pull_request) Successful in 16m33s
83111ce2e0
test_asset_version.py hashed the four assets byte-for-byte. These are text
files and the repo has no .gitattributes, so CI checks them out LF on
debian-trixie while a Windows clone with core.autocrlf=true holds CRLF.
The digest therefore differed by platform, and the guard was green only
for whoever recorded it last.

Both sides then re-recorded their own value in turn, each fix correct
locally and each breaking the other:

  1c94db7  135  d20d1fa11d2d9a94  CRLF   green here, RED in CI
  bbf3c01  136  a932c205c61efb9b  LF     green in CI, RED here   (PR #27)
  9c2faca  137  d9ca43b746e10252  CRLF   green here, RED in CI

So main has been red in CI since 9c2faca, and PR #27's asset commit was
not wrong -- it was right for Linux. Neither side could see the other's
failure, because each measured the assets through its own checkout.

A CRLF/LF difference is not an asset change a browser needs a new ?v=
for, so it must not move the digest. Normalising before hashing makes the
platform difference unrepresentable rather than documented; recorded
digest is now the normalised value, identical under both conventions.

Chosen over a .gitattributes entry deliberately: that would leave the
guard's correctness depending on every contributor's checkout config,
which is the disconnected-mechanism shape this repo keeps paying for.

Proved RED on both properties -- the one fixed and the one that must not
break:

  CRLF checkout (Windows)              GREEN
  LF checkout (CI debian-trixie)       GREEN
  app.js changed, ?v= not bumped       RED
  tokens.css changed, ?v= not bumped   RED

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014adnmRztkdAPwinWXnNQ7N
feat: Norsk Tipping is the only book on the board, and it fetches itself
All checks were successful
CI / syntax (pull_request) Successful in 8s
CI / tests (pull_request) Successful in 54s
CI / wheel (pull_request) Successful in 28s
CI / browser (pull_request) Successful in 16m55s
42bb44b18a
Removes the six-book consensus from the served surfaces and makes NT the
market for match odds, anytime goalscorer and anytime assist. The consensus
files stay on disk and score_goalscorer.py still scores both every round,
so this is a code change, not a lost comparison.

NT does not open its markets all at once -- measured 2026-09-01/02, goals
open ~4 days out and assists ~2, per fixture -- so a single pre-deadline
pull could never carry both. nt_watch in chores.py polls on COVERAGE
("does the newest banked run carry both markets for every fixture of the
next round", answered from the manifests on disk) rather than on a clock
or a completion marker, so a pull someone ran by hand counts. The
denominator is the round's fixture count from FPL, never the run's.

Four producer-without-a-consumer defects surfaced while wiring it, three
created by the NT-only switch itself: disagreements and
fixtures_without_market were computed and discarded by main.matches;
gw{n}_market_nt.json was written by nt_resolve with nothing rebuilding
the cache to read it; and n_fixtures counted match_odds while the table
was drawn from a different subset.

_unpriced_but_ours no longer returns one flat list. "No book priced X --
the omission is the bookmakers' saying he is unlikely to feature" was
printed over five players in two fixtures NT had not opened at all, which
is a verdict reported where the book had said nothing. It returns
{"declined": [...], "unopened": [...]} keyed on the FPL team id, because
the two need opposite responses.

pre_deadline_steps is now pull -> resolve -> rebuild -> snapshot, so the
frozen cache is the one built from the frozen prices. snapshot_pit.odds_for
takes both market files; the raw NT run is zipped rather than pointed at,
since prune_nt_runs can delete the directory.

Retires _books_expect_him: the coverage-share gate cannot be computed from
one book. Reported rather than patched around -- raising our own start_prob
floor would let the weakest number in the system decide membership.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014adnmRztkdAPwinWXnNQ7N
scripts/fetch_understat.py read FPL elements from data/backfill/bootstrap.json,
a 25 July snapshot carrying 558 elements against 651 live. The 93 missing were
all summer signings, and an unresolved player is skipped with a bare
`continue` -- so the join was silently blindest on exactly the cohort this
file exists to serve, "players whose PL rates are position anchors".

Goretzka is in that gap. CLAUDE.md already records him as one of the five
2026-08-28 transfers a free source had right while our own snapshot did not;
this is the same staleness, one artefact over, and it had been there since
July. The FPL API is open and free, so there was never a reason to read a
stale copy. load_elements() now goes live, with --bootstrap as an explicit
opt-in for tests.

Measured on the tracked model input: foreign_rates.json 281 -> 320 rows,
39 gained and NONE lost. The gains are the right cohort -- Di Gregorio,
Maitland-Niles, Norton-Cuffy, Fernandez-Pardo, all cross-league summer
signings whose PL rate would otherwise be a position anchor.

One surviving player's rates moved when zero should have. Chased rather than
shrugged at: element 316 Emersonn, SAME understat_id 14082, minutes 1517 ->
1607 and games 27 -> 28. Understat revised its own 2025/26 Toulouse data
after the July pull. Not a wrong-identity match; worth knowing that a
"completed" season still moves.

Also, and separately: the season is now an ARGUMENT, and the row label is
DERIVED from it. It was the string literal "2025/26" beside a SEASON
constant of "2025" -- consistent only while nobody moved the constant, which
is precisely what this change does. season_label() derives it, and
test_rows_carry_the_season_they_were_fetched_for drives two seasons through
one code path, because a single season cannot tell a derived label from a
lucky constant.

Adds data/backfill/understat_2026_27.json: 210 players, current-season npxG,
which the FPL API does not publish at all. Not yet consumed -- the
season-choice rule and the chore land next. Banked separately rather than
overwriting foreign_rates, because a 2026/27 measurement is not a 2025/26
measurement.

8 tests. Every guard proved RED, including one of my own that was too weak:
it grepped for BOOTSTRAP_URL in the source and stayed GREEN when the
constant was renamed, over code that would NameError. Replaced with a test
that drives load_elements and reads back the URL it actually requested.

Full suite: 1532 passed, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014adnmRztkdAPwinWXnNQ7N
Two seasons of ONE source, never blended. Each row already carries the
`season` it was measured in, so which one won is visible in the row rather
than inferred from which file it came out of -- "carry the season as a
field", which is what this repo asks for instead of averaging a 25/26
measurement with a 26/27 one.

The current season only displaces last season once it clears
CURRENT_SEASON_MIN_MINUTES (450). Verified a TRUE no-op today: 320 rows in,
320 out, byte-identical to foreign_rates.json, because the current season's
maximum is 270 minutes and nothing qualifies. A no-op by construction, not
by luck -- and the switchover branch therefore cannot fire on real data
until roughly GW6, so it is forced with a synthetic fixture. A branch that
has never fired is not a branch.

NO SEASON IS NAMED IN THE CODE. Files are discovered by glob and ordered by
the `season` field inside them. A path like "understat_2026_27.json" written
into a module is the hardcoded-gameweek family with a season in it, and
CLAUDE.md counts fifteen sites of that shape, every guard for it too narrow
by exactly the axis the next instance used. So the guard here is not a grep
for a filename: `test_the_filename_never_decides_the_season` puts the data
in "understat_something_else_entirely.json" and asserts the row still wins
on its own season field, and `test_an_older_file_cannot_overwrite_a_newer_
season` proves the SEASON decides rather than the alphabetical glob order.

Understat's publishing lag, measured rather than assumed: it carries GW1 and
GW2 with GW2's last kickoff 48h ago. One observation bounds the lag at <=48h
and cannot pin it tighter -- which is the argument for keying the coming
chore on COVERAGE rather than on a clock.

5 tests. Four cuts, four REDs: gate removed, comparison removed, comparison
inverted, glob narrowed to a hardcoded season.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014adnmRztkdAPwinWXnNQ7N
feat: the Understat fetch runs itself, keyed on coverage
All checks were successful
CI / syntax (pull_request) Successful in 9s
CI / tests (pull_request) Successful in 1m18s
CI / wheel (pull_request) Successful in 29s
CI / browser (pull_request) Successful in 16m56s
8958166342
Adds `understat_watch` to chores.py, beside `nt_watch`. It asks "does the
banked current-season file reach the round that has been played" and answers
it FROM THE FILE, so a fetch someone ran by hand counts and the ledger is
never the evidence -- a completion marker is not evidence of what was
completed.

NOT folded into after_gameweek.py, and the reason is measured rather than
assumed. Understat had GW2 by 48h after that round's last kickoff, while
after_gameweek fires the moment the round ends. Chaining it there would
fetch before Understat published and leave us permanently one round behind:
self-healing in principle, never actually caught up. One observation bounds
the lag at <=48h and cannot pin it tighter, which is the argument for keying
on coverage rather than on a clock -- the design does not need the number.

THE FETCH IS ALWAYS FOLLOWED BY A REBUILD. foreign_rates.json and the
current-season file are read at CACHE BUILD time and nowhere else, so a
fetch with no rebuild behind it refreshes a file no projection ever sees.
That is the shape nt_resolve sat in for a week, writing gw{n}_market_nt.json
that only a rebuild reads.

No season is written down: the season id and the output filename are both
DERIVED from the label FPL reports (understat_season_arg, understat_out_path).

A BUG THAT WAS LIVE FOR ABOUT A MINUTE, and the sharpest thing here.
Coverage counted all five leagues. Ligue 1 had played 3 rounds while the PL
had played 2, so it returned 3, never fell below the denominator, and the
watch would have been PERMANENTLY SATISFIED without ever firing once. The
denominator is the FPL gameweek, so the numerator has to be EPL rows --
counting one population while describing another is the "20 fixtures"
coverage line in a different file. Pinned by
test_coverage_counts_only_the_league_the_denominator_belongs_to.

8 tests here, 21 across the three Understat files. Every cut RED, including
two on the WIRING rather than the mechanism: removing the planner hook goes
red while every helper test stays green, and making the watch fire
unconditionally goes red too. A mechanism nothing calls is a comment.

Verified at commit time: the 21 targeted tests pass and all mutation cuts
go RED. The full suite was still running; its result is reported separately
rather than asserted here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014adnmRztkdAPwinWXnNQ7N
docs: record the line-ending guard, the stale-bootstrap join, and FotMob's rejection
All checks were successful
CI / syntax (pull_request) Successful in 8s
CI / tests (pull_request) Successful in 1m8s
CI / wheel (pull_request) Successful in 28s
CI / browser (pull_request) Successful in 17m4s
fcdfba153e
Four findings from 2026-09-02, each written where the next reader will hit it.

THE ASSET GUARD WAS PLATFORM-DEPENDENT and main was red in CI for three days
without anyone being able to see it, because we only ever run the suite on
Windows. Both sides had been re-recording their own platform's digest in
turn -- 135 CRLF, 136 LF, 137 CRLF -- each fix correct locally and each
breaking the other. The repo had already solved this one digest over:
projection_digest normalises line endings and its docstring describes the
same bug. Fixing a defect in one location is not fixing the defect, with the
second location in a different FILE rather than a different line. Recorded
with the general form: "the suite passes" means "passes where I ran it".

THE UNDERSTAT JOIN resolved names against a 25 July bootstrap, 558 elements
against 651 live, silently missing 93 summer signings -- exactly the cohort
that file exists to serve. Recorded as its own category: every guard here
checks whether a VALUE is trustworthy or whether a RUN's scope was
specified, and nothing checked whether the identity side of a JOIN was
current. The rates were impeccable and matched against the wrong squad.

FOTMOB IS REJECTED, measured rather than assumed, with the "no public API"
note from 08-02 corrected (it returns 200 now) and the reason it still does
not earn a place. Includes the correction that matters most: I argued for it
from the 08-28 missed-transfers incident, and that attribution was WRONG --
that was our snapshot being 12h stale, not FPL being behind. A lesson
attributed to the wrong cause recommends the wrong remedy, and this one
nearly bought a scraper for a problem a timestamp already solved.

A COVERAGE CHECK COUNTED THE WRONG COMPETITION -- all five leagues against
an FPL-gameweek denominator, so Ligue 1's extra round kept it permanently
satisfied. The props panel's "20 fixtures" line in a different file.

Also reconciles the fpl-data-sources-recon memory, which still listed
Understat's per-player endpoints as working after CLAUDE.md's 07-31 probe
found all six 404. CLAUDE.md is authoritative; the memory was rewritten
rather than left to be resolved at read time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014adnmRztkdAPwinWXnNQ7N
All checks were successful
CI / syntax (pull_request) Successful in 8s
CI / tests (pull_request) Successful in 1m8s
CI / wheel (pull_request) Successful in 28s
CI / browser (pull_request) Successful in 17m4s
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/understat-current-npxg:feat/understat-current-npxg
git switch feat/understat-current-npxg

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch main
git merge --no-ff feat/understat-current-npxg
git switch feat/understat-current-npxg
git rebase main
git switch main
git merge --ff-only feat/understat-current-npxg
git switch feat/understat-current-npxg
git rebase main
git switch main
git merge --no-ff feat/understat-current-npxg
git switch main
git merge --squash feat/understat-current-npxg
git switch main
git merge --ff-only feat/understat-current-npxg
git switch main
git merge feat/understat-current-npxg
git push origin main
Sign in to join this conversation.
No description provided.