Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
95 changes: 95 additions & 0 deletions app/analytics/league_hits_allowed.py
Original file line number Diff line number Diff line change
@@ -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,
)
172 changes: 172 additions & 0 deletions app/analytics/team_hits_allowed.py
Original file line number Diff line number Diff line change
@@ -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,
)
Loading
Loading