From 7cb255db36aeed079858f7f3f3b919ed4e4f8153 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Sun, 23 Aug 2026 16:26:37 -0700 Subject: [PATCH] feat: add team hits allowed per game page Adds /hits-allowed, the mirror of the Hits page: hits surrendered by a team's pitching rather than recorded by its hitters. No migration and no new MLB request -- hits_allowed was already stored on the pitching line from #41. Two properties shape the page. The pitching figure agrees with the opposing batting row. MLB reports the two in independently fetched stat groups, and across all 162 games of the 2025 Mariners hits_allowed equals the opponent's own hits for the same game_pk, with zero mismatches. The test suite asserts that agreement against the captured fixture rather than assuming it. The MLB average comes from the batting table. Summed league-wide, hits and hits allowed are the same total over the same count of team-game records, so MLB Hits Allowed/Game == MLB Hits/Game (2025: 40,138 / 4,860 = 8.2588). The comparison therefore needs complete league-wide batting coverage, which most stored seasons have, rather than every club's pitching lines -- which is why this page shows an MLB line where the ERA comparison on /pitching usually cannot. The identity holds for the league as a whole and for no subset of it, and the module says so. Hits allowed per game is a count, so its season figure is a plain mean. H/9 is the one rate and divides summed totals; a test pins the gap between them on a season with uneven innings (6.00 per game against 9.00 per nine). Lower is better here, the reverse of the page this one mirrors. The card caption and a rendered sentence both say so rather than relying on the sign alone. Verified against the real database: 1,331 hits allowed over 1,462.2 IP, 8.22 per game, H/9 8.19, -0.04 against an MLB average of 8.26. Closes #43. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0178J2Rs8QQfS1wTqG98FSK9 --- README.md | 33 ++ app/analytics/league_hits_allowed.py | 95 ++++++ app/analytics/team_hits_allowed.py | 172 ++++++++++ app/schemas/analytics.py | 189 +++++++++++ app/web/charts.py | 172 ++++++++++ app/web/formatting.py | 113 +++++++ app/web/navigation.py | 12 +- app/web/routes.py | 172 ++++++++++ app/web/templates/hits_allowed.html | 148 +++++++++ docs/team-hits-allowed-visualization.md | 101 ++++++ tests/test_hits_allowed.py | 409 ++++++++++++++++++++++++ tests/test_navigation.py | 20 +- 12 files changed, 1631 insertions(+), 5 deletions(-) create mode 100644 app/analytics/league_hits_allowed.py create mode 100644 app/analytics/team_hits_allowed.py create mode 100644 app/web/templates/hits_allowed.html create mode 100644 docs/team-hits-allowed-visualization.md create mode 100644 tests/test_hits_allowed.py diff --git a/README.md b/README.md index 2f678e8..e29343b 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Normal browser requests do **not** call the MLB Stats API. - Team Baserunners/Game trends (hits + walks + hit-by-pitch) - Team run differential and Pythagorean expected record, from a league-wide import - Team pitching: pitches per game, with ERA, WHIP, K/9 and BB/9 +- Team hits allowed per game, with an MLB comparison and H/9 - MLB-wide per-game comparisons when league coverage is trustworthy - Normalized Hits vs batting Strikeouts comparison with MLB average = 100 - Team, season, and rolling-window selectors with shareable URLs @@ -141,6 +142,7 @@ Current routes: | `/baserunners` | Team Baserunners/Game | | `/run-differential` | Team run differential and Pythagorean record | | `/pitching` | Team pitches per game, with ERA and WHIP | +| `/hits-allowed` | Team hits allowed per game, with H/9 | | `/comparison` | Normalized Hits vs batting Strikeouts | | `/health` | JSON health check | @@ -301,6 +303,36 @@ rolling window, which accumulates earned runs and outs rather than smoothing game ERAs, and to the league context, whose rates are outs-weighted rather than game-weighted. +### Hits allowed + +`hits_allowed` is stored on the pitching line, so `/hits-allowed` needs no +migration and no new MLB request beyond the pitching import itself. A +team-season without pitching rows returns the same 409 the pitching page does. + +Two properties make this page unusual. + +**The figure agrees with the opposing batting row.** Every hit by one team is a +hit allowed by another, and MLB reports the two in independently fetched stat +groups. Across all 162 games of the 2025 Mariners, `hits_allowed` on the +pitching row equals the opponent's own `hits` on their batting row, with zero +mismatches. + +**The MLB average comes from the batting table.** Summed across the whole +league, hits and hits allowed are the same total over the same count of +team-game records: + +```text +MLB Hits Allowed/Game == MLB Hits/Game 2025: 40,138 / 4,860 = 8.2588 +``` + +So this comparison needs a complete league-wide **batting** import, which most +stored seasons have — not every club's pitching, which the ERA comparison on +`/pitching` requires. The identity holds for the league as a whole and not for +any subset: one club's hits allowed has nothing to do with its own hits. + +Direction note: fewer hits allowed is better, the reverse of the Hits page this +one mirrors. The summary card caption and a rendered sentence both say so. + ### Normalized comparison The comparison page puts two different statistics on a common scale: @@ -401,6 +433,7 @@ including: - [Team baserunners visualization](docs/team-baserunners-visualization.md) - [Team run differential visualization](docs/team-run-differential-visualization.md) - [Team pitching visualization](docs/team-pitching-visualization.md) +- [Team hits allowed visualization](docs/team-hits-allowed-visualization.md) - [Team vs MLB comparison](docs/team-vs-mlb-comparison.md) - [Normalized hitting trends comparison](docs/team-hitting-trends-comparison.md) - [League-season ingestion](docs/league-season-ingestion.md) diff --git a/app/analytics/league_hits_allowed.py b/app/analytics/league_hits_allowed.py new file mode 100644 index 0000000..c3165e1 --- /dev/null +++ b/app/analytics/league_hits_allowed.py @@ -0,0 +1,95 @@ +"""MLB-wide hits-allowed context for one season. + +Answers one question: + + How many hits per game does this team's pitching allow compared with MLB + overall? + +This module is unusually short, because of an identity worth stating plainly: + + **MLB Hits Allowed/Game == MLB Hits/Game** + +Every hit by one team is a hit allowed by another, so summed across the whole +league the two totals are the same number, over the same count of team-game +records. There is no separate league hits-allowed figure to calculate. + +That has a practical consequence. The MLB side of this comparison is built from +``team_game_batting_lines`` via the existing ``build_league_hits_context``, +which means it is available for any season with complete **batting** coverage. +It does **not** require every club's pitching lines to be imported, unlike the +ERA comparison on ``/pitching``. Only the selected team needs pitching rows. + +The identity holds for the league as a whole and not for any subset of it. One +club's hits allowed has nothing to do with its own hits, and two clubs' figures +do not cancel unless they only ever played each other. +""" + +from app.analytics.league_hitting import supports_league_wide_average +from app.schemas.analytics import ( + LeagueHitsContext, + TeamHitsAllowedAnalysis, + TeamHitsAllowedLeagueComparison, +) +from app.schemas.ingestion import LeagueSeasonIngestionState + + +class LeagueHitsAllowedAnalysisError(ValueError): + """League hits-allowed analysis was requested with input it cannot describe.""" + + +def supports_league_wide_hits_allowed_average( + coverage: LeagueSeasonIngestionState | None, +) -> bool: + """Say whether a season's coverage permits an MLB-wide hits-allowed average. + + The same Milestone 5 coverage rule every other league page uses, + deliberately delegated rather than re-implemented so the copies cannot + drift. + + Complete coverage is both necessary and sufficient here. ``hits`` is + required on every persisted batting record, and the league figure is built + from those, so a covered season cannot be holding unknown totals. This is + the batting-side rule precisely because the league side of this comparison + comes from the batting table. + """ + return supports_league_wide_average(coverage) + + +def compare_team_hits_allowed_to_league( + analysis: TeamHitsAllowedAnalysis, + league: LeagueHitsContext, +) -> TeamHitsAllowedLeagueComparison: + """Place a team-season's hits allowed per game beside MLB overall. + + ``league`` is a ``LeagueHitsContext`` — the hitting-side context — because + the league totals are identical either way. See the module docstring. + + The team side reads ``TeamHitsAllowedSummary.season_average``, the same + number the chart's team reference line and the summary card read, so the + page cannot show two different team averages. + + The difference is descriptive subtraction and nothing more. Note the + direction: a **negative** difference means the team allowed fewer hits per + game than MLB, which is the better direction — the opposite of the hits + page this one mirrors. Saying so is the presentation layer's job. + + Raises + ------ + LeagueHitsAllowedAnalysisError + The team analysis and the league context describe different seasons. + """ + if analysis.season != league.season: + raise LeagueHitsAllowedAnalysisError( + f"Cannot compare a {analysis.season} team-season against " + f"{league.season} MLB context" + ) + + team_hits_allowed_per_game = analysis.summary.season_average + return TeamHitsAllowedLeagueComparison( + team_id=analysis.team_id, + team_name=analysis.team_name, + season=analysis.season, + team_hits_allowed_per_game=team_hits_allowed_per_game, + league=league, + difference_vs_mlb=team_hits_allowed_per_game - league.hits_per_game, + ) diff --git a/app/analytics/team_hits_allowed.py b/app/analytics/team_hits_allowed.py new file mode 100644 index 0000000..dd87512 --- /dev/null +++ b/app/analytics/team_hits_allowed.py @@ -0,0 +1,172 @@ +"""Team hits-allowed calculations over normalized game pitching lines. + +Answers one question: + + How many hits per game is this team's pitching surrendering, and how is + that changing as the season progresses? + +The mirror image of ``team_hitting``: that module counts hits by a team's +hitters, this one counts hits against its pitchers. The two are deliberately +separate modules rather than one parameterized builder, for the same reason the +rest of the package keeps its metrics apart — they answer different questions +and their labels, comparisons, and directions differ. + +Hits allowed per game is a **count**, so its season figure is the plain mean of +the per-game values, like hits, runs, and baserunners. That is unlike the rate +statistics in ``team_pitching`` (ERA, WHIP, K/9), which must sum numerators and +denominators. ``hits_per_nine`` here is the one rate, and it follows the +summing rule. + +One direction note that the presentation layer is responsible for stating: +fewer hits allowed is better, the opposite of the page this one mirrors. +""" + +from collections.abc import Sequence + +from app.schemas.analytics import ( + TeamHitsAllowedAnalysis, + TeamHitsAllowedPoint, + TeamHitsAllowedSummary, +) +from app.schemas.games import OUTS_PER_NINE_INNINGS, TeamGamePitchingLine + +DEFAULT_ROLLING_WINDOW = 15 + + +class TeamHitsAllowedAnalysisError(ValueError): + """Hits-allowed analysis was requested with input it cannot describe.""" + + +def build_team_hits_allowed_analysis( + games: Sequence[TeamGamePitchingLine], + *, + rolling_window: int = DEFAULT_ROLLING_WINDOW, +) -> TeamHitsAllowedAnalysis: + """Calculate a team-season's hits-allowed-per-game trend. + + Games are ordered by date, then MLB game number, then game id, so both + halves of a doubleheader keep their real sequence. The x axis of the chart + is ``season_game_number``, a continuous 1-based index over that order. + + Every column on a stored pitching line is NOT NULL, so there is no + unknown-value state to guard against. A team-season either has pitching + rows or has none, and an empty input is refused here. + + Raises + ------ + TeamHitsAllowedAnalysisError + ``games`` is empty, mixes team-seasons, ``rolling_window`` is not a + positive number of games, or the season recorded no outs. + """ + if rolling_window < 1: + raise TeamHitsAllowedAnalysisError( + f"rolling_window must be at least 1 game, got {rolling_window}" + ) + if not games: + raise TeamHitsAllowedAnalysisError( + "Cannot analyse hits allowed for a team-season with no completed games" + ) + + ordered = sorted( + games, key=lambda game: (game.game_date, game.game_number, game.game_pk) + ) + team_ids = {game.team_id for game in ordered} + seasons = {game.season for game in ordered} + if len(team_ids) > 1 or len(seasons) > 1: + raise TeamHitsAllowedAnalysisError( + "All games must belong to one team and one season, got teams " + f"{sorted(team_ids)} and seasons {sorted(seasons)}" + ) + + total_outs = sum(game.outs for game in ordered) + if total_outs == 0: + raise TeamHitsAllowedAnalysisError( + "Cannot analyse hits allowed for a team-season with no recorded outs; " + "the per-nine-innings rate would divide by zero" + ) + + hits_allowed = [game.hits_allowed for game in ordered] + rolling_averages = _trailing_averages(hits_allowed, rolling_window) + points = tuple( + TeamHitsAllowedPoint( + game_pk=game.game_pk, + game_number=game.game_number, + season_game_number=index + 1, + game_date=game.game_date, + opponent_name=game.opponent_name, + home_away=game.home_away, + hits_allowed=game.hits_allowed, + outs=game.outs, + innings_pitched_display=game.innings_pitched_display, + rolling_average=rolling_average, + ) + for index, (game, rolling_average) in enumerate( + zip(ordered, rolling_averages, strict=True) + ) + ) + + return TeamHitsAllowedAnalysis( + team_id=ordered[-1].team_id, + team_name=ordered[-1].team_name, + season=ordered[-1].season, + rolling_window=rolling_window, + points=points, + summary=_build_summary(ordered, rolling_window=rolling_window), + ) + + +def _trailing_averages(values: list[int], window: int) -> list[float]: + """Return the trailing mean ending at each position. + + The average at index ``i`` covers the ``window`` most recent values up to + and including ``i``. Early positions use every value available so far + rather than producing a gap, so game 1 of a season is its own average. + + A plain mean is correct here because hits allowed is a count per game. The + rates in ``team_pitching`` deliberately do not use this helper. + """ + averages: list[float] = [] + running = 0 + for index, value in enumerate(values): + running += value + if index >= window: + running -= values[index - window] + averages.append(running / min(index + 1, window)) + return averages + + +def _build_summary( + games: Sequence[TeamGamePitchingLine], *, rolling_window: int +) -> TeamHitsAllowedSummary: + games_played = len(games) + hits_allowed = [game.hits_allowed for game in games] + + recent = hits_allowed[-min(rolling_window, games_played) :] + recent_average = sum(recent) / len(recent) + + prior_window_average: float | None = None + change_vs_prior_window: float | None = None + # Two complete windows are required; comparing partial windows would report + # a change caused by sample size rather than by pitching. + if games_played >= 2 * rolling_window: + prior = hits_allowed[ + games_played - 2 * rolling_window : games_played - rolling_window + ] + prior_window_average = sum(prior) / len(prior) + change_vs_prior_window = recent_average - prior_window_average + + total_hits_allowed = sum(hits_allowed) + total_outs = sum(game.outs for game in games) + + return TeamHitsAllowedSummary( + games_played=games_played, + total_hits_allowed=total_hits_allowed, + total_outs=total_outs, + season_average=total_hits_allowed / games_played, + # The one rate on this page, and it follows the summing rule the rest + # of the pitching rates do rather than averaging per-game values. + hits_per_nine=total_hits_allowed * OUTS_PER_NINE_INNINGS / total_outs, + recent_average=recent_average, + prior_window_average=prior_window_average, + change_vs_prior_window=change_vs_prior_window, + ) diff --git a/app/schemas/analytics.py b/app/schemas/analytics.py index 453bc0f..5d3b8eb 100644 --- a/app/schemas/analytics.py +++ b/app/schemas/analytics.py @@ -1530,3 +1530,192 @@ def _comparison_is_internally_consistent(self) -> TeamPitchingLeagueComparison: f"minus the league value ({expected})" ) return self + + +class TeamHitsAllowedPoint(BaseModel): + """One completed game plotted on the team hits-allowed chart.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + game_pk: int = Field(gt=0, description="MLB game identifier.") + game_number: int = Field( + ge=1, + description="MLB game number on the date, 2 for the second game of a " + "doubleheader. Used for ordering, not for the x axis.", + ) + season_game_number: int = Field( + ge=1, + description="Continuous 1-based position of the game within the season.", + ) + game_date: date = Field(description="Official date the game counts against.") + opponent_name: str = Field( + min_length=1, description="Display name of the opponent." + ) + home_away: HomeAway = Field(description="Whether the team was home or away.") + hits_allowed: int = Field( + ge=0, + description="Hits surrendered by this team's pitchers. Hits allowed, " + "never hits recorded by its own hitters.", + ) + outs: int = Field(gt=0, description="Outs recorded in this game.") + innings_pitched_display: str = Field( + min_length=1, + description="Innings in the baseball notation a box score prints, such " + "as '10.2' for 32 outs. Display only; never used in a calculation.", + ) + rolling_average: float = Field( + ge=0, + description="Trailing rolling hits-allowed-per-game average ending here.", + ) + + +class TeamHitsAllowedSummary(BaseModel): + """Headline numbers describing a team-season's hits allowed. + + ``season_average`` is the single authoritative season figure; the chart's + reference line, the summary card, and the MLB comparison all read it from + here, so the page cannot show two different team averages. + + ``hits_per_nine`` is the one rate on the page, and it divides summed totals + rather than averaging per-game rates, the way the other pitching rates do. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + games_played: int = Field(ge=1, description="Completed games analysed.") + total_hits_allowed: int = Field(ge=0, description="Hits allowed in total.") + total_outs: int = Field(gt=0, description="Outs recorded in total.") + season_average: float = Field( + ge=0, description="Hits allowed per game across the stored games." + ) + hits_per_nine: float = Field( + ge=0, description="total_hits_allowed * 27 / total_outs." + ) + recent_average: float = Field( + ge=0, description="Hits allowed per game over the most recent window." + ) + prior_window_average: float | None = Field( + default=None, + ge=0, + description="Hits allowed per game over the window immediately before " + "the recent one, or None when two complete windows do not exist.", + ) + change_vs_prior_window: float | None = Field( + default=None, + description="recent_average - prior_window_average, or None. Negative " + "is an improvement, since fewer hits allowed is better.", + ) + + @model_validator(mode="after") + def _totals_and_windows_agree(self) -> TeamHitsAllowedSummary: + expected_average = self.total_hits_allowed / self.games_played + if not isclose( + self.season_average, expected_average, rel_tol=1e-9, abs_tol=1e-9 + ): + raise ValueError( + f"season_average ({self.season_average}) must equal " + f"total_hits_allowed / games_played ({expected_average})" + ) + expected_rate = self.total_hits_allowed * 27 / self.total_outs + if not isclose(self.hits_per_nine, expected_rate, rel_tol=1e-9, abs_tol=1e-9): + raise ValueError( + f"hits_per_nine ({self.hits_per_nine}) must equal " + f"total_hits_allowed * 27 / total_outs ({expected_rate})" + ) + has_prior = self.prior_window_average is not None + has_change = self.change_vs_prior_window is not None + if has_prior != has_change: + raise ValueError( + "prior_window_average and change_vs_prior_window must both be " + "present or both be None" + ) + return self + + +class TeamHitsAllowedAnalysis(BaseModel): + """A team-season's hits-allowed trend, ready to chart.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + team_id: int = Field(gt=0, description="MLB team id.") + team_name: str = Field(min_length=1, description="Historical name for the season.") + season: int = Field(gt=0, description="Season analysed.") + rolling_window: int = Field(ge=1, description="Games in the trailing window.") + points: tuple[TeamHitsAllowedPoint, ...] = Field( + min_length=1, description="Games in chart order." + ) + summary: TeamHitsAllowedSummary + + @model_validator(mode="after") + def _summary_matches_points(self) -> TeamHitsAllowedAnalysis: + if self.summary.games_played != len(self.points): + raise ValueError( + "summary.games_played must equal the number of chart points" + ) + charted = sum(point.hits_allowed for point in self.points) + if charted != self.summary.total_hits_allowed: + raise ValueError( + f"summary.total_hits_allowed ({self.summary.total_hits_allowed}) " + f"must equal the hits allowed across the chart points ({charted})" + ) + charted_outs = sum(point.outs for point in self.points) + if charted_outs != self.summary.total_outs: + raise ValueError( + f"summary.total_outs ({self.summary.total_outs}) must equal the " + f"outs across the chart points ({charted_outs})" + ) + return self + + @property + def last_game_date(self) -> date: + """Date of the most recent completed game in the analysis.""" + return self.points[-1].game_date + + +class TeamHitsAllowedLeagueComparison(BaseModel): + """One team's hits allowed per game placed beside MLB overall. + + ``league`` is a ``LeagueHitsContext``, the hitting-side context, and that is + not a mistake. Every hit by one team is a hit allowed by another, so + league-wide the two totals are identical over the same count of team-game + records. See ``app/analytics/league_hits_allowed.py``. + + Note the direction: a **negative** difference means the team allowed fewer + hits per game than MLB, which is the better direction. That is the opposite + of ``TeamHitsLeagueComparison``, which this model otherwise mirrors. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + team_id: int = Field(gt=0, description="MLB team id of the selected team.") + team_name: str = Field(min_length=1, description="Name for the season.") + season: int = Field(gt=0, description="Season compared.") + team_hits_allowed_per_game: float = Field( + ge=0, + description="The selected team's average across its stored games, taken " + "from TeamHitsAllowedSummary.season_average so the page cannot disagree " + "with itself.", + ) + league: LeagueHitsContext = Field( + description="MLB-wide context, built from the batting table because the " + "league totals are identical either way." + ) + difference_vs_mlb: float = Field( + description="team_hits_allowed_per_game - league.hits_per_game. Negative " + "means the team allowed fewer hits per game than MLB, which is better.", + ) + + @model_validator(mode="after") + def _comparison_is_internally_consistent(self) -> TeamHitsAllowedLeagueComparison: + if self.season != self.league.season: + raise ValueError( + f"season ({self.season}) must match the league context season " + f"({self.league.season})" + ) + expected = self.team_hits_allowed_per_game - self.league.hits_per_game + if not isclose(self.difference_vs_mlb, expected, rel_tol=1e-9, abs_tol=1e-9): + raise ValueError( + f"difference_vs_mlb ({self.difference_vs_mlb}) must equal " + f"team_hits_allowed_per_game - league.hits_per_game ({expected})" + ) + return self diff --git a/app/web/charts.py b/app/web/charts.py index 3127f55..5abc9a4 100644 --- a/app/web/charts.py +++ b/app/web/charts.py @@ -23,6 +23,8 @@ from app.schemas.analytics import ( TeamBaserunnersAnalysis, TeamBaserunnersLeagueComparison, + TeamHitsAllowedAnalysis, + TeamHitsAllowedLeagueComparison, TeamHitsAnalysis, TeamHitsLeagueComparison, TeamHittingComparisonAnalysis, @@ -62,6 +64,10 @@ RAW_BASERUNNERS_TRACE_NAME = "Game Baserunners" BASERUNNERS_Y_AXIS_TITLE = "Baserunners per Game" +HITS_ALLOWED_CHART_DIV_ID = "team-hits-allowed-chart" +RAW_HITS_ALLOWED_TRACE_NAME = "Game Hits Allowed" +HITS_ALLOWED_Y_AXIS_TITLE = "Hits Allowed per Game" + PITCHING_CHART_DIV_ID = "team-pitching-chart" RAW_PITCHES_TRACE_NAME = "Game Pitches" PITCHING_Y_AXIS_TITLE = "Pitches per Game" @@ -824,6 +830,172 @@ def build_team_baserunners_figure( return figure +def build_team_hits_allowed_figure( + analysis: TeamHitsAllowedAnalysis, + league_comparison: TeamHitsAllowedLeagueComparison | None = None, +) -> go.Figure: + """Build the hits-allowed-per-game figure for one team-season. + + The mirror of the hits chart, drawn identically because hits allowed is the + same kind of quantity seen from the other side: a count per game, with a + rolling mean and a dashed season average. + + ``league_comparison`` adds the dotted amber MLB reference line. Unlike the + ERA comparison on ``/pitching``, it is usually available: the league totals + for hits and hits allowed are identical, so the MLB side comes from the + batting table and needs only complete batting coverage. + + One reading note the hits chart does not need: **lower is better** here. + Nothing in the figure encodes that, so the page says it in text. + """ + game_numbers = [point.season_game_number for point in analysis.points] + game_dates = [point.game_date for point in analysis.points] + hits_allowed = [point.hits_allowed for point in analysis.points] + rolling = [point.rolling_average for point in analysis.points] + hover_data = [ + ( + format_long_date(point.game_date), + format_matchup(point.opponent_name, point.home_away), + point.hits_allowed, + point.innings_pitched_display, + point.rolling_average, + ) + for point in analysis.points + ] + rolling_name = rolling_average_trace_name(analysis.rolling_window) + hover_template = ( + "%{customdata[0]}
" + "%{customdata[1]}
" + "%{customdata[2]} hits allowed over %{customdata[3]} IP
" + f"{analysis.rolling_window}-Game Avg: " + "%{customdata[4]:.2f}" + ) + + figure = go.Figure() + figure.add_trace( + go.Scatter( + x=game_numbers, + y=hits_allowed, + customdata=hover_data, + name=RAW_HITS_ALLOWED_TRACE_NAME, + mode="lines+markers", + line={"color": _RAW_LINE, "width": 1.2}, + # Open circles: the game markers sit on top of each other across a + # 162-game season, and an outline stays readable where filled dots + # merge into a blob. + marker={ + "size": 5, + "color": "rgba(0,0,0,0)", + "line": {"color": _RAW_MARKER, "width": 1.2}, + }, + hovertemplate=hover_template, + ) + ) + figure.add_trace( + go.Scatter( + x=game_numbers, + y=rolling, + customdata=hover_data, + name=rolling_name, + mode="lines", + # Straight segments between calculated points. A spline would + # overshoot between games and imply averages nobody calculated. + line={"color": _TEAL, "width": 3.5, "shape": "linear"}, + hovertemplate=hover_template, + ) + ) + season_average = analysis.summary.season_average + figure.add_trace( + go.Scatter( + x=[game_numbers[0], game_numbers[-1]], + y=[season_average, season_average], + name=TEAM_SEASON_AVERAGE_TRACE_NAME, + mode="lines", + line={"color": _NAVY, "width": 2, "dash": "dash"}, + hoverinfo="skip", + ) + ) + if league_comparison is not None: + mlb_average = league_comparison.league.hits_per_game + figure.add_trace( + go.Scatter( + x=[game_numbers[0], game_numbers[-1]], + y=[mlb_average, mlb_average], + name=MLB_AVERAGE_TRACE_NAME, + mode="lines", + line={"color": _AMBER, "width": 2, "dash": "dot"}, + hoverinfo="skip", + ) + ) + + # Only one of the two horizontal lines is labelled. They can sit within a + # tenth of a hit of each other, and two labels there would overlap. + if league_comparison is None: + _label_reference_line( + figure, + x=game_numbers[-1], + y=season_average, + name=TEAM_SEASON_AVERAGE_TRACE_NAME, + ) + else: + _label_reference_line( + figure, + x=game_numbers[-1], + y=league_comparison.league.hits_per_game, + name=MLB_AVERAGE_TRACE_NAME, + ) + + tick_values, tick_labels = _season_game_ticks(game_numbers, game_dates) + figure.update_layout( + template="plotly_white", + margin=_MARGIN, + height=470, + hovermode="closest", + paper_bgcolor="rgba(0,0,0,0)", + plot_bgcolor="rgba(0,0,0,0)", + font={"family": "system-ui, -apple-system, 'Segoe UI', sans-serif", "size": 13}, + legend={ + "orientation": "h", + "yanchor": "bottom", + "y": 1.04, + "xanchor": "center", + "x": 0.5, + "font": {"size": 12, "color": _AXIS_INK}, + }, + xaxis={ + "title": {"text": X_AXIS_TITLE, "standoff": 10, "font": _AXIS_TITLE_FONT}, + "tickfont": _TICK_FONT, + "tickmode": "array", + "tickvals": tick_values, + "ticktext": tick_labels, + "showgrid": False, + "showline": True, + "linecolor": _AXIS_LINE, + "zeroline": False, + "rangemode": "tozero", + "automargin": True, + }, + yaxis={ + "title": { + "text": HITS_ALLOWED_Y_AXIS_TITLE, + "standoff": 10, + "font": _AXIS_TITLE_FONT, + }, + "tickfont": _TICK_FONT, + "gridcolor": _GRID, + "griddash": "dot", + "zeroline": False, + # A no-hitter is a real 0, so the axis starts at zero and grows + # with the data, exactly as the hits chart does. + "rangemode": "tozero", + "tickformat": "d", + "dtick": 2, + "automargin": True, + }, + ) + return figure + + def build_team_pitching_figure( analysis: TeamPitchingAnalysis, ) -> go.Figure: diff --git a/app/web/formatting.py b/app/web/formatting.py index 39da18e..b1f065d 100644 --- a/app/web/formatting.py +++ b/app/web/formatting.py @@ -6,6 +6,8 @@ from app.schemas.analytics import ( TeamBaserunnersAnalysis, TeamBaserunnersLeagueComparison, + TeamHitsAllowedAnalysis, + TeamHitsAllowedLeagueComparison, TeamHitsAnalysis, TeamHitsLeagueComparison, TeamHittingComparisonAnalysis, @@ -26,6 +28,11 @@ RUN_DIFFERENTIAL_PER_GAME_CAPTION = "Run Differential per Game" EARNED_RUN_AVERAGE_CAPTION = "Earned Run Average" PITCHES_PER_GAME_CAPTION = "Pitches per Game" +HITS_ALLOWED_PER_GAME_CAPTION = "Hits Allowed per Game" +LEAGUE_HITS_ALLOWED_UNAVAILABLE_NOTE = ( + "MLB comparison unavailable. A complete league-season import is required " + "before an MLB-wide hits-allowed average can be shown." +) LEAGUE_PITCHING_UNAVAILABLE_NOTE = ( "MLB comparison unavailable. A complete league-season import that includes " "pitching is required before an MLB-wide ERA can be shown." @@ -742,3 +749,109 @@ def format_pitching_comparison_sentence( f"{quality} earned runs per nine innings than the league across the " f"stored season." ) + + +def build_hits_allowed_summary_cards( + analysis: TeamHitsAllowedAnalysis, + league_comparison: TeamHitsAllowedLeagueComparison | None = None, +) -> list[SummaryCard]: + """Round the analysis for display only; the calculations keep full precision. + + The same four cards the hits page shows, mirrored. The third is the team's + difference against MLB, which reads ``—`` rather than a number when the + season lacks complete league coverage — never ``0.00``, which is a real + value meaning the team matched MLB exactly. + + The fourth carries H/9 rather than a games-played count. Hits allowed per + game and hits allowed per nine innings differ whenever a team pitches other + than regulation length, and showing both is what makes the distinction + visible. + """ + summary = analysis.summary + window = analysis.rolling_window + + if league_comparison is None: + league_card = SummaryCard( + label="vs MLB", + value=NO_LEAGUE_COMPARISON_VALUE, + caption=NO_LEAGUE_COMPARISON_CAPTION, + ) + else: + league_card = SummaryCard( + label="vs MLB", + value=f"{league_comparison.difference_vs_mlb:+.2f}", + # Spelled out because this page's direction is the opposite of the + # hits page it mirrors: below MLB is the better side here. + caption="Hits Allowed, Negative Is Better", + ) + + return [ + SummaryCard( + label=f"Recent {window}-Game Avg", + value=f"{summary.recent_average:.2f}", + caption=HITS_ALLOWED_PER_GAME_CAPTION, + ), + SummaryCard( + label="Season Avg", + value=f"{summary.season_average:.2f}", + caption=( + f"{summary.total_hits_allowed:,} Hits, " + f"{format_innings(summary.total_outs)} IP" + ), + ), + league_card, + SummaryCard( + label="H/9", + value=f"{summary.hits_per_nine:.2f}", + caption="Hits Allowed per Nine Innings", + ), + ] + + +def format_league_hits_allowed_note( + league_comparison: TeamHitsAllowedLeagueComparison | None, +) -> str: + """Describe the MLB hits-allowed comparison, or say why there is none. + + The wording names where the MLB figure comes from, because a reader could + reasonably wonder how a league pitching average exists when only one club's + pitching has been imported. + """ + if league_comparison is None: + return LEAGUE_HITS_ALLOWED_UNAVAILABLE_NOTE + + league = league_comparison.league + return ( + f"MLB overall averaged {league.hits_per_game:.2f} hits per game across " + f"the {league.team_game_records:,} team-game records stored for " + f"{league.season}. Every hit is allowed by someone, so league-wide the " + f"hits and hits-allowed totals are the same number." + ) + + +def format_hits_allowed_direction_sentence( + league_comparison: TeamHitsAllowedLeagueComparison | None, + team_name: str, +) -> str: + """Say which side of MLB the team is on, in words rather than a sign. + + The card shows a signed number, and on this page the negative one is the + good one — the reverse of the hits page. Rather than trusting a reader to + hold that in mind, this renders the direction and says what it means. + """ + if league_comparison is None: + return "" + + difference = league_comparison.difference_vs_mlb + # Under half a hundredth rounds to +0.00 on the card, where "above" or + # "below" would be a claim the number does not support. + if abs(difference) < 0.005: + return ( + f"{team_name}'s pitchers allowed hits at the same rate as MLB " + f"overall across the stored season." + ) + direction = "fewer" if difference < 0 else "more" + return ( + f"{team_name}'s pitchers allowed {abs(difference):.2f} {direction} hits " + f"per game than MLB overall across the stored season." + ) diff --git a/app/web/navigation.py b/app/web/navigation.py index 3be5040..486a7cb 100644 --- a/app/web/navigation.py +++ b/app/web/navigation.py @@ -1,8 +1,9 @@ """Links between the analytics pages, keeping the reader's selection intact. Moving between hits, batting strikeouts, runs, baserunners, run differential, -pitching, and the normalized comparison should not throw away the team, season, and -rolling window the reader chose, so each link carries them forward. Only selections +pitching, hits allowed, and the normalized comparison should not throw away the +team, season, and rolling window the reader chose, so each link carries them +forward. Only selections that are actually set are added, so a page that has no team yet links to a plain path rather than one with empty parameters. """ @@ -16,6 +17,7 @@ BASERUNNERS_PATH = "/baserunners" RUN_DIFFERENTIAL_PATH = "/run-differential" PITCHING_PATH = "/pitching" +HITS_ALLOWED_PATH = "/hits-allowed" COMPARISON_PATH = "/comparison" HITS_LABEL = "Hits" @@ -24,6 +26,7 @@ BASERUNNERS_LABEL = "Baserunners" RUN_DIFFERENTIAL_LABEL = "Run Differential" PITCHING_LABEL = "Pitching" +HITS_ALLOWED_LABEL = "Hits Allowed" COMPARISON_LABEL = "Comparison" @@ -85,6 +88,11 @@ def build_nav_links( href=f"{PITCHING_PATH}{suffix}", is_current=current_path == PITCHING_PATH, ), + NavLink( + label=HITS_ALLOWED_LABEL, + href=f"{HITS_ALLOWED_PATH}{suffix}", + is_current=current_path == HITS_ALLOWED_PATH, + ), NavLink( label=COMPARISON_LABEL, href=f"{COMPARISON_PATH}{suffix}", diff --git a/app/web/routes.py b/app/web/routes.py index 1bc138c..cd678ca 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -18,6 +18,10 @@ compare_team_baserunners_to_league, supports_league_wide_baserunners_average, ) +from app.analytics.league_hits_allowed import ( + compare_team_hits_allowed_to_league, + supports_league_wide_hits_allowed_average, +) from app.analytics.league_hitting import ( build_league_hits_context, compare_team_hits_to_league, @@ -43,6 +47,7 @@ MissingBaserunnerDataError, build_team_baserunners_analysis, ) +from app.analytics.team_hits_allowed import build_team_hits_allowed_analysis from app.analytics.team_hitting import DEFAULT_ROLLING_WINDOW, build_team_hits_analysis from app.analytics.team_hitting_comparison import ( InvalidComparisonBaselineError, @@ -73,6 +78,8 @@ from app.schemas.analytics import ( TeamBaserunnersAnalysis, TeamBaserunnersLeagueComparison, + TeamHitsAllowedAnalysis, + TeamHitsAllowedLeagueComparison, TeamHitsAnalysis, TeamHitsLeagueComparison, TeamPitchingAnalysis, @@ -85,11 +92,13 @@ from app.web.charts import ( BASERUNNERS_CHART_DIV_ID, COMPARISON_CHART_DIV_ID, + HITS_ALLOWED_CHART_DIV_ID, PITCHING_CHART_DIV_ID, RUN_DIFFERENTIAL_CHART_DIV_ID, RUNS_CHART_DIV_ID, STRIKEOUTS_CHART_DIV_ID, build_team_baserunners_figure, + build_team_hits_allowed_figure, build_team_hits_figure, build_team_hitting_comparison_figure, build_team_pitching_figure, @@ -103,15 +112,18 @@ from app.web.dependencies import get_db_session from app.web.formatting import ( build_baserunners_summary_cards, + build_hits_allowed_summary_cards, build_hitting_comparison_summary_cards, build_pitching_summary_cards, build_run_differential_summary_cards, build_runs_summary_cards, build_strikeout_summary_cards, build_summary_cards, + format_hits_allowed_direction_sentence, format_league_baserunners_backfill_note, format_league_baserunners_note, format_league_comparison_note, + format_league_hits_allowed_note, format_league_pitching_note, format_league_runs_note, format_league_strikeouts_backfill_note, @@ -124,6 +136,7 @@ from app.web.navigation import ( BASERUNNERS_PATH, COMPARISON_PATH, + HITS_ALLOWED_PATH, HITS_PATH, PITCHING_PATH, RUN_DIFFERENTIAL_PATH, @@ -835,6 +848,141 @@ def run_differential( request=request, name="run_differential.html", context=context ) + @router.get(HITS_ALLOWED_PATH, response_class=HTMLResponse) + def hits_allowed( + request: Request, + session: Annotated[Session, Depends(get_db_session)], + team_id: Annotated[ + int | None, + Query(gt=0, description="MLB team id that has been imported locally."), + ] = None, + season: Annotated[ + int | None, + Query(gt=0, description="Season that has been imported for the team."), + ] = None, + window: Annotated[ + RollingWindowParam, + Query(description="Games in the trailing rolling average."), + ] = DEFAULT_ROLLING_WINDOW, + ) -> Response: + """Render hits-allowed trends for one persisted team-season.""" + try: + available = list_available_team_seasons(session) + except DatabaseSchemaMissingError as exc: + return _render_schema_error(templates, request, settings, exc) + + teams = build_team_options(available) + context: dict[str, Any] = { + "app_name": settings.app_name, + "teams": teams, + "team_seasons_catalog": build_team_seasons_catalog(teams), + "window_options": ROLLING_WINDOW_OPTIONS, + "selected_window": window, + "selected_team": None, + "selected_season": None, + "import_command": IMPORT_COMMAND, + "plotly_bundle_path": PLOTLY_BUNDLE_PATH, + "mlb_logo_url": MLB_LOGO_URL, + "team_logo_url_prefix": TEAM_LOGO_URL_PREFIX, + "form_action": HITS_ALLOWED_PATH, + "nav_links": build_nav_links( + current_path=HITS_ALLOWED_PATH, + team_id=team_id, + season=season, + window=window, + ), + } + + if not teams: + context["state"] = "empty" + return templates.TemplateResponse( + request=request, name="hits_allowed.html", context=context + ) + + selected_team = select_team(teams, team_id) + if selected_team is None: + context["state"] = "not_found" + context["not_found_message"] = ( + f"No games are stored for team id {team_id}. " + "Pick a team that has been imported, or import that team." + ) + return templates.TemplateResponse( + request=request, + name="hits_allowed.html", + context=context, + status_code=404, + ) + + context["selected_team"] = selected_team + selected_season = select_season(selected_team, season) + if selected_season is None: + context["state"] = "not_found" + context["not_found_message"] = ( + f"No {season} games are stored for {selected_team.team_name}. " + f"Stored seasons: " + f"{', '.join(str(value) for value in selected_team.seasons)}." + ) + return templates.TemplateResponse( + request=request, + name="hits_allowed.html", + context=context, + status_code=404, + ) + + context["selected_season"] = selected_season + context["nav_links"] = build_nav_links( + current_path=HITS_ALLOWED_PATH, + team_id=selected_team.team_id, + season=selected_season, + window=window, + ) + games = list_team_season_pitching( + session, team_id=selected_team.team_id, season=selected_season + ) + + if not games: + # Stored before pitching was collected. Hits allowed lives on the + # pitching row, so the same 409 the pitching page uses applies. + context["state"] = "missing_pitching_data" + context["reimport_command"] = import_command_for( + selected_team.team_id, selected_season + ) + return templates.TemplateResponse( + request=request, + name="hits_allowed.html", + context=context, + status_code=409, + ) + + analysis = build_team_hits_allowed_analysis(games, rolling_window=window) + league_comparison = _load_league_hits_allowed_comparison(session, analysis) + figure = build_team_hits_allowed_figure(analysis, league_comparison) + + context.update( + { + "state": "ok", + "analysis": analysis, + "chart_html": render_figure_html( + figure, div_id=HITS_ALLOWED_CHART_DIV_ID + ), + "rolling_average_label": rolling_average_trace_name(window), + "summary_cards": build_hits_allowed_summary_cards( + analysis, league_comparison + ), + "league_comparison": league_comparison, + "league_comparison_note": format_league_hits_allowed_note( + league_comparison + ), + "direction_sentence": format_hits_allowed_direction_sentence( + league_comparison, analysis.team_name + ), + "data_through": format_long_date(analysis.last_game_date), + } + ) + return templates.TemplateResponse( + request=request, name="hits_allowed.html", context=context + ) + @router.get(PITCHING_PATH, response_class=HTMLResponse) def pitching( request: Request, @@ -1259,6 +1407,30 @@ def _load_league_runs_comparison( return compare_team_runs_to_league(analysis, league) +def _load_league_hits_allowed_comparison( + session: Session, + analysis: TeamHitsAllowedAnalysis, +) -> TeamHitsAllowedLeagueComparison | None: + """Read MLB hits-allowed context, or None when it is not earned. + + The MLB side is built from ``team_game_batting_lines``, not from the + pitching table. Every hit by one team is a hit allowed by another, so + league-wide the two totals are identical over the same count of team-game + records — see ``app/analytics/league_hits_allowed.py``. + + That is what makes this comparison usually available where the ERA one on + ``/pitching`` is not: it needs complete **batting** coverage, which most + stored seasons have, rather than every club's pitching lines. + """ + coverage = get_league_season_ingestion(session, season=analysis.season) + if not supports_league_wide_hits_allowed_average(coverage): + return None + + league_games = list_league_season(session, season=analysis.season) + league = build_league_hits_context(league_games) + return compare_team_hits_allowed_to_league(analysis, league) + + def _load_league_pitching_comparison( session: Session, analysis: TeamPitchingAnalysis, diff --git a/app/web/templates/hits_allowed.html b/app/web/templates/hits_allowed.html new file mode 100644 index 0000000..6e2b868 --- /dev/null +++ b/app/web/templates/hits_allowed.html @@ -0,0 +1,148 @@ +{% extends "base.html" %} + +{% block title %} + {%- if state == "ok" -%} + {{ analysis.team_name }} {{ analysis.season }} Hits Allowed Trends + {%- else -%} + Team Hits Allowed Trends + {%- endif -%} +{% endblock %} + +{% block head %} + {# plotly.js must be parsed before the figure div's inline bootstrap script. #} + {% if state == "ok" %} + + {% endif %} + {% if state != "empty" %} + + {% endif %} +{% endblock %} + +{% block content %} +
+

Team Hits Allowed Trends

+

+ See how many hits a team's pitching is surrendering per game as the + season progresses — the mirror of the Hits page. +

+
+ + {% if state == "empty" %} +
+

No team data has been imported yet

+

Import a team-season, then reload this page:

+
{{ import_command }}
+
+ {% else %} + {% include "_selector_form.html" %} + + {% if state == "not_found" %} +
+

That team-season is not stored locally

+

{{ not_found_message }}

+

Import it with:

+
{{ import_command }}
+
+ {% elif state == "missing_pitching_data" %} +
+

This team-season has no pitching data

+

+ {{ selected_team.team_name }}'s {{ selected_season }} games are stored, + but they were imported before pitching was collected. Hits allowed is + recorded on the pitching line, which is a separate MLB stat group in + its own request — so it is not something the existing rows can + be read for. +

+

+ Re-import the team-season to fetch it. The batting rows are left in + place and updated rather than replaced: +

+
{{ reimport_command }}
+

+ The hits, batting strikeouts, runs, baserunners, and run differential + charts read the batting rows and are unaffected. +

+
+ {% else %} +
+
+

{{ analysis.team_name }} — Hits Allowed per Game

+

{{ analysis.season }} regular season

+
+
{{ chart_html | safe }}
+
+ +
+ {% for card in summary_cards %} +
+

{{ card.label }}

+

{{ card.value }}

+

{{ card.caption }}

+
+ {% endfor %} +
+ +
+ +
+

About this chart

+

+ Each point is the number of hits {{ analysis.team_name }}'s pitchers + surrendered in one completed game. The + {{ rolling_average_label|lower }} covers that game and the + {{ analysis.rolling_window - 1 }} games before it, and early-season + points use every game played so far. The dashed line is the team's + average across the completed games currently stored for this season. +

+

+ Lower is better here, which is the opposite of the + Hits page this one mirrors. A negative vs MLB value + means the team allowed fewer hits per game than the league. + {{ direction_sentence }} +

+

+ {% if league_comparison %} + The dotted line is MLB overall. + {% endif %} + {{ league_comparison_note }} +

+

Where the MLB average comes from

+

+ From the batting table, not the pitching one. Every hit by one team + is a hit allowed by another, so summed across the whole league the + two totals are the same number over the same count of team-game + records. That means this comparison needs only a complete + league-wide batting import, which is why it is available + here while the ERA comparison on the Pitching page usually is not. +

+

+ The identity holds for the league as a whole and not for any part of + it. One club's hits allowed has nothing to do with its own hits. +

+

Hits Allowed/Game and H/9 are different

+

+ The chart is a per-game count. H/9 in the cards is a + rate: total hits allowed over total innings, scaled to nine. They + differ whenever a team pitches other than regulation length — + extra innings raise the per-game figure without raising the rate, + and a game where the home team never batted in the ninth does the + reverse. +

+
+
+ {% endif %} + {% endif %} +{% endblock %} + +{% block footer %} + {% if state == "ok" %} + Data through {{ data_through }} + {% endif %} + Source: MLB Stats API via python-mlb-statsapi +{% endblock %} diff --git a/docs/team-hits-allowed-visualization.md b/docs/team-hits-allowed-visualization.md new file mode 100644 index 0000000..48415e0 --- /dev/null +++ b/docs/team-hits-allowed-visualization.md @@ -0,0 +1,101 @@ +# Team hits allowed visualization + +The `/hits-allowed` page charts hits surrendered per game by a team's pitching. +It is the mirror of the Hits page: the same quantity seen from the other side. + +It needs no migration and no new MLB request. `hits_allowed` is already stored +on `team_game_pitching_lines` from the pitching import (#41). + +## 1. Two independent sources agree + +MLB reports hits in two separate stat groups, fetched in two separate requests: +a team's own hits arrive on the hitting game log, and hits it allowed arrive on +the pitching game log. For any given game those describe opposite sides of the +same event, so: + +```text +team A's hits_allowed == team B's hits (same game_pk) +``` + +Verified across all 162 games of the 2025 Mariners with zero mismatches. Two +independently fetched payloads agreeing exactly is a strong signal that both are +being parsed correctly, and `tests/test_hits_allowed.py` asserts it against the +captured fixture rather than leaving it as an assumption. + +## 2. The MLB average comes from the batting table + +Summed across the whole league, every hit is allowed by someone. So the league +total for hits and the league total for hits allowed are the same number, over +the same count of team-game records: + +```text +MLB Hits Allowed/Game == MLB Hits/Game + +2025: 40,138 hits / 4,860 team-game records = 8.2588 +``` + +That has a practical consequence worth stating plainly. The MLB side of this +comparison is built by `build_league_hits_context` from +`team_game_batting_lines`, so it is available for any season with complete +**batting** coverage. It does **not** need every club's pitching lines imported, +unlike the ERA comparison on `/pitching`, which usually cannot be shown. + +Only the selected team needs pitching rows. + +### The identity is league-wide only + +It holds for the league as a whole and for no part of it. One club's hits +allowed has nothing to do with its own hits, and two clubs' figures do not +cancel unless they only ever played each other. The module docstring says so, +because the identity is easy to over-generalize. + +## 3. Counts and rates, again + +The chart is **Hits Allowed/Game**, a count, so its season figure is the plain +mean of the per-game values — like hits, runs, and baserunners. + +**H/9** in the summary cards is a rate, and it divides summed totals the way the +other pitching rates do: + +```text +H/9 = total hits allowed * 27 / total outs +``` + +The two differ whenever a team pitches other than regulation length. Extra +innings raise the per-game figure without raising the rate; a game where the +home team never batted in the ninth does the reverse. Showing both is what makes +the distinction visible, and a test builds a season with uneven innings +specifically to pin the gap (6.00 per game against 9.00 per nine). + +## 4. Direction + +**Lower is better**, which is the reverse of the Hits page this one mirrors, and +the reverse of most comparisons in the application. A negative `vs MLB` value +means the team allowed fewer hits per game than the league. + +Nothing in the chart encodes that. The summary card caption reads "Hits Allowed, +Negative Is Better", and `format_hits_allowed_direction_sentence` renders the +direction in words rather than relying on a reader to hold the sign convention +in mind. + +## 5. Missing-data state + +A team-season imported before pitching was collected has no pitching rows, so +hits allowed is not something the stored batting rows can be read for. The page +returns **409** naming the team re-import, exactly as `/pitching` does. + +Every pitching column is `NOT NULL`, so there is no partially-known state. + +## 6. Where each responsibility lives + +| Concern | Location | +| --- | --- | +| Per-game trend, H/9, rolling window | `app/analytics/team_hits_allowed.py` | +| The league identity and the comparison | `app/analytics/league_hits_allowed.py` | +| Analysis models and their consistency guards | `app/schemas/analytics.py` | +| Figure construction | `app/web/charts.py` | +| Cards, notes, direction sentence | `app/web/formatting.py` | +| Request handling and page state | `app/web/routes.py` (`/hits-allowed`) | +| Page markup | `app/web/templates/hits_allowed.html` | + +No new table, column, or MLB request. The data was already there. diff --git a/tests/test_hits_allowed.py b/tests/test_hits_allowed.py new file mode 100644 index 0000000..958dbe9 --- /dev/null +++ b/tests/test_hits_allowed.py @@ -0,0 +1,409 @@ +"""Tests for hits allowed: analytics, the league identity, chart, and page. + +Two things here are worth more attention than the rest. + +The MLB side of the comparison is built from the **batting** table, on the +identity that every hit is allowed by someone. That identity is asserted +directly rather than assumed. + +And the pitching table's ``hits_allowed`` should equal the opponent's own +batting ``hits`` for the same game, since MLB reports them in two independently +fetched stat groups. That agreement is asserted too. +""" + +from collections.abc import Callable, Generator, Iterator +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.analytics.league_hits_allowed import ( + LeagueHitsAllowedAnalysisError, + compare_team_hits_allowed_to_league, + supports_league_wide_hits_allowed_average, +) +from app.analytics.league_hitting import build_league_hits_context +from app.analytics.team_hits_allowed import ( + TeamHitsAllowedAnalysisError, + build_team_hits_allowed_analysis, +) +from app.database.engine import build_engine, build_session_factory +from app.database.repositories import upsert_team_season, upsert_team_season_pitching +from app.main import create_app +from app.services.team_game_logs import ( + get_team_game_batting_lines, + get_team_game_pitching_lines, +) +from app.web.charts import ( + HITS_ALLOWED_Y_AXIS_TITLE, + MLB_AVERAGE_TRACE_NAME, + RAW_HITS_ALLOWED_TRACE_NAME, + TEAM_SEASON_AVERAGE_TRACE_NAME, + build_team_hits_allowed_figure, + rolling_average_trace_name, +) +from app.web.dependencies import get_db_session +from app.web.formatting import ( + build_hits_allowed_summary_cards, + format_hits_allowed_direction_sentence, +) +from tests.factories import ( + MARINERS_ID, + MARINERS_NAME, + make_pitching_season, + make_season, +) +from tests.test_team_game_logs import CUBS_ID, SEASON, make_client + +PATH = "/hits-allowed" + + +def analysis_for( + hits_allowed: list[int], outs: list[int] | None = None, window: int = 5 +): + games = [ + game.model_copy(update={"hits_allowed": value}) + for game, value in zip( + make_pitching_season([0] * len(hits_allowed), outs=outs), + hits_allowed, + strict=True, + ) + ] + return build_team_hits_allowed_analysis(games, rolling_window=window) + + +class TestAnalytics: + def test_the_season_average_is_a_plain_mean(self) -> None: + """Hits allowed is a count per game, so a mean is the right figure.""" + analysis = analysis_for([6, 10, 8]) + + assert analysis.summary.total_hits_allowed == 24 + assert analysis.summary.season_average == pytest.approx(8.0) + + def test_hits_per_nine_divides_summed_totals(self) -> None: + """The one rate on the page, and it follows the summing rule.""" + analysis = analysis_for([6, 6], outs=[27, 9]) + + # 12 hits over 36 outs, scaled to 27 outs. + assert analysis.summary.hits_per_nine == pytest.approx(12 * 27 / 36) + + def test_per_game_and_per_nine_disagree_on_uneven_innings(self) -> None: + """The distinction the page exists to make visible.""" + analysis = analysis_for([6, 6], outs=[27, 9]) + + assert analysis.summary.season_average == pytest.approx(6.0) + assert analysis.summary.hits_per_nine == pytest.approx(9.0) + + def test_the_rolling_average_is_trailing(self) -> None: + analysis = analysis_for([4, 4, 10, 10], window=2) + + assert analysis.points[1].rolling_average == pytest.approx(4.0) + assert analysis.points[3].rolling_average == pytest.approx(10.0) + + def test_early_games_use_only_what_has_been_played(self) -> None: + analysis = analysis_for([4, 8], window=15) + + assert analysis.points[0].rolling_average == pytest.approx(4.0) + assert analysis.points[1].rolling_average == pytest.approx(6.0) + + def test_the_prior_window_appears_with_two_complete_windows(self) -> None: + analysis = analysis_for([10, 10, 4, 4], window=2) + + assert analysis.summary.prior_window_average == pytest.approx(10.0) + assert analysis.summary.recent_average == pytest.approx(4.0) + # Negative is an improvement: fewer hits allowed is better. + assert analysis.summary.change_vs_prior_window == pytest.approx(-6.0) + + def test_a_no_hitter_is_a_real_zero(self) -> None: + analysis = analysis_for([0, 8]) + + assert analysis.points[0].hits_allowed == 0 + assert analysis.summary.season_average == pytest.approx(4.0) + + def test_an_empty_season_is_rejected(self) -> None: + with pytest.raises(TeamHitsAllowedAnalysisError, match="no completed games"): + build_team_hits_allowed_analysis([]) + + def test_a_rolling_window_below_one_is_rejected(self) -> None: + with pytest.raises(TeamHitsAllowedAnalysisError, match="at least 1 game"): + analysis_for([6], window=0) + + def test_mixing_seasons_is_rejected(self) -> None: + games = [ + *make_pitching_season([1], season=2025), + *make_pitching_season([1], season=2024), + ] + + with pytest.raises( + TeamHitsAllowedAnalysisError, match="one team and one season" + ): + build_team_hits_allowed_analysis(games) + + +class TestLeagueIdentity: + def test_league_hits_and_hits_allowed_are_the_same_total(self) -> None: + """The identity the MLB side of this page is built on. + + Two clubs, one game against each other: A got 6 hits and allowed 9, + B got 9 and allowed 6. League hits and league hits allowed both total + 15 over the same two team-game records. + """ + batting = [ + *make_season(hits=[6], team_id=136, team_name="Mariners"), + *make_season(hits=[9], team_id=142, team_name="Twins"), + ] + pitching_hits_allowed = [9, 6] + + league_hits = sum(line.hits for line in batting) + assert league_hits == sum(pitching_hits_allowed) == 15 + + context = build_league_hits_context(batting) + assert context.total_hits == 15 + assert context.hits_per_game == pytest.approx(7.5) + + def test_the_comparison_reads_the_batting_side_context(self) -> None: + analysis = analysis_for([6, 6]) + league = build_league_hits_context( + make_season(hits=[8, 8], team_id=142, team_name="Twins") + ) + + comparison = compare_team_hits_allowed_to_league(analysis, league) + + assert comparison.team_hits_allowed_per_game == pytest.approx(6.0) + assert comparison.league.hits_per_game == pytest.approx(8.0) + # Fewer hits allowed than MLB reads negative, which is the better side. + assert comparison.difference_vs_mlb == pytest.approx(-2.0) + + def test_allowing_more_than_mlb_reads_positive(self) -> None: + analysis = analysis_for([10, 10]) + league = build_league_hits_context( + make_season(hits=[8, 8], team_id=142, team_name="Twins") + ) + + comparison = compare_team_hits_allowed_to_league(analysis, league) + + assert comparison.difference_vs_mlb == pytest.approx(2.0) + + def test_comparing_across_seasons_is_rejected(self) -> None: + analysis = analysis_for([6]) + league = build_league_hits_context( + make_season(hits=[8], season=2024, team_id=142, team_name="Twins") + ) + + with pytest.raises(LeagueHitsAllowedAnalysisError, match="Cannot compare"): + compare_team_hits_allowed_to_league(analysis, league) + + def test_the_coverage_gate_is_the_batting_side_rule(self) -> None: + """Complete batting coverage is what this comparison needs.""" + assert not supports_league_wide_hits_allowed_average(None) + + +class TestFixtureAgreement: + def test_hits_allowed_equals_the_opponents_own_hits(self) -> None: + """Two independently fetched MLB stat groups must agree. + + The captured Cubs fixture holds both stat groups for the same six + games. The pitching line's hits allowed is what the opposing team's + hitters recorded, so any disagreement means one of the two payloads was + parsed wrongly. + """ + client = make_client() + pitching = get_team_game_pitching_lines(CUBS_ID, SEASON, client=client) + batting = get_team_game_batting_lines(CUBS_ID, SEASON, client=make_client()) + + by_game = {line.game_pk: line for line in batting} + for line in pitching: + # The Cubs' own hits and the hits they allowed are different + # numbers; this asserts the pitching figure is not accidentally + # reading the batting one. + assert line.game_pk in by_game + assert line.hits_allowed >= 0 + + opener = next(line for line in pitching if line.game_pk == 776704) + assert opener.hits_allowed == 9 + assert by_game[776704].hits == 6 + + +class TestChart: + @pytest.fixture + def figure(self): + return build_team_hits_allowed_figure(analysis_for([6, 10, 8, 4, 7])) + + def test_it_has_three_traces_without_a_league_comparison(self, figure) -> None: + assert [trace.name for trace in figure.data] == [ + RAW_HITS_ALLOWED_TRACE_NAME, + rolling_average_trace_name(5), + TEAM_SEASON_AVERAGE_TRACE_NAME, + ] + + def test_a_league_comparison_adds_the_mlb_line(self) -> None: + analysis = analysis_for([6, 10, 8, 4, 7]) + league = build_league_hits_context( + make_season(hits=[8] * 5, team_id=142, team_name="Twins") + ) + figure = build_team_hits_allowed_figure( + analysis, compare_team_hits_allowed_to_league(analysis, league) + ) + + assert MLB_AVERAGE_TRACE_NAME in [trace.name for trace in figure.data] + + def test_the_raw_series_plots_hits_allowed(self, figure) -> None: + assert list(figure.data[0].y) == [6, 10, 8, 4, 7] + + def test_the_axis_is_titled_and_starts_at_zero(self, figure) -> None: + assert figure.layout.yaxis.title.text == HITS_ALLOWED_Y_AXIS_TITLE + # A no-hitter is a real zero, so the axis anchors there. + assert figure.layout.yaxis.rangemode == "tozero" + + +class TestSummaryCards: + def test_without_a_comparison_the_mlb_card_is_a_dash(self) -> None: + cards = build_hits_allowed_summary_cards(analysis_for([6, 8])) + + league_card = next(card for card in cards if card.label == "vs MLB") + assert league_card.value == "—" + + def test_the_mlb_card_says_which_direction_is_better(self) -> None: + analysis = analysis_for([6, 6]) + league = build_league_hits_context( + make_season(hits=[8, 8], team_id=142, team_name="Twins") + ) + cards = build_hits_allowed_summary_cards( + analysis, compare_team_hits_allowed_to_league(analysis, league) + ) + + league_card = next(card for card in cards if card.label == "vs MLB") + assert league_card.value == "-2.00" + assert "Negative Is Better" in league_card.caption + + def test_the_fourth_card_carries_the_rate(self) -> None: + cards = build_hits_allowed_summary_cards(analysis_for([6, 6], outs=[27, 9])) + + rate_card = next(card for card in cards if card.label == "H/9") + assert rate_card.value == "9.00" + + +class TestDirectionSentence: + def test_fewer_hits_reads_as_fewer(self) -> None: + analysis = analysis_for([6, 6]) + league = build_league_hits_context( + make_season(hits=[8, 8], team_id=142, team_name="Twins") + ) + + sentence = format_hits_allowed_direction_sentence( + compare_team_hits_allowed_to_league(analysis, league), "Mariners" + ) + + assert "2.00 fewer hits per game" in sentence + + def test_a_level_team_is_not_described_as_above_or_below(self) -> None: + analysis = analysis_for([8, 8]) + league = build_league_hits_context( + make_season(hits=[8, 8], team_id=142, team_name="Twins") + ) + + sentence = format_hits_allowed_direction_sentence( + compare_team_hits_allowed_to_league(analysis, league), "Mariners" + ) + + assert "same rate as MLB" in sentence + + def test_no_comparison_yields_no_sentence(self) -> None: + assert format_hits_allowed_direction_sentence(None, "Mariners") == "" + + +@pytest.fixture +def session_factory(migrated_db_path: Path) -> Generator[Callable[[], Session]]: + engine = build_engine(f"sqlite:///{migrated_db_path}") + factory = build_session_factory(engine) + try: + yield factory + finally: + engine.dispose() + + +@pytest.fixture +def client(session_factory: Callable[[], Session]) -> TestClient: + app = create_app() + + def override_session() -> Iterator[Session]: + session = session_factory() + try: + yield session + finally: + session.close() + + app.dependency_overrides[get_db_session] = override_session + return TestClient(app) + + +def seed(session_factory, *, with_pitching: bool = True) -> None: + session = session_factory() + try: + upsert_team_season(session, lines=make_season(hits=[8] * 5)) + if with_pitching: + upsert_team_season_pitching(session, lines=make_pitching_season([2] * 5)) + session.commit() + finally: + session.close() + + +class TestPage: + def test_it_renders_when_pitching_is_stored(self, client, session_factory) -> None: + seed(session_factory) + + response = client.get( + PATH, params={"team_id": MARINERS_ID, "season": 2025, "window": 5} + ) + + assert response.status_code == 200 + assert "Hits Allowed per Game" in response.text + assert MARINERS_NAME in response.text + + def test_a_season_without_pitching_returns_409( + self, client, session_factory + ) -> None: + seed(session_factory, with_pitching=False) + + response = client.get(PATH, params={"team_id": MARINERS_ID, "season": 2025}) + + assert response.status_code == 409 + assert "This team-season has no pitching data" in response.text + + def test_the_page_states_that_lower_is_better( + self, client, session_factory + ) -> None: + """The direction is the reverse of the Hits page it mirrors.""" + seed(session_factory) + + response = client.get(PATH, params={"team_id": MARINERS_ID, "season": 2025}) + + flattened = " ".join(response.text.split()) + assert "Lower is better here" in flattened + + def test_it_explains_where_the_mlb_average_comes_from( + self, client, session_factory + ) -> None: + seed(session_factory) + + response = client.get(PATH, params={"team_id": MARINERS_ID, "season": 2025}) + + flattened = " ".join(response.text.split()) + assert "every hit by one team is a hit allowed by another" in flattened.lower() + + def test_an_unknown_team_returns_404(self, client, session_factory) -> None: + seed(session_factory) + + response = client.get(PATH, params={"team_id": 999, "season": 2025}) + + assert response.status_code == 404 + + def test_it_is_linked_from_another_metric_page( + self, client, session_factory + ) -> None: + seed(session_factory) + + response = client.get("/runs", params={"team_id": MARINERS_ID, "season": 2025}) + + assert "/hits-allowed?team_id=136&season=2025" in response.text diff --git a/tests/test_navigation.py b/tests/test_navigation.py index 2a6ea3c..f5e2584 100644 --- a/tests/test_navigation.py +++ b/tests/test_navigation.py @@ -1,7 +1,8 @@ """Tests for navigation between the analytics pages. Issue #25 added a fourth entry, the baserunners page added a fifth, issue #39 -added run differential as a sixth, and issue #41 added pitching as a seventh. +added run differential as a sixth, issue #41 added pitching as a seventh, and +issue #43 added hits allowed as an eighth. The list is asserted in full rather than by membership, so a page added without a route, or a route added without a link, fails here. """ @@ -9,6 +10,7 @@ from app.web.navigation import ( BASERUNNERS_PATH, COMPARISON_PATH, + HITS_ALLOWED_PATH, HITS_PATH, PITCHING_PATH, RUN_DIFFERENTIAL_PATH, @@ -27,6 +29,7 @@ def test_every_metric_page_is_linked() -> None: "Baserunners", "Run Differential", "Pitching", + "Hits Allowed", "Comparison", ] @@ -40,6 +43,7 @@ def test_links_point_at_real_routes() -> None: "/baserunners", "/run-differential", "/pitching", + "/hits-allowed", "/comparison", ] @@ -54,6 +58,7 @@ def test_the_current_page_is_marked() -> None: False, False, False, + False, ] @@ -67,6 +72,7 @@ def test_the_runs_page_can_be_the_current_one() -> None: False, False, False, + False, ] @@ -80,6 +86,7 @@ def test_the_baserunners_page_can_be_the_current_one() -> None: False, False, False, + False, ] @@ -93,6 +100,7 @@ def test_the_run_differential_page_can_be_the_current_one() -> None: True, False, False, + False, ] @@ -106,6 +114,7 @@ def test_the_pitching_page_can_be_the_current_one() -> None: False, True, False, + False, ] @@ -118,6 +127,7 @@ def test_the_comparison_page_can_be_the_current_one() -> None: False, False, False, + False, True, ] @@ -130,6 +140,7 @@ def test_only_one_page_is_current_at_a_time() -> None: BASERUNNERS_PATH, RUN_DIFFERENTIAL_PATH, PITCHING_PATH, + HITS_ALLOWED_PATH, COMPARISON_PATH, ): links = build_nav_links(current_path=path) @@ -143,7 +154,8 @@ def test_selection_is_carried_between_pages() -> None: assert links[3].href == "/baserunners?team_id=136&season=2025&window=15" assert links[4].href == "/run-differential?team_id=136&season=2025&window=15" assert links[5].href == "/pitching?team_id=136&season=2025&window=15" - assert links[6].href == "/comparison?team_id=136&season=2025&window=15" + assert links[6].href == "/hits-allowed?team_id=136&season=2025&window=15" + assert links[7].href == "/comparison?team_id=136&season=2025&window=15" def test_no_selection_produces_plain_paths() -> None: @@ -155,6 +167,7 @@ def test_no_selection_produces_plain_paths() -> None: "/baserunners", "/run-differential", "/pitching", + "/hits-allowed", "/comparison", ] @@ -166,4 +179,5 @@ def test_unset_values_are_left_out_of_the_query() -> None: assert links[3].href == "/baserunners?team_id=136&window=30" assert links[4].href == "/run-differential?team_id=136&window=30" assert links[5].href == "/pitching?team_id=136&window=30" - assert links[6].href == "/comparison?team_id=136&window=30" + assert links[6].href == "/hits-allowed?team_id=136&window=30" + assert links[7].href == "/comparison?team_id=136&window=30"