diff --git a/README.md b/README.md index dd57d45..26500b6 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Normal browser requests do **not** call the MLB Stats API. - Team batting Strikeouts/Game trends - Team Runs/Game trends - Team Baserunners/Game trends (hits + walks + hit-by-pitch) +- Team run differential and Pythagorean expected record, from a league-wide import - 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 @@ -137,6 +138,7 @@ Current routes: | `/strikeouts` | Team batting Strikeouts/Game | | `/runs` | Team Runs/Game | | `/baserunners` | Team Baserunners/Game | +| `/run-differential` | Team run differential and Pythagorean record | | `/comparison` | Normalized Hits vs batting Strikeouts | | `/health` | JSON health check | @@ -222,6 +224,36 @@ team's hitters, not pitching strikeouts. Batting K/Game is a per-game count, not K%. Plate appearances are not currently persisted, so the application does not estimate K%. +### Run differential and runs allowed + +Runs allowed is **not** a stored column and not a separate MLB request. Each +team-game row records the opponent's id alongside the team's own runs, so runs +allowed for a game is the opponent's own runs scored on their row for the same +`game_pk`, found by a self-join. + +That makes `/run-differential` the one page needing a league-wide import. The +other pages read a single team's own rows, so a single-team import suits them; +this one has no opponent rows to pair with and refuses rather than treating an +unknown runs-allowed total as zero. + +Win/loss is derived the same way. A completed MLB game cannot end tied, so the +team that outscored its opponent won, which gives an actual record with no W/L +column stored anywhere. + +The page also shows the **Pythagorean expected record**: + +```text +expected_win_pct = RS^1.83 / (RS^1.83 + RA^1.83) +``` + +using the exponent Baseball Reference publishes against, so the figure can be +checked against a public source. The gap between expected and actual describes +games already played; it is not a forecast. + +There is no MLB average line on this page, which is not an omission: +league-wide run differential is exactly zero, because every run scored by one +team is a run allowed by another. The chart's zero line *is* the MLB average. + ### Normalized comparison The comparison page puts two different statistics on a common scale: @@ -320,6 +352,7 @@ including: - [Team batting strikeouts visualization](docs/team-strikeouts-visualization.md) - [Team runs visualization](docs/team-runs-visualization.md) - [Team baserunners visualization](docs/team-baserunners-visualization.md) +- [Team run differential visualization](docs/team-run-differential-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/team_run_differential.py b/app/analytics/team_run_differential.py new file mode 100644 index 0000000..c5bf9fd --- /dev/null +++ b/app/analytics/team_run_differential.py @@ -0,0 +1,255 @@ +"""Team run differential and Pythagorean expectation over paired game results. + +Answers one question: + + Is this team outscoring its opponents, and does its record reflect that? + +Every other analytics module in this package reads one team's own batting +line. This one is different: it reads ``TeamGameRunResult`` records, each of +which pairs a team's game with the opponent's game. Runs allowed is not a +stored column and not an MLB request — it is the opponent's runs scored, which +is the same number seen from the other side. + +Two things follow from having both sides of every game: + +- **Run differential**, the signed per-game margin. Unlike hits, runs, batting + strikeouts, and baserunners, this statistic can be negative, so nothing here + may assume a non-negative value or a zero floor. +- **Win/loss**, derived rather than stored. A completed MLB game cannot end + tied, so a team that outscored its opponent won it. That gives an actual + record to place beside the Pythagorean expectation without a W/L column. + +Like the rest of the package this layer is free of FastAPI, Jinja, SQLAlchemy, +Plotly, and the MLB API. +""" + +from collections.abc import Sequence + +from app.schemas.analytics import ( + PythagoreanRecord, + TeamRunDifferentialAnalysis, + TeamRunDifferentialPoint, + TeamRunDifferentialSummary, +) +from app.schemas.games import TeamGameRunResult + +DEFAULT_ROLLING_WINDOW = 15 + +PYTHAGOREAN_EXPONENT = 1.83 +"""Bill James' Pythagorean exponent as refined by Baseball Reference. + +The original formula squared runs. 1.83 is the exponent that best fits modern +MLB scoring levels, and is the one Baseball Reference publishes against, so +figures on this page can be checked against a public source. +""" + + +class TeamRunDifferentialAnalysisError(ValueError): + """Run differential analysis was requested with input it cannot describe.""" + + +class MissingOpponentDataError(TeamRunDifferentialAnalysisError): + """Some games of the team-season have no stored opponent line. + + Runs allowed for those games is unknown, not zero. Describing the season + without them would understate runs allowed and overstate run differential + by a margin that looks entirely plausible, so the analysis refuses instead. + + The remedy differs from the batting strikeout and baserunner backfills: + nothing is wrong with the team's own rows, and re-importing the team will + not help. The opponents' rows are what is absent, which is what importing + the league season provides. + """ + + def __init__(self, *, season: int, missing_game_count: int, total_games: int): + self.season = season + self.missing_game_count = missing_game_count + self.total_games = total_games + super().__init__( + f"{missing_game_count} of {total_games} games in the {season} season " + "have no stored opponent line, so runs allowed is unknown for them. " + "Import the full league season to pair every game." + ) + + +def build_team_run_differential_analysis( + results: Sequence[TeamGameRunResult], + *, + unpaired_game_count: int = 0, + rolling_window: int = DEFAULT_ROLLING_WINDOW, +) -> TeamRunDifferentialAnalysis: + """Calculate a team-season's run differential trend and Pythagorean record. + + 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. + + Raises + ------ + MissingOpponentDataError + ``unpaired_game_count`` is positive, meaning at least one game of the + season has no stored opponent line and runs allowed is unknown for it. + TeamRunDifferentialAnalysisError + ``results`` is empty, mixes team-seasons, or ``rolling_window`` is not + a positive number of games. + """ + if rolling_window < 1: + raise TeamRunDifferentialAnalysisError( + f"rolling_window must be at least 1 game, got {rolling_window}" + ) + if unpaired_game_count < 0: + raise TeamRunDifferentialAnalysisError( + f"unpaired_game_count cannot be negative, got {unpaired_game_count}" + ) + if not results and not unpaired_game_count: + raise TeamRunDifferentialAnalysisError( + "Cannot analyse run differential for a team-season with no completed games" + ) + + ordered = sorted( + results, key=lambda game: (game.game_date, game.game_number, game.game_pk) + ) + if unpaired_game_count: + # Raised before the team-season consistency check below so that a + # team-season imported on its own — where `ordered` is empty and there + # is no team id to report — still gets the message that names the fix. + season = ordered[-1].season if ordered else 0 + raise MissingOpponentDataError( + season=season, + missing_game_count=unpaired_game_count, + total_games=len(ordered) + unpaired_game_count, + ) + + 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 TeamRunDifferentialAnalysisError( + "All games must belong to one team and one season, got teams " + f"{sorted(team_ids)} and seasons {sorted(seasons)}" + ) + + differentials = [game.run_differential for game in ordered] + rolling_averages = _trailing_averages(differentials, rolling_window) + points = tuple( + TeamRunDifferentialPoint( + 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, + runs_scored=game.runs_scored, + runs_allowed=game.runs_allowed, + run_differential=game.run_differential, + is_win=game.is_win, + rolling_average=rolling_average, + ) + for index, (game, rolling_average) in enumerate( + zip(ordered, rolling_averages, strict=True) + ) + ) + + return TeamRunDifferentialAnalysis( + 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), + pythagorean=_build_pythagorean_record(ordered), + ) + + +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. + + Identical in shape to the helper in the other trend modules, but the values + here are signed, so the running total can go negative. + """ + 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: list[TeamGameRunResult], *, rolling_window: int +) -> TeamRunDifferentialSummary: + games_played = len(games) + differentials = [game.run_differential for game in games] + + recent = differentials[-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 run differential. + if games_played >= 2 * rolling_window: + prior = differentials[ + 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_runs_scored = sum(game.runs_scored for game in games) + total_runs_allowed = sum(game.runs_allowed for game in games) + + return TeamRunDifferentialSummary( + games_played=games_played, + total_runs_scored=total_runs_scored, + total_runs_allowed=total_runs_allowed, + total_run_differential=total_runs_scored - total_runs_allowed, + season_average=(total_runs_scored - total_runs_allowed) / games_played, + recent_average=recent_average, + prior_window_average=prior_window_average, + change_vs_prior_window=change_vs_prior_window, + ) + + +def _build_pythagorean_record(games: list[TeamGameRunResult]) -> PythagoreanRecord: + """Build the expected-versus-actual record for a team-season. + + Both halves come from the same paired games, so the expectation and the + record it is compared against always describe exactly the same sample. + """ + total_runs_scored = sum(game.runs_scored for game in games) + total_runs_allowed = sum(game.runs_allowed for game in games) + games_played = len(games) + + actual_wins = sum(1 for game in games if game.is_win) + actual_losses = games_played - actual_wins + + scored_component = total_runs_scored**PYTHAGOREAN_EXPONENT + allowed_component = total_runs_allowed**PYTHAGOREAN_EXPONENT + denominator = scored_component + allowed_component + if denominator == 0: + # Reachable only if a team neither scored nor allowed a run across + # every completed game of the season, which no real season contains. + raise TeamRunDifferentialAnalysisError( + "Pythagorean expectation is undefined for a team-season with no runs " + "scored and no runs allowed" + ) + + expected_win_pct = scored_component / denominator + expected_wins = expected_win_pct * games_played + + return PythagoreanRecord( + exponent=PYTHAGOREAN_EXPONENT, + runs_scored=total_runs_scored, + runs_allowed=total_runs_allowed, + expected_win_pct=expected_win_pct, + expected_wins=expected_wins, + actual_wins=actual_wins, + actual_losses=actual_losses, + actual_win_pct=actual_wins / games_played, + wins_above_expectation=actual_wins - expected_wins, + ) diff --git a/app/database/repositories.py b/app/database/repositories.py index f5d09ba..99469b9 100644 --- a/app/database/repositories.py +++ b/app/database/repositories.py @@ -4,11 +4,15 @@ from sqlalchemy import func, select from sqlalchemy.exc import OperationalError -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, aliased from app.database.models import LeagueSeasonIngestionRecord, TeamGameBattingLineRecord from app.schemas.catalog import AvailableTeamSeason -from app.schemas.games import TeamGameBattingLine +from app.schemas.games import ( + TeamGameBattingLine, + TeamGameRunResult, + TeamSeasonRunResults, +) from app.schemas.ingestion import ( LeagueSeasonIngestionState, LeagueSeasonIngestionStatus, @@ -150,6 +154,75 @@ def list_league_season( return [record.to_domain() for record in records] +def list_team_season_run_results( + session: Session, + *, + team_id: int, + season: int, +) -> TeamSeasonRunResults: + """Pair a team-season's games with the opponent's stored line for each game. + + Runs allowed is the opponent's runs scored in the same game, so this is an + outer self-join of ``team_game_batting_lines`` onto itself on ``game_pk``, + matching the opponent row by ``team_id``. No MLB request is involved and no + runs-allowed column exists; the figure is already in the table, on the other + team's row. + + The join is an outer join on purpose. A team-season imported on its own has + no opponent rows at all, and an inner join would quietly return zero games + for it — indistinguishable from a team that has not been imported. Instead + the unpaired ``game_pk`` values are reported so the caller can say which + state it is in. + + Games are returned in the same chart order as ``list_team_season``. + """ + opponent = aliased(TeamGameBattingLineRecord, name="opponent") + stmt = ( + select(TeamGameBattingLineRecord, opponent) + .outerjoin( + opponent, + (opponent.game_pk == TeamGameBattingLineRecord.game_pk) + & (opponent.team_id == TeamGameBattingLineRecord.opponent_id), + ) + .where( + TeamGameBattingLineRecord.team_id == team_id, + TeamGameBattingLineRecord.season == season, + ) + .order_by( + TeamGameBattingLineRecord.game_date, + TeamGameBattingLineRecord.game_number, + TeamGameBattingLineRecord.game_pk, + ) + ) + + results: list[TeamGameRunResult] = [] + unpaired: list[int] = [] + for row, opponent_row in session.execute(stmt): + if opponent_row is None: + unpaired.append(row.game_pk) + continue + results.append( + TeamGameRunResult( + game_pk=row.game_pk, + game_date=row.game_date, + season=row.season, + team_id=row.team_id, + team_name=row.team_name, + opponent_id=row.opponent_id, + opponent_name=row.opponent_name, + home_away=row.home_away, + runs_scored=row.runs, + runs_allowed=opponent_row.runs, + game_number=row.game_number, + ) + ) + + return TeamSeasonRunResults( + results=tuple(results), + unpaired_game_pks=tuple(unpaired), + ) + + def upsert_team_season( session: Session, *, diff --git a/app/schemas/analytics.py b/app/schemas/analytics.py index db60294..6e63eef 100644 --- a/app/schemas/analytics.py +++ b/app/schemas/analytics.py @@ -936,3 +936,250 @@ def _comparison_is_internally_consistent(self) -> TeamBaserunnersLeagueCompariso f"({expected})" ) return self + + +class TeamRunDifferentialPoint(BaseModel): + """One completed game plotted on the team run differential chart. + + Unlike every other per-game point in this module, ``run_differential`` is + signed: a team can be outscored, and the chart's zero line is the whole + point of the page. + """ + + 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.") + runs_scored: int = Field(ge=0, description="Runs scored by the team.") + runs_allowed: int = Field( + ge=0, description="Runs scored by the opponent in the same game." + ) + run_differential: int = Field( + description="runs_scored - runs_allowed. Negative when outscored." + ) + is_win: bool = Field(description="Whether the team outscored the opponent.") + rolling_average: float = Field( + description="Trailing rolling run-differential average ending at this game. " + "Signed, so this field has no lower bound.", + ) + + @model_validator(mode="after") + def _differential_and_result_match_the_runs(self) -> TeamRunDifferentialPoint: + expected = self.runs_scored - self.runs_allowed + if self.run_differential != expected: + raise ValueError( + f"run_differential ({self.run_differential}) must equal " + f"runs_scored - runs_allowed ({expected})" + ) + if self.is_win != (self.runs_scored > self.runs_allowed): + raise ValueError( + f"is_win ({self.is_win}) must equal runs_scored > runs_allowed " + f"({self.runs_scored} > {self.runs_allowed})" + ) + return self + + +class PythagoreanRecord(BaseModel): + """Expected record from runs scored and allowed, beside the actual record. + + Pythagorean expectation estimates the winning percentage a team's run + scoring and run prevention *should* have produced, using the Bill James + formula with the exponent 1.83 that Baseball Reference settled on:: + + expected_win_pct = RS^1.83 / (RS^1.83 + RA^1.83) + + The gap between expected and actual is the interesting number. A team well + above its expectation has usually won a lot of close games and lost a few + blowouts, which historically does not persist; a team below it has usually + done the reverse. It is a description of what has already happened, not a + forecast, and one season is a small enough sample that a few games of gap + is noise. + + The formula is undefined when a team has neither scored nor allowed a run, + which cannot happen across any real completed game, so it is not modelled. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + exponent: float = Field( + gt=0, description="Exponent used in the Pythagorean formula." + ) + runs_scored: int = Field(ge=0, description="Runs scored across the season.") + runs_allowed: int = Field(ge=0, description="Runs allowed across the season.") + expected_win_pct: float = Field( + ge=0, le=1, description="Pythagorean expected winning percentage." + ) + expected_wins: float = Field( + ge=0, description="expected_win_pct * games_played, not rounded." + ) + actual_wins: int = Field(ge=0, description="Games the team outscored the opponent.") + actual_losses: int = Field(ge=0, description="Games the team was outscored.") + actual_win_pct: float = Field( + ge=0, le=1, description="actual_wins / (actual_wins + actual_losses)." + ) + wins_above_expectation: float = Field( + description="actual_wins - expected_wins. Positive means the team has won " + "more than its run scoring and prevention alone would predict.", + ) + + @model_validator(mode="after") + def _record_is_internally_consistent(self) -> PythagoreanRecord: + games = self.actual_wins + self.actual_losses + if games == 0: + raise ValueError("A Pythagorean record needs at least one decided game") + + expected_pct = self.runs_scored**self.exponent / ( + self.runs_scored**self.exponent + self.runs_allowed**self.exponent + ) + if not isclose(self.expected_win_pct, expected_pct, rel_tol=1e-9, abs_tol=1e-9): + raise ValueError( + f"expected_win_pct ({self.expected_win_pct}) must equal " + f"RS^{self.exponent} / (RS^{self.exponent} + RA^{self.exponent}) " + f"({expected_pct})" + ) + if not isclose( + self.expected_wins, + self.expected_win_pct * games, + rel_tol=1e-9, + abs_tol=1e-9, + ): + raise ValueError( + f"expected_wins ({self.expected_wins}) must equal expected_win_pct " + f"* games played ({self.expected_win_pct * games})" + ) + if not isclose( + self.actual_win_pct, self.actual_wins / games, rel_tol=1e-9, abs_tol=1e-9 + ): + raise ValueError( + f"actual_win_pct ({self.actual_win_pct}) must equal actual_wins / " + f"games played ({self.actual_wins / games})" + ) + if not isclose( + self.wins_above_expectation, + self.actual_wins - self.expected_wins, + rel_tol=1e-9, + abs_tol=1e-9, + ): + raise ValueError( + f"wins_above_expectation ({self.wins_above_expectation}) must equal " + f"actual_wins - expected_wins " + f"({self.actual_wins - self.expected_wins})" + ) + return self + + +class TeamRunDifferentialSummary(BaseModel): + """Headline numbers describing a team-season's run differential. + + ``season_average`` is the single authoritative season average, read by the + chart's reference line and the summary cards alike, so the page cannot show + two different figures for the same statistic. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + games_played: int = Field(ge=1, description="Completed games analysed.") + total_runs_scored: int = Field(ge=0, description="Runs scored across the season.") + total_runs_allowed: int = Field(ge=0, description="Runs allowed across the season.") + total_run_differential: int = Field( + description="total_runs_scored - total_runs_allowed. Signed." + ) + season_average: float = Field( + description="Run differential per game across the stored completed games. " + "Signed, so this field has no lower bound.", + ) + recent_average: float = Field( + description="Run differential per game over the most recent rolling window." + ) + prior_window_average: float | None = Field( + default=None, + description="Run differential 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.", + ) + + @model_validator(mode="after") + def _totals_and_windows_agree(self) -> TeamRunDifferentialSummary: + expected_total = self.total_runs_scored - self.total_runs_allowed + if self.total_run_differential != expected_total: + raise ValueError( + f"total_run_differential ({self.total_run_differential}) must equal " + f"total_runs_scored - total_runs_allowed ({expected_total})" + ) + expected_average = self.total_run_differential / 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_run_differential / games_played ({expected_average})" + ) + 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 TeamRunDifferentialAnalysis(BaseModel): + """A team-season's run differential trend and Pythagorean record, 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[TeamRunDifferentialPoint, ...] = Field( + min_length=1, description="Games in chart order." + ) + summary: TeamRunDifferentialSummary + pythagorean: PythagoreanRecord + + @model_validator(mode="after") + def _summary_matches_points(self) -> TeamRunDifferentialAnalysis: + if self.summary.games_played != len(self.points): + raise ValueError( + "summary.games_played must equal the number of chart points" + ) + decided = self.pythagorean.actual_wins + self.pythagorean.actual_losses + if decided != len(self.points): + raise ValueError( + f"pythagorean wins plus losses ({decided}) must equal the number " + f"of chart points ({len(self.points)})" + ) + if self.pythagorean.runs_scored != self.summary.total_runs_scored: + raise ValueError( + f"pythagorean.runs_scored ({self.pythagorean.runs_scored}) must " + f"equal summary.total_runs_scored ({self.summary.total_runs_scored})" + ) + if self.pythagorean.runs_allowed != self.summary.total_runs_allowed: + raise ValueError( + f"pythagorean.runs_allowed ({self.pythagorean.runs_allowed}) must " + f"equal summary.total_runs_allowed ({self.summary.total_runs_allowed})" + ) + 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 diff --git a/app/schemas/games.py b/app/schemas/games.py index 8410280..691eba6 100644 --- a/app/schemas/games.py +++ b/app/schemas/games.py @@ -74,3 +74,75 @@ class TeamGameBattingLine(BaseModel): ge=1, description="Innings the game was scheduled for, which is not always nine.", ) + + +class TeamGameRunResult(BaseModel): + """One completed game seen from both sides: runs scored and runs allowed. + + Built by pairing a team's stored batting line with the opponent's stored + batting line for the same ``game_pk``. Runs allowed is not a figure the + MLB API is asked for; it is the opponent's own runs scored, which is the + same number. + + Only games where both rows are stored can be represented. A team-season + imported on its own has no opponent rows, and the repository reports those + games as unpaired rather than inventing a zero. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + game_pk: int = Field(gt=0, description="MLB game identifier.") + game_date: date = Field(description="Official date the game counts against.") + season: int = Field(gt=0, description="Season the game belongs to.") + team_id: int = Field(gt=0, description="MLB team id of the selected team.") + team_name: str = Field(min_length=1, description="Display name of the team.") + opponent_id: int = Field(gt=0, description="MLB team id of the opponent.") + opponent_name: str = Field( + min_length=1, description="Display name of the opponent." + ) + home_away: HomeAway = Field( + description="Whether the selected team was home or away." + ) + runs_scored: int = Field(ge=0, description="Runs scored by the selected team.") + runs_allowed: int = Field( + ge=0, + description="Runs scored by the opponent, which is this team's runs allowed.", + ) + game_number: int = Field( + ge=1, + description="Game number on the date, 2 for the second game of a doubleheader.", + ) + + @property + def run_differential(self) -> int: + """Runs scored minus runs allowed for this game.""" + return self.runs_scored - self.runs_allowed + + @property + def is_win(self) -> bool: + """Whether the selected team won. + + A completed MLB game cannot end tied, so outscoring the opponent is + the whole definition. Games that never reached a final are not stored + as completed team-game records in the first place. + """ + return self.runs_scored > self.runs_allowed + + +class TeamSeasonRunResults(BaseModel): + """Every game of a team-season that could be paired, and those that could not. + + Reporting the unpaired games rather than silently dropping them is what + lets the analytics layer refuse to describe a partial season. Dropping + them would understate runs allowed and produce a run differential that + looks plausible and is wrong. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + results: tuple[TeamGameRunResult, ...] = Field( + description="Games where both teams' batting lines are stored, in chart order." + ) + unpaired_game_pks: tuple[int, ...] = Field( + description="Games of this team-season with no stored opponent row." + ) diff --git a/app/web/charts.py b/app/web/charts.py index 213e081..89581fe 100644 --- a/app/web/charts.py +++ b/app/web/charts.py @@ -3,11 +3,13 @@ Kept out of the route so the figure contract can be tested without HTTP and so the route stays about request handling. -The hits, batting strikeout, runs, baserunners, and normalized comparison -figures are built by separate functions that share only the rendering helpers -below. They look alike, but a single parameterized builder would have to -encode which labels, colours, and axis semantics belong to which statistic, -which is harder to read than five explicit builders. +The hits, batting strikeout, runs, baserunners, run differential, and +normalized comparison figures are built by separate functions that share only +the rendering helpers below. They look alike, but a single parameterized +builder would have to encode which labels, colours, and axis semantics belong +to which statistic, which is harder to read than six explicit builders. The run +differential figure is the clearest case for keeping them apart: it is the only +signed metric, so it is the only one that must not anchor its y axis at zero. """ from datetime import date @@ -23,6 +25,8 @@ TeamHitsAnalysis, TeamHitsLeagueComparison, TeamHittingComparisonAnalysis, + TeamRunDifferentialAnalysis, + TeamRunDifferentialPoint, TeamRunsAnalysis, TeamRunsLeagueComparison, TeamStrikeoutsAnalysis, @@ -56,6 +60,11 @@ RAW_BASERUNNERS_TRACE_NAME = "Game Baserunners" BASERUNNERS_Y_AXIS_TITLE = "Baserunners per Game" +RUN_DIFFERENTIAL_CHART_DIV_ID = "team-run-differential-chart" +WIN_MARGIN_TRACE_NAME = "Win Margin" +LOSS_MARGIN_TRACE_NAME = "Loss Margin" +RUN_DIFFERENTIAL_Y_AXIS_TITLE = "Run Differential" + COMPARISON_CHART_DIV_ID = "team-hitting-comparison-chart" HITS_INDEX_TRACE_NAME = "Hits Index" STRIKEOUTS_INDEX_TRACE_NAME = "Batting Strikeout Index" @@ -69,6 +78,12 @@ _AMBER = "#b26a00" _RAW_LINE = "#b7c7d8" _RAW_MARKER = "#7c93ab" +# Win and loss margins on the run differential chart. Teal already means "the +# team's own trend" across every page, so wins keep it; the losses are a warm +# red that stays distinguishable from the amber MLB reference used elsewhere. +_WIN_BAR = "#3f9c9d" +_LOSS_BAR = "#c2544d" +_ZERO_LINE = "#8a99a8" _GRID = "#dbe2ea" _AXIS_LINE = "#c9d3de" _AXIS_INK = "#5b6b7c" @@ -803,6 +818,166 @@ def build_team_baserunners_figure( return figure +def build_team_run_differential_figure( + analysis: TeamRunDifferentialAnalysis, +) -> go.Figure: + """Build the run differential figure for one team-season. + + Two things make this chart deliberately unlike the other four. + + It uses **diverging bars** rather than a line of open markers. Run + differential is the only signed metric in the application, and a bar + growing up or down from a zero baseline shows the sign at a glance in a way + a line through a cloud of markers does not. The bars are split into two + traces, wins and losses, so the legend explains the colours and a reader + can isolate either one. + + It also has **no MLB reference line**, which is not an omission. League-wide + run differential is exactly zero by construction: every run scored by one + team is a run allowed by another, so the MLB total cancels. The zero line + the chart already draws *is* the league average, and a second amber line on + top of it would say the same thing twice. + """ + game_numbers = [point.season_game_number for point in analysis.points] + game_dates = [point.game_date for point in analysis.points] + rolling = [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]} %{customdata[3]}-%{customdata[4]}
" + "Run Differential: %{customdata[5]}
" + f"{analysis.rolling_window}-Game Avg: " + "%{customdata[6]:.2f}" + ) + + def hover_row( + point: TeamRunDifferentialPoint, + ) -> tuple[str, str, str, int, int, str, float]: + # Scores read high-low the way a box score does, so a 7-2 win and a + # 2-7 loss are told apart by the W/L flag rather than by field order. + winner_runs = max(point.runs_scored, point.runs_allowed) + loser_runs = min(point.runs_scored, point.runs_allowed) + return ( + format_long_date(point.game_date), + format_matchup(point.opponent_name, point.home_away), + "W" if point.is_win else "L", + winner_runs, + loser_runs, + # Explicit sign: "+3" and "-3" are opposite outcomes and the plus + # is what stops a reader scanning the column from missing it. + f"{point.run_differential:+d}", + point.rolling_average, + ) + + figure = go.Figure() + for trace_name, colour, wanted in ( + (WIN_MARGIN_TRACE_NAME, _WIN_BAR, True), + (LOSS_MARGIN_TRACE_NAME, _LOSS_BAR, False), + ): + selected = [point for point in analysis.points if point.is_win is wanted] + figure.add_trace( + go.Bar( + x=[point.season_game_number for point in selected], + y=[point.run_differential for point in selected], + customdata=[hover_row(point) for point in selected], + name=trace_name, + marker={"color": colour, "line": {"width": 0}}, + hovertemplate=hover_template, + ) + ) + + figure.add_trace( + go.Scatter( + x=game_numbers, + y=rolling, + customdata=[hover_row(point) for point in analysis.points], + name=rolling_name, + mode="lines", + # Straight segments between calculated points. A spline would + # overshoot between games and imply averages nobody calculated. + line={"color": _NAVY, "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": _AMBER, "width": 2, "dash": "dash"}, + hoverinfo="skip", + ) + ) + _label_reference_line( + figure, + x=game_numbers[-1], + y=season_average, + name=TEAM_SEASON_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", + # The two bar traces are one series split by outcome, not two series to + # be stacked or placed side by side: every game has exactly one bar. + barmode="overlay", + bargap=0.15, + 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": False, + "zeroline": False, + "rangemode": "tozero", + "automargin": True, + }, + yaxis={ + "title": { + "text": RUN_DIFFERENTIAL_Y_AXIS_TITLE, + "standoff": 10, + "font": _AXIS_TITLE_FONT, + }, + "tickfont": _TICK_FONT, + "gridcolor": _GRID, + "griddash": "dot", + # The one chart in the application that draws its zero line, and + # draws it darker than the grid. Zero is the win/loss boundary + # here, not an arbitrary axis end. + "zeroline": True, + "zerolinecolor": _ZERO_LINE, + "zerolinewidth": 1.5, + # Emphatically not "tozero": the axis has to hold negative values, + # and anchoring it at zero would clip every loss off the chart. + "rangemode": "normal", + "tickformat": "d", + "automargin": True, + }, + ) + return figure + + def build_team_hitting_comparison_figure( analysis: TeamHittingComparisonAnalysis, ) -> go.Figure: diff --git a/app/web/formatting.py b/app/web/formatting.py index 9de4e39..8a319e2 100644 --- a/app/web/formatting.py +++ b/app/web/formatting.py @@ -9,6 +9,7 @@ TeamHitsAnalysis, TeamHitsLeagueComparison, TeamHittingComparisonAnalysis, + TeamRunDifferentialAnalysis, TeamRunsAnalysis, TeamRunsLeagueComparison, TeamStrikeoutsAnalysis, @@ -20,6 +21,7 @@ STRIKEOUTS_PER_GAME_CAPTION = "Batting Strikeouts per Game" RUNS_PER_GAME_CAPTION = "Runs Scored per Game" BASERUNNERS_PER_GAME_CAPTION = "Baserunners per Game" +RUN_DIFFERENTIAL_PER_GAME_CAPTION = "Run Differential per Game" NORMALIZED_INDEX_CAPTION = "MLB Avg = 100" NO_LEAGUE_COMPARISON_VALUE = "—" NO_LEAGUE_COMPARISON_CAPTION = "Comparison unavailable" @@ -498,3 +500,134 @@ def format_league_baserunners_backfill_note( f"and the rest are not presented as MLB overall. Re-import the league " f"season to backfill them: {reimport_command}" ) + + +def build_run_differential_summary_cards( + analysis: TeamRunDifferentialAnalysis, +) -> list[SummaryCard]: + """Round the analysis for display only; the calculations keep full precision. + + Four cards, like every other metric page, but the third is the Pythagorean + record rather than a comparison against MLB. There is no MLB run + differential to compare against — league-wide it is zero by construction, + since every run scored is a run allowed — so the slot that holds ``vs MLB`` + elsewhere holds the expected record here. + + Every signed figure is rendered with an explicit sign. ``+0.42`` and + ``-0.42`` are opposite seasons, and a bare ``0.42`` in a column of numbers + invites a reader to miss which one they are looking at. + """ + summary = analysis.summary + pythagorean = analysis.pythagorean + window = analysis.rolling_window + + return [ + SummaryCard( + label=f"Recent {window}-Game Avg", + value=f"{summary.recent_average:+.2f}", + caption=RUN_DIFFERENTIAL_PER_GAME_CAPTION, + ), + SummaryCard( + label="Season Run Differential", + value=f"{summary.total_run_differential:+d}", + caption=( + f"{summary.total_runs_scored:,} Scored, " + f"{summary.total_runs_allowed:,} Allowed" + ), + ), + SummaryCard( + label="Pythagorean Record", + value=( + f"{pythagorean.expected_wins:.1f}-" + f"{len(analysis.points) - pythagorean.expected_wins:.1f}" + ), + caption=f"Expected {format_win_pct(pythagorean.expected_win_pct)}", + ), + SummaryCard( + label="Actual Record", + value=f"{pythagorean.actual_wins}-{pythagorean.actual_losses}", + caption=( + f"{format_win_pct(pythagorean.actual_win_pct)}, " + f"{pythagorean.wins_above_expectation:+.1f} vs Expected" + ), + ), + ] + + +def format_win_pct(value: float) -> str: + """Render a winning percentage the way baseball writes it: ``.512``. + + Baseball drops the leading zero and shows three decimal places. A perfect + or winless season is written ``1.000`` and ``.000``, so the leading digit + is kept only when it is not a zero. + """ + rendered = f"{value:.3f}" + return rendered[1:] if rendered.startswith("0.") else rendered + + +def format_pythagorean_note(analysis: TeamRunDifferentialAnalysis) -> str: + """Explain what the expected-versus-actual gap does and does not mean. + + The gap is the reason the Pythagorean record is on the page at all, and it + is the number most likely to be over-read. The wording says what it + describes — games already played — and avoids implying it forecasts + anything. + """ + gap = analysis.pythagorean.wins_above_expectation + exponent = analysis.pythagorean.exponent + basis = ( + f"Expected record from runs scored and allowed, using the Pythagorean " + f"formula with exponent {exponent}." + ) + + # Under a game either way is smaller than the rounding on a single blowout + # and should not be narrated as a finding. + if abs(gap) < 1: + return ( + f"{basis} {analysis.team_name}'s actual record is within a game of " + f"it, so run scoring and run prevention alone account for the " + f"season so far." + ) + + games = abs(gap) + game_word = "game" if round(games, 1) == 1.0 else "games" + direction = "above" if gap > 0 else "below" + explanation = ( + "usually meaning close games won and blowouts lost" + if gap > 0 + else "usually meaning close games lost and blowouts won" + ) + return ( + f"{basis} {analysis.team_name} is {games:.1f} {game_word} {direction} " + f"that expectation, {explanation}. It describes games already played " + f"rather than predicting the rest of the season." + ) + + +def format_missing_opponent_note( + *, + season: int, + missing_game_count: int, + total_games: int, + league_import_command: str, +) -> str: + """Say that opponent lines are missing, and how to fix it. + + Unlike the batting strikeout and baserunner backfill notes, nothing is + wrong with this team's own rows and re-importing the team will not help. + Runs allowed lives on the opponents' rows, which a single-team import never + fetches, so the remedy named here is a league-season import. + """ + # One missing game is as disqualifying as a hundred: without it, both the + # run differential and the record derived from it are wrong. + game_word = "game" if missing_game_count == 1 else "games" + has_have = "has" if missing_game_count == 1 else "have" + them = "it" if missing_game_count == 1 else "them" + return ( + f"Run differential unavailable. {missing_game_count:,} of the " + f"{total_games:,} {season} {game_word} stored for this team {has_have} " + f"no opponent line, so runs allowed is unknown for {them}. " + f"Runs allowed comes from the opponent's own record, which a " + f"single-team import does not fetch. Import the league season to pair " + f"every game: {league_import_command}" + ) diff --git a/app/web/navigation.py b/app/web/navigation.py index dff8595..48baeec 100644 --- a/app/web/navigation.py +++ b/app/web/navigation.py @@ -1,8 +1,8 @@ """Links between the analytics pages, keeping the reader's selection intact. -Moving between hits, batting strikeouts, runs, baserunners, and their -normalized comparison should not throw away the team, season, and rolling -window the reader chose, so each link carries them forward. Only selections +Moving between hits, batting strikeouts, runs, baserunners, run differential, +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. """ @@ -14,12 +14,14 @@ STRIKEOUTS_PATH = "/strikeouts" RUNS_PATH = "/runs" BASERUNNERS_PATH = "/baserunners" +RUN_DIFFERENTIAL_PATH = "/run-differential" COMPARISON_PATH = "/comparison" HITS_LABEL = "Hits" STRIKEOUTS_LABEL = "Batting Strikeouts" RUNS_LABEL = "Runs" BASERUNNERS_LABEL = "Baserunners" +RUN_DIFFERENTIAL_LABEL = "Run Differential" COMPARISON_LABEL = "Comparison" @@ -71,6 +73,11 @@ def build_nav_links( href=f"{BASERUNNERS_PATH}{suffix}", is_current=current_path == BASERUNNERS_PATH, ), + NavLink( + label=RUN_DIFFERENTIAL_LABEL, + href=f"{RUN_DIFFERENTIAL_PATH}{suffix}", + is_current=current_path == RUN_DIFFERENTIAL_PATH, + ), NavLink( label=COMPARISON_LABEL, href=f"{COMPARISON_PATH}{suffix}", diff --git a/app/web/routes.py b/app/web/routes.py index 92ed3c0..93919d4 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -43,6 +43,10 @@ InvalidComparisonBaselineError, build_team_hitting_comparison_analysis, ) +from app.analytics.team_run_differential import ( + MissingOpponentDataError, + build_team_run_differential_analysis, +) from app.analytics.team_runs import build_team_runs_analysis from app.analytics.team_strikeouts import ( MissingStrikeoutDataError, @@ -56,6 +60,7 @@ list_available_team_seasons, list_league_season, list_team_season, + list_team_season_run_results, ) from app.schemas.analytics import ( TeamBaserunnersAnalysis, @@ -70,11 +75,13 @@ from app.web.charts import ( BASERUNNERS_CHART_DIV_ID, COMPARISON_CHART_DIV_ID, + RUN_DIFFERENTIAL_CHART_DIV_ID, RUNS_CHART_DIV_ID, STRIKEOUTS_CHART_DIV_ID, build_team_baserunners_figure, build_team_hits_figure, build_team_hitting_comparison_figure, + build_team_run_differential_figure, build_team_runs_figure, build_team_strikeouts_figure, plotly_bundle_javascript, @@ -85,6 +92,7 @@ from app.web.formatting import ( build_baserunners_summary_cards, build_hitting_comparison_summary_cards, + build_run_differential_summary_cards, build_runs_summary_cards, build_strikeout_summary_cards, build_summary_cards, @@ -95,11 +103,14 @@ format_league_strikeouts_backfill_note, format_league_strikeouts_note, format_long_date, + format_missing_opponent_note, + format_pythagorean_note, ) from app.web.navigation import ( BASERUNNERS_PATH, COMPARISON_PATH, HITS_PATH, + RUN_DIFFERENTIAL_PATH, RUNS_PATH, STRIKEOUTS_PATH, build_nav_links, @@ -668,6 +679,146 @@ def baserunners( request=request, name="baserunners.html", context=context ) + @router.get(RUN_DIFFERENTIAL_PATH, response_class=HTMLResponse) + def run_differential( + 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 run differential and Pythagorean record for one 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": RUN_DIFFERENTIAL_PATH, + "nav_links": build_nav_links( + current_path=RUN_DIFFERENTIAL_PATH, + team_id=team_id, + season=season, + window=window, + ), + } + + if not teams: + context["state"] = "empty" + return templates.TemplateResponse( + request=request, name="run_differential.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="run_differential.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="run_differential.html", + context=context, + status_code=404, + ) + + context["selected_season"] = selected_season + context["nav_links"] = build_nav_links( + current_path=RUN_DIFFERENTIAL_PATH, + team_id=selected_team.team_id, + season=selected_season, + window=window, + ) + run_results = list_team_season_run_results( + session, team_id=selected_team.team_id, season=selected_season + ) + + try: + analysis = build_team_run_differential_analysis( + run_results.results, + unpaired_game_count=len(run_results.unpaired_game_pks), + rolling_window=window, + ) + except MissingOpponentDataError as exc: + # The opponents' rows are absent, so runs allowed is unknown for + # those games. Charting them would mean inventing a total or + # quietly analysing a subset, either of which produces a run + # differential that looks right and is not. + context["state"] = "missing_opponent_data" + context["missing_message"] = format_missing_opponent_note( + season=selected_season, + missing_game_count=exc.missing_game_count, + total_games=exc.total_games, + league_import_command=league_import_command_for(selected_season), + ) + context["games_missing"] = exc.missing_game_count + context["games_total"] = exc.total_games + context["league_import_command"] = league_import_command_for( + selected_season + ) + return templates.TemplateResponse( + request=request, + name="run_differential.html", + context=context, + status_code=409, + ) + + figure = build_team_run_differential_figure(analysis) + context.update( + { + "state": "ok", + "analysis": analysis, + "chart_html": render_figure_html( + figure, div_id=RUN_DIFFERENTIAL_CHART_DIV_ID + ), + "rolling_average_label": rolling_average_trace_name(window), + "summary_cards": build_run_differential_summary_cards(analysis), + "pythagorean_note": format_pythagorean_note(analysis), + "data_through": format_long_date(analysis.last_game_date), + } + ) + return templates.TemplateResponse( + request=request, name="run_differential.html", context=context + ) + @router.get(COMPARISON_PATH, response_class=HTMLResponse) def hitting_comparison( request: Request, diff --git a/app/web/templates/run_differential.html b/app/web/templates/run_differential.html new file mode 100644 index 0000000..38c7940 --- /dev/null +++ b/app/web/templates/run_differential.html @@ -0,0 +1,141 @@ +{% extends "base.html" %} + +{% block title %} + {%- if state == "ok" -%} + {{ analysis.team_name }} {{ analysis.season }} Run Differential + {%- else -%} + Team Run Differential + {%- 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 Run Differential

+

+ See whether a team is outscoring its opponents, and whether its record + reflects that. +

+
+ + {% 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_opponent_data" %} +
+

This season needs a league-wide import

+

+ {{ games_missing }} of the {{ games_total }} stored games for + {{ selected_team.team_name }} in {{ selected_season }} have no + opponent line, so runs allowed is unknown for them. +

+

+ Runs allowed is not a stored column and not a separate MLB request. It + is the opponent's own runs scored in the same game, read from the + opponent's record — which a single-team import never fetches. Import + the league season to pair every game: +

+
{{ league_import_command }}
+

+ The hits, batting strikeouts, runs, and baserunners charts read only + this team's own rows, so they are unaffected and still work for this + team-season. +

+
+ {% else %} +
+
+

{{ analysis.team_name }} — Run Differential 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 bar is one completed game's margin: the runs + {{ analysis.team_name }} scored minus the runs they allowed. Bars + above the zero line are wins, bars below it are losses. A completed + MLB game cannot end tied, so every game is one or the other. 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 margin across the completed games currently stored for this + season. +

+

+ Runs allowed is not fetched from MLB as its own statistic. It is the + opponent's runs scored in the same game, read from the opponent's + own stored record, which is why this page needs a league-wide + import while the other charts do not. +

+

Expected record

+

{{ pythagorean_note }}

+

Why there is no MLB average line

+

+ The other charts draw a dotted MLB reference line. This one does not, + and that is not an omission: league-wide run differential is exactly + zero, because every run scored by one team is a run allowed by + another and the totals cancel. The zero line already on the chart + is the MLB average. +

+

What run differential does not tell you

+

+ It weights a twelve-run win the same as twelve one-run wins, which + is why a team's margin and its record can disagree. It also says + nothing about how the runs were scored or prevented — hitting, + pitching, and defense all land in the same number. +

+
+
+ {% 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-run-differential-visualization.md b/docs/team-run-differential-visualization.md new file mode 100644 index 0000000..2c8a765 --- /dev/null +++ b/docs/team-run-differential-visualization.md @@ -0,0 +1,199 @@ +# Team run differential visualization + +The `/run-differential` page answers one question: is this team outscoring its +opponents, and does its record reflect that? + +It is the fifth per-game metric page, but it is not built like the other four. +Hits, batting strikeouts, runs, and baserunners all read one team's own batting +line. This page reads **both sides of every game**, which changes where the +data comes from, what can go wrong, and what the chart looks like. + +## 1. Runs allowed is not a column + +There is no `runs_allowed` column and no new MLB request. The existing +`team_game_batting_lines` table already stores everything needed: + +| Column | Role here | +| --- | --- | +| `game_pk` | Identifies the game both clubs played in | +| `team_id` | The selected team | +| `opponent_id` | Which club's row holds runs allowed | +| `runs` | Runs scored by whichever team the row belongs to | + +For a league-wide import, one MLB game produces two rows — one per club. The +selected team's runs allowed in that game is the *opponent's* `runs` on the +opponent's own row, which is the same number seen from the other side. + +`list_team_season_run_results` finds it with a self-join: + +```sql +FROM team_game_batting_lines AS team +LEFT JOIN team_game_batting_lines AS opponent + ON opponent.game_pk = team.game_pk + AND opponent.team_id = team.opponent_id +WHERE team.team_id = :team_id AND team.season = :season +``` + +Two details in that join matter: + +- It matches on `opponent.team_id = team.opponent_id`, not merely on sharing a + `game_pk`. Matching on `game_pk` alone would be correct today but would break + the moment any third row shared the id. +- It is a **LEFT** join. An inner join would silently return zero games for a + team-season with no opponent rows, which is indistinguishable from a team + that was never imported. The outer join instead reports which `game_pk` + values found no partner, so the caller can tell the two states apart. + +## 2. Why this page needs a league-wide import + +`scripts/import_team_season.py` fetches one club. Nothing in that import +contains the opponents' batting lines, so every game comes back unpaired and +runs allowed is unknown for all of them. + +The page refuses in that state rather than charting a partial season. This is +the same refuse-and-guide pattern the batting strikeout and baserunner pages +use, with one difference worth stating plainly: **re-importing the team cannot +fix it.** Nothing is wrong with the team's own rows. The remedy the page names +is `scripts/import_league_season.py`. + +A partially paired season — some opponents stored, some not — is refused too. +An average over only the paired games would understate runs allowed and produce +a run differential that looks entirely plausible and is wrong. + +## 3. Win/loss is derived, not stored + +There is no W/L column anywhere in the schema, and this page does not add one. +A completed MLB game cannot end tied, so: + +```text +is_win = runs_scored > runs_allowed +``` + +That is the whole definition. It gives an actual record for free, which is what +the Pythagorean comparison in section 5 is measured against. Issue #29 covers +showing W/L markers on the other charts; this page needed only the record. + +## 4. Run differential calculation + +```text +run_differential = runs_scored - runs_allowed +``` + +Per game, and summed across the season for the headline figure. + +This is the only **signed** metric in the application. Every other per-game +value — hits, runs, batting strikeouts, baserunners — has a floor of zero. Run +differential does not, and that single fact drives most of the differences in +the rest of this document: the schema fields have no `ge=0`, the chart's y axis +must not anchor at zero, and every rendered figure carries an explicit sign. + +The rolling average uses the same trailing-window definition as the other +pages: the average at game *i* covers the `window` most recent games up to and +including *i*, and early-season games average only what has been played. The +running total can go negative. + +## 5. Pythagorean expected record + +```text +expected_win_pct = RS^1.83 / (RS^1.83 + RA^1.83) +expected_wins = expected_win_pct * games_played +``` + +The exponent is 1.83, the refinement of Bill James' original squared formula +that Baseball Reference publishes against. Pinning it to a public source means +the number on the page can be checked rather than taken on trust; the exponent +is stated on the page for the same reason. + +The interesting figure is the gap: + +```text +wins_above_expectation = actual_wins - expected_wins +``` + +A team above its expectation has usually won a lot of close games and lost a +few blowouts. A team below it has usually done the reverse. The page says so, +and says the gap describes games already played rather than predicting +anything — that is the reading most likely to be over-interpreted. + +Under one game either way is narrated as "within a game" rather than as a +finding, since that is smaller than the effect of a single blowout. + +Both halves are computed from the same paired games, so the expectation and the +record it is compared against always describe exactly the same sample. The +`TeamRunDifferentialAnalysis` validator enforces this: the Pythagorean wins plus +losses must equal the number of chart points, and its run totals must equal the +summary's. + +## 6. Chart + +Two deliberate departures from the other four charts. + +**Diverging bars, not a marker line.** The other charts draw open markers with +a rolling line through them. This one draws bars growing up or down from zero, +split into two traces by outcome so the legend explains the colours. For a +signed quantity the sign is the primary reading, and a bar against a baseline +shows it at a glance where a line through a marker cloud does not. + +The two bar traces use `barmode="overlay"`. They are one series split by +outcome, not two series to stack or group — every game has exactly one bar, and +grouping would shift bars off their true x position. + +**No MLB reference line.** Every other metric chart draws a dotted amber MLB +average. This one does not, and that is not an omission. League-wide run +differential is exactly zero by construction: every run scored by one team is a +run allowed by another, so the MLB total cancels. The zero line the chart +already draws **is** the league average, and a second line on top of it would +say the same thing twice. The page states this rather than leaving a reader to +wonder what is missing. + +The y axis uses `rangemode="normal"`, not the `"tozero"` the other charts use. +Anchoring at zero would clip every loss off the chart. Its zero line is drawn +darker than the gridlines, because here zero is the win/loss boundary rather +than an arbitrary axis end. + +## 7. Summary cards + +Four cards, like every other metric page, but the third slot holds the +Pythagorean record rather than a `vs MLB` comparison — there being no MLB run +differential to compare against. + +| Card | Value | +| --- | --- | +| Recent *n*-Game Avg | Signed run differential per game over the window | +| Season Run Differential | Signed season total, captioned with both run totals | +| Pythagorean Record | Expected wins-losses and expected win % | +| Actual Record | Real wins-losses, win %, and the gap vs expected | + +Every signed figure is rendered with an explicit sign, including `+0`. A dead +even season is a real result, and a bare `0` in a column of numbers reads like +a missing value. + +Winning percentages are written the way baseball writes them — `.512`, leading +zero dropped, three decimals — with `1.000` keeping its leading digit. + +## 8. What run differential does not tell you + +It weights a twelve-run win the same as twelve one-run wins. That is exactly +why a team's margin and its record can disagree, and why the Pythagorean gap is +worth showing — but it also means the metric alone does not describe how a +season was won. + +It also says nothing about *how* runs were scored or prevented. Hitting, +pitching, and defense all land in the same number. A team with a strong run +differential built on run prevention and one built on run scoring look +identical here. + +## 9. Where each responsibility lives + +| Concern | Location | +| --- | --- | +| Pairing a team's games with the opponent's rows | `app/database/repositories.py` (`list_team_season_run_results`) | +| Paired-game domain model | `app/schemas/games.py` (`TeamGameRunResult`, `TeamSeasonRunResults`) | +| Differential, rolling average, Pythagorean record | `app/analytics/team_run_differential.py` | +| Analysis models and their consistency guards | `app/schemas/analytics.py` | +| Figure construction | `app/web/charts.py` (`build_team_run_differential_figure`) | +| Cards, notes, win-pct formatting | `app/web/formatting.py` | +| Request handling and page state | `app/web/routes.py` (`/run-differential`) | +| Page markup | `app/web/templates/run_differential.html` | + +No migration was required. Every column this page reads already existed. diff --git a/tests/factories.py b/tests/factories.py index 8760dea..86a92b3 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -10,7 +10,7 @@ LeagueRunsContext, LeagueStrikeoutsContext, ) -from app.schemas.games import TeamGameBattingLine +from app.schemas.games import TeamGameBattingLine, TeamGameRunResult MARINERS_ID = 136 MARINERS_NAME = "Seattle Mariners" @@ -108,6 +108,75 @@ def make_season( ] +def make_run_result(**overrides: Any) -> TeamGameRunResult: + """Build one paired game result, overriding only the fields a test cares about.""" + base: dict[str, Any] = { + "game_pk": 776000, + "game_date": OPENING_DAY, + "season": 2025, + "team_id": MARINERS_ID, + "team_name": MARINERS_NAME, + "opponent_id": TWINS_ID, + "opponent_name": TWINS_NAME, + "home_away": "home", + "runs_scored": 5, + "runs_allowed": 3, + "game_number": 1, + } + base.update(overrides) + return TeamGameRunResult(**base) + + +def make_run_result_season( + runs_scored: Sequence[int], + runs_allowed: Sequence[int], + *, + team_id: int = MARINERS_ID, + team_name: str = MARINERS_NAME, + season: int = 2025, + start_date: date | None = None, +) -> list[TeamGameRunResult]: + """Build one paired game per score pair, on consecutive days, in season order. + + Game ids are derived from the season the same way ``make_season`` derives + them, so a test can build a team's batting lines and its paired results for + the same season and have the two agree on ``game_pk``. + + Real completed games are never tied, so a tied pair is rejected here rather + than silently producing a game that could not have happened. + """ + if len(runs_scored) != len(runs_allowed): + raise ValueError( + f"runs_scored has {len(runs_scored)} values but runs_allowed has " + f"{len(runs_allowed)}" + ) + tied = [ + index + for index, (scored, allowed) in enumerate( + zip(runs_scored, runs_allowed, strict=True) + ) + if scored == allowed + ] + if tied: + raise ValueError( + f"A completed MLB game cannot end tied, but games {tied} are tied" + ) + opening_day = start_date or date(season, OPENING_DAY.month, OPENING_DAY.day) + return [ + make_run_result( + game_pk=season * 1000 + index, + game_date=opening_day + timedelta(days=index), + season=season, + team_id=team_id, + team_name=team_name, + home_away="home" if index % 2 == 0 else "away", + runs_scored=scored, + runs_allowed=runs_allowed[index], + ) + for index, scored in enumerate(runs_scored) + ] + + def make_league_hits_context( *, season: int = 2025, diff --git a/tests/test_analytics_team_run_differential.py b/tests/test_analytics_team_run_differential.py new file mode 100644 index 0000000..76b3175 --- /dev/null +++ b/tests/test_analytics_team_run_differential.py @@ -0,0 +1,316 @@ +"""Tests for team run differential and Pythagorean expectation. + +The metric is unlike the other four in one way that shapes most of these +tests: it is signed. A team can be outscored, so nothing here may assume a +non-negative value, and several cases below exist only to prove that a losing +team is described correctly rather than clamped at zero. +""" + +from datetime import date + +import pytest + +from app.analytics.team_run_differential import ( + PYTHAGOREAN_EXPONENT, + MissingOpponentDataError, + TeamRunDifferentialAnalysisError, + build_team_run_differential_analysis, +) +from tests.factories import ( + MARINERS_ID, + MARINERS_NAME, + make_run_result, + make_run_result_season, +) + + +def test_a_winning_season_reports_a_positive_differential() -> None: + games = make_run_result_season([6, 5, 7], [2, 3, 1]) + + analysis = build_team_run_differential_analysis(games, rolling_window=3) + + assert analysis.summary.total_runs_scored == 18 + assert analysis.summary.total_runs_allowed == 6 + assert analysis.summary.total_run_differential == 12 + assert analysis.summary.season_average == pytest.approx(4.0) + + +def test_a_losing_season_reports_a_negative_differential() -> None: + """The signed case. A clamped or absolute-valued metric fails here.""" + games = make_run_result_season([1, 2, 0], [5, 4, 9]) + + analysis = build_team_run_differential_analysis(games, rolling_window=3) + + assert analysis.summary.total_run_differential == -15 + assert analysis.summary.season_average == pytest.approx(-5.0) + assert analysis.summary.recent_average == pytest.approx(-5.0) + assert all(point.run_differential < 0 for point in analysis.points) + assert not any(point.is_win for point in analysis.points) + + +def test_each_point_carries_both_sides_of_the_game() -> None: + games = make_run_result_season([4, 2], [1, 8]) + + analysis = build_team_run_differential_analysis(games, rolling_window=2) + + first, second = analysis.points + assert (first.runs_scored, first.runs_allowed) == (4, 1) + assert first.run_differential == 3 + assert first.is_win is True + assert (second.runs_scored, second.runs_allowed) == (2, 8) + assert second.run_differential == -6 + assert second.is_win is False + + +def test_wins_and_losses_are_derived_from_the_score() -> None: + """No W/L column is stored; outscoring the opponent is the whole definition.""" + games = make_run_result_season([5, 1, 3, 9], [2, 4, 8, 0]) + + analysis = build_team_run_differential_analysis(games, rolling_window=4) + + assert [point.is_win for point in analysis.points] == [True, False, False, True] + assert analysis.pythagorean.actual_wins == 2 + assert analysis.pythagorean.actual_losses == 2 + assert analysis.pythagorean.actual_win_pct == pytest.approx(0.5) + + +def test_the_rolling_average_can_go_negative() -> None: + games = make_run_result_season([1, 1, 1, 9], [4, 4, 4, 0]) + + analysis = build_team_run_differential_analysis(games, rolling_window=2) + + # Games 1-2 are both -3, so the trailing pair average is -3. + assert analysis.points[1].rolling_average == pytest.approx(-3.0) + # Games 3-4 are -3 and +9, averaging +3. + assert analysis.points[3].rolling_average == pytest.approx(3.0) + + +def test_early_games_average_only_what_has_been_played() -> None: + games = make_run_result_season([7, 1], [2, 5]) + + analysis = build_team_run_differential_analysis(games, rolling_window=15) + + assert analysis.points[0].rolling_average == pytest.approx(5.0) + assert analysis.points[1].rolling_average == pytest.approx(0.5) + + +def test_games_are_ordered_by_date_then_doubleheader_game_number() -> None: + second_game = make_run_result( + game_pk=776002, + game_date=date(2025, 4, 2), + game_number=2, + runs_scored=1, + runs_allowed=7, + ) + first_game = make_run_result( + game_pk=776001, + game_date=date(2025, 4, 2), + game_number=1, + runs_scored=8, + runs_allowed=2, + ) + opener = make_run_result( + game_pk=776000, + game_date=date(2025, 4, 1), + game_number=1, + runs_scored=3, + runs_allowed=1, + ) + + analysis = build_team_run_differential_analysis( + [second_game, opener, first_game], rolling_window=3 + ) + + assert [point.game_pk for point in analysis.points] == [776000, 776001, 776002] + assert [point.season_game_number for point in analysis.points] == [1, 2, 3] + + +def test_the_prior_window_comparison_needs_two_complete_windows() -> None: + games = make_run_result_season([5] * 3, [1] * 3) + + analysis = build_team_run_differential_analysis(games, rolling_window=2) + + # Three games is not two complete windows of two. + assert analysis.summary.prior_window_average is None + assert analysis.summary.change_vs_prior_window is None + + +def test_the_prior_window_comparison_appears_with_two_complete_windows() -> None: + games = make_run_result_season([1, 1, 9, 9], [4, 4, 0, 0]) + + analysis = build_team_run_differential_analysis(games, rolling_window=2) + + assert analysis.summary.prior_window_average == pytest.approx(-3.0) + assert analysis.summary.recent_average == pytest.approx(9.0) + assert analysis.summary.change_vs_prior_window == pytest.approx(12.0) + + +class TestPythagoreanExpectation: + def test_it_matches_the_published_formula(self) -> None: + games = make_run_result_season([4] * 10, [3] * 10) + + record = build_team_run_differential_analysis( + games, rolling_window=5 + ).pythagorean + + expected = 40**PYTHAGOREAN_EXPONENT / ( + 40**PYTHAGOREAN_EXPONENT + 30**PYTHAGOREAN_EXPONENT + ) + assert record.exponent == PYTHAGOREAN_EXPONENT + assert record.runs_scored == 40 + assert record.runs_allowed == 30 + assert record.expected_win_pct == pytest.approx(expected) + assert record.expected_wins == pytest.approx(expected * 10) + + def test_equal_runs_scored_and_allowed_expect_a_500_season(self) -> None: + games = make_run_result_season([5, 1], [1, 5]) + + record = build_team_run_differential_analysis( + games, rolling_window=2 + ).pythagorean + + assert record.runs_scored == record.runs_allowed == 6 + assert record.expected_win_pct == pytest.approx(0.5) + + def test_winning_close_and_losing_big_beats_the_expectation(self) -> None: + """Three one-run wins and one blowout loss: a real 3-1 on a -6 differential.""" + games = make_run_result_season([2, 2, 2, 1], [1, 1, 1, 12]) + + record = build_team_run_differential_analysis( + games, rolling_window=4 + ).pythagorean + + assert record.actual_wins == 3 + assert record.runs_scored == 7 + assert record.runs_allowed == 15 + # Outscored overall, so the expectation is below .500 while the real + # record is .750. That gap is the whole point of the statistic. + assert record.expected_win_pct < 0.5 + assert record.actual_win_pct == pytest.approx(0.75) + assert record.wins_above_expectation > 0 + + def test_losing_close_and_winning_big_trails_the_expectation(self) -> None: + games = make_run_result_season([1, 1, 1, 12], [2, 2, 2, 1]) + + record = build_team_run_differential_analysis( + games, rolling_window=4 + ).pythagorean + + assert record.actual_wins == 1 + assert record.expected_win_pct > 0.5 + assert record.actual_win_pct == pytest.approx(0.25) + assert record.wins_above_expectation < 0 + + def test_wins_and_losses_cover_every_analysed_game(self) -> None: + games = make_run_result_season([3, 1, 7, 2, 6], [1, 5, 2, 9, 0]) + + analysis = build_team_run_differential_analysis(games, rolling_window=3) + + decided = analysis.pythagorean.actual_wins + analysis.pythagorean.actual_losses + assert decided == len(analysis.points) == 5 + + def test_a_shutout_season_expects_no_wins(self) -> None: + """Scoring zero across the season drives the numerator to zero.""" + games = make_run_result_season([0, 0, 0], [4, 2, 6]) + + record = build_team_run_differential_analysis( + games, rolling_window=3 + ).pythagorean + + assert record.expected_win_pct == pytest.approx(0.0) + assert record.expected_wins == pytest.approx(0.0) + assert record.actual_wins == 0 + + +class TestMissingOpponentData: + def test_unpaired_games_are_refused(self) -> None: + games = make_run_result_season([5, 3], [1, 7]) + + with pytest.raises(MissingOpponentDataError) as caught: + build_team_run_differential_analysis( + games, unpaired_game_count=4, rolling_window=2 + ) + + assert caught.value.missing_game_count == 4 + assert caught.value.total_games == 6 + assert caught.value.season == 2025 + + def test_the_message_names_the_league_import_as_the_fix(self) -> None: + """Re-importing the team cannot help; the opponents' rows are what is absent.""" + games = make_run_result_season([5], [1]) + + with pytest.raises(MissingOpponentDataError) as caught: + build_team_run_differential_analysis(games, unpaired_game_count=1) + + message = str(caught.value) + assert "league season" in message + assert "runs allowed is unknown" in message + + def test_a_team_season_with_no_pairs_at_all_is_refused(self) -> None: + """A single-team import: every game is unpaired and nothing can be charted.""" + with pytest.raises(MissingOpponentDataError) as caught: + build_team_run_differential_analysis([], unpaired_game_count=162) + + assert caught.value.missing_game_count == 162 + assert caught.value.total_games == 162 + + def test_a_partial_season_is_never_quietly_analysed(self) -> None: + """The refusal is what stops an understated runs-allowed total.""" + games = make_run_result_season([9, 9], [0, 0]) + + with pytest.raises(MissingOpponentDataError): + build_team_run_differential_analysis(games, unpaired_game_count=1) + + +class TestInvalidInput: + def test_an_empty_season_is_rejected(self) -> None: + with pytest.raises( + TeamRunDifferentialAnalysisError, match="no completed games" + ): + build_team_run_differential_analysis([]) + + def test_a_rolling_window_below_one_is_rejected(self) -> None: + games = make_run_result_season([5], [1]) + + with pytest.raises(TeamRunDifferentialAnalysisError, match="at least 1 game"): + build_team_run_differential_analysis(games, rolling_window=0) + + def test_a_negative_unpaired_count_is_rejected(self) -> None: + games = make_run_result_season([5], [1]) + + with pytest.raises( + TeamRunDifferentialAnalysisError, match="cannot be negative" + ): + build_team_run_differential_analysis(games, unpaired_game_count=-1) + + def test_mixing_teams_is_rejected(self) -> None: + mariners = make_run_result_season([5], [1]) + twins = make_run_result_season( + [3], [2], team_id=142, team_name="Minnesota Twins" + ) + + with pytest.raises( + TeamRunDifferentialAnalysisError, match="one team and one season" + ): + build_team_run_differential_analysis([*mariners, *twins]) + + def test_mixing_seasons_is_rejected(self) -> None: + this_year = make_run_result_season([5], [1], season=2025) + last_year = make_run_result_season([3], [2], season=2024) + + with pytest.raises( + TeamRunDifferentialAnalysisError, match="one team and one season" + ): + build_team_run_differential_analysis([*this_year, *last_year]) + + +def test_the_analysis_identifies_the_team_and_season() -> None: + games = make_run_result_season([5, 3], [1, 7]) + + analysis = build_team_run_differential_analysis(games, rolling_window=2) + + assert analysis.team_id == MARINERS_ID + assert analysis.team_name == MARINERS_NAME + assert analysis.season == 2025 + assert analysis.rolling_window == 2 + assert analysis.last_game_date == analysis.points[-1].game_date diff --git a/tests/test_charts_run_differential.py b/tests/test_charts_run_differential.py new file mode 100644 index 0000000..32d917d --- /dev/null +++ b/tests/test_charts_run_differential.py @@ -0,0 +1,165 @@ +"""Tests for the run differential figure contract, not for Plotly itself. + +This figure departs from the other four in two ways that the tests below pin +down: it draws diverging bars split by outcome instead of a marker line, and +its y axis must hold negative values rather than anchoring at zero. +""" + +import pytest + +from app.analytics.team_run_differential import build_team_run_differential_analysis +from app.web.charts import ( + LOSS_MARGIN_TRACE_NAME, + RUN_DIFFERENTIAL_CHART_DIV_ID, + RUN_DIFFERENTIAL_Y_AXIS_TITLE, + TEAM_SEASON_AVERAGE_TRACE_NAME, + WIN_MARGIN_TRACE_NAME, + X_AXIS_TITLE, + build_team_run_differential_figure, + render_figure_html, + rolling_average_trace_name, +) +from tests.factories import make_run_result_season + +SCORED = [6, 2, 8, 1, 5] +ALLOWED = [3, 7, 1, 9, 2] + + +def analysis_for(scored=SCORED, allowed=ALLOWED, window: int = 5): + return build_team_run_differential_analysis( + make_run_result_season(scored, allowed), rolling_window=window + ) + + +@pytest.fixture +def figure(): + return build_team_run_differential_figure(analysis_for()) + + +def trace_named(figure, name): + for trace in figure.data: + if trace.name == name: + return trace + raise AssertionError(f"No trace named {name!r} in {[t.name for t in figure.data]}") + + +def test_the_figure_has_wins_losses_rolling_and_season_average(figure) -> None: + assert [trace.name for trace in figure.data] == [ + WIN_MARGIN_TRACE_NAME, + LOSS_MARGIN_TRACE_NAME, + rolling_average_trace_name(5), + TEAM_SEASON_AVERAGE_TRACE_NAME, + ] + + +def test_wins_and_losses_are_split_into_separate_bar_traces(figure) -> None: + wins = trace_named(figure, WIN_MARGIN_TRACE_NAME) + losses = trace_named(figure, LOSS_MARGIN_TRACE_NAME) + + assert wins.type == "bar" + assert losses.type == "bar" + # 6-3, 8-1 and 5-2 are wins; 2-7 and 1-9 are losses. + assert list(wins.y) == [3, 7, 3] + assert list(losses.y) == [-5, -8] + + +def test_every_game_appears_in_exactly_one_bar_trace(figure) -> None: + wins = trace_named(figure, WIN_MARGIN_TRACE_NAME) + losses = trace_named(figure, LOSS_MARGIN_TRACE_NAME) + + plotted = sorted([*wins.x, *losses.x]) + assert plotted == [1, 2, 3, 4, 5] + + +def test_the_bars_overlay_rather_than_stack(figure) -> None: + """Each game has one bar; stacking or grouping would misplace it on the x axis.""" + assert figure.layout.barmode == "overlay" + + +def test_wins_and_losses_are_drawn_in_different_colours(figure) -> None: + wins = trace_named(figure, WIN_MARGIN_TRACE_NAME) + losses = trace_named(figure, LOSS_MARGIN_TRACE_NAME) + + assert wins.marker.color != losses.marker.color + + +def test_the_y_axis_is_not_anchored_at_zero(figure) -> None: + """The whole point: anchoring at zero would clip every loss off the chart.""" + assert figure.layout.yaxis.rangemode != "tozero" + + +def test_the_zero_line_is_drawn_and_darker_than_the_grid(figure) -> None: + """Zero is the win/loss boundary here, not an arbitrary axis end.""" + assert figure.layout.yaxis.zeroline is True + assert figure.layout.yaxis.zerolinecolor != figure.layout.yaxis.gridcolor + + +def test_a_losing_season_still_plots_its_bars(figure) -> None: + losing = build_team_run_differential_figure( + analysis_for([0, 1, 2], [8, 6, 9], window=3) + ) + losses = trace_named(losing, LOSS_MARGIN_TRACE_NAME) + wins = trace_named(losing, WIN_MARGIN_TRACE_NAME) + + assert list(losses.y) == [-8, -5, -7] + assert list(wins.y) == [] + + +def test_the_season_average_line_spans_the_season(figure) -> None: + average = trace_named(figure, TEAM_SEASON_AVERAGE_TRACE_NAME) + + assert list(average.x) == [1, 5] + # 22 scored, 22 allowed across the five games: a dead-even season. + assert list(average.y) == [0.0, 0.0] + + +def test_there_is_no_mlb_reference_trace(figure) -> None: + """League-wide run differential is zero by construction: nothing to draw.""" + assert "MLB Average" not in [trace.name for trace in figure.data] + + +def test_the_rolling_trace_follows_the_analysis(figure) -> None: + analysis = analysis_for() + rolling = trace_named(figure, rolling_average_trace_name(5)) + + assert list(rolling.y) == pytest.approx( + [point.rolling_average for point in analysis.points] + ) + + +def test_the_axes_are_titled(figure) -> None: + assert figure.layout.xaxis.title.text == X_AXIS_TITLE + assert figure.layout.yaxis.title.text == RUN_DIFFERENTIAL_Y_AXIS_TITLE + + +def test_the_hover_shows_the_score_and_a_signed_differential(figure) -> None: + wins = trace_named(figure, WIN_MARGIN_TRACE_NAME) + + first_win = wins.customdata[0] + # (date, matchup, W/L, winner runs, loser runs, signed differential, rolling) + assert first_win[2] == "W" + assert (first_win[3], first_win[4]) == (6, 3) + assert first_win[5] == "+3" + + +def test_a_loss_hover_reads_high_low_with_an_l_flag(figure) -> None: + losses = trace_named(figure, LOSS_MARGIN_TRACE_NAME) + + first_loss = losses.customdata[0] + assert first_loss[2] == "L" + # 2-7 shown as 7-2 with the L flag, the way a box score reads. + assert (first_loss[3], first_loss[4]) == (7, 2) + assert first_loss[5] == "-5" + + +def test_the_figure_renders_into_the_expected_div(figure) -> None: + html = render_figure_html(figure, div_id=RUN_DIFFERENTIAL_CHART_DIV_ID) + + assert RUN_DIFFERENTIAL_CHART_DIV_ID in html + + +def test_a_one_game_season_renders(figure) -> None: + single = build_team_run_differential_figure(analysis_for([4], [1], window=15)) + + wins = trace_named(single, WIN_MARGIN_TRACE_NAME) + assert list(wins.y) == [3] diff --git a/tests/test_formatting_run_differential.py b/tests/test_formatting_run_differential.py new file mode 100644 index 0000000..9d2888a --- /dev/null +++ b/tests/test_formatting_run_differential.py @@ -0,0 +1,190 @@ +"""Tests for the run differential presentation helpers. + +Signs carry meaning on this page in a way they do not elsewhere, so most of +these assert that a sign is present and correct rather than that a number is. +""" + +import pytest + +from app.analytics.team_run_differential import build_team_run_differential_analysis +from app.web.formatting import ( + build_run_differential_summary_cards, + format_missing_opponent_note, + format_pythagorean_note, + format_win_pct, +) +from tests.factories import make_run_result_season + + +def analysis_for(scored, allowed, window: int = 5): + return build_team_run_differential_analysis( + make_run_result_season(scored, allowed), rolling_window=window + ) + + +class TestWinPct: + @pytest.mark.parametrize( + ("value", "expected"), + [ + (0.5, ".500"), + (0.512, ".512"), + (0.0, ".000"), + (1.0, "1.000"), + (0.6215, ".622"), + ], + ) + def test_it_is_written_the_way_baseball_writes_it( + self, value: float, expected: str + ) -> None: + """Leading zero dropped, three decimals, except for a perfect 1.000.""" + assert format_win_pct(value) == expected + + +class TestSummaryCards: + def test_there_are_four_cards(self) -> None: + cards = build_run_differential_summary_cards(analysis_for([6, 2], [3, 7])) + + assert len(cards) == 4 + + def test_a_positive_differential_carries_a_plus(self) -> None: + cards = build_run_differential_summary_cards(analysis_for([6, 8], [3, 1])) + + season = next(card for card in cards if card.label == "Season Run Differential") + assert season.value == "+10" + + def test_a_negative_differential_carries_a_minus(self) -> None: + cards = build_run_differential_summary_cards(analysis_for([1, 2], [5, 8])) + + season = next(card for card in cards if card.label == "Season Run Differential") + assert season.value == "-10" + + def test_a_dead_even_season_still_shows_a_sign(self) -> None: + """+0 is a real result and must not be mistaken for a missing value.""" + cards = build_run_differential_summary_cards(analysis_for([5, 1], [1, 5])) + + season = next(card for card in cards if card.label == "Season Run Differential") + assert season.value == "+0" + + def test_the_caption_names_both_run_totals(self) -> None: + cards = build_run_differential_summary_cards(analysis_for([6, 8], [3, 1])) + + season = next(card for card in cards if card.label == "Season Run Differential") + assert season.caption == "14 Scored, 4 Allowed" + + def test_the_actual_record_card_shows_wins_losses_and_the_gap(self) -> None: + cards = build_run_differential_summary_cards( + analysis_for([5, 1, 3, 9], [2, 4, 8, 0], window=4) + ) + + actual = next(card for card in cards if card.label == "Actual Record") + assert actual.value == "2-2" + assert ".500" in actual.caption + assert "vs Expected" in actual.caption + + def test_the_recent_average_carries_a_sign(self) -> None: + cards = build_run_differential_summary_cards(analysis_for([1, 2], [5, 8])) + + recent = next(card for card in cards if "Recent" in card.label) + assert recent.value.startswith("-") + + def test_there_is_no_vs_mlb_card(self) -> None: + """League-wide run differential is zero, so the slot holds the expectation.""" + cards = build_run_differential_summary_cards(analysis_for([6, 2], [3, 7])) + + assert "vs MLB" not in [card.label for card in cards] + assert "Pythagorean Record" in [card.label for card in cards] + + +class TestPythagoreanNote: + def test_a_team_matching_its_expectation_is_described_as_such(self) -> None: + """Alternating 4-3 wins and 3-4 losses: even runs, even record, no gap.""" + note = format_pythagorean_note( + analysis_for([4, 3, 4, 3], [3, 4, 3, 4], window=4) + ) + + assert "within a game" in note + + def test_outperforming_the_expectation_is_explained(self) -> None: + note = format_pythagorean_note( + analysis_for([2, 2, 2, 1], [1, 1, 1, 12], window=4) + ) + + assert "above" in note + assert "close games won and blowouts lost" in note + + def test_underperforming_the_expectation_is_explained(self) -> None: + note = format_pythagorean_note( + analysis_for([1, 1, 1, 12], [2, 2, 2, 1], window=4) + ) + + assert "below" in note + assert "close games lost and blowouts won" in note + + def test_it_names_the_exponent_so_the_figure_can_be_checked(self) -> None: + note = format_pythagorean_note(analysis_for([6, 2], [3, 7])) + + assert "1.83" in note + + def test_it_does_not_present_itself_as_a_forecast(self) -> None: + note = format_pythagorean_note( + analysis_for([2, 2, 2, 1], [1, 1, 1, 12], window=4) + ) + + assert "describes games already played" in note + + +class TestMissingOpponentNote: + def test_it_names_the_league_import_not_a_team_reimport(self) -> None: + note = format_missing_opponent_note( + season=2025, + missing_game_count=12, + total_games=100, + league_import_command=( + "poetry run python scripts/import_league_season.py --season 2025" + ), + ) + + assert "import_league_season.py" in note + assert "import_team_season.py" not in note + + def test_it_explains_where_runs_allowed_comes_from(self) -> None: + note = format_missing_opponent_note( + season=2025, + missing_game_count=12, + total_games=100, + league_import_command="cmd", + ) + + assert "opponent's own record" in note + + def test_one_missing_game_reads_in_the_singular(self) -> None: + note = format_missing_opponent_note( + season=2025, + missing_game_count=1, + total_games=100, + league_import_command="cmd", + ) + + assert "1 of the 100 2025 game stored for this team has" in note + assert "unknown for it" in note + + def test_several_missing_games_read_in_the_plural(self) -> None: + note = format_missing_opponent_note( + season=2025, + missing_game_count=12, + total_games=100, + league_import_command="cmd", + ) + + assert "12 of the 100 2025 games stored for this team have" in note + assert "unknown for them" in note + + def test_large_counts_are_grouped(self) -> None: + note = format_missing_opponent_note( + season=2025, + missing_game_count=1620, + total_games=4860, + league_import_command="cmd", + ) + + assert "1,620 of the 4,860" in note diff --git a/tests/test_navigation.py b/tests/test_navigation.py index ec05d26..aa3f0db 100644 --- a/tests/test_navigation.py +++ b/tests/test_navigation.py @@ -1,14 +1,16 @@ """Tests for navigation between the analytics pages. -Issue #25 added a fourth entry, and the baserunners page added a fifth. 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. +Issue #25 added a fourth entry, the baserunners page added a fifth, and issue +#39 added run differential as a sixth. 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. """ from app.web.navigation import ( BASERUNNERS_PATH, COMPARISON_PATH, HITS_PATH, + RUN_DIFFERENTIAL_PATH, RUNS_PATH, STRIKEOUTS_PATH, build_nav_links, @@ -22,6 +24,7 @@ def test_every_metric_page_is_linked() -> None: "Batting Strikeouts", "Runs", "Baserunners", + "Run Differential", "Comparison", ] @@ -33,28 +36,69 @@ def test_links_point_at_real_routes() -> None: "/strikeouts", "/runs", "/baserunners", + "/run-differential", "/comparison", ] def test_the_current_page_is_marked() -> None: links = build_nav_links(current_path=STRIKEOUTS_PATH) - assert [link.is_current for link in links] == [False, True, False, False, False] + assert [link.is_current for link in links] == [ + False, + True, + False, + False, + False, + False, + ] def test_the_runs_page_can_be_the_current_one() -> None: links = build_nav_links(current_path=RUNS_PATH) - assert [link.is_current for link in links] == [False, False, True, False, False] + assert [link.is_current for link in links] == [ + False, + False, + True, + False, + False, + False, + ] def test_the_baserunners_page_can_be_the_current_one() -> None: links = build_nav_links(current_path=BASERUNNERS_PATH) - assert [link.is_current for link in links] == [False, False, False, True, False] + assert [link.is_current for link in links] == [ + False, + False, + False, + True, + False, + False, + ] + + +def test_the_run_differential_page_can_be_the_current_one() -> None: + links = build_nav_links(current_path=RUN_DIFFERENTIAL_PATH) + assert [link.is_current for link in links] == [ + False, + False, + False, + False, + True, + False, + ] def test_the_comparison_page_can_be_the_current_one() -> None: links = build_nav_links(current_path=COMPARISON_PATH) - assert [link.is_current for link in links] == [False, False, False, False, True] + assert [link.is_current for link in links] == [ + False, + False, + False, + False, + False, + True, + ] def test_only_one_page_is_current_at_a_time() -> None: @@ -63,6 +107,7 @@ def test_only_one_page_is_current_at_a_time() -> None: STRIKEOUTS_PATH, RUNS_PATH, BASERUNNERS_PATH, + RUN_DIFFERENTIAL_PATH, COMPARISON_PATH, ): links = build_nav_links(current_path=path) @@ -74,7 +119,8 @@ def test_selection_is_carried_between_pages() -> None: assert links[1].href == "/strikeouts?team_id=136&season=2025&window=15" assert links[2].href == "/runs?team_id=136&season=2025&window=15" assert links[3].href == "/baserunners?team_id=136&season=2025&window=15" - assert links[4].href == "/comparison?team_id=136&season=2025&window=15" + assert links[4].href == "/run-differential?team_id=136&season=2025&window=15" + assert links[5].href == "/comparison?team_id=136&season=2025&window=15" def test_no_selection_produces_plain_paths() -> None: @@ -84,6 +130,7 @@ def test_no_selection_produces_plain_paths() -> None: "/strikeouts", "/runs", "/baserunners", + "/run-differential", "/comparison", ] @@ -93,4 +140,5 @@ def test_unset_values_are_left_out_of_the_query() -> None: assert links[1].href == "/strikeouts?team_id=136&window=30" assert links[2].href == "/runs?team_id=136&window=30" assert links[3].href == "/baserunners?team_id=136&window=30" - assert links[4].href == "/comparison?team_id=136&window=30" + assert links[4].href == "/run-differential?team_id=136&window=30" + assert links[5].href == "/comparison?team_id=136&window=30" diff --git a/tests/test_repositories_run_results.py b/tests/test_repositories_run_results.py new file mode 100644 index 0000000..b883c22 --- /dev/null +++ b/tests/test_repositories_run_results.py @@ -0,0 +1,309 @@ +"""Tests for pairing a team-season's games with the opponent's stored line. + +``list_team_season_run_results`` is the only repository function that reads +two rows per game. Runs allowed is not a column: it is the opponent's own runs +scored for the same ``game_pk``, so these tests are mostly about the join +finding the right second row, and about what happens when there isn't one. +""" + +from datetime import date + +from sqlalchemy.orm import Session + +from app.database.repositories import ( + list_team_season_run_results, + upsert_team_season, +) +from app.schemas.games import TeamGameBattingLine + +CUBS_ID = 112 +PIRATES_ID = 134 +BREWERS_ID = 158 +SEASON = 2025 + + +def make_line(**overrides: object) -> TeamGameBattingLine: + base = { + "game_pk": 776704, + "game_date": date(2025, 8, 17), + "season": SEASON, + "team_id": CUBS_ID, + "team_name": "Chicago Cubs", + "opponent_id": PIRATES_ID, + "opponent_name": "Pittsburgh Pirates", + "home_away": "home", + "hits": 6, + "runs": 4, + "status": "Final", + "game_number": 1, + "doubleheader": False, + "scheduled_innings": 9, + } + base.update(overrides) + return TeamGameBattingLine(**base) + + +def store(session: Session, lines: list[TeamGameBattingLine]) -> None: + """Persist lines that may span several clubs. + + ``upsert_team_season`` deliberately handles one team-season per call, which + is how the real import scripts use it. Pairing tests need both clubs stored, + so this groups the lines the way two separate imports would arrive. + """ + grouped: dict[tuple[int, int], list[TeamGameBattingLine]] = {} + for line in lines: + grouped.setdefault((line.team_id, line.season), []).append(line) + for group in grouped.values(): + upsert_team_season(session, lines=group) + # The repository deliberately leaves committing to its caller, and the test + # session does not autoflush, so the join below would see nothing without + # this. + session.commit() + + +def both_sides_of( + *, + game_pk: int, + game_date: date, + cubs_runs: int, + pirates_runs: int, + game_number: int = 1, +) -> tuple[TeamGameBattingLine, TeamGameBattingLine]: + """Build the two rows one real game produces, one per club.""" + cubs = make_line( + game_pk=game_pk, + game_date=game_date, + runs=cubs_runs, + game_number=game_number, + home_away="home", + ) + pirates = make_line( + game_pk=game_pk, + game_date=game_date, + team_id=PIRATES_ID, + team_name="Pittsburgh Pirates", + opponent_id=CUBS_ID, + opponent_name="Chicago Cubs", + runs=pirates_runs, + game_number=game_number, + home_away="away", + ) + return cubs, pirates + + +def test_runs_allowed_comes_from_the_opponents_row(migrated_session: Session) -> None: + cubs, pirates = both_sides_of( + game_pk=776704, game_date=date(2025, 8, 17), cubs_runs=4, pirates_runs=9 + ) + store(migrated_session, [cubs, pirates]) + + paired = list_team_season_run_results( + migrated_session, team_id=CUBS_ID, season=SEASON + ) + + assert paired.unpaired_game_pks == () + (result,) = paired.results + assert result.runs_scored == 4 + assert result.runs_allowed == 9 + assert result.run_differential == -5 + assert result.is_win is False + + +def test_the_same_game_is_symmetric_from_the_other_side( + migrated_session: Session, +) -> None: + """One game, two rows: each club's runs allowed is the other's runs scored.""" + cubs, pirates = both_sides_of( + game_pk=776704, game_date=date(2025, 8, 17), cubs_runs=4, pirates_runs=9 + ) + store(migrated_session, [cubs, pirates]) + + from_cubs = list_team_season_run_results( + migrated_session, team_id=CUBS_ID, season=SEASON + ).results[0] + from_pirates = list_team_season_run_results( + migrated_session, team_id=PIRATES_ID, season=SEASON + ).results[0] + + assert from_cubs.runs_scored == from_pirates.runs_allowed == 4 + assert from_cubs.runs_allowed == from_pirates.runs_scored == 9 + assert from_cubs.run_differential == -from_pirates.run_differential + assert from_cubs.is_win is not from_pirates.is_win + + +def test_a_single_team_import_reports_every_game_as_unpaired( + migrated_session: Session, +) -> None: + """No opponent rows exist, so nothing can be charted and nothing is invented.""" + cubs, _ = both_sides_of( + game_pk=776704, game_date=date(2025, 8, 17), cubs_runs=4, pirates_runs=9 + ) + store(migrated_session, [cubs]) + + paired = list_team_season_run_results( + migrated_session, team_id=CUBS_ID, season=SEASON + ) + + assert paired.results == () + assert paired.unpaired_game_pks == (776704,) + + +def test_a_partially_imported_season_separates_paired_from_unpaired( + migrated_session: Session, +) -> None: + """The failure the outer join exists to expose: some opponents stored, some not.""" + paired_cubs, paired_pirates = both_sides_of( + game_pk=776704, game_date=date(2025, 8, 17), cubs_runs=4, pirates_runs=9 + ) + lonely_cubs = make_line( + game_pk=776705, + game_date=date(2025, 8, 18), + opponent_id=BREWERS_ID, + opponent_name="Milwaukee Brewers", + runs=7, + ) + store(migrated_session, [paired_cubs, paired_pirates, lonely_cubs]) + + paired = list_team_season_run_results( + migrated_session, team_id=CUBS_ID, season=SEASON + ) + + assert [result.game_pk for result in paired.results] == [776704] + assert paired.unpaired_game_pks == (776705,) + + +def test_results_come_back_in_chart_order(migrated_session: Session) -> None: + lines: list[TeamGameBattingLine] = [] + # Inserted newest first so an unordered query would return them backwards. + for game_pk, game_date, game_number in ( + (776706, date(2025, 8, 19), 1), + (776705, date(2025, 8, 18), 2), + (776704, date(2025, 8, 18), 1), + ): + lines.extend( + both_sides_of( + game_pk=game_pk, + game_date=game_date, + cubs_runs=5, + pirates_runs=1, + game_number=game_number, + ) + ) + store(migrated_session, lines) + + paired = list_team_season_run_results( + migrated_session, team_id=CUBS_ID, season=SEASON + ) + + assert [result.game_pk for result in paired.results] == [776704, 776705, 776706] + + +def test_a_doubleheader_pairs_each_game_separately(migrated_session: Session) -> None: + """Two games share a date, so the join must key on game_pk, not the date.""" + lines: list[TeamGameBattingLine] = [] + lines.extend( + both_sides_of( + game_pk=776704, + game_date=date(2025, 8, 18), + cubs_runs=3, + pirates_runs=1, + game_number=1, + ) + ) + lines.extend( + both_sides_of( + game_pk=776705, + game_date=date(2025, 8, 18), + cubs_runs=0, + pirates_runs=6, + game_number=2, + ) + ) + store(migrated_session, lines) + + paired = list_team_season_run_results( + migrated_session, team_id=CUBS_ID, season=SEASON + ) + + assert [ + (result.game_number, result.runs_scored, result.runs_allowed) + for result in paired.results + ] == [(1, 3, 1), (2, 0, 6)] + + +def test_another_seasons_games_are_not_paired_in(migrated_session: Session) -> None: + this_year = both_sides_of( + game_pk=776704, game_date=date(2025, 8, 17), cubs_runs=4, pirates_runs=9 + ) + last_year = tuple( + line.model_copy(update={"season": 2024, "game_pk": 700001}) + for line in both_sides_of( + game_pk=700001, game_date=date(2024, 8, 17), cubs_runs=1, pirates_runs=2 + ) + ) + store(migrated_session, [*this_year, *last_year]) + + paired = list_team_season_run_results( + migrated_session, team_id=CUBS_ID, season=SEASON + ) + + assert [result.game_pk for result in paired.results] == [776704] + assert all(result.season == SEASON for result in paired.results) + + +def test_a_third_team_in_the_season_is_not_mistaken_for_the_opponent( + migrated_session: Session, +) -> None: + """The join matches on opponent_id, not merely on sharing a game_pk.""" + cubs, pirates = both_sides_of( + game_pk=776704, game_date=date(2025, 8, 17), cubs_runs=4, pirates_runs=9 + ) + # A Brewers row that is not part of this game at all. + brewers = make_line( + game_pk=776799, + game_date=date(2025, 8, 17), + team_id=BREWERS_ID, + team_name="Milwaukee Brewers", + opponent_id=PIRATES_ID, + opponent_name="Pittsburgh Pirates", + runs=15, + ) + store(migrated_session, [cubs, pirates, brewers]) + + paired = list_team_season_run_results( + migrated_session, team_id=CUBS_ID, season=SEASON + ) + + (result,) = paired.results + assert result.runs_allowed == 9 + assert result.opponent_id == PIRATES_ID + + +def test_a_team_with_no_stored_games_returns_nothing( + migrated_session: Session, +) -> None: + paired = list_team_season_run_results( + migrated_session, team_id=CUBS_ID, season=SEASON + ) + + assert paired.results == () + assert paired.unpaired_game_pks == () + + +def test_the_selected_teams_identity_is_carried_not_the_opponents( + migrated_session: Session, +) -> None: + cubs, pirates = both_sides_of( + game_pk=776704, game_date=date(2025, 8, 17), cubs_runs=4, pirates_runs=9 + ) + store(migrated_session, [cubs, pirates]) + + (result,) = list_team_season_run_results( + migrated_session, team_id=CUBS_ID, season=SEASON + ).results + + assert result.team_id == CUBS_ID + assert result.team_name == "Chicago Cubs" + assert result.opponent_id == PIRATES_ID + assert result.opponent_name == "Pittsburgh Pirates" + assert result.home_away == "home" diff --git a/tests/test_run_differential_schemas.py b/tests/test_run_differential_schemas.py new file mode 100644 index 0000000..6052036 --- /dev/null +++ b/tests/test_run_differential_schemas.py @@ -0,0 +1,183 @@ +"""Tests for the run differential schema guards. + +These models carry several figures that are derived from each other — a +differential from two run totals, a win flag from the same two, an expected +win percentage from season totals. The validators exist so a construction that +disagrees with itself cannot be built, and these tests hold that line. +""" + +from datetime import date + +import pytest +from pydantic import ValidationError + +from app.schemas.analytics import ( + PythagoreanRecord, + TeamRunDifferentialPoint, + TeamRunDifferentialSummary, +) +from app.schemas.games import TeamGameRunResult + + +def point(**overrides: object) -> TeamRunDifferentialPoint: + base: dict[str, object] = { + "game_pk": 776000, + "game_number": 1, + "season_game_number": 1, + "game_date": date(2025, 4, 1), + "opponent_name": "Minnesota Twins", + "home_away": "home", + "runs_scored": 6, + "runs_allowed": 2, + "run_differential": 4, + "is_win": True, + "rolling_average": 4.0, + } + base.update(overrides) + return TeamRunDifferentialPoint(**base) + + +def summary(**overrides: object) -> TeamRunDifferentialSummary: + base: dict[str, object] = { + "games_played": 4, + "total_runs_scored": 20, + "total_runs_allowed": 12, + "total_run_differential": 8, + "season_average": 2.0, + "recent_average": 2.0, + } + base.update(overrides) + return TeamRunDifferentialSummary(**base) + + +def record(**overrides: object) -> PythagoreanRecord: + scored, allowed, exponent = 20, 12, 1.83 + expected_pct = scored**exponent / (scored**exponent + allowed**exponent) + base: dict[str, object] = { + "exponent": exponent, + "runs_scored": scored, + "runs_allowed": allowed, + "expected_win_pct": expected_pct, + "expected_wins": expected_pct * 4, + "actual_wins": 3, + "actual_losses": 1, + "actual_win_pct": 0.75, + "wins_above_expectation": 3 - expected_pct * 4, + } + base.update(overrides) + return PythagoreanRecord(**base) + + +class TestPoint: + def test_a_consistent_point_is_accepted(self) -> None: + assert point().run_differential == 4 + + def test_a_differential_that_contradicts_the_runs_is_rejected(self) -> None: + with pytest.raises(ValidationError, match="run_differential"): + point(run_differential=99) + + def test_a_win_flag_that_contradicts_the_runs_is_rejected(self) -> None: + with pytest.raises(ValidationError, match="is_win"): + point(is_win=False) + + def test_a_negative_differential_is_allowed(self) -> None: + """The signed case: a losing game is a valid point, not a validation error.""" + losing = point(runs_scored=1, runs_allowed=8, run_differential=-7, is_win=False) + + assert losing.run_differential == -7 + + def test_a_negative_rolling_average_is_allowed(self) -> None: + assert point(rolling_average=-3.5).rolling_average == -3.5 + + +class TestSummary: + def test_a_consistent_summary_is_accepted(self) -> None: + assert summary().total_run_differential == 8 + + def test_a_total_that_contradicts_the_run_totals_is_rejected(self) -> None: + with pytest.raises(ValidationError, match="total_run_differential"): + summary(total_run_differential=99) + + def test_a_season_average_that_contradicts_the_total_is_rejected(self) -> None: + with pytest.raises(ValidationError, match="season_average"): + summary(season_average=99.0) + + def test_a_prior_window_without_a_change_is_rejected(self) -> None: + with pytest.raises(ValidationError, match="both be"): + summary(prior_window_average=1.0) + + def test_a_change_without_a_prior_window_is_rejected(self) -> None: + with pytest.raises(ValidationError, match="both be"): + summary(change_vs_prior_window=1.0) + + def test_a_negative_season_average_is_allowed(self) -> None: + outscored = summary( + total_runs_scored=12, + total_runs_allowed=20, + total_run_differential=-8, + season_average=-2.0, + recent_average=-2.0, + ) + + assert outscored.season_average == -2.0 + + +class TestPythagoreanRecord: + def test_a_consistent_record_is_accepted(self) -> None: + assert record().actual_wins == 3 + + def test_an_expected_pct_that_contradicts_the_formula_is_rejected(self) -> None: + with pytest.raises(ValidationError, match="expected_win_pct"): + record(expected_win_pct=0.5) + + def test_expected_wins_that_contradict_the_pct_are_rejected(self) -> None: + with pytest.raises(ValidationError, match="expected_wins"): + record(expected_wins=99.0) + + def test_an_actual_pct_that_contradicts_the_record_is_rejected(self) -> None: + with pytest.raises(ValidationError, match="actual_win_pct"): + record(actual_win_pct=0.1) + + def test_a_gap_that_contradicts_its_two_sides_is_rejected(self) -> None: + with pytest.raises(ValidationError, match="wins_above_expectation"): + record(wins_above_expectation=99.0) + + def test_a_record_with_no_decided_games_is_rejected(self) -> None: + with pytest.raises(ValidationError, match="at least one decided game"): + record(actual_wins=0, actual_losses=0) + + +class TestRunResult: + def test_run_differential_and_win_are_derived_not_stored(self) -> None: + result = TeamGameRunResult( + game_pk=776000, + game_date=date(2025, 4, 1), + season=2025, + team_id=136, + team_name="Seattle Mariners", + opponent_id=142, + opponent_name="Minnesota Twins", + home_away="home", + runs_scored=2, + runs_allowed=9, + game_number=1, + ) + + assert result.run_differential == -7 + assert result.is_win is False + + def test_a_negative_run_total_is_rejected(self) -> None: + with pytest.raises(ValidationError): + TeamGameRunResult( + game_pk=776000, + game_date=date(2025, 4, 1), + season=2025, + team_id=136, + team_name="Seattle Mariners", + opponent_id=142, + opponent_name="Minnesota Twins", + home_away="home", + runs_scored=-1, + runs_allowed=9, + game_number=1, + ) diff --git a/tests/test_web_run_differential.py b/tests/test_web_run_differential.py new file mode 100644 index 0000000..c75ff6e --- /dev/null +++ b/tests/test_web_run_differential.py @@ -0,0 +1,349 @@ +"""Tests for the /run-differential page. + +The page differs from the other four in what it needs from the database: it +reads two rows per game rather than one, so a team-season imported on its own +cannot be charted no matter how complete that team's own rows are. Most of +these tests are about that boundary. +""" + +from collections.abc import Callable, Generator, Iterator +from datetime import date, timedelta +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.database.engine import build_engine, build_session_factory +from app.database.repositories import upsert_team_season +from app.main import create_app +from app.schemas.games import TeamGameBattingLine +from app.web.dependencies import get_db_session +from tests.factories import MARINERS_ID, MARINERS_NAME, TWINS_ID, TWINS_NAME + +SEASON = 2025 +OPENING_DAY = date(2025, 3, 27) +PATH = "/run-differential" + + +@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 line(**overrides: object) -> TeamGameBattingLine: + base: dict[str, object] = { + "game_pk": 776000, + "game_date": OPENING_DAY, + "season": SEASON, + "team_id": MARINERS_ID, + "team_name": MARINERS_NAME, + "opponent_id": TWINS_ID, + "opponent_name": TWINS_NAME, + "home_away": "home", + "hits": 8, + "runs": 4, + "status": "Final", + "game_number": 1, + "doubleheader": False, + "scheduled_innings": 9, + } + base.update(overrides) + return TeamGameBattingLine(**base) + + +@pytest.fixture +def seed_both_clubs( + session_factory: Callable[[], Session], +) -> Callable[[list[int], list[int]], None]: + """Seed both clubs' rows for each game, as a league-wide import would.""" + + def _seed(scored: list[int], allowed: list[int]) -> None: + mariners: list[TeamGameBattingLine] = [] + twins: list[TeamGameBattingLine] = [] + for index, (own, other) in enumerate(zip(scored, allowed, strict=True)): + game_pk = SEASON * 1000 + index + game_date = OPENING_DAY + timedelta(days=index) + mariners.append( + line(game_pk=game_pk, game_date=game_date, runs=own, home_away="home") + ) + twins.append( + line( + game_pk=game_pk, + game_date=game_date, + team_id=TWINS_ID, + team_name=TWINS_NAME, + opponent_id=MARINERS_ID, + opponent_name=MARINERS_NAME, + runs=other, + home_away="away", + ) + ) + session = session_factory() + try: + upsert_team_season(session, lines=mariners) + upsert_team_season(session, lines=twins) + session.commit() + finally: + session.close() + + return _seed + + +@pytest.fixture +def seed_one_club( + session_factory: Callable[[], Session], +) -> Callable[[list[int]], None]: + """Seed only the selected team's rows, as a single-team import would.""" + + def _seed(scored: list[int]) -> None: + session = session_factory() + try: + upsert_team_season( + session, + lines=[ + line( + game_pk=SEASON * 1000 + index, + game_date=OPENING_DAY + timedelta(days=index), + runs=own, + ) + for index, own in enumerate(scored) + ], + ) + session.commit() + finally: + session.close() + + return _seed + + +def test_the_page_renders_for_a_league_imported_season( + client: TestClient, seed_both_clubs +) -> None: + seed_both_clubs([6, 2, 8], [3, 7, 1]) + + response = client.get(PATH, params={"team_id": MARINERS_ID, "season": SEASON}) + + assert response.status_code == 200 + assert "Run Differential per Game" in response.text + assert MARINERS_NAME in response.text + + +def test_the_season_totals_are_shown(client: TestClient, seed_both_clubs) -> None: + seed_both_clubs([6, 2, 8], [3, 7, 1]) + + response = client.get(PATH, params={"team_id": MARINERS_ID, "season": SEASON}) + + # 16 scored, 11 allowed, +5 differential. + assert "+5" in response.text + assert "16 Scored, 11 Allowed" in response.text + + +def test_a_negative_differential_is_shown_with_its_sign( + client: TestClient, seed_both_clubs +) -> None: + seed_both_clubs([1, 2, 0], [5, 4, 9]) + + response = client.get(PATH, params={"team_id": MARINERS_ID, "season": SEASON}) + + assert response.status_code == 200 + assert "-15" in response.text + + +def test_the_actual_record_is_derived_from_the_scores( + client: TestClient, seed_both_clubs +) -> None: + """Two wins, two losses, with no W/L column stored anywhere.""" + seed_both_clubs([5, 1, 3, 9], [2, 4, 8, 0]) + + response = client.get(PATH, params={"team_id": MARINERS_ID, "season": SEASON}) + + assert "Actual Record" in response.text + assert "2-2" in response.text + + +def test_the_pythagorean_record_is_shown(client: TestClient, seed_both_clubs) -> None: + seed_both_clubs([6, 2, 8], [3, 7, 1]) + + response = client.get(PATH, params={"team_id": MARINERS_ID, "season": SEASON}) + + assert "Pythagorean Record" in response.text + assert "Expected record from runs scored and allowed" in response.text + + +def test_the_page_explains_why_there_is_no_mlb_line( + client: TestClient, seed_both_clubs +) -> None: + seed_both_clubs([6, 2, 8], [3, 7, 1]) + + response = client.get(PATH, params={"team_id": MARINERS_ID, "season": SEASON}) + + # The template wraps its prose, so the sentence is matched against the + # rendered text with runs of whitespace collapsed. + flattened = " ".join(response.text.split()) + assert "league-wide run differential is exactly zero" in flattened + + +class TestMissingOpponentRows: + def test_a_single_team_import_returns_409( + self, client: TestClient, seed_one_club + ) -> None: + seed_one_club([6, 2, 8]) + + response = client.get(PATH, params={"team_id": MARINERS_ID, "season": SEASON}) + + assert response.status_code == 409 + + def test_the_page_names_the_league_import_as_the_fix( + self, client: TestClient, seed_one_club + ) -> None: + """Re-importing the team cannot help, so the page must not suggest it.""" + seed_one_club([6, 2, 8]) + + response = client.get(PATH, params={"team_id": MARINERS_ID, "season": SEASON}) + + assert "scripts/import_league_season.py --season 2025" in response.text + assert "This season needs a league-wide import" in response.text + + def test_the_page_says_how_many_games_are_unpaired( + self, client: TestClient, seed_one_club + ) -> None: + seed_one_club([6, 2, 8]) + + response = client.get(PATH, params={"team_id": MARINERS_ID, "season": SEASON}) + + assert "3 of the 3 stored games" in response.text + + def test_a_partially_paired_season_is_also_refused( + self, client: TestClient, session_factory + ) -> None: + """One opponent row stored, two missing: still not chartable.""" + session = session_factory() + try: + upsert_team_season( + session, + lines=[ + line( + game_pk=SEASON * 1000 + index, + runs=5, + game_date=OPENING_DAY + timedelta(days=index), + ) + for index in range(3) + ], + ) + upsert_team_season( + session, + lines=[ + line( + game_pk=SEASON * 1000, + game_date=OPENING_DAY, + team_id=TWINS_ID, + team_name=TWINS_NAME, + opponent_id=MARINERS_ID, + opponent_name=MARINERS_NAME, + runs=2, + home_away="away", + ) + ], + ) + session.commit() + finally: + session.close() + + response = client.get(PATH, params={"team_id": MARINERS_ID, "season": SEASON}) + + assert response.status_code == 409 + assert "2 of the 3 stored games" in response.text + + def test_the_other_metric_pages_still_work( + self, client: TestClient, seed_one_club + ) -> None: + """They read only the team's own rows, so a single-team import suits them.""" + seed_one_club([6, 2, 8]) + + for path in ("/", "/runs"): + response = client.get( + path, params={"team_id": MARINERS_ID, "season": SEASON} + ) + assert response.status_code == 200, path + + +class TestSelection: + def test_no_imported_data_shows_the_import_command( + self, client: TestClient + ) -> None: + response = client.get(PATH) + + assert response.status_code == 200 + assert "No team data has been imported yet" in response.text + + def test_an_unknown_team_returns_404( + self, client: TestClient, seed_both_clubs + ) -> None: + seed_both_clubs([6], [3]) + + response = client.get(PATH, params={"team_id": 999, "season": SEASON}) + + assert response.status_code == 404 + + def test_an_unknown_season_returns_404( + self, client: TestClient, seed_both_clubs + ) -> None: + seed_both_clubs([6], [3]) + + response = client.get(PATH, params={"team_id": MARINERS_ID, "season": 1999}) + + assert response.status_code == 404 + + def test_the_rolling_window_is_honoured( + self, client: TestClient, seed_both_clubs + ) -> None: + seed_both_clubs([6, 2, 8, 1, 5], [3, 7, 1, 9, 2]) + + response = client.get( + PATH, params={"team_id": MARINERS_ID, "season": SEASON, "window": 5} + ) + + assert response.status_code == 200 + assert "Recent 5-Game Avg" in response.text + + def test_an_unsupported_window_is_rejected( + self, client: TestClient, seed_both_clubs + ) -> None: + seed_both_clubs([6], [3]) + + response = client.get( + PATH, params={"team_id": MARINERS_ID, "season": SEASON, "window": 7} + ) + + assert response.status_code == 422 + + +def test_the_page_is_linked_from_the_other_metric_pages( + client: TestClient, seed_both_clubs +) -> None: + seed_both_clubs([6, 2, 8], [3, 7, 1]) + + response = client.get("/runs", params={"team_id": MARINERS_ID, "season": SEASON}) + + assert "/run-differential?team_id=136&season=2025" in response.text