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"