paper-dynasty-database/app/routers_v2/season_stats.py
Cal Corum bd8e4578cc
Some checks failed
Build Docker Image / build (push) Has been cancelled
refactor: split PlayerSeasonStats into BattingSeasonStats and PitchingSeasonStats
Separate batting and pitching into distinct tables with descriptive column
names. Eliminates naming collisions (so/k ambiguity) and column mismatches
between the ORM model and raw SQL. Each table now covers all aggregatable
fields from its source (BattingStat/PitchingStat) including sac, ibb, gidp,
earned_runs, runs_allowed, wild_pitches, balks, and games_started.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 09:43:22 -05:00

233 lines
8.3 KiB
Python

"""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 batting_season_stats / pitching_season_stats
rows via an additive upsert.
"""
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 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 batting_season_stats and pitching_season_stats respectively.
Replaying the same game_id 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,
runs,
hits,
doubles,
triples,
hr,
rbi,
bb,
strikeouts,
hbp,
sac,
ibb,
gidp,
sb,
cs,
) = row
db.execute_sql(
"""
INSERT INTO batting_season_stats
(player_id, team_id, season,
pa, ab, runs, hits, doubles, triples, hr, rbi,
bb, strikeouts, 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 = batting_season_stats.pa + EXCLUDED.pa,
ab = batting_season_stats.ab + EXCLUDED.ab,
runs = batting_season_stats.runs + EXCLUDED.runs,
hits = batting_season_stats.hits + EXCLUDED.hits,
doubles = batting_season_stats.doubles + EXCLUDED.doubles,
triples = batting_season_stats.triples + EXCLUDED.triples,
hr = batting_season_stats.hr + EXCLUDED.hr,
rbi = batting_season_stats.rbi + EXCLUDED.rbi,
bb = batting_season_stats.bb + EXCLUDED.bb,
strikeouts= batting_season_stats.strikeouts+ EXCLUDED.strikeouts,
hbp = batting_season_stats.hbp + EXCLUDED.hbp,
sac = batting_season_stats.sac + EXCLUDED.sac,
ibb = batting_season_stats.ibb + EXCLUDED.ibb,
gidp = batting_season_stats.gidp + EXCLUDED.gidp,
sb = batting_season_stats.sb + EXCLUDED.sb,
cs = batting_season_stats.cs + EXCLUDED.cs
""",
(
player_id,
team_id,
season,
pa,
ab,
runs,
hits,
doubles,
triples,
hr,
rbi,
bb,
strikeouts,
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,
strikeouts,
hits_allowed,
runs_allowed,
earned_runs,
bb,
hbp,
wild_pitches,
balks,
hr_allowed,
games_started,
wins,
losses,
holds,
saves,
blown_saves,
) = row
outs = _ip_to_outs(float(ip))
db.execute_sql(
"""
INSERT INTO pitching_season_stats
(player_id, team_id, season,
outs, strikeouts, hits_allowed, runs_allowed, earned_runs,
bb, hbp, wild_pitches, balks, hr_allowed,
games_started, wins, losses, holds, saves, blown_saves)
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 = pitching_season_stats.outs + EXCLUDED.outs,
strikeouts = pitching_season_stats.strikeouts + EXCLUDED.strikeouts,
hits_allowed= pitching_season_stats.hits_allowed+ EXCLUDED.hits_allowed,
runs_allowed= pitching_season_stats.runs_allowed+ EXCLUDED.runs_allowed,
earned_runs = pitching_season_stats.earned_runs + EXCLUDED.earned_runs,
bb = pitching_season_stats.bb + EXCLUDED.bb,
hbp = pitching_season_stats.hbp + EXCLUDED.hbp,
wild_pitches= pitching_season_stats.wild_pitches+ EXCLUDED.wild_pitches,
balks = pitching_season_stats.balks + EXCLUDED.balks,
hr_allowed = pitching_season_stats.hr_allowed + EXCLUDED.hr_allowed,
games_started= pitching_season_stats.games_started+ EXCLUDED.games_started,
wins = pitching_season_stats.wins + EXCLUDED.wins,
losses = pitching_season_stats.losses + EXCLUDED.losses,
holds = pitching_season_stats.holds + EXCLUDED.holds,
saves = pitching_season_stats.saves + EXCLUDED.saves,
blown_saves = pitching_season_stats.blown_saves + EXCLUDED.blown_saves
""",
(
player_id,
team_id,
season,
outs,
strikeouts,
hits_allowed,
runs_allowed,
earned_runs,
bb,
hbp,
wild_pitches,
balks,
hr_allowed,
games_started,
wins,
losses,
holds,
saves,
blown_saves,
),
)
updated += 1
logging.info(f"update-game/{game_id}: updated {updated} season stats rows")
return {"updated": updated}