refactor: rename PlayerSeasonStats so to so_batter and k to so_pitcher
All checks were successful
Build Docker Image / build (push) Successful in 8m41s

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) <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-03-17 09:31:52 -05:00
parent 6d972114b7
commit 4ed62dea2c
5 changed files with 254 additions and 24 deletions

View File

@ -1065,7 +1065,7 @@ class PlayerSeasonStats(BaseModel):
triples = IntegerField(default=0) triples = IntegerField(default=0)
bb = IntegerField(default=0) bb = IntegerField(default=0)
hbp = IntegerField(default=0) hbp = IntegerField(default=0)
so = IntegerField(default=0) so_batter = IntegerField(default=0)
rbi = IntegerField(default=0) rbi = IntegerField(default=0)
runs = IntegerField(default=0) runs = IntegerField(default=0)
sb = IntegerField(default=0) sb = IntegerField(default=0)
@ -1074,9 +1074,7 @@ class PlayerSeasonStats(BaseModel):
# Pitching stats # Pitching stats
games_pitching = IntegerField(default=0) games_pitching = IntegerField(default=0)
outs = IntegerField(default=0) outs = IntegerField(default=0)
k = IntegerField( so_pitcher = IntegerField(default=0)
default=0
) # pitcher Ks; spec names this "so (K)" but renamed to avoid collision with batting so
bb_allowed = IntegerField(default=0) bb_allowed = IntegerField(default=0)
hits_allowed = IntegerField(default=0) hits_allowed = IntegerField(default=0)
hr_allowed = IntegerField(default=0) hr_allowed = IntegerField(default=0)

View File

@ -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}

View File

@ -5,8 +5,8 @@ plus helpers for formula dispatch and tier classification.
Stats attributes expected by each formula: Stats attributes expected by each formula:
compute_batter_value: pa, hits, doubles, triples, hr compute_batter_value: pa, hits, doubles, triples, hr
compute_sp_value: outs, k (k = pitcher strikeouts, from PlayerSeasonStats) compute_sp_value: outs, so_pitcher (pitcher strikeouts, from PlayerSeasonStats)
compute_rp_value: outs, k compute_rp_value: outs, so_pitcher
""" """
from typing import Protocol from typing import Protocol
@ -22,7 +22,7 @@ class BatterStats(Protocol):
class PitcherStats(Protocol): class PitcherStats(Protocol):
outs: int outs: int
k: int so_pitcher: int
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -38,13 +38,13 @@ def compute_batter_value(stats) -> float:
def compute_sp_value(stats) -> float: def compute_sp_value(stats) -> float:
"""IP + K where IP = outs / 3. Uses stats.k (pitcher strikeouts).""" """IP + K where IP = outs / 3. Uses stats.so_pitcher (pitcher strikeouts)."""
return stats.outs / 3 + stats.k return stats.outs / 3 + stats.so_pitcher
def compute_rp_value(stats) -> float: def compute_rp_value(stats) -> float:
"""IP + K (same formula as SP; thresholds differ). Uses stats.k.""" """IP + K (same formula as SP; thresholds differ). Uses stats.so_pitcher."""
return stats.outs / 3 + stats.k return stats.outs / 3 + stats.so_pitcher
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@ -35,7 +35,7 @@ def batter_stats(**kwargs):
def pitcher_stats(**kwargs): def pitcher_stats(**kwargs):
"""Build a minimal pitcher stats object with all fields defaulting to 0.""" """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) defaults.update(kwargs)
return SimpleNamespace(**defaults) return SimpleNamespace(**defaults)
@ -84,7 +84,7 @@ def test_batter_formula_hr_heavy():
def test_sp_formula_standard(): def test_sp_formula_standard():
"""18 outs + 5 K: IP = 18/3 = 6.0, value = 6.0 + 5 = 11.0.""" """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 assert compute_sp_value(stats) == 11.0
@ -95,7 +95,7 @@ def test_sp_formula_standard():
def test_rp_formula_standard(): def test_rp_formula_standard():
"""3 outs + 2 K: IP = 3/3 = 1.0, value = 1.0 + 2 = 3.0.""" """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 assert compute_rp_value(stats) == 3.0
@ -132,13 +132,13 @@ def test_dispatch_batter():
def test_dispatch_sp(): def test_dispatch_sp():
"""compute_value_for_track('sp', ...) delegates to compute_sp_value.""" """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) assert compute_value_for_track("sp", stats) == compute_sp_value(stats)
def test_dispatch_rp(): def test_dispatch_rp():
"""compute_value_for_track('rp', ...) delegates to compute_rp_value.""" """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) assert compute_value_for_track("rp", stats) == compute_rp_value(stats)

View File

@ -112,7 +112,7 @@ class TestColumnCompleteness:
"triples", "triples",
"bb", "bb",
"hbp", "hbp",
"so", "so_batter",
"rbi", "rbi",
"runs", "runs",
"sb", "sb",
@ -121,7 +121,7 @@ class TestColumnCompleteness:
PITCHING_COLS = [ PITCHING_COLS = [
"games_pitching", "games_pitching",
"outs", "outs",
"k", "so_pitcher",
"bb_allowed", "bb_allowed",
"hits_allowed", "hits_allowed",
"hr_allowed", "hr_allowed",
@ -181,14 +181,14 @@ class TestDefaultValues:
"triples", "triples",
"bb", "bb",
"hbp", "hbp",
"so", "so_batter",
"rbi", "rbi",
"runs", "runs",
"sb", "sb",
"cs", "cs",
"games_pitching", "games_pitching",
"outs", "outs",
"k", "so_pitcher",
"bb_allowed", "bb_allowed",
"hits_allowed", "hits_allowed",
"hr_allowed", "hr_allowed",
@ -284,16 +284,16 @@ class TestDeltaUpdatePattern:
assert updated.games_pitching == 0 # untouched assert updated.games_pitching == 0 # untouched
def test_increment_pitching_stats(self): 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() rarity = make_rarity()
cardset = make_cardset() cardset = make_cardset()
player = make_player(cardset, rarity) player = make_player(cardset, rarity)
team = make_team() 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( PlayerSeasonStats.update(
outs=PlayerSeasonStats.outs + 6, outs=PlayerSeasonStats.outs + 6,
k=PlayerSeasonStats.k + 2, so_pitcher=PlayerSeasonStats.so_pitcher + 2,
).where( ).where(
(PlayerSeasonStats.player == player) (PlayerSeasonStats.player == player)
& (PlayerSeasonStats.team == team) & (PlayerSeasonStats.team == team)
@ -302,7 +302,7 @@ class TestDeltaUpdatePattern:
updated = PlayerSeasonStats.get_by_id(row.id) updated = PlayerSeasonStats.get_by_id(row.id)
assert updated.outs == 15 assert updated.outs == 15
assert updated.k == 5 assert updated.so_pitcher == 5
assert updated.pa == 0 # untouched assert updated.pa == 0 # untouched
def test_last_game_fk_is_nullable(self): def test_last_game_fk_is_nullable(self):