This document describes how Milestone 3.5 extends the Milestone 3 team hits page with a second metric: a team's batting strikeouts per game, with a trailing rolling average.
It answers one question:
Is this team's offense striking out more or less frequently per game as the season progresses?
Issue #23 later added a second question to the same page, documented in section 13:
How many times per game does this team's offense strike out compared with MLB overall?
Throughout this document and the UI, "strikeouts" means batting strikeouts — times the team's own hitters struck out. Strikeouts recorded by the team's pitchers are a different statistic and are not in scope.
Batting strikeouts come from the team hitting gameLog split that Milestone 1
already retrieves.
| Layer | Name |
|---|---|
| MLB Stats API JSON | strikeOuts |
python-mlb-statsapi model |
SimpleHittingSplit |
| Python attribute | split.stat.strikeouts |
| Python type | Optional[int], default None |
The attribute was verified against the installed package (python-mlb-statsapi
0.8.0) rather than guessed:
from mlbstatsapi.models.stats.hitting import SimpleHittingSplit
SimpleHittingSplit.model_fields["strikeouts"].alias # -> "strikeOuts"strike_outs does not exist on the model; only strikeouts does. The
alias is what maps it to MLB's raw strikeOuts key.
The team-season strategy is unchanged at three requests:
get_team(team_id, season=...) → the team's name for that season
get_team_stats(..., stats=["gameLog"], → one split per played game:
groups=["hitting"]) hits, runs, AND strikeOuts
get_schedule(...) → status, opponent, doubleheader, innings
Hits, runs, and batting strikeouts all arrive in the same hitting game-log split. Strikeouts were already being fetched and discarded; Milestone 3.5 only starts reading the field. Adding a request for them would have paid for data already in hand.
tests/test_team_season_ingestion.py::test_import_makes_exactly_three_mlb_requests
pins this so a future change cannot quietly add a fourth call.
TeamGameBattingLine gains:
strikeouts: int | None = Field(default=None, ge=0)The two layers apply deliberately different rules:
| Layer | Rule |
|---|---|
| Domain schema | None is permitted, so rows persisted before this milestone can be loaded |
| MLB normalization | None is refused; a fresh response must carry a real count |
app/services/team_game_logs.py::_require_batting_strikeouts raises
TeamGameDataError naming the gamePk and the field when the value is absent,
JSON null, not an integer, or negative. Nothing is substituted:
- a missing total never becomes
0 - a game with missing data is never silently dropped from the import
A genuine 0 — a game in which nobody struck out — is a real value and is
stored as 0.
strikeouts INTEGER NULL
CHECK (strikeouts IS NULL OR strikeouts >= 0)Rows written before this milestone were ingested without strikeouts. Their real
totals are unknown, not zero. A DEFAULT 0 would have written a plausible
number into every historical row and made a fabricated season indistinguishable
from a real one.
So the column is nullable with no default, and NULL means exactly "this game's
batting strikeout total was never collected". The check constraint allows NULL
while rejecting negatives, so an unknown stays honest and a known value stays
sane.
Revision 94dec6973c80, following 166b6424e4f9.
SQLite cannot attach a CHECK constraint with ALTER TABLE, so the table is
rebuilt with op.batch_alter_table(..., recreate="always"). The migration
passes copy_from= with the pre-revision table written out in full, because
the SQLite dialect does not reflect CHECK constraints — a rebuild driven by
reflection alone would have silently dropped the hits >= 0, runs >= 0, and
home_away IN (...) rules. tests/test_migrations.py asserts those constraints
still fire after the upgrade.
The migration preserves every existing row and the query index. downgrade()
drops the column and its constraint, returning to the pre-3.5 schema; recorded
strikeout values are lost, because the older schema has nowhere to keep them.
There is no separate strikeout import. The existing command backfills naturally, because the upsert compares the whole domain record:
poetry run alembic upgrade head
poetry run python scripts/import_team_season.py --team-id 136 --season 2025| State | Result |
|---|---|
| Before re-import | strikeouts is NULL for rows imported earlier |
| First re-import | rows counted as updated, strikeouts set to the MLB value |
| Second re-import (unchanged MLB data) | rows counted as unchanged |
Idempotency is unchanged from Milestone 2: strikeouts simply join the set of fields the stored-vs-incoming comparison covers.
For one completed game, the plotted value is the raw count of batting strikeouts. No denominator is applied.
The season average is:
season_average = sum(strikeouts over stored completed games)
/ number of stored completed games
TeamStrikeoutsSummary.season_average is the single authoritative value; the
chart's dashed reference line and the "Season Avg" card both read it from there
rather than recomputing it.
Identical in shape to the hits page. The average is trailing, never
centered. For game N it covers the window most recent games including N:
window = 15
game 1 → average of game 1
game 2 → average of games 1-2
...
game 15 → average of games 1-15
game 16 → average of games 2-16
Early-season points use every game played so far rather than leaving a gap. A centered average would let later games influence an earlier point and misrepresent what the offense looked like at the time.
Supported windows are 5, 10, 15, and 30. Full floating-point precision is kept
in the analytics layer; rounding happens only in app/web/formatting.py and in
the chart's hover template.
Since issue #23 this comparison is calculated but no longer displayed. The
vs Prior {window}card was replaced by thevs MLBcard so the row keeps four cards instead of growing a fifth.prior_window_averageandchange_vs_prior_windoware still calculated, still validated, and still tested; the chart's rolling average shows the same trend the card described. The definition below is unchanged and still governs those fields.
The prior-window comparison measures the current complete window against the immediately preceding equal-sized complete window:
games_played >= 2 * window → prior window = games [-2*window : -window]
games_played < 2 * window → both fields are None
Two complete windows are required. Comparing a full window against a partial one would report a change caused by sample size rather than by hitters.
A positive change means more batting strikeouts. It is not styled as good or bad, and no positive/negative colouring is applied. Whether more strikeouts matter depends on the question being asked and on what else the offense is doing — a team may strike out more while also hitting for more power. The page states the direction and leaves the judgement to the reader.
The dependency direction is the repository's existing one:
scripts/import_team_season.py ──► MLB Stats API ──► SQLite (offline, CLI)
GET /strikeouts?team_id=136&season=2025&window=15
│
├─ list_available_team_seasons(session) ──► selector options
├─ list_team_season(session, ...) ──► list[TeamGameBattingLine]
├─ build_team_strikeouts_analysis(games, ...) ──► TeamStrikeoutsAnalysis
│
├─ get_league_season_ingestion(session, season=...) ──► coverage
├─ supports_league_wide_strikeout_average(coverage) ──► rule 1
├─ list_league_season(session, season=...) ──► every stored record
├─ build_league_strikeouts_context(records) ──► rule 2 + the formula
├─ compare_team_strikeouts_to_league(analysis, context)
│
├─ build_team_strikeouts_figure(analysis, comparison) ──► plotly Figure
└─ strikeouts.html ──► HTML
The MLB-wide steps are the ones issue #23 added; section 13 describes them.
/ remains the hits page and its URL is unchanged. Like /, /strikeouts
reads persisted SQLite only and never calls MLB during a browser request.
app/analytics/team_strikeouts.py imports Pydantic schemas and nothing else
from the application — no SQLAlchemy, FastAPI, Jinja, Plotly, or MLB client
models.
The two metrics have the same visualization shape, and that was deliberately
not turned into a shared metric abstraction. AGENTS.md asks for a small
amount of obvious duplication over a premature abstraction, and these two
statistics are not interchangeable: they mean different things, they read
different source fields, one tolerates NULL history and the other does not,
and their labels must stay distinct. A GenericMetricEngine would have to
encode all of that as configuration.
What is shared is genuinely shared infrastructure that already existed: the
team-season selector (app/web/selection.py), the selector form partial
(_selector_form.html), figure rendering, and date/matchup formatting.
If any stored game in the selected team-season has strikeouts IS NULL,
build_team_strikeouts_analysis raises MissingStrikeoutDataError and the page
renders an actionable state instead of a chart (HTTP 409).
It explicitly does not:
- chart unknown games as zero
- drop them and present the remaining games as a complete season
- calculate an average over a silently reduced set of games
The rendered guidance names the count of affected games and the exact command for the selected team and season, not a hardcoded example:
poetry run python scripts/import_team_season.py --team-id 136 --season 2025One unknown game is enough to trigger this: a partially backfilled season cannot be described honestly as either complete or incomplete-but-fine.
The hits page is unaffected. / continues to work normally against rows with
null strikeouts, because hits were never missing.
K/Game is a count, not a rate. This is the most important limitation of the page and it is stated on the page itself.
Games do not contain equal numbers of opportunities. Extra-inning games give hitters more plate appearances; games shortened by weather give fewer; a long inning inflates a single game's totals. A game with more opportunities can show more strikeouts without hitters striking out any more often.
So K/Game legitimately supports:
- how many times this team's hitters struck out in each stored completed game
- whether that per-game count is trending up or down across the stored season
It does not support:
- how often hitters struck out per opportunity
- comparison against teams with different game-length profiles
- any claim about league context unless the season holds complete league-season coverage and a known strikeout total on every stored record, which is exactly what section 13 requires before an MLB average is shown
Every number describes the completed games currently stored, which may be a season in progress or a partial import. The footer's "Data through" date shows how current the stored games are.
Strikeout rate:
K% = strikeouts / plate appearances
is the opportunity-adjusted measure, and it is the better answer to "is this offense striking out more often". It is deferred because plate appearances are not persisted.
The hitting game-log split does carry plateAppearances, so the data is
reachable — but persisting it is a schema change, a migration, and a re-import
of its own, with the same nullable-history problem this milestone just solved
for strikeouts. Bundling it here would have widened Milestone 3.5 beyond its one
question.
K% is not estimated or approximated in the meantime. Dividing by innings, by games, or by at-bats would produce a number that looks like K% and is not, which is worse than not showing it. The page says plainly that K% is a better measure for some questions and that it is deferred.
Issue #23 gives this page the same MLB-wide context the hits page received in Milestone 5, answering:
How many times per game does this team's offense strike out compared with MLB overall?
This is batting strikeouts per game. It is not pitching strikeouts, not K%, not a plate-appearance rate, and not a measure where a higher value is automatically better or worse.
MLB batting K/Game = total batting strikeouts across all persisted team-game
records for the selected season
────────────────────────────────────────────────────────
total persisted team-game records for that season
Implemented in app/analytics/league_strikeouts.py::build_league_strikeouts_context,
returning a LeagueStrikeoutsContext:
| Field | Meaning |
|---|---|
season |
The season every counted record belongs to |
teams_represented |
Distinct team_ids with at least one stored game |
team_game_records |
Team-game batting lines counted |
total_strikeouts |
Batting strikeouts summed across those records |
strikeouts_per_game |
total_strikeouts / team_game_records |
The schema re-derives strikeouts_per_game from the two totals in a validator,
so a context holding an average nothing in it produced cannot be constructed.
One real MLB game produces two team batting lines, one per club, so the denominator counts team-game records, not games. A full 30-team, 162-game season is about 4,860 of them.
Every stored team-game record counts once, so a club that has played more games contributes proportionally more. That is what "MLB overall" means here.
The alternative — averaging each club's own K/Game — answers a different question and gives a different number:
Team A: 10 K, 8 K (2 games)
Team B: 6 K (1 game)
game-weighted (implemented) : (10 + 8 + 6) / 3 = 8.0
mean of team averages : ((10 + 8) / 2 + 6) / 2 = 7.5
Unequal game counts are the normal case for an in-progress season, and the
unweighted mean would silently give a club with 40 games the same weight as one
with 162. test_unequal_team_game_counts_are_weighted_by_games_played exists to
catch exactly that mistake.
Nothing assumes thirty clubs, 162 games, or 4,860 records. The denominator is always counted from the records actually stored.
Unlike hits, an MLB batting K/Game needs two conditions, because strikeouts
have a NULL history that hits never had.
Rule 1 — complete league-season coverage.
supports_league_wide_strikeout_average reuses the Milestone 5 rule unchanged:
coverage is not None
and coverage.status is LeagueSeasonIngestionStatus.COMPLETECompleteness is never inferred from a row count, a team count, thirty team ids,
162 games, or 4,860 records. COMPLETE describes the latest league-wide
refresh covering every discovered team — not that the baseball season is over.
The rule is deliberately not re-implemented here; two copies could drift and let
one page call a season MLB-wide while the other did not.
Rule 2 — every counted record has a known strikeout total.
Rows imported before Milestone 3.5 hold strikeouts IS NULL, meaning unknown.
build_league_strikeouts_context raises MissingLeagueStrikeoutDataError when
any record for the season is NULL. It does not:
- drop those rows and label the remainder MLB-wide
- read
NULLas0 - average only the non-
NULLsubset
Complete coverage says every team was refreshed; it says nothing about whether older rows were rewritten with strikeout totals, so a season can satisfy rule 1 and fail rule 2.
| State | MLB average | Chart | vs MLB card |
Page |
|---|---|---|---|---|
COMPLETE + every record has a total |
calculated and shown | MLB reference line added | signed number | works |
INCOMPLETE |
not calculated | no MLB line | — |
works |
RUNNING |
not calculated | no MLB line | — |
works |
| no coverage record | not calculated | no MLB line | — |
works |
COMPLETE + any league record is NULL |
not calculated | no MLB line | — |
works, with backfill guidance |
selected team has NULL totals |
not calculated | no chart (HTTP 409) | not rendered | existing re-import guidance, unchanged |
RUNNING is treated exactly like INCOMPLETE: a run that never finished left
its coverage unknown.
The first four unavailable states read:
MLB comparison unavailable. A complete league-season import is required before
an MLB-wide batting strikeout average can be shown.
The NULL-records state is a different problem with a different remedy, so it
gets its own wording naming the gap and the league import:
MLB comparison unavailable. 12 of the 4,860 team-game records stored for 2025
have no batting strikeout total, because they were imported before batting
strikeouts were persisted. They are not counted as zero and the rest are not
presented as MLB overall. Re-import the league season to backfill them:
poetry run python scripts/import_league_season.py --season 2025
The selected team's own behavior is untouched: a team-season with any NULL
total still returns HTTP 409 with the team re-import guidance from section 10,
rather than a partial analysis.
None of these states breaks an otherwise valid page. Everything Milestone 3.5 rendered still renders.
COMPLETE describes the refresh, not the season. Both of these are valid:
| Season | Coverage | Situation | Comparison |
|---|---|---|---|
| 2025 | COMPLETE |
finished historical season | available |
| 2026 | COMPLETE |
still being played, well under a full season | available |
The 2026 case works precisely because coverage is not a row-count rule. Forty stored team-game records with complete coverage qualify exactly as 4,860 would. What the MLB average then describes is MLB-wide batting strikeouts across the completed games currently stored, which is what the page says in words.
difference_vs_mlb = team batting strikeouts per game
- MLB batting strikeouts per game
Team K/Game: 7.80 MLB K/Game: 8.40 vs MLB: -0.60
A negative value means the team's hitters struck out fewer times per game than MLB overall; a positive value means more.
That is all it means. It is plain subtraction of two descriptive averages: not normalized, not ranked, not a percentile, not tested for significance, and not a claim about cause. Neither direction is labelled good or bad, and the card carries no positive/negative styling. Striking out less often is not automatically better hitting — a club may strike out more while also hitting for more power — so the page states the direction and leaves the judgement to the reader.
The team side is TeamStrikeoutsAnalysis.summary.season_average, the same value
the chart's team reference line and the Season Avg card read, so the page
cannot show two different team averages.
K/Game is still not K%. MLB context does not adjust for opportunity; section
11 and section 12 still apply, and section 12's reasons for deferring K% are
unchanged.
| # | Name | Style | Purpose |
|---|---|---|---|
| 1 | Game Strikeouts |
thin grey line, open markers | game-to-game variation |
| 2 | {window}-Game Average |
thick teal line | the trend |
| 3 | Team Season Average |
dashed navy horizontal line | the team's own level |
| 4 | MLB Average |
dotted amber horizontal line | MLB overall, when both rules hold |
The team reference line was renamed from Season Average to
Team Season Average, matching the hits chart: with two horizontal lines on one
chart, "Season Average" no longer says whose. The two lines differ in both hue
and dash pattern so they stay apart in greyscale and for a colour-blind reader.
Both are straight lines with hoverinfo: skip, no spline is used anywhere, and
no existing trace changed meaning. Without a comparison the figure is exactly
the three-trace chart Milestone 3.5 built.
Only one horizontal line is annotated with its value — the MLB line when it is drawn, the team line otherwise — because the two can sit a tenth of a strikeout apart, where two labels would collide.
Recent 15-Game Avg Season Avg vs MLB Games Played
8.53 7.80 -0.60 40
The vs MLB card replaced the vs Prior 15 card rather than being added beside
it, so the row keeps four cards on the existing grid (see section 8). Without a
comparison it reads — / "Comparison unavailable"; it never shows 0.00 for
unavailable, because 0.00 is a real value meaning the team matched MLB
exactly. Rounding to two decimals happens only in app/web/formatting.py.
Normal browser requests remain database-only. The comparison reads two
repository queries — the recorded coverage state and the season's stored records
— and nothing else. No MLB request was added to /strikeouts or to any other
page request, and no new persistence column was needed.
Getting league data into the database is still an explicit CLI step:
poetry run python scripts/import_league_season.py --season 2025Tests assert this directly by making requests.Session.request,
mlbstatsapi.Mlb.__init__, get_team_game_batting_lines, discover_mlb_teams,
and ingest_league_season raise, then loading the page with complete coverage
and asserting the MLB line is still drawn.
| Layer | Holds |
|---|---|
app/database/repositories.py |
list_league_season — persistence and querying only |
app/analytics/league_strikeouts.py |
the formula, the difference, the NULL rule |
app/analytics/league_hitting.py |
the shared COMPLETE coverage rule |
app/schemas/analytics.py |
LeagueStrikeoutsContext, TeamStrikeoutsLeagueComparison and their invariants |
app/web/routes.py |
wiring, in _load_league_strikeouts_comparison |
app/web/charts.py |
the MLB reference trace |
app/web/formatting.py |
rounding and the wording |
app/analytics/league_strikeouts.py is a focused module, not a generic metric
framework. Hits and batting strikeouts keep separate league implementations for
the same reasons section 9 gives for their team implementations, and issue #23
added one more: hits have no NULL history and strikeouts do, so the two
league calculations do not have the same preconditions.
- The comparison covers hits and batting strikeouts. Runs have no league context yet.
teams_representedcounts clubs with stored games for the season. Under complete coverage that is the league; it is reported for transparency, not used as a completeness rule.- Coverage is per season. Complete 2025 coverage says nothing about 2026, and the page treats them independently.
- The
NULLcheck scans the season's stored records on each page request. A full season is roughly 4,860 domain objects, which is immediate; if that ever stops being true, the change belongs with the measurement that motivated it. - No live MLB validation was performed for this change; every automated test is offline, seeding rows and coverage states directly.