Add team pitching: new table, ingestion path, and pitches-per-game page - #42
Merged
Conversation
Adds team_game_pitching_lines, the pitching counterpart to the batting line table, plus the ingestion path that fills it. Innings are stored as outs, an integer, and never as innings pitched. MLB returns inningsPitched as a string in baseball notation where '10.2' means ten and two-thirds innings, so parsing it as a decimal would silently corrupt every derived rate. The same split carries outs=32 for that game, and ER * 27 / outs reproduces the API's own published ERA exactly. Only raw components are stored; ERA, WHIP, K/9 and BB/9 are derived on read so a stored rate cannot drift from its components. A separate table rather than more columns on the batting line: the two are different stat groups from different requests, and half of each one's columns would be meaningless on the other row. It also means a season imported before pitching existed simply has no pitching rows, so nothing needs the nullable-until-backfilled treatment the strikeout and baserunner columns got. Fetching both groups costs four MLB requests, not six -- get_team_game_lines shares the team lookup and the season schedule between the two game logs, which is 60 requests saved over a 30-club league import. Both persist in the same transaction so a team-season can never hold batting rows without pitching ones. The two logs are validated against opposite sides of the score: a hitting split's runs must equal the selected team's scheduled score, a pitching split's runs are runs allowed and must equal the opponent's. Three definitional check constraints (earned runs within runs, home runs within hits, batters faced covering outs) were verified against 648 real 2025 team-games before being encoded. The batting and pitching upserts are one generic function rather than two near-identical copies, addressing duplication flagged in the review of #38. Refs #41. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0178J2Rs8QQfS1wTqG98FSK9
Adds the team and league pitching analytics, the ERA figure, the summary-card and note formatters, the /pitching route, and the nav entry. The correctness point of this layer is that ERA, WHIP, K/9 and BB/9 are rates, not counts. Every other analytics module averages per-game values because those are counts; averaging per-game rates is a different statistic. For the 2025 Mariners the season ERA is 3.870 while the mean of the 162 game ERAs is 3.965, an error that would match no published figure. Every rate here sums the numerator and denominator across the games in scope and divides once, including the rolling window, which accumulates earned runs and outs rather than smoothing game ERAs. League rates are outs-weighted rather than game-weighted for the same reason. The sign convention on this page is the opposite of every other one: a negative difference against MLB is the better direction, because a lower ERA is better. The comparison card caption and a rendered sentence both say so rather than leaving a reader to infer it. Verified against the live API: 162 games, ERA 3.87, WHIP 1.220, K/9 8.77, BB/9 2.79, matching the figures computed directly from the raw payload. The pitching.html template and the test suite for this layer are still to come, so /pitching is not yet reachable end to end. Refs #41. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0178J2Rs8QQfS1wTqG98FSK9
Makes /pitching reachable end to end with pitches per game as the chart, plus ERA, WHIP, K/9 and BB/9 in the summary cards. Adds number_of_pitches and strikes to the pitching table. Balls are not stored: MLB leaves that field empty on the team game log even though it populates strikes, and balls are number_of_pitches - strikes, so a column would only invite the two to drift. These columns are folded into the existing create migration rather than added by a second one. That revision has not been merged or released, and the local database was still on the previous revision, so amending it avoids a nullable column that could never legitimately be null. Pitches per game is a count, not a rate, so unlike ERA and WHIP its season figure is a plain mean of the per-game values. build_pitch_count_points says so explicitly, since the surrounding module exists largely to warn against averaging rates. A team-season imported before pitching was collected has no pitching rows at all, and the page returns 409 naming the team re-import as the fix. Every pitching column is NOT NULL, so there is no partially-known state to report. Verified against the real database: 162 rows imported for the 2025 Mariners, 23,623 pitches over 1,462.2 IP, 145.8 per game, 65% strikes, ERA 3.87, WHIP 1.22. The batting rows re-imported as unchanged. Refs #41. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0178J2Rs8QQfS1wTqG98FSK9
Adds 59 tests across the pitching layer and the design doc for it, taking coverage back to 94% with the two pitching analytics modules at 96% and 100%. The tests that matter most build seasons with unequal innings. Rate aggregation is only distinguishable from a naive mean of per-game rates when the denominators differ -- with a regulation nine innings in every game the two agree exactly, so a test built that way would pass against a wrong implementation. One test documents that case explicitly so the choice is not mistaken for an accident. Also covered: that the '10.2' display string is never used in a calculation (reading it as a decimal gives 7.90 rather than the correct 7.59), that a team-season without pitching rows returns 409 naming the team re-import, and that unearned runs stay distinct from earned ones in the captured fixture. Refs #41. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0178J2Rs8QQfS1wTqG98FSK9
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The first feature here that is not free. Every previous page read the hitting
gameLogsplit already being fetched, or derived new figures from rows already stored. Pitching is a separate MLB stat group in a separate request, landing in a new table.team_game_pitching_linestable + migration27a202039134/pitchingpage: pitches per game as the chart, with ERA, WHIP, K/9 and BB/9 in the summary cardsCloses #41.
Innings are stored as outs
The single most important decision in this PR. MLB returns
inningsPitchedas a string in baseball notation:Parsing that as a decimal silently corrupts every derived rate, and the result stays plausible enough to go unnoticed. The same split carries
outsas an exact integer, so that is the column:innings_pitched_displayreconstructs the10.2form for display only, and a test asserts it is never used in a calculation.Only raw components are persisted — ERA, WHIP, K/9 and BB/9 are derived on read, so a stored rate cannot drift from its components. Balls are not stored either: MLB leaves that field empty on the team game log, and balls are
number_of_pitches - strikes.Counts and rates aggregate differently
Every previous page charts a count, where the mean of the per-game values is the right season figure. Pitches per game follows that rule.
ERA, WHIP, K/9 and BB/9 are rates, and a rate over several games is the ratio of the summed totals:
That 0.094 gap would match no published source. The same rule governs the rolling window (which accumulates earned runs and outs rather than smoothing game ERAs) and the league context (outs-weighted, not game-weighted).
The tests that cover this build seasons with unequal innings on purpose — with a regulation nine innings in every game the correct aggregation and a naive mean agree exactly, so a test built that way would pass against a wrong implementation. One test documents that case explicitly.
Ingestion costs one extra request, not three
get_team_game_linesshares the team lookup and the season schedule between the two game logs:Over a 30-club league import that is 60 requests saved. Both persist in the same transaction, so a team-season can never hold batting rows without pitching ones.
include_pitching=Falsedrops back to the original three requests.The two logs are validated against opposite sides of the score — a hitting split's runs must equal the selected team's scheduled score, a pitching split's runs are runs allowed and must equal the opponent's. That makes the schedule an independent check on which stat group a split belongs to.
Notes for review
NOT NULL.number_of_pitchesandstrikeswere folded into the create migration, which had not been merged or released. Any machine that already ran the earlier version needsalembic downgrade -1 && alembic upgrade head.include_pitching=False.Test plan
poetry run pytest— 1370 tests passing, 94% coverage (59 new;team_pitching96%,league_pitching100%)poetry run ruff check ./ruff format --check .cleanUnchanged: 162, confirming ingestion stays idempotent/pitchingreturns 200 for a season with pitching and 409 with re-import guidance for one without🤖 Generated with Claude Code
https://claude.ai/code/session_0178J2Rs8QQfS1wTqG98FSK9