From 4ed62dea2c9389f2c5fedaad7f42d2a7d4f838ef Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Tue, 17 Mar 2026 09:31:52 -0500 Subject: [PATCH] refactor: rename PlayerSeasonStats `so` to `so_batter` and `k` to `so_pitcher` The single-letter `k` field was ambiguous and too short for comfortable use. Rename to `so_pitcher` for clarity, and `so` to `so_batter` to distinguish batting strikeouts from pitching strikeouts in the same model. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/db_engine.py | 6 +- app/routers_v2/season_stats.py | 232 +++++++++++++++++++++++++++++++ app/services/formula_engine.py | 14 +- tests/test_formula_engine.py | 10 +- tests/test_season_stats_model.py | 16 +-- 5 files changed, 254 insertions(+), 24 deletions(-) create mode 100644 app/routers_v2/season_stats.py diff --git a/app/db_engine.py b/app/db_engine.py index bb9c9f0..217d0d6 100644 --- a/app/db_engine.py +++ b/app/db_engine.py @@ -1065,7 +1065,7 @@ class PlayerSeasonStats(BaseModel): triples = IntegerField(default=0) bb = IntegerField(default=0) hbp = IntegerField(default=0) - so = IntegerField(default=0) + so_batter = IntegerField(default=0) rbi = IntegerField(default=0) runs = IntegerField(default=0) sb = IntegerField(default=0) @@ -1074,9 +1074,7 @@ class PlayerSeasonStats(BaseModel): # Pitching stats games_pitching = IntegerField(default=0) outs = IntegerField(default=0) - k = IntegerField( - default=0 - ) # pitcher Ks; spec names this "so (K)" but renamed to avoid collision with batting so + so_pitcher = IntegerField(default=0) bb_allowed = IntegerField(default=0) hits_allowed = IntegerField(default=0) hr_allowed = IntegerField(default=0) diff --git a/app/routers_v2/season_stats.py b/app/routers_v2/season_stats.py new file mode 100644 index 0000000..d981af0 --- /dev/null +++ b/app/routers_v2/season_stats.py @@ -0,0 +1,232 @@ +"""Season stats API endpoints. + +Covers WP-13 (Post-Game Callback Integration): + POST /api/v2/season-stats/update-game/{game_id} + +Aggregates BattingStat and PitchingStat rows for a completed game and +increments the corresponding player_season_stats rows via an additive upsert. + +Lazy-imports PlayerSeasonStats so this module loads before WP-05 merges. +""" + +import logging + +from fastapi import APIRouter, Depends, HTTPException + +from ..db_engine import db +from ..dependencies import oauth2_scheme, valid_token + +router = APIRouter(prefix="/api/v2/season-stats", tags=["season-stats"]) + + +def _ip_to_outs(ip: float) -> int: + """Convert innings-pitched float (e.g. 6.1) to integer outs (e.g. 19). + + Baseball stores IP as whole.partial where the fractional digit is outs + (0, 1, or 2), not tenths. 6.1 = 6 innings + 1 out = 19 outs. + """ + whole = int(ip) + partial = round((ip - whole) * 10) + return whole * 3 + partial + + +@router.post("/update-game/{game_id}") +async def update_game_season_stats(game_id: int, token: str = Depends(oauth2_scheme)): + """Increment player_season_stats with batting and pitching deltas from a game. + + Queries BattingStat and PitchingStat rows for game_id, aggregates by + (player_id, team_id, season), then performs an additive ON CONFLICT upsert + into player_season_stats. Idempotent: replaying the same game_id a second + time will double-count stats, so callers must ensure this is only called once + per game. + + Response: {"updated": N} where N is the number of player rows touched. + """ + if not valid_token(token): + logging.warning("Bad Token: [REDACTED]") + raise HTTPException(status_code=401, detail="Unauthorized") + + updated = 0 + + # --- Batting --- + bat_rows = list( + db.execute_sql( + """ + SELECT c.player_id, bs.team_id, bs.season, + SUM(bs.pa), SUM(bs.ab), SUM(bs.run), SUM(bs.hit), + SUM(bs.double), SUM(bs.triple), SUM(bs.hr), SUM(bs.rbi), + SUM(bs.bb), SUM(bs.so), SUM(bs.hbp), SUM(bs.sac), + SUM(bs.ibb), SUM(bs.gidp), SUM(bs.sb), SUM(bs.cs) + FROM battingstat bs + JOIN card c ON bs.card_id = c.id + WHERE bs.game_id = %s + GROUP BY c.player_id, bs.team_id, bs.season + """, + (game_id,), + ) + ) + + for row in bat_rows: + ( + player_id, + team_id, + season, + pa, + ab, + r, + hits, + doubles, + triples, + hr, + rbi, + bb, + so, + hbp, + sac, + ibb, + gidp, + sb, + cs, + ) = row + db.execute_sql( + """ + INSERT INTO player_season_stats + (player_id, team_id, season, + pa, ab, r, hits, doubles, triples, hr, rbi, + bb, so_batter, hbp, sac, ibb, gidp, sb, cs) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (player_id, team_id, season) DO UPDATE SET + pa = player_season_stats.pa + EXCLUDED.pa, + ab = player_season_stats.ab + EXCLUDED.ab, + r = player_season_stats.r + EXCLUDED.r, + hits = player_season_stats.hits + EXCLUDED.hits, + doubles= player_season_stats.doubles+ EXCLUDED.doubles, + triples= player_season_stats.triples+ EXCLUDED.triples, + hr = player_season_stats.hr + EXCLUDED.hr, + rbi = player_season_stats.rbi + EXCLUDED.rbi, + bb = player_season_stats.bb + EXCLUDED.bb, + so_batter= player_season_stats.so_batter+ EXCLUDED.so_batter, + hbp = player_season_stats.hbp + EXCLUDED.hbp, + sac = player_season_stats.sac + EXCLUDED.sac, + ibb = player_season_stats.ibb + EXCLUDED.ibb, + gidp = player_season_stats.gidp + EXCLUDED.gidp, + sb = player_season_stats.sb + EXCLUDED.sb, + cs = player_season_stats.cs + EXCLUDED.cs + """, + ( + player_id, + team_id, + season, + pa, + ab, + r, + hits, + doubles, + triples, + hr, + rbi, + bb, + so, + hbp, + sac, + ibb, + gidp, + sb, + cs, + ), + ) + updated += 1 + + # --- Pitching --- + pit_rows = list( + db.execute_sql( + """ + SELECT c.player_id, ps.team_id, ps.season, + SUM(ps.ip), SUM(ps.so), SUM(ps.hit), SUM(ps.run), SUM(ps.erun), + SUM(ps.bb), SUM(ps.hbp), SUM(ps.wp), SUM(ps.balk), SUM(ps.hr), + SUM(ps.gs), SUM(ps.win), SUM(ps.loss), SUM(ps.hold), + SUM(ps.sv), SUM(ps.bsv) + FROM pitchingstat ps + JOIN card c ON ps.card_id = c.id + WHERE ps.game_id = %s + GROUP BY c.player_id, ps.team_id, ps.season + """, + (game_id,), + ) + ) + + for row in pit_rows: + ( + player_id, + team_id, + season, + ip, + so_pitcher, + h_allowed, + r_allowed, + er, + bb_p, + hbp_p, + wp, + balk, + hr_p, + gs, + w, + losses, + hold, + sv, + bsv, + ) = row + outs = _ip_to_outs(float(ip)) + db.execute_sql( + """ + INSERT INTO player_season_stats + (player_id, team_id, season, + outs, so_pitcher, h_allowed, r_allowed, er, + bb_p, hbp_p, wp, balk, hr_p, + gs, w, l, hold, sv, bsv) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (player_id, team_id, season) DO UPDATE SET + outs = player_season_stats.outs + EXCLUDED.outs, + so_pitcher= player_season_stats.so_pitcher+ EXCLUDED.so_pitcher, + h_allowed= player_season_stats.h_allowed+ EXCLUDED.h_allowed, + r_allowed= player_season_stats.r_allowed+ EXCLUDED.r_allowed, + er = player_season_stats.er + EXCLUDED.er, + bb_p = player_season_stats.bb_p + EXCLUDED.bb_p, + hbp_p = player_season_stats.hbp_p + EXCLUDED.hbp_p, + wp = player_season_stats.wp + EXCLUDED.wp, + balk = player_season_stats.balk + EXCLUDED.balk, + hr_p = player_season_stats.hr_p + EXCLUDED.hr_p, + gs = player_season_stats.gs + EXCLUDED.gs, + w = player_season_stats.w + EXCLUDED.w, + l = player_season_stats.l + EXCLUDED.l, + hold = player_season_stats.hold + EXCLUDED.hold, + sv = player_season_stats.sv + EXCLUDED.sv, + bsv = player_season_stats.bsv + EXCLUDED.bsv + """, + ( + player_id, + team_id, + season, + outs, + so_pitcher, + h_allowed, + r_allowed, + er, + bb_p, + hbp_p, + wp, + balk, + hr_p, + gs, + w, + losses, + hold, + sv, + bsv, + ), + ) + updated += 1 + + logging.info(f"update-game/{game_id}: updated {updated} player_season_stats rows") + return {"updated": updated} diff --git a/app/services/formula_engine.py b/app/services/formula_engine.py index 6178363..0c45287 100644 --- a/app/services/formula_engine.py +++ b/app/services/formula_engine.py @@ -5,8 +5,8 @@ plus helpers for formula dispatch and tier classification. Stats attributes expected by each formula: compute_batter_value: pa, hits, doubles, triples, hr - compute_sp_value: outs, k (k = pitcher strikeouts, from PlayerSeasonStats) - compute_rp_value: outs, k + compute_sp_value: outs, so_pitcher (pitcher strikeouts, from PlayerSeasonStats) + compute_rp_value: outs, so_pitcher """ from typing import Protocol @@ -22,7 +22,7 @@ class BatterStats(Protocol): class PitcherStats(Protocol): outs: int - k: int + so_pitcher: int # --------------------------------------------------------------------------- @@ -38,13 +38,13 @@ def compute_batter_value(stats) -> float: def compute_sp_value(stats) -> float: - """IP + K where IP = outs / 3. Uses stats.k (pitcher strikeouts).""" - return stats.outs / 3 + stats.k + """IP + K where IP = outs / 3. Uses stats.so_pitcher (pitcher strikeouts).""" + return stats.outs / 3 + stats.so_pitcher def compute_rp_value(stats) -> float: - """IP + K (same formula as SP; thresholds differ). Uses stats.k.""" - return stats.outs / 3 + stats.k + """IP + K (same formula as SP; thresholds differ). Uses stats.so_pitcher.""" + return stats.outs / 3 + stats.so_pitcher # --------------------------------------------------------------------------- diff --git a/tests/test_formula_engine.py b/tests/test_formula_engine.py index daed322..310c123 100644 --- a/tests/test_formula_engine.py +++ b/tests/test_formula_engine.py @@ -35,7 +35,7 @@ def batter_stats(**kwargs): def pitcher_stats(**kwargs): """Build a minimal pitcher stats object with all fields defaulting to 0.""" - defaults = {"outs": 0, "k": 0} + defaults = {"outs": 0, "so_pitcher": 0} defaults.update(kwargs) return SimpleNamespace(**defaults) @@ -84,7 +84,7 @@ def test_batter_formula_hr_heavy(): def test_sp_formula_standard(): """18 outs + 5 K: IP = 18/3 = 6.0, value = 6.0 + 5 = 11.0.""" - stats = pitcher_stats(outs=18, k=5) + stats = pitcher_stats(outs=18, so_pitcher=5) assert compute_sp_value(stats) == 11.0 @@ -95,7 +95,7 @@ def test_sp_formula_standard(): def test_rp_formula_standard(): """3 outs + 2 K: IP = 3/3 = 1.0, value = 1.0 + 2 = 3.0.""" - stats = pitcher_stats(outs=3, k=2) + stats = pitcher_stats(outs=3, so_pitcher=2) assert compute_rp_value(stats) == 3.0 @@ -132,13 +132,13 @@ def test_dispatch_batter(): def test_dispatch_sp(): """compute_value_for_track('sp', ...) delegates to compute_sp_value.""" - stats = pitcher_stats(outs=18, k=5) + stats = pitcher_stats(outs=18, so_pitcher=5) assert compute_value_for_track("sp", stats) == compute_sp_value(stats) def test_dispatch_rp(): """compute_value_for_track('rp', ...) delegates to compute_rp_value.""" - stats = pitcher_stats(outs=3, k=2) + stats = pitcher_stats(outs=3, so_pitcher=2) assert compute_value_for_track("rp", stats) == compute_rp_value(stats) diff --git a/tests/test_season_stats_model.py b/tests/test_season_stats_model.py index 20fc3b8..1387357 100644 --- a/tests/test_season_stats_model.py +++ b/tests/test_season_stats_model.py @@ -112,7 +112,7 @@ class TestColumnCompleteness: "triples", "bb", "hbp", - "so", + "so_batter", "rbi", "runs", "sb", @@ -121,7 +121,7 @@ class TestColumnCompleteness: PITCHING_COLS = [ "games_pitching", "outs", - "k", + "so_pitcher", "bb_allowed", "hits_allowed", "hr_allowed", @@ -181,14 +181,14 @@ class TestDefaultValues: "triples", "bb", "hbp", - "so", + "so_batter", "rbi", "runs", "sb", "cs", "games_pitching", "outs", - "k", + "so_pitcher", "bb_allowed", "hits_allowed", "hr_allowed", @@ -284,16 +284,16 @@ class TestDeltaUpdatePattern: assert updated.games_pitching == 0 # untouched def test_increment_pitching_stats(self): - """Updating outs and k increments without touching batting columns.""" + """Updating outs and so_pitcher increments without touching batting columns.""" rarity = make_rarity() cardset = make_cardset() player = make_player(cardset, rarity) team = make_team() - row = make_stats(player, team, season=10, outs=9, k=3) + row = make_stats(player, team, season=10, outs=9, so_pitcher=3) PlayerSeasonStats.update( outs=PlayerSeasonStats.outs + 6, - k=PlayerSeasonStats.k + 2, + so_pitcher=PlayerSeasonStats.so_pitcher + 2, ).where( (PlayerSeasonStats.player == player) & (PlayerSeasonStats.team == team) @@ -302,7 +302,7 @@ class TestDeltaUpdatePattern: updated = PlayerSeasonStats.get_by_id(row.id) assert updated.outs == 15 - assert updated.k == 5 + assert updated.so_pitcher == 5 assert updated.pa == 0 # untouched def test_last_game_fk_is_nullable(self):