Skip to content

Add team pitching: new table, ingestion path, and pitches-per-game page - #42

Merged
Mattsface merged 4 commits into
mainfrom
issue-41-pitching
Aug 23, 2026
Merged

Add team pitching: new table, ingestion path, and pitches-per-game page#42
Mattsface merged 4 commits into
mainfrom
issue-41-pitching

Conversation

@Mattsface

Copy link
Copy Markdown
Member

Summary

The first feature here that is not free. Every previous page read the hitting gameLog split 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.

  • New team_game_pitching_lines table + migration 27a202039134
  • /pitching page: pitches per game as the chart, with ERA, WHIP, K/9 and BB/9 in the summary cards
  • Ingestion fetches both stat groups and persists them in one transaction

Closes #41.

Innings are stored as outs

The single most important decision in this PR. MLB returns inningsPitched as a string in baseball notation:

inningsPitched = '10.2'   means ten and two-thirds innings, NOT 10.2 of them

Parsing that as a decimal silently corrupts every derived rate, and the result stays plausible enough to go unnoticed. The same split carries outs as an exact integer, so that is the column:

outs 32  ->  ER 9 * 27 / 32 = 7.59        MLB's own era for that game: '7.59'
reading '10.2' as a decimal:  9 * 9 / 10.2 = 7.90     wrong

innings_pitched_display reconstructs the 10.2 form 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:

season ERA (correct)   629 ER * 27 / 4388 outs   =  3.870
mean of 162 game ERAs                            =  3.965

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_lines shares the team lookup and the season schedule between the two game logs:

get_team 1 + get_schedule 1 + get_team_stats 2  =  4 requests, not 6

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=False drops 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

  • A separate table, not more columns. A season imported before pitching existed has no pitching rows, so nothing needs the nullable-until-backfilled treatment the strikeout and baserunner columns required. Every pitching column is NOT NULL.
  • The migration was amended rather than stacked. number_of_pitches and strikes were folded into the create migration, which had not been merged or released. Any machine that already ran the earlier version needs alembic downgrade -1 && alembic upgrade head.
  • Shared test fakes now serve both stat groups and filter by the requested group, so a service reading the wrong group fails loudly. Existing batting-path tests pass include_pitching=False.
  • The batting/pitching upserts are one generic function, addressing duplication flagged in the review of Add team baserunners/game trend #38.
  • No MLB line on the chart. A league-wide pitches-per-game figure needs every club's pitching imported. The league comparison machinery is built, tested, and wired into the route; it simply has no line yet. Its sign convention is inverted from every other page — negative ERA difference is better — which is worth a look before it surfaces.

Test plan

  • poetry run pytest — 1370 tests passing, 94% coverage (59 new; team_pitching 96%, league_pitching 100%)
  • poetry run ruff check . / ruff format --check . clean
  • Migration upgrade/downgrade/upgrade round-trip verified; 18 check constraints present; the three definitional constraints verified to reject bad rows
  • Relational constraints validated against 648 real 2025 team-games across four clubs before being encoded
  • Verified against the live API and a real local database: 162 rows imported for the 2025 Mariners — 23,623 pitches over 1,462.2 IP, 145.8/game, 65% strikes, ERA 3.87, WHIP 1.22, K/9 8.77, BB/9 2.79, all matching figures computed directly from the raw payload
  • Re-importing batting reported Unchanged: 162, confirming ingestion stays idempotent
  • /pitching returns 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

Mattsface and others added 4 commits August 23, 2026 11:37
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
@Mattsface
Mattsface merged commit 5ee96a7 into main Aug 23, 2026
1 check passed
@Mattsface
Mattsface deleted the issue-41-pitching branch August 23, 2026 23:03
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 pitching stats: new table, ingestion path, and ERA page

1 participant