Skip to content

Add team run differential and Pythagorean expectation - #40

Merged
Mattsface merged 1 commit into
mainfrom
issue-39-run-differential
Aug 23, 2026
Merged

Add team run differential and Pythagorean expectation#40
Mattsface merged 1 commit into
mainfrom
issue-39-run-differential

Conversation

@Mattsface

Copy link
Copy Markdown
Member

Summary

  • Adds a /run-differential page: per-game run differential trend plus the Pythagorean expected record beside the actual one
  • No migration and no new MLB request. Runs allowed is already in the database — it is the opponent's own runs on their row for the same game_pk. A self-join in list_team_season_run_results recovers it
  • Win/loss is derived rather than stored: a completed MLB game cannot end tied, so outscoring the opponent is the whole definition. That gives an actual record without waiting on Add game result context and W/L markers to team charts #29
  • Pythagorean expectation uses the exponent 1.83 that Baseball Reference publishes against, so the figure can be checked against a public source

Closes #39.

What makes this page different from the other four

It is the only signed metric. Every other per-game value has a floor of zero; this one does not. The chart draws diverging bars split into win and loss traces rather than a marker line, and its y axis uses rangemode="normal" — anchoring at zero would clip every loss off the chart. Its zero line is drawn darker than the grid, because zero is the win/loss boundary here rather than an arbitrary axis end.

It has no MLB reference line, which is not an omission. League-wide run differential is exactly zero by construction: every run scored by one team is a run allowed by another, so the totals cancel. The zero line already on the chart is the MLB average, and the page says so rather than leaving a reader wondering what is missing.

Missing-data handling

The join is a LEFT join on purpose. A single-team import has no opponent rows at all, and an inner join would silently return zero games for it — indistinguishable from a team that was never imported. Instead the unpaired game_pk values are reported, and the page refuses with the established 409 pattern.

The remedy differs from the batting-strikeout and baserunner backfills: nothing is wrong with the team's own rows and re-importing the team cannot help, so the page names scripts/import_league_season.py instead. A partially paired season is refused too — an average over only the paired games would understate runs allowed and produce a differential that looks plausible and is wrong.

The other four pages read only the team's own rows, so they are unaffected by a single-team import and keep working.

Test plan

  • poetry run pytest — 1309 tests passing, 98% coverage (new: 110 tests across analytics, repository join, chart contract, schemas, formatting, and the web page)
  • poetry run ruff check . / poetry run ruff format --check . clean
  • Repository tests cover the join's real failure modes: a third team sharing a date, doubleheaders (keyed on game_pk, not the date), cross-season isolation, and symmetry from both clubs' perspectives
  • Verified end-to-end against a seeded 162-game two-club season: 162 games paired, 0 unpaired, 94-68 actual vs 88.0 expected wins, page renders 200 with 94 win bars and 68 loss bars spanning −10 to +11

🤖 Generated with Claude Code

https://claude.ai/code/session_0178J2Rs8QQfS1wTqG98FSK9

Adds a fifth per-game metric page (/run-differential) covering run prevention,
which none of the existing pages describe. No migration and no new MLB request
were needed: runs allowed is already in the database, on the opponent's own row
for the same game_pk, so a self-join in list_team_season_run_results recovers it.

Win/loss is derived the same way — a completed MLB game cannot end tied, so
outscoring the opponent is the whole definition — which gives an actual record
to place beside the Pythagorean expectation without a W/L column.

Two things make this page unlike the other four. It is the only signed metric,
so the chart draws diverging bars split by outcome and its y axis must not
anchor at zero. And it has no MLB reference line, which is not an omission:
league-wide run differential is exactly zero by construction, so the zero line
already on the chart is the league average.

The join is a LEFT join on purpose. A single-team import has no opponent rows,
and an inner join would return zero games — indistinguishable from a team that
was never imported. Instead unpaired game_pks are reported and the page refuses
with a 409, naming the league-season import as the fix rather than the team
re-import the other missing-data pages ask for.

Closes #39.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0178J2Rs8QQfS1wTqG98FSK9
@Mattsface
Mattsface merged commit 8ba6d66 into main Aug 23, 2026
1 check passed
@Mattsface
Mattsface deleted the issue-39-run-differential branch August 23, 2026 18:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add team run differential and Pythagorean expectation page

1 participant