Merge pull request 'Card Evolution: season stats full recalculation → next-release' (#112) from card-evolution into next-release
All checks were successful
Build Docker Image / build (push) Successful in 8m49s
All checks were successful
Build Docker Image / build (push) Successful in 8m49s
Reviewed-on: #112 Reviewed-by: Claude <cal.corum+openclaw@gmail.com>
This commit is contained in:
commit
cf0b1d1d1c
@ -4,11 +4,13 @@ Covers WP-13 (Post-Game Callback Integration):
|
|||||||
POST /api/v2/season-stats/update-game/{game_id}
|
POST /api/v2/season-stats/update-game/{game_id}
|
||||||
|
|
||||||
Delegates to app.services.season_stats.update_season_stats() which
|
Delegates to app.services.season_stats.update_season_stats() which
|
||||||
aggregates StratPlay and Decision rows for a completed game and
|
recomputes full-season stats from all StratPlay and Decision rows for
|
||||||
performs an additive upsert into player_season_stats.
|
every player who appeared in the game, then writes those totals into
|
||||||
|
batting_season_stats and pitching_season_stats.
|
||||||
|
|
||||||
Idempotency is enforced by the service layer: re-delivery of the same
|
Idempotency is enforced by the service layer: re-delivery of the same
|
||||||
game_id returns {"updated": 0, "skipped": true} without modifying stats.
|
game_id returns {"updated": 0, "skipped": true} without modifying stats.
|
||||||
|
Pass force=true to bypass the idempotency guard and force recalculation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@ -23,18 +25,24 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/update-game/{game_id}")
|
@router.post("/update-game/{game_id}")
|
||||||
async def update_game_season_stats(game_id: int, token: str = Depends(oauth2_scheme)):
|
async def update_game_season_stats(
|
||||||
"""Increment season stats with batting and pitching deltas from a game.
|
game_id: int, force: bool = False, token: str = Depends(oauth2_scheme)
|
||||||
|
):
|
||||||
|
"""Recalculate season stats from all StratPlay and Decision rows for a game.
|
||||||
|
|
||||||
Calls update_season_stats(game_id) from the service layer which:
|
Calls update_season_stats(game_id, force=force) from the service layer which:
|
||||||
- Aggregates all StratPlay rows by (player_id, team_id, season)
|
- Recomputes full-season totals from all StratPlay rows for each player
|
||||||
- Merges Decision rows into pitching groups
|
- Aggregates Decision rows for pitching win/loss/save/hold stats
|
||||||
- Performs an additive ON CONFLICT upsert into player_season_stats
|
- Writes totals into batting_season_stats and pitching_season_stats
|
||||||
- Guards against double-counting via the last_game FK check
|
- Guards against redundant work via the ProcessedGame ledger
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
- force: if true, bypasses the idempotency guard and reprocesses a
|
||||||
|
previously seen game_id (useful for correcting stats after data fixes)
|
||||||
|
|
||||||
Response: {"updated": N, "skipped": false}
|
Response: {"updated": N, "skipped": false}
|
||||||
- N: total player_season_stats rows upserted (batters + pitchers)
|
- N: total player_season_stats rows upserted (batters + pitchers)
|
||||||
- skipped: true when this game_id was already processed (idempotent re-delivery)
|
- skipped: true when this game_id was already processed and force=false
|
||||||
|
|
||||||
Errors from the service are logged but re-raised as 500 so the bot
|
Errors from the service are logged but re-raised as 500 so the bot
|
||||||
knows to retry.
|
knows to retry.
|
||||||
@ -46,7 +54,7 @@ async def update_game_season_stats(game_id: int, token: str = Depends(oauth2_sch
|
|||||||
from ..services.season_stats import update_season_stats
|
from ..services.season_stats import update_season_stats
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = update_season_stats(game_id)
|
result = update_season_stats(game_id, force=force)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("update-game/%d failed: %s", game_id, exc, exc_info=True)
|
logger.error("update-game/%d failed: %s", game_id, exc, exc_info=True)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
Force-recalculates a card's evolution state from career totals.
|
Force-recalculates a card's evolution state from career totals.
|
||||||
|
|
||||||
evaluate_card() is the main entry point:
|
evaluate_card() is the main entry point:
|
||||||
1. Load career totals: SUM all player_season_stats rows for (player_id, team_id)
|
1. Load career totals: SUM all BattingSeasonStats/PitchingSeasonStats rows for (player_id, team_id)
|
||||||
2. Determine track from card_state.track
|
2. Determine track from card_state.track
|
||||||
3. Compute formula value (delegated to formula engine, WP-09)
|
3. Compute formula value (delegated to formula engine, WP-09)
|
||||||
4. Compare value to track thresholds to determine new_tier
|
4. Compare value to track thresholds to determine new_tier
|
||||||
@ -14,9 +14,9 @@ evaluate_card() is the main entry point:
|
|||||||
|
|
||||||
Idempotent: calling multiple times with the same data produces the same result.
|
Idempotent: calling multiple times with the same data produces the same result.
|
||||||
|
|
||||||
Depends on WP-05 (EvolutionCardState), WP-07 (PlayerSeasonStats), and WP-09
|
Depends on WP-05 (EvolutionCardState), WP-07 (BattingSeasonStats/PitchingSeasonStats),
|
||||||
(formula engine). Models and formula functions are imported lazily so this
|
and WP-09 (formula engine). Models and formula functions are imported lazily so
|
||||||
module can be imported before those PRs merge.
|
this module can be imported before those PRs merge.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@ -29,7 +29,7 @@ class _CareerTotals:
|
|||||||
Passed to the formula engine as a stats-duck-type object with the attributes
|
Passed to the formula engine as a stats-duck-type object with the attributes
|
||||||
required by compute_value_for_track:
|
required by compute_value_for_track:
|
||||||
batter: pa, hits, doubles, triples, hr
|
batter: pa, hits, doubles, triples, hr
|
||||||
sp/rp: outs, k
|
sp/rp: outs, strikeouts
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__slots__ = ("pa", "hits", "doubles", "triples", "hr", "outs", "strikeouts")
|
__slots__ = ("pa", "hits", "doubles", "triples", "hr", "outs", "strikeouts")
|
||||||
@ -54,9 +54,9 @@ def evaluate_card(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""Force-recalculate a card's evolution tier from career stats.
|
"""Force-recalculate a card's evolution tier from career stats.
|
||||||
|
|
||||||
Sums all player_season_stats rows for (player_id, team_id) across all
|
Sums all BattingSeasonStats or PitchingSeasonStats rows (based on
|
||||||
seasons, then delegates formula computation and tier classification to the
|
card_type) for (player_id, team_id) across all seasons, then delegates
|
||||||
formula engine. The result is written back to evolution_card_state and
|
formula computation and tier classification to the formula engine. The result is written back to evolution_card_state and
|
||||||
returned as a dict.
|
returned as a dict.
|
||||||
|
|
||||||
current_tier never decreases (no regression):
|
current_tier never decreases (no regression):
|
||||||
@ -65,8 +65,8 @@ def evaluate_card(
|
|||||||
Args:
|
Args:
|
||||||
player_id: Player primary key.
|
player_id: Player primary key.
|
||||||
team_id: Team primary key.
|
team_id: Team primary key.
|
||||||
_stats_model: Override for PlayerSeasonStats (used in tests to avoid
|
_stats_model: Override for BattingSeasonStats/PitchingSeasonStats
|
||||||
importing from db_engine before WP-07 merges).
|
(used in tests to inject a stub model with all stat fields).
|
||||||
_state_model: Override for EvolutionCardState (used in tests to avoid
|
_state_model: Override for EvolutionCardState (used in tests to avoid
|
||||||
importing from db_engine before WP-05 merges).
|
importing from db_engine before WP-05 merges).
|
||||||
_compute_value_fn: Override for formula_engine.compute_value_for_track
|
_compute_value_fn: Override for formula_engine.compute_value_for_track
|
||||||
@ -81,9 +81,6 @@ def evaluate_card(
|
|||||||
Raises:
|
Raises:
|
||||||
ValueError: If no evolution_card_state row exists for (player_id, team_id).
|
ValueError: If no evolution_card_state row exists for (player_id, team_id).
|
||||||
"""
|
"""
|
||||||
if _stats_model is None:
|
|
||||||
from app.db_engine import PlayerSeasonStats as _stats_model # noqa: PLC0415
|
|
||||||
|
|
||||||
if _state_model is None:
|
if _state_model is None:
|
||||||
from app.db_engine import EvolutionCardState as _state_model # noqa: PLC0415
|
from app.db_engine import EvolutionCardState as _state_model # noqa: PLC0415
|
||||||
|
|
||||||
@ -107,22 +104,63 @@ def evaluate_card(
|
|||||||
f"No evolution_card_state for player_id={player_id} team_id={team_id}"
|
f"No evolution_card_state for player_id={player_id} team_id={team_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2. Load career totals: SUM all player_season_stats rows for (player_id, team_id)
|
# 2. Load career totals from the appropriate season stats table
|
||||||
rows = list(
|
if _stats_model is not None:
|
||||||
_stats_model.select().where(
|
# Test override: use the injected stub model for all fields
|
||||||
(_stats_model.player_id == player_id) & (_stats_model.team_id == team_id)
|
rows = list(
|
||||||
|
_stats_model.select().where(
|
||||||
|
(_stats_model.player_id == player_id)
|
||||||
|
& (_stats_model.team_id == team_id)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
totals = _CareerTotals(
|
||||||
|
pa=sum(r.pa for r in rows),
|
||||||
|
hits=sum(r.hits for r in rows),
|
||||||
|
doubles=sum(r.doubles for r in rows),
|
||||||
|
triples=sum(r.triples for r in rows),
|
||||||
|
hr=sum(r.hr for r in rows),
|
||||||
|
outs=sum(r.outs for r in rows),
|
||||||
|
strikeouts=sum(r.strikeouts for r in rows),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
from app.db_engine import (
|
||||||
|
BattingSeasonStats,
|
||||||
|
PitchingSeasonStats,
|
||||||
|
) # noqa: PLC0415
|
||||||
|
|
||||||
totals = _CareerTotals(
|
card_type = card_state.track.card_type
|
||||||
pa=sum(r.pa for r in rows),
|
if card_type == "batter":
|
||||||
hits=sum(r.hits for r in rows),
|
rows = list(
|
||||||
doubles=sum(r.doubles for r in rows),
|
BattingSeasonStats.select().where(
|
||||||
triples=sum(r.triples for r in rows),
|
(BattingSeasonStats.player == player_id)
|
||||||
hr=sum(r.hr for r in rows),
|
& (BattingSeasonStats.team == team_id)
|
||||||
outs=sum(r.outs for r in rows),
|
)
|
||||||
strikeouts=sum(r.k for r in rows),
|
)
|
||||||
)
|
totals = _CareerTotals(
|
||||||
|
pa=sum(r.pa for r in rows),
|
||||||
|
hits=sum(r.hits for r in rows),
|
||||||
|
doubles=sum(r.doubles for r in rows),
|
||||||
|
triples=sum(r.triples for r in rows),
|
||||||
|
hr=sum(r.hr for r in rows),
|
||||||
|
outs=0,
|
||||||
|
strikeouts=sum(r.strikeouts for r in rows),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
rows = list(
|
||||||
|
PitchingSeasonStats.select().where(
|
||||||
|
(PitchingSeasonStats.player == player_id)
|
||||||
|
& (PitchingSeasonStats.team == team_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
totals = _CareerTotals(
|
||||||
|
pa=0,
|
||||||
|
hits=0,
|
||||||
|
doubles=0,
|
||||||
|
triples=0,
|
||||||
|
hr=0,
|
||||||
|
outs=sum(r.outs for r in rows),
|
||||||
|
strikeouts=sum(r.strikeouts for r in rows),
|
||||||
|
)
|
||||||
|
|
||||||
# 3. Determine track
|
# 3. Determine track
|
||||||
track = card_state.track
|
track = card_state.track
|
||||||
|
|||||||
@ -1,27 +1,32 @@
|
|||||||
"""
|
"""
|
||||||
season_stats.py — Incremental BattingSeasonStats and PitchingSeasonStats update logic.
|
season_stats.py — Full-recalculation BattingSeasonStats and PitchingSeasonStats update logic.
|
||||||
|
|
||||||
Called once per completed StratGame to accumulate batting and pitching
|
Called once per completed StratGame to recompute the full season batting and
|
||||||
statistics into the batting_season_stats and pitching_season_stats tables
|
pitching statistics for every player who appeared in that game, then write
|
||||||
respectively.
|
those totals to the batting_season_stats and pitching_season_stats tables.
|
||||||
|
|
||||||
Idempotency: re-delivery of a game (including out-of-order re-delivery)
|
Unlike the previous incremental (delta) approach, each call recomputes totals
|
||||||
is detected via an atomic INSERT into the ProcessedGame ledger table
|
from scratch by aggregating all StratPlay rows for the player+team+season
|
||||||
keyed on game_id. The first call for a given game_id succeeds; all
|
triple. This eliminates double-counting on re-delivery and makes every row a
|
||||||
subsequent calls return early with "skipped": True without modifying
|
faithful snapshot of the full season to date.
|
||||||
any stats rows.
|
|
||||||
|
|
||||||
Peewee upsert strategy:
|
Idempotency: re-delivery of a game is detected via the ProcessedGame ledger
|
||||||
- SQLite: read-modify-write inside db.atomic() transaction
|
table, keyed on game_id.
|
||||||
- PostgreSQL: ON CONFLICT ... DO UPDATE with column-level EXCLUDED increments
|
- First call: records the ledger entry and proceeds with recalculation.
|
||||||
|
- Subsequent calls without force=True: return early with "skipped": True.
|
||||||
|
- force=True: skips the early-return check and recalculates anyway (useful
|
||||||
|
for correcting data after retroactive stat adjustments).
|
||||||
|
|
||||||
|
Upsert strategy: get_or_create + field assignment + save(). Because we are
|
||||||
|
writing the full recomputed total rather than adding a delta, there is no
|
||||||
|
risk of concurrent-write skew between games. A single unified path works for
|
||||||
|
both SQLite and PostgreSQL.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
from collections import defaultdict
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from peewee import EXCLUDED
|
from peewee import Case, fn
|
||||||
|
|
||||||
from app.db_engine import (
|
from app.db_engine import (
|
||||||
db,
|
db,
|
||||||
@ -35,464 +40,309 @@ from app.db_engine import (
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DATABASE_TYPE = os.environ.get("DATABASE_TYPE", "sqlite").lower()
|
|
||||||
|
|
||||||
|
def _get_player_pairs(game_id: int) -> tuple[set, set]:
|
||||||
def _build_batting_groups(plays):
|
|
||||||
"""
|
"""
|
||||||
Aggregate per-play batting stats by (batter_id, batter_team_id).
|
Return the sets of (player_id, team_id) pairs that appeared in the game.
|
||||||
|
|
||||||
Only plays where pa > 0 are counted toward games, but all
|
Queries StratPlay for all rows belonging to game_id and extracts:
|
||||||
play-level stat fields are accumulated regardless of pa value so
|
- batting_pairs: set of (batter_id, batter_team_id), excluding rows where
|
||||||
that rare edge cases (e.g. sac bunt without official PA) are
|
batter_id is None (e.g. automatic outs, walk-off plays without a PA).
|
||||||
correctly included in the totals.
|
- pitching_pairs: set of (pitcher_id, pitcher_team_id) from all plays
|
||||||
|
(pitcher is always present), plus any pitchers from the Decision table
|
||||||
|
who may not have StratPlay rows (rare edge case).
|
||||||
|
|
||||||
Returns a dict keyed by (batter_id, batter_team_id) with stat dicts
|
Args:
|
||||||
matching BattingSeasonStats column names.
|
game_id: Primary key of the StratGame to query.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (batting_pairs, pitching_pairs) where each element is a set
|
||||||
|
of (int, int) tuples.
|
||||||
"""
|
"""
|
||||||
groups = defaultdict(
|
plays = (
|
||||||
lambda: {
|
StratPlay.select(
|
||||||
"games": 0,
|
StratPlay.batter,
|
||||||
"pa": 0,
|
StratPlay.batter_team,
|
||||||
"ab": 0,
|
StratPlay.pitcher,
|
||||||
"hits": 0,
|
StratPlay.pitcher_team,
|
||||||
"doubles": 0,
|
)
|
||||||
"triples": 0,
|
.where(StratPlay.game == game_id)
|
||||||
"hr": 0,
|
.tuples()
|
||||||
"rbi": 0,
|
|
||||||
"runs": 0,
|
|
||||||
"bb": 0,
|
|
||||||
"strikeouts": 0,
|
|
||||||
"hbp": 0,
|
|
||||||
"sac": 0,
|
|
||||||
"ibb": 0,
|
|
||||||
"gidp": 0,
|
|
||||||
"sb": 0,
|
|
||||||
"cs": 0,
|
|
||||||
"appeared": False, # tracks whether batter appeared at all in this game
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
for play in plays:
|
batting_pairs: set[tuple[int, int]] = set()
|
||||||
batter_id = play.batter_id
|
pitching_pairs: set[tuple[int, int]] = set()
|
||||||
batter_team_id = play.batter_team_id
|
|
||||||
|
|
||||||
if batter_id is None:
|
for batter_id, batter_team_id, pitcher_id, pitcher_team_id in plays:
|
||||||
continue
|
if batter_id is not None:
|
||||||
|
batting_pairs.add((batter_id, batter_team_id))
|
||||||
|
pitching_pairs.add((pitcher_id, pitcher_team_id))
|
||||||
|
|
||||||
key = (batter_id, batter_team_id)
|
# Include pitchers who have a Decision but no StratPlay rows for this game
|
||||||
g = groups[key]
|
# (rare edge case, e.g. a pitcher credited with a decision without recording
|
||||||
|
# any plays — the old code handled this explicitly in _apply_decisions).
|
||||||
|
decision_pitchers = (
|
||||||
|
Decision.select(Decision.pitcher, Decision.pitcher_team)
|
||||||
|
.where(Decision.game == game_id)
|
||||||
|
.tuples()
|
||||||
|
)
|
||||||
|
for pitcher_id, pitcher_team_id in decision_pitchers:
|
||||||
|
pitching_pairs.add((pitcher_id, pitcher_team_id))
|
||||||
|
|
||||||
g["pa"] += play.pa
|
return batting_pairs, pitching_pairs
|
||||||
g["ab"] += play.ab
|
|
||||||
g["hits"] += play.hit
|
|
||||||
g["doubles"] += play.double
|
|
||||||
g["triples"] += play.triple
|
|
||||||
g["hr"] += play.homerun
|
|
||||||
g["rbi"] += play.rbi
|
|
||||||
g["runs"] += play.run
|
|
||||||
g["bb"] += play.bb
|
|
||||||
g["strikeouts"] += play.so
|
|
||||||
g["hbp"] += play.hbp
|
|
||||||
g["sac"] += play.sac
|
|
||||||
g["ibb"] += play.ibb
|
|
||||||
g["gidp"] += play.gidp
|
|
||||||
g["sb"] += play.sb
|
|
||||||
g["cs"] += play.cs
|
|
||||||
|
|
||||||
if play.pa > 0 and not g["appeared"]:
|
|
||||||
g["games"] = 1
|
|
||||||
g["appeared"] = True
|
|
||||||
|
|
||||||
# Clean up the helper flag before returning
|
|
||||||
for key in groups:
|
|
||||||
del groups[key]["appeared"]
|
|
||||||
|
|
||||||
return groups
|
|
||||||
|
|
||||||
|
|
||||||
def _build_pitching_groups(plays):
|
def _recalc_batting(player_id: int, team_id: int, season: int) -> dict:
|
||||||
"""
|
"""
|
||||||
Aggregate per-play pitching stats by (pitcher_id, pitcher_team_id).
|
Recompute full-season batting totals for a player+team+season triple.
|
||||||
|
|
||||||
Stats on StratPlay are recorded from the batter's perspective, so
|
Aggregates every StratPlay row where batter == player_id and
|
||||||
when accumulating pitcher stats we collect:
|
batter_team == team_id across all games in the given season.
|
||||||
- outs → pitcher outs recorded (directly on play)
|
|
||||||
- so → strikeouts (batter's so = pitcher's strikeouts)
|
|
||||||
- hit → hits allowed
|
|
||||||
- bb → walks allowed (batter bb, separate from hbp)
|
|
||||||
- hbp → hit batters
|
|
||||||
- homerun → home runs allowed
|
|
||||||
|
|
||||||
games counts unique pitchers who appeared (at least one play as
|
games counts only games where the player had at least one official PA
|
||||||
pitcher), capped at 1 per game since this function processes a
|
(pa > 0). The COUNT(DISTINCT ...) with a CASE expression achieves this:
|
||||||
single game. games_started is populated later via _apply_decisions().
|
NULL values from the CASE are ignored by COUNT, so only game IDs where
|
||||||
|
pa > 0 contribute.
|
||||||
|
|
||||||
Fields not available from StratPlay (runs_allowed, earned_runs,
|
Args:
|
||||||
wild_pitches, balks) default to 0 and are not incremented.
|
player_id: FK to the player record.
|
||||||
|
team_id: FK to the team record.
|
||||||
|
season: Integer season year.
|
||||||
|
|
||||||
Returns a dict keyed by (pitcher_id, pitcher_team_id) with stat dicts
|
Returns:
|
||||||
matching PitchingSeasonStats column names.
|
Dict with keys matching BattingSeasonStats columns; all values are
|
||||||
|
native Python ints (defaulting to 0 if no rows matched).
|
||||||
"""
|
"""
|
||||||
groups = defaultdict(
|
row = (
|
||||||
lambda: {
|
StratPlay.select(
|
||||||
"games": 1, # pitcher appeared in this game by definition
|
fn.COUNT(
|
||||||
"games_started": 0, # populated later via _apply_decisions
|
Case(None, [(StratPlay.pa > 0, StratPlay.game)], None).distinct()
|
||||||
"outs": 0,
|
).alias("games"),
|
||||||
"strikeouts": 0,
|
fn.SUM(StratPlay.pa).alias("pa"),
|
||||||
"bb": 0,
|
fn.SUM(StratPlay.ab).alias("ab"),
|
||||||
"hits_allowed": 0,
|
fn.SUM(StratPlay.hit).alias("hits"),
|
||||||
"runs_allowed": 0, # not available from StratPlay
|
fn.SUM(StratPlay.double).alias("doubles"),
|
||||||
"earned_runs": 0, # not available from StratPlay
|
fn.SUM(StratPlay.triple).alias("triples"),
|
||||||
"hr_allowed": 0,
|
fn.SUM(StratPlay.homerun).alias("hr"),
|
||||||
"hbp": 0,
|
fn.SUM(StratPlay.rbi).alias("rbi"),
|
||||||
"wild_pitches": 0, # not available from StratPlay
|
fn.SUM(StratPlay.run).alias("runs"),
|
||||||
"balks": 0, # not available from StratPlay
|
fn.SUM(StratPlay.bb).alias("bb"),
|
||||||
"wins": 0,
|
fn.SUM(StratPlay.so).alias("strikeouts"),
|
||||||
"losses": 0,
|
fn.SUM(StratPlay.hbp).alias("hbp"),
|
||||||
"holds": 0,
|
fn.SUM(StratPlay.sac).alias("sac"),
|
||||||
"saves": 0,
|
fn.SUM(StratPlay.ibb).alias("ibb"),
|
||||||
"blown_saves": 0,
|
fn.SUM(StratPlay.gidp).alias("gidp"),
|
||||||
}
|
fn.SUM(StratPlay.sb).alias("sb"),
|
||||||
|
fn.SUM(StratPlay.cs).alias("cs"),
|
||||||
|
)
|
||||||
|
.join(StratGame, on=(StratPlay.game == StratGame.id))
|
||||||
|
.where(
|
||||||
|
StratPlay.batter == player_id,
|
||||||
|
StratPlay.batter_team == team_id,
|
||||||
|
StratGame.season == season,
|
||||||
|
)
|
||||||
|
.dicts()
|
||||||
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
for play in plays:
|
if row is None:
|
||||||
pitcher_id = play.pitcher_id
|
row = {}
|
||||||
pitcher_team_id = play.pitcher_team_id
|
|
||||||
|
|
||||||
if pitcher_id is None:
|
return {
|
||||||
continue
|
"games": row.get("games") or 0,
|
||||||
|
"pa": row.get("pa") or 0,
|
||||||
key = (pitcher_id, pitcher_team_id)
|
"ab": row.get("ab") or 0,
|
||||||
g = groups[key]
|
"hits": row.get("hits") or 0,
|
||||||
|
"doubles": row.get("doubles") or 0,
|
||||||
g["outs"] += play.outs
|
"triples": row.get("triples") or 0,
|
||||||
g["strikeouts"] += play.so
|
"hr": row.get("hr") or 0,
|
||||||
g["hits_allowed"] += play.hit
|
"rbi": row.get("rbi") or 0,
|
||||||
g["bb"] += play.bb
|
"runs": row.get("runs") or 0,
|
||||||
g["hbp"] += play.hbp
|
"bb": row.get("bb") or 0,
|
||||||
g["hr_allowed"] += play.homerun
|
"strikeouts": row.get("strikeouts") or 0,
|
||||||
|
"hbp": row.get("hbp") or 0,
|
||||||
return groups
|
"sac": row.get("sac") or 0,
|
||||||
|
"ibb": row.get("ibb") or 0,
|
||||||
|
"gidp": row.get("gidp") or 0,
|
||||||
|
"sb": row.get("sb") or 0,
|
||||||
|
"cs": row.get("cs") or 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _apply_decisions(pitching_groups, decisions):
|
def _recalc_pitching(player_id: int, team_id: int, season: int) -> dict:
|
||||||
"""
|
"""
|
||||||
Merge Decision rows into the pitching stat groups.
|
Recompute full-season pitching totals for a player+team+season triple.
|
||||||
|
|
||||||
Each Decision belongs to exactly one pitcher in the game, containing
|
Aggregates every StratPlay row where pitcher == player_id and
|
||||||
win/loss/save/hold/blown-save flags and the is_start indicator.
|
pitcher_team == team_id across all games in the given season. games counts
|
||||||
|
all distinct games in which the pitcher appeared (any play qualifies).
|
||||||
|
|
||||||
|
Stats derived from StratPlay (from the batter-perspective columns):
|
||||||
|
- outs = SUM(outs)
|
||||||
|
- strikeouts = SUM(so) — batter SO = pitcher K
|
||||||
|
- hits_allowed = SUM(hit)
|
||||||
|
- bb = SUM(bb) — walks allowed
|
||||||
|
- hbp = SUM(hbp)
|
||||||
|
- hr_allowed = SUM(homerun)
|
||||||
|
- wild_pitches = SUM(wild_pitch)
|
||||||
|
- balks = SUM(balk)
|
||||||
|
|
||||||
|
Fields not available from StratPlay (runs_allowed, earned_runs) default
|
||||||
|
to 0. Decision-level fields (wins, losses, etc.) are populated separately
|
||||||
|
by _recalc_decisions() and merged in the caller.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
player_id: FK to the player record.
|
||||||
|
team_id: FK to the team record.
|
||||||
|
season: Integer season year.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with keys matching PitchingSeasonStats columns (excluding
|
||||||
|
decision fields, which are filled by _recalc_decisions).
|
||||||
"""
|
"""
|
||||||
for decision in decisions:
|
row = (
|
||||||
pitcher_id = decision.pitcher_id
|
StratPlay.select(
|
||||||
pitcher_team_id = decision.pitcher_team_id
|
fn.COUNT(StratPlay.game.distinct()).alias("games"),
|
||||||
key = (pitcher_id, pitcher_team_id)
|
fn.SUM(StratPlay.outs).alias("outs"),
|
||||||
|
fn.SUM(StratPlay.so).alias("strikeouts"),
|
||||||
# Pitcher may have a Decision without plays (rare edge case for
|
fn.SUM(StratPlay.hit).alias("hits_allowed"),
|
||||||
# games where the Decision was recorded without StratPlay rows).
|
fn.SUM(StratPlay.bb).alias("bb"),
|
||||||
# Initialise a zeroed entry if not already present.
|
fn.SUM(StratPlay.hbp).alias("hbp"),
|
||||||
if key not in pitching_groups:
|
fn.SUM(StratPlay.homerun).alias("hr_allowed"),
|
||||||
pitching_groups[key] = {
|
fn.SUM(StratPlay.wild_pitch).alias("wild_pitches"),
|
||||||
"games": 1,
|
fn.SUM(StratPlay.balk).alias("balks"),
|
||||||
"games_started": 0,
|
)
|
||||||
"outs": 0,
|
.join(StratGame, on=(StratPlay.game == StratGame.id))
|
||||||
"strikeouts": 0,
|
.where(
|
||||||
"bb": 0,
|
StratPlay.pitcher == player_id,
|
||||||
"hits_allowed": 0,
|
StratPlay.pitcher_team == team_id,
|
||||||
"runs_allowed": 0,
|
StratGame.season == season,
|
||||||
"earned_runs": 0,
|
)
|
||||||
"hr_allowed": 0,
|
.dicts()
|
||||||
"hbp": 0,
|
.first()
|
||||||
"wild_pitches": 0,
|
|
||||||
"balks": 0,
|
|
||||||
"wins": 0,
|
|
||||||
"losses": 0,
|
|
||||||
"holds": 0,
|
|
||||||
"saves": 0,
|
|
||||||
"blown_saves": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
g = pitching_groups[key]
|
|
||||||
g["wins"] += decision.win
|
|
||||||
g["losses"] += decision.loss
|
|
||||||
g["saves"] += decision.is_save
|
|
||||||
g["holds"] += decision.hold
|
|
||||||
g["blown_saves"] += decision.b_save
|
|
||||||
g["games_started"] += 1 if decision.is_start else 0
|
|
||||||
|
|
||||||
|
|
||||||
def _upsert_batting_postgres(player_id, team_id, season, game_id, batting):
|
|
||||||
"""
|
|
||||||
PostgreSQL upsert for BattingSeasonStats using ON CONFLICT ... DO UPDATE.
|
|
||||||
Each stat column is incremented by the EXCLUDED (incoming) value,
|
|
||||||
ensuring concurrent games don't overwrite each other.
|
|
||||||
"""
|
|
||||||
now = datetime.now()
|
|
||||||
|
|
||||||
increment_cols = [
|
|
||||||
"games",
|
|
||||||
"pa",
|
|
||||||
"ab",
|
|
||||||
"hits",
|
|
||||||
"doubles",
|
|
||||||
"triples",
|
|
||||||
"hr",
|
|
||||||
"rbi",
|
|
||||||
"runs",
|
|
||||||
"bb",
|
|
||||||
"strikeouts",
|
|
||||||
"hbp",
|
|
||||||
"sac",
|
|
||||||
"ibb",
|
|
||||||
"gidp",
|
|
||||||
"sb",
|
|
||||||
"cs",
|
|
||||||
]
|
|
||||||
|
|
||||||
conflict_target = [
|
|
||||||
BattingSeasonStats.player,
|
|
||||||
BattingSeasonStats.team,
|
|
||||||
BattingSeasonStats.season,
|
|
||||||
]
|
|
||||||
|
|
||||||
update_dict = {}
|
|
||||||
for col in increment_cols:
|
|
||||||
field_obj = getattr(BattingSeasonStats, col)
|
|
||||||
update_dict[field_obj] = field_obj + EXCLUDED[col]
|
|
||||||
update_dict[BattingSeasonStats.last_game] = EXCLUDED["last_game_id"]
|
|
||||||
update_dict[BattingSeasonStats.last_updated_at] = EXCLUDED["last_updated_at"]
|
|
||||||
|
|
||||||
BattingSeasonStats.insert(
|
|
||||||
player=player_id,
|
|
||||||
team=team_id,
|
|
||||||
season=season,
|
|
||||||
games=batting.get("games", 0),
|
|
||||||
pa=batting.get("pa", 0),
|
|
||||||
ab=batting.get("ab", 0),
|
|
||||||
hits=batting.get("hits", 0),
|
|
||||||
doubles=batting.get("doubles", 0),
|
|
||||||
triples=batting.get("triples", 0),
|
|
||||||
hr=batting.get("hr", 0),
|
|
||||||
rbi=batting.get("rbi", 0),
|
|
||||||
runs=batting.get("runs", 0),
|
|
||||||
bb=batting.get("bb", 0),
|
|
||||||
strikeouts=batting.get("strikeouts", 0),
|
|
||||||
hbp=batting.get("hbp", 0),
|
|
||||||
sac=batting.get("sac", 0),
|
|
||||||
ibb=batting.get("ibb", 0),
|
|
||||||
gidp=batting.get("gidp", 0),
|
|
||||||
sb=batting.get("sb", 0),
|
|
||||||
cs=batting.get("cs", 0),
|
|
||||||
last_game=game_id,
|
|
||||||
last_updated_at=now,
|
|
||||||
).on_conflict(
|
|
||||||
conflict_target=conflict_target,
|
|
||||||
action="update",
|
|
||||||
update=update_dict,
|
|
||||||
).execute()
|
|
||||||
|
|
||||||
|
|
||||||
def _upsert_pitching_postgres(player_id, team_id, season, game_id, pitching):
|
|
||||||
"""
|
|
||||||
PostgreSQL upsert for PitchingSeasonStats using ON CONFLICT ... DO UPDATE.
|
|
||||||
Each stat column is incremented by the EXCLUDED (incoming) value,
|
|
||||||
ensuring concurrent games don't overwrite each other.
|
|
||||||
"""
|
|
||||||
now = datetime.now()
|
|
||||||
|
|
||||||
increment_cols = [
|
|
||||||
"games",
|
|
||||||
"games_started",
|
|
||||||
"outs",
|
|
||||||
"strikeouts",
|
|
||||||
"bb",
|
|
||||||
"hits_allowed",
|
|
||||||
"runs_allowed",
|
|
||||||
"earned_runs",
|
|
||||||
"hr_allowed",
|
|
||||||
"hbp",
|
|
||||||
"wild_pitches",
|
|
||||||
"balks",
|
|
||||||
"wins",
|
|
||||||
"losses",
|
|
||||||
"holds",
|
|
||||||
"saves",
|
|
||||||
"blown_saves",
|
|
||||||
]
|
|
||||||
|
|
||||||
conflict_target = [
|
|
||||||
PitchingSeasonStats.player,
|
|
||||||
PitchingSeasonStats.team,
|
|
||||||
PitchingSeasonStats.season,
|
|
||||||
]
|
|
||||||
|
|
||||||
update_dict = {}
|
|
||||||
for col in increment_cols:
|
|
||||||
field_obj = getattr(PitchingSeasonStats, col)
|
|
||||||
update_dict[field_obj] = field_obj + EXCLUDED[col]
|
|
||||||
update_dict[PitchingSeasonStats.last_game] = EXCLUDED["last_game_id"]
|
|
||||||
update_dict[PitchingSeasonStats.last_updated_at] = EXCLUDED["last_updated_at"]
|
|
||||||
|
|
||||||
PitchingSeasonStats.insert(
|
|
||||||
player=player_id,
|
|
||||||
team=team_id,
|
|
||||||
season=season,
|
|
||||||
games=pitching.get("games", 0),
|
|
||||||
games_started=pitching.get("games_started", 0),
|
|
||||||
outs=pitching.get("outs", 0),
|
|
||||||
strikeouts=pitching.get("strikeouts", 0),
|
|
||||||
bb=pitching.get("bb", 0),
|
|
||||||
hits_allowed=pitching.get("hits_allowed", 0),
|
|
||||||
runs_allowed=pitching.get("runs_allowed", 0),
|
|
||||||
earned_runs=pitching.get("earned_runs", 0),
|
|
||||||
hr_allowed=pitching.get("hr_allowed", 0),
|
|
||||||
hbp=pitching.get("hbp", 0),
|
|
||||||
wild_pitches=pitching.get("wild_pitches", 0),
|
|
||||||
balks=pitching.get("balks", 0),
|
|
||||||
wins=pitching.get("wins", 0),
|
|
||||||
losses=pitching.get("losses", 0),
|
|
||||||
holds=pitching.get("holds", 0),
|
|
||||||
saves=pitching.get("saves", 0),
|
|
||||||
blown_saves=pitching.get("blown_saves", 0),
|
|
||||||
last_game=game_id,
|
|
||||||
last_updated_at=now,
|
|
||||||
).on_conflict(
|
|
||||||
conflict_target=conflict_target,
|
|
||||||
action="update",
|
|
||||||
update=update_dict,
|
|
||||||
).execute()
|
|
||||||
|
|
||||||
|
|
||||||
def _upsert_batting_sqlite(player_id, team_id, season, game_id, batting):
|
|
||||||
"""
|
|
||||||
SQLite upsert for BattingSeasonStats: read-modify-write inside the outer atomic() block.
|
|
||||||
|
|
||||||
SQLite doesn't support EXCLUDED-based increments via Peewee's
|
|
||||||
on_conflict(), so we use get_or_create + field-level addition.
|
|
||||||
This is safe because the entire update_season_stats() call is
|
|
||||||
wrapped in db.atomic().
|
|
||||||
"""
|
|
||||||
now = datetime.now()
|
|
||||||
|
|
||||||
obj, _ = BattingSeasonStats.get_or_create(
|
|
||||||
player_id=player_id,
|
|
||||||
team_id=team_id,
|
|
||||||
season=season,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
obj.games += batting.get("games", 0)
|
if row is None:
|
||||||
obj.pa += batting.get("pa", 0)
|
row = {}
|
||||||
obj.ab += batting.get("ab", 0)
|
|
||||||
obj.hits += batting.get("hits", 0)
|
|
||||||
obj.doubles += batting.get("doubles", 0)
|
|
||||||
obj.triples += batting.get("triples", 0)
|
|
||||||
obj.hr += batting.get("hr", 0)
|
|
||||||
obj.rbi += batting.get("rbi", 0)
|
|
||||||
obj.runs += batting.get("runs", 0)
|
|
||||||
obj.bb += batting.get("bb", 0)
|
|
||||||
obj.strikeouts += batting.get("strikeouts", 0)
|
|
||||||
obj.hbp += batting.get("hbp", 0)
|
|
||||||
obj.sac += batting.get("sac", 0)
|
|
||||||
obj.ibb += batting.get("ibb", 0)
|
|
||||||
obj.gidp += batting.get("gidp", 0)
|
|
||||||
obj.sb += batting.get("sb", 0)
|
|
||||||
obj.cs += batting.get("cs", 0)
|
|
||||||
|
|
||||||
obj.last_game_id = game_id
|
return {
|
||||||
obj.last_updated_at = now
|
"games": row.get("games") or 0,
|
||||||
obj.save()
|
"outs": row.get("outs") or 0,
|
||||||
|
"strikeouts": row.get("strikeouts") or 0,
|
||||||
|
"hits_allowed": row.get("hits_allowed") or 0,
|
||||||
|
"bb": row.get("bb") or 0,
|
||||||
|
"hbp": row.get("hbp") or 0,
|
||||||
|
"hr_allowed": row.get("hr_allowed") or 0,
|
||||||
|
"wild_pitches": row.get("wild_pitches") or 0,
|
||||||
|
"balks": row.get("balks") or 0,
|
||||||
|
# Not available from play-by-play data
|
||||||
|
"runs_allowed": 0,
|
||||||
|
"earned_runs": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _upsert_pitching_sqlite(player_id, team_id, season, game_id, pitching):
|
def _recalc_decisions(player_id: int, team_id: int, season: int) -> dict:
|
||||||
"""
|
"""
|
||||||
SQLite upsert for PitchingSeasonStats: read-modify-write inside the outer atomic() block.
|
Recompute full-season decision totals for a pitcher+team+season triple.
|
||||||
|
|
||||||
SQLite doesn't support EXCLUDED-based increments via Peewee's
|
Aggregates all Decision rows for the pitcher across the season. Decision
|
||||||
on_conflict(), so we use get_or_create + field-level addition.
|
rows are keyed by (pitcher, pitcher_team, season) independently of the
|
||||||
This is safe because the entire update_season_stats() call is
|
StratPlay table, so this query is separate from _recalc_pitching().
|
||||||
wrapped in db.atomic().
|
|
||||||
|
Decision.is_start is a BooleanField; CAST to INTEGER before summing to
|
||||||
|
ensure correct arithmetic across SQLite (True/False) and PostgreSQL
|
||||||
|
(boolean).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
player_id: FK to the player record (pitcher).
|
||||||
|
team_id: FK to the team record.
|
||||||
|
season: Integer season year.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with keys: wins, losses, holds, saves, blown_saves,
|
||||||
|
games_started. All values are native Python ints.
|
||||||
"""
|
"""
|
||||||
now = datetime.now()
|
row = (
|
||||||
|
Decision.select(
|
||||||
obj, _ = PitchingSeasonStats.get_or_create(
|
fn.SUM(Decision.win).alias("wins"),
|
||||||
player_id=player_id,
|
fn.SUM(Decision.loss).alias("losses"),
|
||||||
team_id=team_id,
|
fn.SUM(Decision.hold).alias("holds"),
|
||||||
season=season,
|
fn.SUM(Decision.is_save).alias("saves"),
|
||||||
|
fn.SUM(Decision.b_save).alias("blown_saves"),
|
||||||
|
fn.SUM(Decision.is_start.cast("INTEGER")).alias("games_started"),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
Decision.pitcher == player_id,
|
||||||
|
Decision.pitcher_team == team_id,
|
||||||
|
Decision.season == season,
|
||||||
|
)
|
||||||
|
.dicts()
|
||||||
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
obj.games += pitching.get("games", 0)
|
if row is None:
|
||||||
obj.games_started += pitching.get("games_started", 0)
|
row = {}
|
||||||
obj.outs += pitching.get("outs", 0)
|
|
||||||
obj.strikeouts += pitching.get("strikeouts", 0)
|
|
||||||
obj.bb += pitching.get("bb", 0)
|
|
||||||
obj.hits_allowed += pitching.get("hits_allowed", 0)
|
|
||||||
obj.runs_allowed += pitching.get("runs_allowed", 0)
|
|
||||||
obj.earned_runs += pitching.get("earned_runs", 0)
|
|
||||||
obj.hr_allowed += pitching.get("hr_allowed", 0)
|
|
||||||
obj.hbp += pitching.get("hbp", 0)
|
|
||||||
obj.wild_pitches += pitching.get("wild_pitches", 0)
|
|
||||||
obj.balks += pitching.get("balks", 0)
|
|
||||||
obj.wins += pitching.get("wins", 0)
|
|
||||||
obj.losses += pitching.get("losses", 0)
|
|
||||||
obj.holds += pitching.get("holds", 0)
|
|
||||||
obj.saves += pitching.get("saves", 0)
|
|
||||||
obj.blown_saves += pitching.get("blown_saves", 0)
|
|
||||||
|
|
||||||
obj.last_game_id = game_id
|
return {
|
||||||
obj.last_updated_at = now
|
"wins": row.get("wins") or 0,
|
||||||
obj.save()
|
"losses": row.get("losses") or 0,
|
||||||
|
"holds": row.get("holds") or 0,
|
||||||
|
"saves": row.get("saves") or 0,
|
||||||
|
"blown_saves": row.get("blown_saves") or 0,
|
||||||
|
"games_started": row.get("games_started") or 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def update_season_stats(game_id: int) -> dict:
|
def update_season_stats(game_id: int, force: bool = False) -> dict:
|
||||||
"""
|
"""
|
||||||
Accumulate per-game batting and pitching stats into BattingSeasonStats
|
Recompute full-season batting and pitching stats for every player in the game.
|
||||||
and PitchingSeasonStats respectively.
|
|
||||||
|
|
||||||
This function is safe to call exactly once per game. Idempotency is
|
Unlike the previous incremental approach, this function recalculates each
|
||||||
enforced via an atomic INSERT into the ProcessedGame ledger table.
|
player's season totals from scratch by querying all StratPlay rows for
|
||||||
The first call for a given game_id succeeds and returns full results;
|
the player+team+season triple. The resulting totals replace whatever was
|
||||||
any subsequent call (including out-of-order re-delivery after a later
|
previously stored — no additive delta is applied.
|
||||||
game has been processed) finds the existing row and returns early with
|
|
||||||
"skipped": True without touching any stats rows.
|
|
||||||
|
|
||||||
Algorithm:
|
Algorithm:
|
||||||
1. Fetch StratGame to get the season.
|
1. Fetch StratGame to get the season.
|
||||||
2. Atomic INSERT into ProcessedGame — if the row already exists,
|
2. Check the ProcessedGame ledger:
|
||||||
return early (skipped).
|
- If already processed and force=False, return early (skipped=True).
|
||||||
3. Collect all StratPlay rows for the game.
|
- If already processed and force=True, continue (overwrite allowed).
|
||||||
4. Group batting stats by (batter_id, batter_team_id).
|
- If not yet processed, create the ledger entry.
|
||||||
5. Group pitching stats by (pitcher_id, pitcher_team_id).
|
3. Determine (player_id, team_id) pairs via _get_player_pairs().
|
||||||
6. Merge Decision rows into pitching groups.
|
4. For each batting pair: recompute season totals, then get_or_create
|
||||||
7. Upsert each batter into BattingSeasonStats using either:
|
BattingSeasonStats and overwrite all fields.
|
||||||
- PostgreSQL: atomic SQL increment via ON CONFLICT DO UPDATE
|
5. For each pitching pair: recompute season play totals and decision
|
||||||
- SQLite: read-modify-write inside a transaction
|
totals, merge, then get_or_create PitchingSeasonStats and overwrite
|
||||||
8. Upsert each pitcher into PitchingSeasonStats using the same strategy.
|
all fields.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
game_id: Primary key of the StratGame to process.
|
game_id: Primary key of the StratGame to process.
|
||||||
|
force: If True, re-process even if the game was previously recorded
|
||||||
|
in the ProcessedGame ledger. Useful for correcting stats after
|
||||||
|
retroactive data adjustments.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Summary dict with keys: game_id, season, batters_updated,
|
Dict with keys:
|
||||||
pitchers_updated. If the game was already processed, also
|
game_id — echoed back
|
||||||
includes "skipped": True.
|
season — season integer from StratGame
|
||||||
|
batters_updated — number of BattingSeasonStats rows written
|
||||||
|
pitchers_updated — number of PitchingSeasonStats rows written
|
||||||
|
skipped — True only when the game was already processed
|
||||||
|
and force=False; absent otherwise.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
StratGame.DoesNotExist: If no StratGame row matches game_id.
|
StratGame.DoesNotExist: If no StratGame row matches game_id.
|
||||||
"""
|
"""
|
||||||
logger.info("update_season_stats: starting for game_id=%d", game_id)
|
logger.info("update_season_stats: starting for game_id=%d force=%s", game_id, force)
|
||||||
|
|
||||||
# Step 1 — Fetch the game to get season
|
|
||||||
game = StratGame.get_by_id(game_id)
|
game = StratGame.get_by_id(game_id)
|
||||||
season = game.season
|
season = game.season
|
||||||
|
|
||||||
with db.atomic():
|
with db.atomic():
|
||||||
# Step 2 — Full idempotency via ProcessedGame ledger.
|
# Idempotency check via ProcessedGame ledger.
|
||||||
# Atomic INSERT: if the row already exists (same game_id), get_or_create
|
|
||||||
# returns created=False and we skip. This handles same-game immediate
|
|
||||||
# replay AND out-of-order re-delivery (game G re-delivered after G+1
|
|
||||||
# was already processed).
|
|
||||||
_, created = ProcessedGame.get_or_create(game_id=game_id)
|
_, created = ProcessedGame.get_or_create(game_id=game_id)
|
||||||
if not created:
|
|
||||||
|
if not created and not force:
|
||||||
logger.info(
|
logger.info(
|
||||||
"update_season_stats: game_id=%d already processed, skipping",
|
"update_season_stats: game_id=%d already processed, skipping",
|
||||||
game_id,
|
game_id,
|
||||||
@ -505,41 +355,85 @@ def update_season_stats(game_id: int) -> dict:
|
|||||||
"skipped": True,
|
"skipped": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Step 3 — Load plays
|
if not created and force:
|
||||||
plays = list(StratPlay.select().where(StratPlay.game == game_id))
|
logger.info(
|
||||||
|
"update_season_stats: game_id=%d already processed, force=True — recalculating",
|
||||||
|
game_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
batting_pairs, pitching_pairs = _get_player_pairs(game_id)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"update_season_stats: game_id=%d loaded %d plays", game_id, len(plays)
|
"update_season_stats: game_id=%d found %d batting pairs, %d pitching pairs",
|
||||||
|
game_id,
|
||||||
|
len(batting_pairs),
|
||||||
|
len(pitching_pairs),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Steps 4 & 5 — Aggregate batting and pitching groups
|
now = datetime.now()
|
||||||
batting_groups = _build_batting_groups(plays)
|
|
||||||
pitching_groups = _build_pitching_groups(plays)
|
|
||||||
|
|
||||||
# Step 6 — Merge Decision rows into pitching groups
|
# Recompute and overwrite batting season stats for each batter.
|
||||||
decisions = list(Decision.select().where(Decision.game == game_id))
|
|
||||||
_apply_decisions(pitching_groups, decisions)
|
|
||||||
|
|
||||||
upsert_batting = (
|
|
||||||
_upsert_batting_postgres
|
|
||||||
if DATABASE_TYPE == "postgresql"
|
|
||||||
else _upsert_batting_sqlite
|
|
||||||
)
|
|
||||||
upsert_pitching = (
|
|
||||||
_upsert_pitching_postgres
|
|
||||||
if DATABASE_TYPE == "postgresql"
|
|
||||||
else _upsert_pitching_sqlite
|
|
||||||
)
|
|
||||||
|
|
||||||
# Step 7 — Upsert batting rows into BattingSeasonStats
|
|
||||||
batters_updated = 0
|
batters_updated = 0
|
||||||
for (player_id, team_id), batting in batting_groups.items():
|
for player_id, team_id in batting_pairs:
|
||||||
upsert_batting(player_id, team_id, season, game_id, batting)
|
stats = _recalc_batting(player_id, team_id, season)
|
||||||
|
|
||||||
|
obj, _ = BattingSeasonStats.get_or_create(
|
||||||
|
player_id=player_id,
|
||||||
|
team_id=team_id,
|
||||||
|
season=season,
|
||||||
|
)
|
||||||
|
obj.games = stats["games"]
|
||||||
|
obj.pa = stats["pa"]
|
||||||
|
obj.ab = stats["ab"]
|
||||||
|
obj.hits = stats["hits"]
|
||||||
|
obj.doubles = stats["doubles"]
|
||||||
|
obj.triples = stats["triples"]
|
||||||
|
obj.hr = stats["hr"]
|
||||||
|
obj.rbi = stats["rbi"]
|
||||||
|
obj.runs = stats["runs"]
|
||||||
|
obj.bb = stats["bb"]
|
||||||
|
obj.strikeouts = stats["strikeouts"]
|
||||||
|
obj.hbp = stats["hbp"]
|
||||||
|
obj.sac = stats["sac"]
|
||||||
|
obj.ibb = stats["ibb"]
|
||||||
|
obj.gidp = stats["gidp"]
|
||||||
|
obj.sb = stats["sb"]
|
||||||
|
obj.cs = stats["cs"]
|
||||||
|
obj.last_game_id = game_id
|
||||||
|
obj.last_updated_at = now
|
||||||
|
obj.save()
|
||||||
batters_updated += 1
|
batters_updated += 1
|
||||||
|
|
||||||
# Step 8 — Upsert pitching rows into PitchingSeasonStats
|
# Recompute and overwrite pitching season stats for each pitcher.
|
||||||
pitchers_updated = 0
|
pitchers_updated = 0
|
||||||
for (player_id, team_id), pitching in pitching_groups.items():
|
for player_id, team_id in pitching_pairs:
|
||||||
upsert_pitching(player_id, team_id, season, game_id, pitching)
|
play_stats = _recalc_pitching(player_id, team_id, season)
|
||||||
|
decision_stats = _recalc_decisions(player_id, team_id, season)
|
||||||
|
|
||||||
|
obj, _ = PitchingSeasonStats.get_or_create(
|
||||||
|
player_id=player_id,
|
||||||
|
team_id=team_id,
|
||||||
|
season=season,
|
||||||
|
)
|
||||||
|
obj.games = play_stats["games"]
|
||||||
|
obj.games_started = decision_stats["games_started"]
|
||||||
|
obj.outs = play_stats["outs"]
|
||||||
|
obj.strikeouts = play_stats["strikeouts"]
|
||||||
|
obj.bb = play_stats["bb"]
|
||||||
|
obj.hits_allowed = play_stats["hits_allowed"]
|
||||||
|
obj.runs_allowed = play_stats["runs_allowed"]
|
||||||
|
obj.earned_runs = play_stats["earned_runs"]
|
||||||
|
obj.hr_allowed = play_stats["hr_allowed"]
|
||||||
|
obj.hbp = play_stats["hbp"]
|
||||||
|
obj.wild_pitches = play_stats["wild_pitches"]
|
||||||
|
obj.balks = play_stats["balks"]
|
||||||
|
obj.wins = decision_stats["wins"]
|
||||||
|
obj.losses = decision_stats["losses"]
|
||||||
|
obj.holds = decision_stats["holds"]
|
||||||
|
obj.saves = decision_stats["saves"]
|
||||||
|
obj.blown_saves = decision_stats["blown_saves"]
|
||||||
|
obj.last_game_id = game_id
|
||||||
|
obj.last_updated_at = now
|
||||||
|
obj.save()
|
||||||
pitchers_updated += 1
|
pitchers_updated += 1
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@ -85,7 +85,7 @@ class StatsStub(Model):
|
|||||||
triples = IntegerField(default=0)
|
triples = IntegerField(default=0)
|
||||||
hr = IntegerField(default=0)
|
hr = IntegerField(default=0)
|
||||||
outs = IntegerField(default=0)
|
outs = IntegerField(default=0)
|
||||||
k = IntegerField(default=0)
|
strikeouts = IntegerField(default=0)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
database = _test_db
|
database = _test_db
|
||||||
|
|||||||
@ -20,7 +20,7 @@ from peewee import IntegrityError
|
|||||||
from playhouse.shortcuts import model_to_dict
|
from playhouse.shortcuts import model_to_dict
|
||||||
|
|
||||||
from app.db_engine import (
|
from app.db_engine import (
|
||||||
PlayerSeasonStats,
|
BattingSeasonStats,
|
||||||
EvolutionCardState,
|
EvolutionCardState,
|
||||||
EvolutionCosmetic,
|
EvolutionCosmetic,
|
||||||
EvolutionTierBoost,
|
EvolutionTierBoost,
|
||||||
@ -248,13 +248,13 @@ class TestEvolutionCosmetic:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestPlayerSeasonStats:
|
class TestBattingSeasonStats:
|
||||||
"""Tests for BattingSeasonStats, the per-season accumulation table.
|
"""Tests for BattingSeasonStats, the per-season batting accumulation table.
|
||||||
|
|
||||||
Each row aggregates game-by-game batting and pitching stats for one
|
Each row aggregates game-by-game batting stats for one player on one
|
||||||
player on one team in one season. The three-column unique constraint
|
team in one season. The three-column unique constraint prevents
|
||||||
prevents double-counting and ensures a single authoritative row for
|
double-counting and ensures a single authoritative row for each
|
||||||
each (player, team, season) combination.
|
(player, team, season) combination.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def test_create_season_stats(self, player, team):
|
def test_create_season_stats(self, player, team):
|
||||||
@ -264,11 +264,11 @@ class TestPlayerSeasonStats:
|
|||||||
are not provided, which is the initial state before any games are
|
are not provided, which is the initial state before any games are
|
||||||
processed.
|
processed.
|
||||||
"""
|
"""
|
||||||
stats = PlayerSeasonStats.create(
|
stats = BattingSeasonStats.create(
|
||||||
player=player,
|
player=player,
|
||||||
team=team,
|
team=team,
|
||||||
season=11,
|
season=11,
|
||||||
games_batting=5,
|
games=5,
|
||||||
pa=20,
|
pa=20,
|
||||||
ab=18,
|
ab=18,
|
||||||
hits=6,
|
hits=6,
|
||||||
@ -277,25 +277,21 @@ class TestPlayerSeasonStats:
|
|||||||
hr=2,
|
hr=2,
|
||||||
bb=2,
|
bb=2,
|
||||||
hbp=0,
|
hbp=0,
|
||||||
so=4,
|
strikeouts=4,
|
||||||
rbi=5,
|
rbi=5,
|
||||||
runs=3,
|
runs=3,
|
||||||
sb=1,
|
sb=1,
|
||||||
cs=0,
|
cs=0,
|
||||||
)
|
)
|
||||||
fetched = PlayerSeasonStats.get_by_id(stats.id)
|
fetched = BattingSeasonStats.get_by_id(stats.id)
|
||||||
assert fetched.player_id == player.player_id
|
assert fetched.player_id == player.player_id
|
||||||
assert fetched.team_id == team.id
|
assert fetched.team_id == team.id
|
||||||
assert fetched.season == 11
|
assert fetched.season == 11
|
||||||
assert fetched.games_batting == 5
|
assert fetched.games == 5
|
||||||
assert fetched.pa == 20
|
assert fetched.pa == 20
|
||||||
assert fetched.hits == 6
|
assert fetched.hits == 6
|
||||||
assert fetched.hr == 2
|
assert fetched.hr == 2
|
||||||
# Pitching fields were not set — confirm default zero values
|
assert fetched.strikeouts == 4
|
||||||
assert fetched.games_pitching == 0
|
|
||||||
assert fetched.outs == 0
|
|
||||||
assert fetched.wins == 0
|
|
||||||
assert fetched.saves == 0
|
|
||||||
# Nullable meta fields
|
# Nullable meta fields
|
||||||
assert fetched.last_game is None
|
assert fetched.last_game is None
|
||||||
assert fetched.last_updated_at is None
|
assert fetched.last_updated_at is None
|
||||||
@ -307,9 +303,9 @@ class TestPlayerSeasonStats:
|
|||||||
player-team-season combination has exactly one accumulation row,
|
player-team-season combination has exactly one accumulation row,
|
||||||
preventing duplicate stat aggregation that would inflate totals.
|
preventing duplicate stat aggregation that would inflate totals.
|
||||||
"""
|
"""
|
||||||
PlayerSeasonStats.create(player=player, team=team, season=11)
|
BattingSeasonStats.create(player=player, team=team, season=11)
|
||||||
with pytest.raises(IntegrityError):
|
with pytest.raises(IntegrityError):
|
||||||
PlayerSeasonStats.create(player=player, team=team, season=11)
|
BattingSeasonStats.create(player=player, team=team, season=11)
|
||||||
|
|
||||||
def test_season_stats_increment(self, player, team):
|
def test_season_stats_increment(self, player, team):
|
||||||
"""Manually incrementing hits on an existing row persists the change.
|
"""Manually incrementing hits on an existing row persists the change.
|
||||||
@ -319,7 +315,7 @@ class TestPlayerSeasonStats:
|
|||||||
writes back to the database and that subsequent reads reflect the
|
writes back to the database and that subsequent reads reflect the
|
||||||
updated value.
|
updated value.
|
||||||
"""
|
"""
|
||||||
stats = PlayerSeasonStats.create(
|
stats = BattingSeasonStats.create(
|
||||||
player=player,
|
player=player,
|
||||||
team=team,
|
team=team,
|
||||||
season=11,
|
season=11,
|
||||||
@ -328,5 +324,5 @@ class TestPlayerSeasonStats:
|
|||||||
stats.hits += 3
|
stats.hits += 3
|
||||||
stats.save()
|
stats.save()
|
||||||
|
|
||||||
refreshed = PlayerSeasonStats.get_by_id(stats.id)
|
refreshed = BattingSeasonStats.get_by_id(stats.id)
|
||||||
assert refreshed.hits == 13
|
assert refreshed.hits == 13
|
||||||
|
|||||||
@ -63,7 +63,9 @@ from app.db_engine import (
|
|||||||
Pack,
|
Pack,
|
||||||
PackType,
|
PackType,
|
||||||
Player,
|
Player,
|
||||||
PlayerSeasonStats,
|
BattingSeasonStats,
|
||||||
|
PitchingSeasonStats,
|
||||||
|
ProcessedGame,
|
||||||
Rarity,
|
Rarity,
|
||||||
Roster,
|
Roster,
|
||||||
RosterSlot,
|
RosterSlot,
|
||||||
@ -106,7 +108,9 @@ _WP13_MODELS = [
|
|||||||
Decision,
|
Decision,
|
||||||
ScoutOpportunity,
|
ScoutOpportunity,
|
||||||
ScoutClaim,
|
ScoutClaim,
|
||||||
PlayerSeasonStats,
|
BattingSeasonStats,
|
||||||
|
PitchingSeasonStats,
|
||||||
|
ProcessedGame,
|
||||||
EvolutionTrack,
|
EvolutionTrack,
|
||||||
EvolutionCardState,
|
EvolutionCardState,
|
||||||
EvolutionTierBoost,
|
EvolutionTierBoost,
|
||||||
@ -328,7 +332,7 @@ def test_update_game_creates_season_stats_rows(client):
|
|||||||
"""POST update-game creates player_season_stats rows for players in the game.
|
"""POST update-game creates player_season_stats rows for players in the game.
|
||||||
|
|
||||||
What: Set up a batter and pitcher in a game with 3 PA for the batter.
|
What: Set up a batter and pitcher in a game with 3 PA for the batter.
|
||||||
After the endpoint call, assert a PlayerSeasonStats row exists with pa=3.
|
After the endpoint call, assert a BattingSeasonStats row exists with pa=3.
|
||||||
|
|
||||||
Why: This is the core write path. If the row is not created, the
|
Why: This is the core write path. If the row is not created, the
|
||||||
evolution evaluator will always see zero career stats.
|
evolution evaluator will always see zero career stats.
|
||||||
@ -347,10 +351,10 @@ def test_update_game_creates_season_stats_rows(client):
|
|||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
||||||
stats = PlayerSeasonStats.get_or_none(
|
stats = BattingSeasonStats.get_or_none(
|
||||||
(PlayerSeasonStats.player == batter)
|
(BattingSeasonStats.player == batter)
|
||||||
& (PlayerSeasonStats.team == team_a)
|
& (BattingSeasonStats.team == team_a)
|
||||||
& (PlayerSeasonStats.season == 11)
|
& (BattingSeasonStats.season == 11)
|
||||||
)
|
)
|
||||||
assert stats is not None
|
assert stats is not None
|
||||||
assert stats.pa == 3
|
assert stats.pa == 3
|
||||||
@ -417,8 +421,8 @@ def test_update_game_idempotent(client):
|
|||||||
assert data2["skipped"] is True
|
assert data2["skipped"] is True
|
||||||
assert data2["updated"] == 0
|
assert data2["updated"] == 0
|
||||||
|
|
||||||
stats = PlayerSeasonStats.get(
|
stats = BattingSeasonStats.get(
|
||||||
(PlayerSeasonStats.player == batter) & (PlayerSeasonStats.team == team_a)
|
(BattingSeasonStats.player == batter) & (BattingSeasonStats.team == team_a)
|
||||||
)
|
)
|
||||||
assert stats.pa == 3 # not 6
|
assert stats.pa == 3 # not 6
|
||||||
|
|
||||||
@ -468,7 +472,7 @@ def test_evaluate_game_tier_advancement(client):
|
|||||||
"""A game that pushes a card past a tier threshold advances the tier.
|
"""A game that pushes a card past a tier threshold advances the tier.
|
||||||
|
|
||||||
What: Set the batter's career value just below T1 (37) by manually seeding
|
What: Set the batter's career value just below T1 (37) by manually seeding
|
||||||
a prior PlayerSeasonStats row with pa=34. Then add a game that brings the
|
a prior BattingSeasonStats row with pa=34. Then add a game that brings the
|
||||||
total past 37 and call evaluate-game. current_tier must advance to >= 1.
|
total past 37 and call evaluate-game. current_tier must advance to >= 1.
|
||||||
|
|
||||||
Why: Tier advancement is the core deliverable of card evolution. If the
|
Why: Tier advancement is the core deliverable of card evolution. If the
|
||||||
@ -484,7 +488,7 @@ def test_evaluate_game_tier_advancement(client):
|
|||||||
_make_state(batter, team_a, track, current_tier=0, current_value=34.0)
|
_make_state(batter, team_a, track, current_tier=0, current_value=34.0)
|
||||||
|
|
||||||
# Seed prior stats: 34 PA (value = 34; T1 threshold = 37)
|
# Seed prior stats: 34 PA (value = 34; T1 threshold = 37)
|
||||||
PlayerSeasonStats.create(
|
BattingSeasonStats.create(
|
||||||
player=batter,
|
player=batter,
|
||||||
team=team_a,
|
team=team_a,
|
||||||
season=10, # previous season
|
season=10, # previous season
|
||||||
@ -565,7 +569,7 @@ def test_evaluate_game_tier_ups_in_response(client):
|
|||||||
_make_state(batter, team_a, track, current_tier=0)
|
_make_state(batter, team_a, track, current_tier=0)
|
||||||
|
|
||||||
# Seed prior stats below threshold
|
# Seed prior stats below threshold
|
||||||
PlayerSeasonStats.create(player=batter, team=team_a, season=10, pa=34)
|
BattingSeasonStats.create(player=batter, team=team_a, season=10, pa=34)
|
||||||
|
|
||||||
# Game pushes past T1
|
# Game pushes past T1
|
||||||
for i in range(4):
|
for i in range(4):
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
Tests for app/services/season_stats.py — update_season_stats().
|
Tests for app/services/season_stats.py — update_season_stats().
|
||||||
|
|
||||||
What: Verify that the incremental stat accumulation function correctly
|
What: Verify that the full-recalculation stat engine correctly aggregates
|
||||||
aggregates StratPlay and Decision rows into BattingSeasonStats and
|
StratPlay and Decision rows into BattingSeasonStats and PitchingSeasonStats,
|
||||||
PitchingSeasonStats, handles duplicate calls idempotently, and
|
handles duplicate calls idempotently, accumulates stats across multiple games,
|
||||||
accumulates stats across multiple games.
|
and supports forced reprocessing for self-healing.
|
||||||
|
|
||||||
Why: This is the core bookkeeping engine for card evolution scoring. A
|
Why: This is the core bookkeeping engine for card evolution scoring. A
|
||||||
double-count bug, a missed Decision merge, or a team-isolation failure
|
double-count bug, a missed Decision merge, or a team-isolation failure
|
||||||
@ -191,7 +191,7 @@ def game(team_a, team_b):
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Tests
|
# Tests — Existing behavior (kept)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@ -200,7 +200,7 @@ def test_single_game_batting_stats(team_a, team_b, player_batter, player_pitcher
|
|||||||
|
|
||||||
What: Create three plate appearances (2 hits, 1 strikeout, a walk, and a
|
What: Create three plate appearances (2 hits, 1 strikeout, a walk, and a
|
||||||
home run) for one batter. After update_season_stats(), the
|
home run) for one batter. After update_season_stats(), the
|
||||||
PlayerSeasonStats row should reflect the exact sum of all play fields.
|
BattingSeasonStats row should reflect the exact sum of all play fields.
|
||||||
|
|
||||||
Why: The core of the batting aggregation pipeline. If any field mapping
|
Why: The core of the batting aggregation pipeline. If any field mapping
|
||||||
is wrong (e.g. 'hit' mapped to 'doubles' instead of 'hits'), evolution
|
is wrong (e.g. 'hit' mapped to 'doubles' instead of 'hits'), evolution
|
||||||
@ -287,11 +287,11 @@ def test_single_game_pitching_stats(
|
|||||||
|
|
||||||
What: The same plays that create batting stats for the batter are also
|
What: The same plays that create batting stats for the batter are also
|
||||||
the source for the pitcher's opposing stats. This test checks that
|
the source for the pitcher's opposing stats. This test checks that
|
||||||
_build_pitching_groups() correctly inverts batter-perspective fields.
|
_recalc_pitching() correctly inverts batter-perspective fields.
|
||||||
|
|
||||||
Why: The batter's 'so' becomes the pitcher's 'k', the batter's 'hit'
|
Why: The batter's 'so' becomes the pitcher's 'strikeouts', the batter's
|
||||||
becomes 'hits_allowed', etc. Any transposition in this mapping would
|
'hit' becomes 'hits_allowed', etc. Any transposition in this mapping
|
||||||
corrupt pitcher stats silently.
|
would corrupt pitcher stats silently.
|
||||||
"""
|
"""
|
||||||
# Play 1: strikeout — batter so=1, outs=1
|
# Play 1: strikeout — batter so=1, outs=1
|
||||||
make_play(
|
make_play(
|
||||||
@ -347,14 +347,14 @@ def test_single_game_pitching_stats(
|
|||||||
|
|
||||||
|
|
||||||
def test_decision_integration(team_a, team_b, player_batter, player_pitcher, game):
|
def test_decision_integration(team_a, team_b, player_batter, player_pitcher, game):
|
||||||
"""Decision.win=1 for a pitcher results in wins=1 in PlayerSeasonStats.
|
"""Decision.win=1 for a pitcher results in wins=1 in PitchingSeasonStats.
|
||||||
|
|
||||||
What: Add a single StratPlay to establish the pitcher in pitching_groups,
|
What: Add a single StratPlay to establish the pitcher in pitching pairs,
|
||||||
then create a Decision row recording a win. Call update_season_stats()
|
then create a Decision row recording a win. Call update_season_stats()
|
||||||
and verify the wins column is 1.
|
and verify the wins column is 1.
|
||||||
|
|
||||||
Why: Decisions are stored in a separate table from StratPlay. If
|
Why: Decisions are stored in a separate table from StratPlay. If
|
||||||
_apply_decisions() fails to merge them (wrong FK lookup, key mismatch),
|
_recalc_decisions() fails to merge them (wrong FK lookup, key mismatch),
|
||||||
pitchers would always show 0 wins/losses/saves regardless of actual game
|
pitchers would always show 0 wins/losses/saves regardless of actual game
|
||||||
outcomes, breaking standings and evolution criteria.
|
outcomes, breaking standings and evolution criteria.
|
||||||
"""
|
"""
|
||||||
@ -441,9 +441,9 @@ def test_two_games_accumulate(team_a, team_b, player_batter, player_pitcher):
|
|||||||
What: Process game 1 (pa=2) then game 2 (pa=3) for the same batter/team.
|
What: Process game 1 (pa=2) then game 2 (pa=3) for the same batter/team.
|
||||||
After both updates the stats row should show pa=5.
|
After both updates the stats row should show pa=5.
|
||||||
|
|
||||||
Why: PlayerSeasonStats is a season-long accumulator, not a per-game
|
Why: BattingSeasonStats is a season-long accumulator, not a per-game
|
||||||
snapshot. If the upsert logic overwrites instead of increments, a player's
|
snapshot. The full recalculation queries all StratPlay rows for the season,
|
||||||
stats would always reflect only their most recent game.
|
so processing game 2 recomputes with all 5 PAs included.
|
||||||
"""
|
"""
|
||||||
game1 = StratGame.create(
|
game1 = StratGame.create(
|
||||||
season=11, game_type="ranked", away_team=team_a, home_team=team_b
|
season=11, game_type="ranked", away_team=team_a, home_team=team_b
|
||||||
@ -593,18 +593,15 @@ def test_two_team_game(team_a, team_b):
|
|||||||
|
|
||||||
|
|
||||||
def test_out_of_order_replay_prevented(team_a, team_b, player_batter, player_pitcher):
|
def test_out_of_order_replay_prevented(team_a, team_b, player_batter, player_pitcher):
|
||||||
"""Out-of-order re-delivery of game G (after G+1 was processed) must not double-count.
|
"""Out-of-order processing and re-delivery produce correct stats.
|
||||||
|
|
||||||
What: Process game G+1 first (pa=2), then process game G (pa=3). Now
|
What: Process game G+1 first (pa=2), then game G (pa=3). The full
|
||||||
re-deliver game G. The third call must return 'skipped'=True and leave
|
recalculation approach means both calls query all StratPlay rows for the
|
||||||
the batter's pa unchanged at 5 (3 + 2), not 8 (3 + 2 + 3).
|
season, so the final stats are always correct regardless of processing
|
||||||
|
order. Re-delivering game G returns 'skipped'=True and leaves stats at 5.
|
||||||
|
|
||||||
Why: This is the failure mode that the old last_game FK guard could not
|
Why: With full recalculation, out-of-order processing is inherently safe.
|
||||||
catch. After G+1 is processed, no BattingSeasonStats row carries
|
The ProcessedGame ledger still prevents redundant work on re-delivery.
|
||||||
last_game=G anymore (it was overwritten to G+1). The old guard would
|
|
||||||
have returned already_processed=False and double-counted. The
|
|
||||||
ProcessedGame ledger fixes this by keying on game_id independently of
|
|
||||||
the stats rows.
|
|
||||||
"""
|
"""
|
||||||
game_g = StratGame.create(
|
game_g = StratGame.create(
|
||||||
season=11, game_type="ranked", away_team=team_a, home_team=team_b
|
season=11, game_type="ranked", away_team=team_a, home_team=team_b
|
||||||
@ -657,5 +654,257 @@ def test_out_of_order_replay_prevented(team_a, team_b, player_batter, player_pit
|
|||||||
assert replay_result.get("skipped") is True
|
assert replay_result.get("skipped") is True
|
||||||
|
|
||||||
# Stats must remain at 5, not 8
|
# Stats must remain at 5, not 8
|
||||||
stats.refresh()
|
stats = BattingSeasonStats.get(
|
||||||
|
BattingSeasonStats.player == player_batter,
|
||||||
|
BattingSeasonStats.team == team_a,
|
||||||
|
BattingSeasonStats.season == 11,
|
||||||
|
)
|
||||||
assert stats.pa == 5
|
assert stats.pa == 5
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests — New (force recalc / idempotency / self-healing)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_force_recalc(team_a, team_b, player_batter, player_pitcher, game):
|
||||||
|
"""Processing with force=True after initial processing does not double stats.
|
||||||
|
|
||||||
|
What: Process a game normally (pa=3), then reprocess with force=True.
|
||||||
|
Because the recalculation reads all StratPlay rows and writes totals
|
||||||
|
(not deltas), the stats remain at pa=3 after the forced reprocess.
|
||||||
|
|
||||||
|
Why: The force flag bypasses the ProcessedGame ledger skip, but since
|
||||||
|
the underlying data hasn't changed, the recalculated totals must be
|
||||||
|
identical. This proves the replacement upsert is safe.
|
||||||
|
"""
|
||||||
|
for i in range(3):
|
||||||
|
make_play(
|
||||||
|
game,
|
||||||
|
i + 1,
|
||||||
|
player_batter,
|
||||||
|
team_a,
|
||||||
|
player_pitcher,
|
||||||
|
team_b,
|
||||||
|
pa=1,
|
||||||
|
ab=1,
|
||||||
|
hit=1,
|
||||||
|
outs=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
first_result = update_season_stats(game.id)
|
||||||
|
assert first_result["batters_updated"] >= 1
|
||||||
|
assert "skipped" not in first_result
|
||||||
|
|
||||||
|
# Force reprocess — should NOT double stats
|
||||||
|
force_result = update_season_stats(game.id, force=True)
|
||||||
|
assert "skipped" not in force_result
|
||||||
|
assert force_result["batters_updated"] >= 1
|
||||||
|
|
||||||
|
stats = BattingSeasonStats.get(
|
||||||
|
BattingSeasonStats.player == player_batter,
|
||||||
|
BattingSeasonStats.team == team_a,
|
||||||
|
BattingSeasonStats.season == 11,
|
||||||
|
)
|
||||||
|
assert stats.pa == 3
|
||||||
|
assert stats.hits == 3
|
||||||
|
assert stats.games == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_idempotent_reprocessing(team_a, team_b, player_batter, player_pitcher, game):
|
||||||
|
"""Two consecutive force=True calls produce identical stats.
|
||||||
|
|
||||||
|
What: Force-process the same game twice. Both calls recompute from
|
||||||
|
scratch, so the stats after the second call must be identical to the
|
||||||
|
stats after the first call.
|
||||||
|
|
||||||
|
Why: Idempotency is a critical property of the recalculation engine.
|
||||||
|
External systems (admin scripts, retry loops) may call force=True
|
||||||
|
multiple times; the result must be stable.
|
||||||
|
"""
|
||||||
|
for i in range(4):
|
||||||
|
make_play(
|
||||||
|
game,
|
||||||
|
i + 1,
|
||||||
|
player_batter,
|
||||||
|
team_a,
|
||||||
|
player_pitcher,
|
||||||
|
team_b,
|
||||||
|
pa=1,
|
||||||
|
ab=1,
|
||||||
|
so=1 if i % 2 == 0 else 0,
|
||||||
|
hit=0 if i % 2 == 0 else 1,
|
||||||
|
outs=1 if i % 2 == 0 else 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
update_season_stats(game.id, force=True)
|
||||||
|
stats_after_first = BattingSeasonStats.get(
|
||||||
|
BattingSeasonStats.player == player_batter,
|
||||||
|
BattingSeasonStats.team == team_a,
|
||||||
|
BattingSeasonStats.season == 11,
|
||||||
|
)
|
||||||
|
pa_1, hits_1, so_1 = (
|
||||||
|
stats_after_first.pa,
|
||||||
|
stats_after_first.hits,
|
||||||
|
stats_after_first.strikeouts,
|
||||||
|
)
|
||||||
|
|
||||||
|
update_season_stats(game.id, force=True)
|
||||||
|
stats_after_second = BattingSeasonStats.get(
|
||||||
|
BattingSeasonStats.player == player_batter,
|
||||||
|
BattingSeasonStats.team == team_a,
|
||||||
|
BattingSeasonStats.season == 11,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert stats_after_second.pa == pa_1
|
||||||
|
assert stats_after_second.hits == hits_1
|
||||||
|
assert stats_after_second.strikeouts == so_1
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_reprocessing_heals(
|
||||||
|
team_a, team_b, player_batter, player_pitcher, game
|
||||||
|
):
|
||||||
|
"""Force reprocessing corrects manually corrupted stats.
|
||||||
|
|
||||||
|
What: Process a game (pa=3, hits=2), then manually corrupt the stats
|
||||||
|
row (set pa=999). Force-reprocess the game. The stats should be healed
|
||||||
|
back to the correct totals (pa=3, hits=2).
|
||||||
|
|
||||||
|
Why: This is the primary self-healing benefit of full recalculation.
|
||||||
|
Partial processing, bugs, or manual edits can corrupt season stats;
|
||||||
|
force=True recomputes from the source-of-truth StratPlay data and
|
||||||
|
writes the correct totals regardless of current row state.
|
||||||
|
"""
|
||||||
|
# PA 1: single
|
||||||
|
make_play(
|
||||||
|
game,
|
||||||
|
1,
|
||||||
|
player_batter,
|
||||||
|
team_a,
|
||||||
|
player_pitcher,
|
||||||
|
team_b,
|
||||||
|
pa=1,
|
||||||
|
ab=1,
|
||||||
|
hit=1,
|
||||||
|
outs=0,
|
||||||
|
)
|
||||||
|
# PA 2: double
|
||||||
|
make_play(
|
||||||
|
game,
|
||||||
|
2,
|
||||||
|
player_batter,
|
||||||
|
team_a,
|
||||||
|
player_pitcher,
|
||||||
|
team_b,
|
||||||
|
pa=1,
|
||||||
|
ab=1,
|
||||||
|
hit=1,
|
||||||
|
double=1,
|
||||||
|
outs=0,
|
||||||
|
)
|
||||||
|
# PA 3: strikeout
|
||||||
|
make_play(
|
||||||
|
game,
|
||||||
|
3,
|
||||||
|
player_batter,
|
||||||
|
team_a,
|
||||||
|
player_pitcher,
|
||||||
|
team_b,
|
||||||
|
pa=1,
|
||||||
|
ab=1,
|
||||||
|
so=1,
|
||||||
|
outs=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
update_season_stats(game.id)
|
||||||
|
|
||||||
|
# Verify correct initial state
|
||||||
|
stats = BattingSeasonStats.get(
|
||||||
|
BattingSeasonStats.player == player_batter,
|
||||||
|
BattingSeasonStats.team == team_a,
|
||||||
|
BattingSeasonStats.season == 11,
|
||||||
|
)
|
||||||
|
assert stats.pa == 3
|
||||||
|
assert stats.hits == 2
|
||||||
|
assert stats.doubles == 1
|
||||||
|
|
||||||
|
# Corrupt the stats manually
|
||||||
|
stats.pa = 999
|
||||||
|
stats.hits = 0
|
||||||
|
stats.doubles = 50
|
||||||
|
stats.save()
|
||||||
|
|
||||||
|
# Verify corruption took effect
|
||||||
|
stats = BattingSeasonStats.get_by_id(stats.id)
|
||||||
|
assert stats.pa == 999
|
||||||
|
|
||||||
|
# Force reprocess — should heal the corruption
|
||||||
|
update_season_stats(game.id, force=True)
|
||||||
|
|
||||||
|
stats = BattingSeasonStats.get(
|
||||||
|
BattingSeasonStats.player == player_batter,
|
||||||
|
BattingSeasonStats.team == team_a,
|
||||||
|
BattingSeasonStats.season == 11,
|
||||||
|
)
|
||||||
|
assert stats.pa == 3
|
||||||
|
assert stats.hits == 2
|
||||||
|
assert stats.doubles == 1
|
||||||
|
assert stats.strikeouts == 1
|
||||||
|
assert stats.games == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_decision_only_pitcher(team_a, team_b, player_batter, player_pitcher, game):
|
||||||
|
"""A pitcher with a Decision but no StratPlay rows still gets stats recorded.
|
||||||
|
|
||||||
|
What: Create a second pitcher who has a Decision (win) for the game but
|
||||||
|
does not appear in any StratPlay rows. After update_season_stats(), the
|
||||||
|
decision-only pitcher should have a PitchingSeasonStats row with wins=1
|
||||||
|
and all play-level stats at 0.
|
||||||
|
|
||||||
|
Why: In rare cases a pitcher may be credited with a decision without
|
||||||
|
recording any plays (e.g. inherited runner scoring rules, edge cases in
|
||||||
|
game simulation). The old code handled this in _apply_decisions(); the
|
||||||
|
new code must include Decision-scanned pitchers in _get_player_pairs().
|
||||||
|
"""
|
||||||
|
relief_pitcher = _make_player("Relief Pitcher", pos="RP")
|
||||||
|
|
||||||
|
# The main pitcher has plays
|
||||||
|
make_play(
|
||||||
|
game,
|
||||||
|
1,
|
||||||
|
player_batter,
|
||||||
|
team_a,
|
||||||
|
player_pitcher,
|
||||||
|
team_b,
|
||||||
|
pa=1,
|
||||||
|
ab=1,
|
||||||
|
outs=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
# The relief pitcher has a Decision but NO StratPlay rows
|
||||||
|
Decision.create(
|
||||||
|
season=11,
|
||||||
|
game=game,
|
||||||
|
pitcher=relief_pitcher,
|
||||||
|
pitcher_team=team_b,
|
||||||
|
win=1,
|
||||||
|
loss=0,
|
||||||
|
is_save=0,
|
||||||
|
hold=0,
|
||||||
|
b_save=0,
|
||||||
|
is_start=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
update_season_stats(game.id)
|
||||||
|
|
||||||
|
# The relief pitcher should have a PitchingSeasonStats row
|
||||||
|
stats = PitchingSeasonStats.get(
|
||||||
|
PitchingSeasonStats.player == relief_pitcher,
|
||||||
|
PitchingSeasonStats.team == team_b,
|
||||||
|
PitchingSeasonStats.season == 11,
|
||||||
|
)
|
||||||
|
assert stats.wins == 1
|
||||||
|
assert stats.games == 0 # no plays, so COUNT(DISTINCT game) = 0
|
||||||
|
assert stats.outs == 0
|
||||||
|
assert stats.strikeouts == 0
|
||||||
|
assert stats.games_started == 0
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user