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
28 changes: 21 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ discovered club, and a persisted record of whether a run actually covered them
all. It adds no visualization. See
[docs/league-season-ingestion.md](docs/league-season-ingestion.md).

Milestone 5 adds MLB-wide context to the team hits page: an MLB hits-per-game
reference line, a `vs MLB` summary card, and a difference between the two. The
MLB average is a game-weighted mean over stored team-game records, and it is
shown only when Milestone 4 recorded `COMPLETE` league coverage for the
selected season. See
[docs/team-vs-mlb-comparison.md](docs/team-vs-mlb-comparison.md).

## Planned MVP

A local web application that:
Expand Down Expand Up @@ -443,12 +450,14 @@ poetry run ruff format --check .
│ ├── league-season-ingestion.md
│ ├── team-game-data-spike.md
│ ├── team-hits-visualization.md
│ └── team-season-ingestion.md
│ ├── team-season-ingestion.md
│ └── team-vs-mlb-comparison.md
├── tests/
│ ├── conftest.py
│ ├── factories.py
│ ├── fixtures/
│ │ └── team_game_logs/
│ ├── test_analytics_league_hitting.py
│ ├── test_analytics_schemas.py
│ ├── test_analytics_team_hitting.py
│ ├── test_charts.py
Expand All @@ -463,10 +472,12 @@ poetry run ruff format --check .
│ ├── test_repositories.py
│ ├── test_repositories_catalog.py
│ ├── test_repositories_league.py
│ ├── test_repositories_league_season.py
│ ├── test_selection.py
│ ├── test_team_game_logs.py
│ ├── test_team_season_ingestion.py
│ └── test_web.py
│ ├── test_web.py
│ └── test_web_league_comparison.py
├── .github/
│ └── workflows/
│ └── test.yml
Expand All @@ -479,11 +490,14 @@ poetry run ruff format --check .

## Later milestones

League-wide ingestion landed in Milestone 4, so the league comparison it was a
prerequisite for is now unblocked. MLB average lines, league ranks, percentiles,
and team-vs-league comparisons belong to Milestone 5, and should check a
season's stored ingestion coverage before presenting any league statistic. See
[docs/league-season-ingestion.md](docs/league-season-ingestion.md).
Milestone 5 delivered the MLB hits-per-game comparison the Milestone 4 coverage
state unblocked, gated on that state exactly as planned. See
[docs/team-vs-mlb-comparison.md](docs/team-vs-mlb-comparison.md).

Still unimplemented, and each needing its own definition before it is drawn:
league rank, percentiles, normalized indexes, and a league comparison for
batting strikeouts. Any of them must check a season's stored ingestion coverage
before presenting a league statistic, the way the hits comparison does.

## Disclaimer

Expand Down
126 changes: 126 additions & 0 deletions app/analytics/league_hitting.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""MLB-wide hitting calculations over normalized game batting lines.

Separate from ``app/analytics/team_hitting.py`` because it answers a different
question — how MLB overall hit, not how one club hit — and separate from
ingestion because it only reads what ingestion already persisted. Like every
other module under ``app/analytics``, it knows nothing about FastAPI, Jinja,
SQLAlchemy, Plotly, or the MLB API.
"""

from collections.abc import Sequence

from app.schemas.analytics import (
LeagueHitsContext,
TeamHitsAnalysis,
TeamHitsLeagueComparison,
)
from app.schemas.games import TeamGameBattingLine
from app.schemas.ingestion import (
LeagueSeasonIngestionState,
LeagueSeasonIngestionStatus,
)


class LeagueHitsAnalysisError(ValueError):
"""League hitting analysis was requested with input it cannot describe."""


def supports_league_wide_average(
coverage: LeagueSeasonIngestionState | None,
) -> bool:
"""Say whether a season's stored games may be described as MLB-wide.

The only acceptable evidence is the coverage state Milestone 4 records:
``COMPLETE`` means one league-wide run discovered every MLB team for that
season and successfully ingested all of them. Anything else — a run still
``RUNNING``, a run that lost a club, or a season no league-wide run has
ever touched — leaves an unknown number of teams missing, and an average of
whichever teams happen to be stored is not an MLB average.

Completeness is never inferred from how many rows exist. A row count cannot
tell a full season from a season missing a club, and it certainly cannot
tell either from a season still being played.

``COMPLETE`` describes the refresh, not the season: an in-progress season
can hold complete coverage while every club still has games left to play.
"""
if coverage is None:
return False
return coverage.status is LeagueSeasonIngestionStatus.COMPLETE


def build_league_hits_context(
games: Sequence[TeamGameBattingLine],
) -> LeagueHitsContext:
"""Calculate MLB hits per game across every stored team-game record.

The average is **game-weighted**::

hits per game = total hits / total team-game records

Every stored team-game record counts once, so a club that has played more
games contributes proportionally more. Averaging each club's own average
instead would silently give a team with 40 games the same weight as a team
with 162, which answers a different question and is wrong for this one.

Raises
------
LeagueHitsAnalysisError
``games`` is empty, or the records span more than one season.
"""
if not games:
raise LeagueHitsAnalysisError(
"Cannot describe MLB hitting from no team-game records"
)

seasons = {game.season for game in games}
if len(seasons) > 1:
raise LeagueHitsAnalysisError(
f"All team-game records must belong to one season, got {sorted(seasons)}"
)

team_game_records = len(games)
total_hits = sum(game.hits for game in games)
return LeagueHitsContext(
season=games[0].season,
teams_represented=len({game.team_id for game in games}),
team_game_records=team_game_records,
total_hits=total_hits,
hits_per_game=total_hits / team_game_records,
)


def compare_team_hits_to_league(
analysis: TeamHitsAnalysis,
league: LeagueHitsContext,
) -> TeamHitsLeagueComparison:
"""Place a team-season's hits per game beside MLB overall.

The team side is ``TeamHitsAnalysis.summary.season_average``, which is the
same number the chart's team reference line and the Season Avg card read,
so the page cannot show two different team averages.

The difference is descriptive subtraction and nothing more. It is not
normalized, not tested for significance, and carries no claim about why the
two numbers differ.

Raises
------
LeagueHitsAnalysisError
The team analysis and the league context describe different seasons.
"""
if analysis.season != league.season:
raise LeagueHitsAnalysisError(
f"Cannot compare a {analysis.season} team-season against "
f"{league.season} MLB context"
)

team_hits_per_game = analysis.summary.season_average
return TeamHitsLeagueComparison(
team_id=analysis.team_id,
team_name=analysis.team_name,
season=analysis.season,
team_hits_per_game=team_hits_per_game,
league=league,
difference_vs_mlb=team_hits_per_game - league.hits_per_game,
)
30 changes: 30 additions & 0 deletions app/database/repositories.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,36 @@ def list_team_season(
return [record.to_domain() for record in records]


def list_league_season(
session: Session,
*,
season: int,
) -> list[TeamGameBattingLine]:
"""Return every persisted batting line for a season, across all teams.

Whether that is actually MLB-wide is not a question this function answers.
It reports what is stored; the recorded league-season coverage state is
what says whether the stored rows may be described as covering the league.

Ordered by team, then in each team's chart order, so a run over the season
is reproducible. A full MLB season is roughly 4,860 team-game records, so
the rows are returned as domain objects and the statistics are calculated
in the analytics layer rather than pushed into SQL.
"""
stmt = (
select(TeamGameBattingLineRecord)
.where(TeamGameBattingLineRecord.season == season)
.order_by(
TeamGameBattingLineRecord.team_id,
TeamGameBattingLineRecord.game_date,
TeamGameBattingLineRecord.game_number,
TeamGameBattingLineRecord.game_pk,
)
)
records = session.scalars(stmt).all()
return [record.to_domain() for record in records]


def upsert_team_season(
session: Session,
*,
Expand Down
91 changes: 91 additions & 0 deletions app/schemas/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from __future__ import annotations

from datetime import date
from math import isclose

from pydantic import BaseModel, ConfigDict, Field, model_validator

Expand Down Expand Up @@ -215,3 +216,93 @@ def _summary_matches_points(self) -> TeamStrikeoutsAnalysis:
def last_game_date(self) -> date:
"""Date of the most recent completed game in the analysis."""
return self.points[-1].game_date


class LeagueHitsContext(BaseModel):
"""MLB-wide hitting context for one season.

Built from every persisted team-game batting line for the season, so
``hits_per_game`` is a game-weighted mean across team-game records rather
than the unweighted mean of each club's own average. Teams do not all play
the same number of games, so those two numbers are not the same statistic.

Every field describes the games **currently stored** for the season. For a
season still being played that is the completed games held by the most
recent complete league-wide refresh, not a whole season.
"""

model_config = ConfigDict(frozen=True, extra="forbid")

season: int = Field(gt=0, description="Season the context describes.")
teams_represented: int = Field(
ge=1,
description="Distinct teams with at least one stored game in the season.",
)
team_game_records: int = Field(
ge=1,
description="Team-game batting lines counted. One MLB game contributes "
"two records once both clubs are stored, so this is not a game count.",
)
total_hits: int = Field(
ge=0, description="Hits summed across every counted team-game record."
)
hits_per_game: float = Field(
ge=0,
description="total_hits / team_game_records.",
)

@model_validator(mode="after")
def _hits_per_game_matches_the_totals(self) -> LeagueHitsContext:
expected = self.total_hits / self.team_game_records
if not isclose(self.hits_per_game, expected, rel_tol=1e-9, abs_tol=1e-9):
raise ValueError(
f"hits_per_game ({self.hits_per_game}) must equal total_hits / "
f"team_game_records ({expected})"
)
if self.teams_represented > self.team_game_records:
raise ValueError(
f"teams_represented ({self.teams_represented}) cannot exceed "
f"team_game_records ({self.team_game_records})"
)
return self


class TeamHitsLeagueComparison(BaseModel):
"""One team's hits per game placed beside MLB overall for the same season.

Purely descriptive. A difference here says the selected team averaged more
or fewer hits per game than MLB across the stored season; it carries no
claim of significance, of skill, or of anything predictive.
"""

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_per_game: float = Field(
ge=0,
description="The selected team's average across its stored games, taken "
"from TeamHitsSummary.season_average so the page cannot disagree with "
"itself.",
)
league: LeagueHitsContext = Field(description="MLB-wide context compared against.")
difference_vs_mlb: float = Field(
description="team_hits_per_game - league.hits_per_game. Positive means "
"the team averaged more hits per game than MLB overall.",
)

@model_validator(mode="after")
def _comparison_is_internally_consistent(self) -> TeamHitsLeagueComparison:
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_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_per_game - league.hits_per_game ({expected})"
)
return self
Loading
Loading