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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
584 changes: 151 additions & 433 deletions README.md

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions app/analytics/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
TeamHitsAnalysisError,
build_team_hits_analysis,
)
from app.analytics.team_hitting_comparison import (
InvalidComparisonBaselineError,
TeamHittingComparisonError,
build_team_hitting_comparison_analysis,
)
from app.analytics.team_runs import (
TeamRunsAnalysisError,
build_team_runs_analysis,
Expand All @@ -17,11 +22,14 @@

__all__ = [
"DEFAULT_ROLLING_WINDOW",
"InvalidComparisonBaselineError",
"MissingStrikeoutDataError",
"TeamHitsAnalysisError",
"TeamHittingComparisonError",
"TeamRunsAnalysisError",
"TeamStrikeoutsAnalysisError",
"build_team_hits_analysis",
"build_team_hitting_comparison_analysis",
"build_team_runs_analysis",
"build_team_strikeouts_analysis",
]
200 changes: 200 additions & 0 deletions app/analytics/team_hitting_comparison.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
"""Normalized rolling team hits-versus-batting-strikeouts comparison.

This module combines the existing, independently calculated team Hits/Game and
batting K/Game rolling analyses. Each rolling value is divided by its matching
MLB per-game context and multiplied by 100, so MLB average is 100 on both
scales. The indexes are descriptive only: above 100 means more of the named
statistic than the MLB baseline, not automatically better performance.

The module is deliberately narrow. It does not query persistence, decide
whether league coverage is complete, build a Plotly figure, or generalize the
two statistics into a metric framework.
"""

from app.schemas.analytics import (
LeagueHitsContext,
LeagueStrikeoutsContext,
TeamHitsAnalysis,
TeamHittingComparisonAnalysis,
TeamHittingComparisonPoint,
TeamHittingComparisonSummary,
TeamStrikeoutsAnalysis,
)

NORMALIZED_INDEX_BASELINE = 100.0


class TeamHittingComparisonError(ValueError):
"""The supplied analyses cannot form one trustworthy comparison."""


class InvalidComparisonBaselineError(TeamHittingComparisonError):
"""An MLB per-game baseline is not positive, so division is unsafe."""

def __init__(self, *, metric: str, value: float) -> None:
self.metric = metric
self.value = value
super().__init__(
f"MLB {metric} baseline must be greater than zero to calculate a "
f"normalized index, got {value}"
)


def build_team_hitting_comparison_analysis(
hits_analysis: TeamHitsAnalysis,
strikeouts_analysis: TeamStrikeoutsAnalysis,
league_hits: LeagueHitsContext,
league_strikeouts: LeagueStrikeoutsContext,
) -> TeamHittingComparisonAnalysis:
"""Normalize rolling team Hits/Game and batting K/Game to MLB = 100.

For each aligned rolling point::

Hits Index = rolling team Hits/Game / MLB Hits/Game * 100
Batting Strikeout Index = rolling team batting K/Game
/ MLB batting K/Game * 100

``trend_gap`` is the latest Hits Index minus the latest batting strikeout
index. It is only a difference between two normalized indexes, not a
validated overall offensive-performance statistic.

Coverage is intentionally decided before this function is called. The
caller must only build the two league contexts after the existing complete
league-coverage checks pass; constructing the batting-strikeout context
additionally refuses any stored record with an unknown strikeout total.

Raises
------
InvalidComparisonBaselineError
Either MLB per-game baseline is zero or otherwise non-positive.
TeamHittingComparisonError
The team analyses do not describe the same team, season, rolling
window, or ordered games, or a league context is for another season.
"""
_validate_analysis_identity(hits_analysis, strikeouts_analysis)
_validate_league_seasons(hits_analysis, league_hits, league_strikeouts)
_validate_positive_baselines(league_hits, league_strikeouts)

points = tuple(
TeamHittingComparisonPoint(
game_pk=hits_point.game_pk,
season_game_number=hits_point.season_game_number,
game_date=hits_point.game_date,
opponent_name=hits_point.opponent_name,
hits_index=(
hits_point.rolling_average
/ league_hits.hits_per_game
* NORMALIZED_INDEX_BASELINE
),
strikeouts_index=(
strikeouts_point.rolling_average
/ league_strikeouts.strikeouts_per_game
* NORMALIZED_INDEX_BASELINE
),
)
for hits_point, strikeouts_point in zip(
hits_analysis.points,
strikeouts_analysis.points,
strict=True,
)
)
recent = points[-1]

return TeamHittingComparisonAnalysis(
team_id=hits_analysis.team_id,
team_name=hits_analysis.team_name,
season=hits_analysis.season,
rolling_window=hits_analysis.rolling_window,
mlb_hits_per_game=league_hits.hits_per_game,
mlb_strikeouts_per_game=league_strikeouts.strikeouts_per_game,
baseline_index=NORMALIZED_INDEX_BASELINE,
points=points,
summary=TeamHittingComparisonSummary(
games_played=len(points),
recent_hits_index=recent.hits_index,
recent_strikeouts_index=recent.strikeouts_index,
trend_gap=recent.hits_index - recent.strikeouts_index,
),
)


def _validate_analysis_identity(
hits_analysis: TeamHitsAnalysis,
strikeouts_analysis: TeamStrikeoutsAnalysis,
) -> None:
hits_identity = (
hits_analysis.team_id,
hits_analysis.team_name,
hits_analysis.season,
hits_analysis.rolling_window,
)
strikeouts_identity = (
strikeouts_analysis.team_id,
strikeouts_analysis.team_name,
strikeouts_analysis.season,
strikeouts_analysis.rolling_window,
)
if hits_identity != strikeouts_identity:
raise TeamHittingComparisonError(
"Hits and batting strikeout analyses must describe the same team, "
"season, and rolling window"
)

if len(hits_analysis.points) != len(strikeouts_analysis.points):
raise TeamHittingComparisonError(
"Hits and batting strikeout analyses must contain the same games"
)

for hits_point, strikeouts_point in zip(
hits_analysis.points,
strikeouts_analysis.points,
strict=True,
):
hits_game = (
hits_point.game_pk,
hits_point.game_number,
hits_point.season_game_number,
hits_point.game_date,
hits_point.opponent_name,
hits_point.home_away,
)
strikeouts_game = (
strikeouts_point.game_pk,
strikeouts_point.game_number,
strikeouts_point.season_game_number,
strikeouts_point.game_date,
strikeouts_point.opponent_name,
strikeouts_point.home_away,
)
if hits_game != strikeouts_game:
raise TeamHittingComparisonError(
"Hits and batting strikeout analyses must contain the same "
"games in the same order"
)


def _validate_league_seasons(
hits_analysis: TeamHitsAnalysis,
league_hits: LeagueHitsContext,
league_strikeouts: LeagueStrikeoutsContext,
) -> None:
if not (hits_analysis.season == league_hits.season == league_strikeouts.season):
raise TeamHittingComparisonError(
"Team and MLB hitting contexts must describe the same season"
)


def _validate_positive_baselines(
league_hits: LeagueHitsContext,
league_strikeouts: LeagueStrikeoutsContext,
) -> None:
if league_hits.hits_per_game <= 0:
raise InvalidComparisonBaselineError(
metric="Hits/Game",
value=league_hits.hits_per_game,
)
if league_strikeouts.strikeouts_per_game <= 0:
raise InvalidComparisonBaselineError(
metric="batting K/Game",
value=league_strikeouts.strikeouts_per_game,
)
6 changes: 6 additions & 0 deletions app/schemas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
TeamHitsAnalysis,
TeamHitsPoint,
TeamHitsSummary,
TeamHittingComparisonAnalysis,
TeamHittingComparisonPoint,
TeamHittingComparisonSummary,
TeamStrikeoutsAnalysis,
TeamStrikeoutsPoint,
TeamStrikeoutsSummary,
Expand All @@ -14,6 +17,9 @@
__all__ = [
"AvailableTeamSeason",
"TeamGameBattingLine",
"TeamHittingComparisonAnalysis",
"TeamHittingComparisonPoint",
"TeamHittingComparisonSummary",
"TeamHitsAnalysis",
"TeamHitsPoint",
"TeamHitsSummary",
Expand Down
125 changes: 125 additions & 0 deletions app/schemas/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,131 @@ def _comparison_is_internally_consistent(self) -> TeamStrikeoutsLeagueComparison
return self


class TeamHittingComparisonPoint(BaseModel):
"""Normalized rolling hits and batting strikeouts for one completed game.

Both indexes use their own MLB per-game average as the 100 baseline. The
values are descriptive: an index above 100 means more of that statistic
than the MLB baseline, which is not automatically favourable.
"""

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

game_pk: int = Field(gt=0, description="MLB game identifier.")
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."
)
hits_index: float = Field(
ge=0,
description="Rolling team Hits/Game divided by MLB Hits/Game, times 100.",
)
strikeouts_index: float = Field(
ge=0,
description="Rolling team batting K/Game divided by MLB batting K/Game, "
"times 100.",
)


class TeamHittingComparisonSummary(BaseModel):
"""Headline values for the normalized hitting comparison.

``trend_gap`` is only the arithmetic difference between the two most
recent normalized indexes. It is not an overall offensive-performance
statistic, ranking, percentile, or causal claim.
"""

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

games_played: int = Field(ge=1, description="Completed games analysed.")
recent_hits_index: float = Field(
ge=0, description="Hits index at the most recent rolling point."
)
recent_strikeouts_index: float = Field(
ge=0,
description="Batting strikeout index at the most recent rolling point.",
)
trend_gap: float = Field(description="recent_hits_index - recent_strikeouts_index.")

@model_validator(mode="after")
def _trend_gap_matches_recent_indexes(self) -> TeamHittingComparisonSummary:
expected = self.recent_hits_index - self.recent_strikeouts_index
if not isclose(self.trend_gap, expected, rel_tol=1e-9, abs_tol=1e-9):
raise ValueError(
f"trend_gap ({self.trend_gap}) must equal recent_hits_index - "
f"recent_strikeouts_index ({expected})"
)
return self


class TeamHittingComparisonAnalysis(BaseModel):
"""A team-season's normalized rolling hits-versus-strikeouts trend."""

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.")
mlb_hits_per_game: float = Field(
gt=0, description="Positive MLB Hits/Game denominator used for every point."
)
mlb_strikeouts_per_game: float = Field(
gt=0,
description="Positive MLB batting K/Game denominator used for every point.",
)
baseline_index: float = Field(
default=100.0,
description="MLB average on both normalized index scales. Always 100.",
)
points: tuple[TeamHittingComparisonPoint, ...] = Field(
min_length=1, description="Games in chart order."
)
summary: TeamHittingComparisonSummary

@model_validator(mode="after")
def _comparison_is_internally_consistent(
self,
) -> TeamHittingComparisonAnalysis:
if not isclose(self.baseline_index, 100.0, rel_tol=0.0, abs_tol=1e-12):
raise ValueError("baseline_index must equal 100")
if self.summary.games_played != len(self.points):
raise ValueError(
"summary.games_played must equal the number of chart points"
)

recent = self.points[-1]
if not isclose(
self.summary.recent_hits_index,
recent.hits_index,
rel_tol=1e-9,
abs_tol=1e-9,
):
raise ValueError(
"summary.recent_hits_index must equal the final point's hits_index"
)
if not isclose(
self.summary.recent_strikeouts_index,
recent.strikeouts_index,
rel_tol=1e-9,
abs_tol=1e-9,
):
raise ValueError(
"summary.recent_strikeouts_index must equal the final point's "
"strikeouts_index"
)
return self

@property
def last_game_date(self) -> date:
"""Date of the most recent completed game in the comparison."""
return self.points[-1].game_date


class TeamRunsPoint(BaseModel):
"""One completed game plotted on the team runs chart."""

Expand Down
Loading
Loading