From 0c76cd924a0f09b83b34a0823efc9f6776c13928 Mon Sep 17 00:00:00 2001 From: Carter Grove Date: Sun, 1 Feb 2026 02:05:48 +0000 Subject: [PATCH 1/7] Add WAR and advanced sabermetric statistics feature (#94) Backend: - Update DTOs to expose all advanced stats (WAR, wOBA, FIP, Statcast) - Add 11 new leaderboard endpoints: - Batting: WAR, wOBA, wRC+, exit velocity, barrel% - Pitching: WAR, FIP, xFIP, xERA, whiff% - Add repository queries for advanced stat leaders Frontend: - Update TypeScript types for 27 new stat fields - Add Advanced Analytics section to PlayerStats component - Display batting: WAR, wOBA, wRC+, xBA, xSLG, xwOBA, K%, BB%, exit velocity, launch angle, hard hit%, barrel%, sprint speed - Display pitching: WAR, FIP, xFIP, SIERA, xERA, K%, BB%, GB%, FB%, whiff%, chase%, exit velocity against, hard hit% against, spin rate - Add tooltips to StatCard for stat explanations - Only show Advanced Analytics section when data is available Co-Authored-By: Claude Opus 4.5 --- .../api/controller/PlayerController.java | 90 ++++++++++++++ .../com/mlbstats/api/dto/BattingStatsDto.java | 31 ++++- .../mlbstats/api/dto/PitchingStatsDto.java | 33 +++++- .../api/service/PlayerApiService.java | 112 ++++++++++++++++++ .../stats/PlayerBattingStatsRepository.java | 16 +++ .../stats/PlayerPitchingStatsRepository.java | 16 +++ frontend/src/components/common/StatCard.tsx | 10 +- .../src/components/player/PlayerStats.tsx | 67 ++++++++++- frontend/src/index.css | 18 +++ frontend/src/types/stats.ts | 29 +++++ 10 files changed, 414 insertions(+), 8 deletions(-) diff --git a/backend/src/main/java/com/mlbstats/api/controller/PlayerController.java b/backend/src/main/java/com/mlbstats/api/controller/PlayerController.java index 50df951..1a41e16 100644 --- a/backend/src/main/java/com/mlbstats/api/controller/PlayerController.java +++ b/backend/src/main/java/com/mlbstats/api/controller/PlayerController.java @@ -170,6 +170,96 @@ public ResponseEntity> getWhipLeaders( return ResponseEntity.ok(playerApiService.getTopWhip(season, minInnings, limit)); } + // Advanced Sabermetric Leaderboards + + @GetMapping("/leaders/war/batting") + @Operation(summary = "Get batting WAR leaders", description = "Returns top position players by WAR for a season") + public ResponseEntity> getBattingWarLeaders( + @RequestParam(required = false) Integer season, + @RequestParam(defaultValue = "10") int limit) { + return ResponseEntity.ok(playerApiService.getTopBattingWar(season, limit)); + } + + @GetMapping("/leaders/woba") + @Operation(summary = "Get wOBA leaders", description = "Returns top players by weighted on-base average for a season") + public ResponseEntity> getWobaLeaders( + @RequestParam(required = false) Integer season, + @RequestParam(defaultValue = "200") int minPa, + @RequestParam(defaultValue = "10") int limit) { + return ResponseEntity.ok(playerApiService.getTopWoba(season, minPa, limit)); + } + + @GetMapping("/leaders/wrc-plus") + @Operation(summary = "Get wRC+ leaders", description = "Returns top players by weighted runs created plus for a season") + public ResponseEntity> getWrcPlusLeaders( + @RequestParam(required = false) Integer season, + @RequestParam(defaultValue = "200") int minPa, + @RequestParam(defaultValue = "10") int limit) { + return ResponseEntity.ok(playerApiService.getTopWrcPlus(season, minPa, limit)); + } + + @GetMapping("/leaders/exit-velocity") + @Operation(summary = "Get exit velocity leaders", description = "Returns top players by average exit velocity for a season") + public ResponseEntity> getExitVelocityLeaders( + @RequestParam(required = false) Integer season, + @RequestParam(defaultValue = "100") int minPa, + @RequestParam(defaultValue = "10") int limit) { + return ResponseEntity.ok(playerApiService.getTopExitVelocity(season, minPa, limit)); + } + + @GetMapping("/leaders/barrel-pct") + @Operation(summary = "Get barrel percentage leaders", description = "Returns top players by barrel percentage for a season") + public ResponseEntity> getBarrelPctLeaders( + @RequestParam(required = false) Integer season, + @RequestParam(defaultValue = "100") int minPa, + @RequestParam(defaultValue = "10") int limit) { + return ResponseEntity.ok(playerApiService.getTopBarrelPct(season, minPa, limit)); + } + + @GetMapping("/leaders/war/pitching") + @Operation(summary = "Get pitching WAR leaders", description = "Returns top pitchers by WAR for a season") + public ResponseEntity> getPitchingWarLeaders( + @RequestParam(required = false) Integer season, + @RequestParam(defaultValue = "10") int limit) { + return ResponseEntity.ok(playerApiService.getTopPitchingWar(season, limit)); + } + + @GetMapping("/leaders/fip") + @Operation(summary = "Get FIP leaders", description = "Returns top pitchers by fielding independent pitching for a season") + public ResponseEntity> getFipLeaders( + @RequestParam(required = false) Integer season, + @RequestParam(defaultValue = "50") java.math.BigDecimal minInnings, + @RequestParam(defaultValue = "10") int limit) { + return ResponseEntity.ok(playerApiService.getTopFip(season, minInnings, limit)); + } + + @GetMapping("/leaders/xfip") + @Operation(summary = "Get xFIP leaders", description = "Returns top pitchers by expected fielding independent pitching for a season") + public ResponseEntity> getXfipLeaders( + @RequestParam(required = false) Integer season, + @RequestParam(defaultValue = "50") java.math.BigDecimal minInnings, + @RequestParam(defaultValue = "10") int limit) { + return ResponseEntity.ok(playerApiService.getTopXfip(season, minInnings, limit)); + } + + @GetMapping("/leaders/xera") + @Operation(summary = "Get xERA leaders", description = "Returns top pitchers by expected ERA for a season") + public ResponseEntity> getXeraLeaders( + @RequestParam(required = false) Integer season, + @RequestParam(defaultValue = "50") java.math.BigDecimal minInnings, + @RequestParam(defaultValue = "10") int limit) { + return ResponseEntity.ok(playerApiService.getTopXera(season, minInnings, limit)); + } + + @GetMapping("/leaders/whiff-pct") + @Operation(summary = "Get whiff percentage leaders", description = "Returns top pitchers by whiff percentage for a season") + public ResponseEntity> getWhiffPctLeaders( + @RequestParam(required = false) Integer season, + @RequestParam(defaultValue = "50") java.math.BigDecimal minInnings, + @RequestParam(defaultValue = "10") int limit) { + return ResponseEntity.ok(playerApiService.getTopWhiffPct(season, minInnings, limit)); + } + @GetMapping("/{id}/batting-game-log") @Operation(summary = "Get player batting game log", description = "Returns game-by-game batting stats for a player") public ResponseEntity> getPlayerBattingGameLog( diff --git a/backend/src/main/java/com/mlbstats/api/dto/BattingStatsDto.java b/backend/src/main/java/com/mlbstats/api/dto/BattingStatsDto.java index 884d583..562d114 100644 --- a/backend/src/main/java/com/mlbstats/api/dto/BattingStatsDto.java +++ b/backend/src/main/java/com/mlbstats/api/dto/BattingStatsDto.java @@ -30,7 +30,21 @@ public record BattingStatsDto( BigDecimal iso, Integer plateAppearances, Integer totalBases, - Integer extraBaseHits + Integer extraBaseHits, + // Advanced Sabermetric Stats + BigDecimal war, + BigDecimal woba, + Integer wrcPlus, + BigDecimal hardHitPct, + BigDecimal barrelPct, + BigDecimal avgExitVelocity, + BigDecimal avgLaunchAngle, + BigDecimal sprintSpeed, + BigDecimal xba, + BigDecimal xslg, + BigDecimal xwoba, + BigDecimal kPct, + BigDecimal bbPct ) { public static BattingStatsDto fromEntity(PlayerBattingStats stats) { return new BattingStatsDto( @@ -59,7 +73,20 @@ public static BattingStatsDto fromEntity(PlayerBattingStats stats) { stats.getIso(), stats.getPlateAppearances(), stats.getTotalBases(), - stats.getExtraBaseHits() + stats.getExtraBaseHits(), + stats.getWar(), + stats.getWoba(), + stats.getWrcPlus(), + stats.getHardHitPct(), + stats.getBarrelPct(), + stats.getAvgExitVelocity(), + stats.getAvgLaunchAngle(), + stats.getSprintSpeed(), + stats.getXba(), + stats.getXslg(), + stats.getXwoba(), + stats.getKPct(), + stats.getBbPct() ); } } diff --git a/backend/src/main/java/com/mlbstats/api/dto/PitchingStatsDto.java b/backend/src/main/java/com/mlbstats/api/dto/PitchingStatsDto.java index b7c3773..58f7ac4 100644 --- a/backend/src/main/java/com/mlbstats/api/dto/PitchingStatsDto.java +++ b/backend/src/main/java/com/mlbstats/api/dto/PitchingStatsDto.java @@ -29,7 +29,22 @@ public record PitchingStatsDto( BigDecimal bbPer9, BigDecimal hPer9, Integer completeGames, - Integer shutouts + Integer shutouts, + // Advanced Sabermetric Stats + BigDecimal war, + BigDecimal fip, + BigDecimal xfip, + BigDecimal siera, + BigDecimal kPct, + BigDecimal bbPct, + BigDecimal gbPct, + BigDecimal fbPct, + BigDecimal hardHitPctAgainst, + BigDecimal avgExitVelocityAgainst, + BigDecimal xera, + Integer avgSpinRate, + BigDecimal whiffPct, + BigDecimal chasePct ) { public static PitchingStatsDto fromEntity(PlayerPitchingStats stats) { return new PitchingStatsDto( @@ -57,7 +72,21 @@ public static PitchingStatsDto fromEntity(PlayerPitchingStats stats) { stats.getBbPer9(), stats.getHPer9(), stats.getCompleteGames(), - stats.getShutouts() + stats.getShutouts(), + stats.getWar(), + stats.getFip(), + stats.getXfip(), + stats.getSiera(), + stats.getKPct(), + stats.getBbPct(), + stats.getGbPct(), + stats.getFbPct(), + stats.getHardHitPctAgainst(), + stats.getAvgExitVelocityAgainst(), + stats.getXera(), + stats.getAvgSpinRate(), + stats.getWhiffPct(), + stats.getChasePct() ); } } diff --git a/backend/src/main/java/com/mlbstats/api/service/PlayerApiService.java b/backend/src/main/java/com/mlbstats/api/service/PlayerApiService.java index 44c0a01..ac4dfbf 100644 --- a/backend/src/main/java/com/mlbstats/api/service/PlayerApiService.java +++ b/backend/src/main/java/com/mlbstats/api/service/PlayerApiService.java @@ -479,4 +479,116 @@ private java.math.BigDecimal getPitchingStatValue(PlayerComparisonDto.Comparison default -> null; }; } + + // Advanced Stats Leaderboards + + @Cacheable(value = CacheConfig.LEADERBOARDS, key = "'batting_war_' + #season + '_' + #limit") + public List getTopBattingWar(Integer season, int limit) { + if (season == null) { + season = DateUtils.getCurrentSeason(); + } + return battingStatsRepository.findTopWar(season).stream() + .limit(limit) + .map(BattingStatsDto::fromEntity) + .toList(); + } + + @Cacheable(value = CacheConfig.LEADERBOARDS, key = "'woba_' + #season + '_' + #minPa + '_' + #limit") + public List getTopWoba(Integer season, int minPa, int limit) { + if (season == null) { + season = DateUtils.getCurrentSeason(); + } + return battingStatsRepository.findTopWoba(season, minPa).stream() + .limit(limit) + .map(BattingStatsDto::fromEntity) + .toList(); + } + + @Cacheable(value = CacheConfig.LEADERBOARDS, key = "'wrcplus_' + #season + '_' + #minPa + '_' + #limit") + public List getTopWrcPlus(Integer season, int minPa, int limit) { + if (season == null) { + season = DateUtils.getCurrentSeason(); + } + return battingStatsRepository.findTopWrcPlus(season, minPa).stream() + .limit(limit) + .map(BattingStatsDto::fromEntity) + .toList(); + } + + @Cacheable(value = CacheConfig.LEADERBOARDS, key = "'exitvelo_' + #season + '_' + #minPa + '_' + #limit") + public List getTopExitVelocity(Integer season, int minPa, int limit) { + if (season == null) { + season = DateUtils.getCurrentSeason(); + } + return battingStatsRepository.findTopExitVelocity(season, minPa).stream() + .limit(limit) + .map(BattingStatsDto::fromEntity) + .toList(); + } + + @Cacheable(value = CacheConfig.LEADERBOARDS, key = "'barrel_' + #season + '_' + #minPa + '_' + #limit") + public List getTopBarrelPct(Integer season, int minPa, int limit) { + if (season == null) { + season = DateUtils.getCurrentSeason(); + } + return battingStatsRepository.findTopBarrelPct(season, minPa).stream() + .limit(limit) + .map(BattingStatsDto::fromEntity) + .toList(); + } + + @Cacheable(value = CacheConfig.LEADERBOARDS, key = "'pitching_war_' + #season + '_' + #limit") + public List getTopPitchingWar(Integer season, int limit) { + if (season == null) { + season = DateUtils.getCurrentSeason(); + } + return pitchingStatsRepository.findTopWar(season).stream() + .limit(limit) + .map(PitchingStatsDto::fromEntity) + .toList(); + } + + @Cacheable(value = CacheConfig.LEADERBOARDS, key = "'fip_' + #season + '_' + #minInnings + '_' + #limit") + public List getTopFip(Integer season, java.math.BigDecimal minInnings, int limit) { + if (season == null) { + season = DateUtils.getCurrentSeason(); + } + return pitchingStatsRepository.findTopFip(season, minInnings).stream() + .limit(limit) + .map(PitchingStatsDto::fromEntity) + .toList(); + } + + @Cacheable(value = CacheConfig.LEADERBOARDS, key = "'xfip_' + #season + '_' + #minInnings + '_' + #limit") + public List getTopXfip(Integer season, java.math.BigDecimal minInnings, int limit) { + if (season == null) { + season = DateUtils.getCurrentSeason(); + } + return pitchingStatsRepository.findTopXfip(season, minInnings).stream() + .limit(limit) + .map(PitchingStatsDto::fromEntity) + .toList(); + } + + @Cacheable(value = CacheConfig.LEADERBOARDS, key = "'xera_' + #season + '_' + #minInnings + '_' + #limit") + public List getTopXera(Integer season, java.math.BigDecimal minInnings, int limit) { + if (season == null) { + season = DateUtils.getCurrentSeason(); + } + return pitchingStatsRepository.findTopXera(season, minInnings).stream() + .limit(limit) + .map(PitchingStatsDto::fromEntity) + .toList(); + } + + @Cacheable(value = CacheConfig.LEADERBOARDS, key = "'whiff_' + #season + '_' + #minInnings + '_' + #limit") + public List getTopWhiffPct(Integer season, java.math.BigDecimal minInnings, int limit) { + if (season == null) { + season = DateUtils.getCurrentSeason(); + } + return pitchingStatsRepository.findTopWhiffPct(season, minInnings).stream() + .limit(limit) + .map(PitchingStatsDto::fromEntity) + .toList(); + } } diff --git a/backend/src/main/java/com/mlbstats/domain/stats/PlayerBattingStatsRepository.java b/backend/src/main/java/com/mlbstats/domain/stats/PlayerBattingStatsRepository.java index e36d490..96385e5 100644 --- a/backend/src/main/java/com/mlbstats/domain/stats/PlayerBattingStatsRepository.java +++ b/backend/src/main/java/com/mlbstats/domain/stats/PlayerBattingStatsRepository.java @@ -56,4 +56,20 @@ Optional findByPlayerIdAndTeamIdAndSeasonAndGameType( @Query("SELECT pbs FROM PlayerBattingStats pbs JOIN FETCH pbs.player JOIN FETCH pbs.team WHERE pbs.player.id IN :playerIds AND pbs.season = :season AND pbs.gameType = 'R'") List findByPlayerIdsAndSeason(@Param("playerIds") List playerIds, @Param("season") Integer season); + + // Advanced Stats Leaderboards + @Query("SELECT pbs FROM PlayerBattingStats pbs JOIN FETCH pbs.player JOIN FETCH pbs.team WHERE pbs.season = :season AND pbs.war IS NOT NULL ORDER BY pbs.war DESC") + List findTopWar(@Param("season") Integer season); + + @Query("SELECT pbs FROM PlayerBattingStats pbs JOIN FETCH pbs.player JOIN FETCH pbs.team WHERE pbs.season = :season AND pbs.plateAppearances >= :minPa AND pbs.woba IS NOT NULL ORDER BY pbs.woba DESC") + List findTopWoba(@Param("season") Integer season, @Param("minPa") Integer minPa); + + @Query("SELECT pbs FROM PlayerBattingStats pbs JOIN FETCH pbs.player JOIN FETCH pbs.team WHERE pbs.season = :season AND pbs.plateAppearances >= :minPa AND pbs.wrcPlus IS NOT NULL ORDER BY pbs.wrcPlus DESC") + List findTopWrcPlus(@Param("season") Integer season, @Param("minPa") Integer minPa); + + @Query("SELECT pbs FROM PlayerBattingStats pbs JOIN FETCH pbs.player JOIN FETCH pbs.team WHERE pbs.season = :season AND pbs.plateAppearances >= :minPa AND pbs.avgExitVelocity IS NOT NULL ORDER BY pbs.avgExitVelocity DESC") + List findTopExitVelocity(@Param("season") Integer season, @Param("minPa") Integer minPa); + + @Query("SELECT pbs FROM PlayerBattingStats pbs JOIN FETCH pbs.player JOIN FETCH pbs.team WHERE pbs.season = :season AND pbs.plateAppearances >= :minPa AND pbs.barrelPct IS NOT NULL ORDER BY pbs.barrelPct DESC") + List findTopBarrelPct(@Param("season") Integer season, @Param("minPa") Integer minPa); } diff --git a/backend/src/main/java/com/mlbstats/domain/stats/PlayerPitchingStatsRepository.java b/backend/src/main/java/com/mlbstats/domain/stats/PlayerPitchingStatsRepository.java index e2826ca..afd1fc5 100644 --- a/backend/src/main/java/com/mlbstats/domain/stats/PlayerPitchingStatsRepository.java +++ b/backend/src/main/java/com/mlbstats/domain/stats/PlayerPitchingStatsRepository.java @@ -51,4 +51,20 @@ Optional findByPlayerIdAndTeamIdAndSeasonAndGameType( @Query("SELECT pps FROM PlayerPitchingStats pps JOIN FETCH pps.player JOIN FETCH pps.team WHERE pps.player.id IN :playerIds AND pps.season = :season AND pps.gameType = 'R'") List findByPlayerIdsAndSeason(@Param("playerIds") List playerIds, @Param("season") Integer season); + + // Advanced Stats Leaderboards + @Query("SELECT pps FROM PlayerPitchingStats pps JOIN FETCH pps.player JOIN FETCH pps.team WHERE pps.season = :season AND pps.war IS NOT NULL ORDER BY pps.war DESC") + List findTopWar(@Param("season") Integer season); + + @Query("SELECT pps FROM PlayerPitchingStats pps JOIN FETCH pps.player JOIN FETCH pps.team WHERE pps.season = :season AND pps.inningsPitched >= :minInnings AND pps.fip IS NOT NULL ORDER BY pps.fip ASC") + List findTopFip(@Param("season") Integer season, @Param("minInnings") BigDecimal minInnings); + + @Query("SELECT pps FROM PlayerPitchingStats pps JOIN FETCH pps.player JOIN FETCH pps.team WHERE pps.season = :season AND pps.inningsPitched >= :minInnings AND pps.xfip IS NOT NULL ORDER BY pps.xfip ASC") + List findTopXfip(@Param("season") Integer season, @Param("minInnings") BigDecimal minInnings); + + @Query("SELECT pps FROM PlayerPitchingStats pps JOIN FETCH pps.player JOIN FETCH pps.team WHERE pps.season = :season AND pps.inningsPitched >= :minInnings AND pps.xera IS NOT NULL ORDER BY pps.xera ASC") + List findTopXera(@Param("season") Integer season, @Param("minInnings") BigDecimal minInnings); + + @Query("SELECT pps FROM PlayerPitchingStats pps JOIN FETCH pps.player JOIN FETCH pps.team WHERE pps.season = :season AND pps.inningsPitched >= :minInnings AND pps.whiffPct IS NOT NULL ORDER BY pps.whiffPct DESC") + List findTopWhiffPct(@Param("season") Integer season, @Param("minInnings") BigDecimal minInnings); } diff --git a/frontend/src/components/common/StatCard.tsx b/frontend/src/components/common/StatCard.tsx index 770215b..0739a52 100644 --- a/frontend/src/components/common/StatCard.tsx +++ b/frontend/src/components/common/StatCard.tsx @@ -1,13 +1,17 @@ interface StatCardProps { value: string | number; label: string; + tooltip?: string; } -function StatCard({ value, label }: StatCardProps) { +function StatCard({ value, label, tooltip }: StatCardProps) { return ( -
+
{value}
-
{label}
+
+ {label} + {tooltip && ?} +
); } diff --git a/frontend/src/components/player/PlayerStats.tsx b/frontend/src/components/player/PlayerStats.tsx index 9b5050d..ab7cd8d 100644 --- a/frontend/src/components/player/PlayerStats.tsx +++ b/frontend/src/components/player/PlayerStats.tsx @@ -21,6 +21,26 @@ function formatRate(value: number | null): string { return value.toFixed(1); } +function formatPct(value: number | null): string { + if (value === null || value === undefined) return '--%'; + return `${value.toFixed(1)}%`; +} + +function formatWar(value: number | null): string { + if (value === null || value === undefined) return '--'; + return value.toFixed(1); +} + +function hasAdvancedBattingStats(stats: BattingStats): boolean { + return stats.war != null || stats.woba != null || stats.wrcPlus != null || + stats.avgExitVelocity != null || stats.barrelPct != null; +} + +function hasAdvancedPitchingStats(stats: PitchingStats): boolean { + return stats.war != null || stats.fip != null || stats.xfip != null || + stats.whiffPct != null || stats.xera != null; +} + function PlayerStats({ battingStats, pitchingStats }: PlayerStatsProps) { const latestBatting = battingStats?.[0]; const latestPitching = pitchingStats?.[0]; @@ -62,6 +82,28 @@ function PlayerStats({ battingStats, pitchingStats }: PlayerStatsProps) {
+ + {/* Advanced Sabermetric Stats */} + {hasAdvancedBattingStats(latestBatting) && ( + <> +

Advanced Analytics

+
+ + + + + + + + + + + + + +
+ + )} )} @@ -86,7 +128,7 @@ function PlayerStats({ battingStats, pitchingStats }: PlayerStatsProps) { {/* Additional Stats */}

Additional Stats

-
+
@@ -99,6 +141,29 @@ function PlayerStats({ battingStats, pitchingStats }: PlayerStatsProps) {
+ + {/* Advanced Sabermetric Stats */} + {hasAdvancedPitchingStats(latestPitching) && ( + <> +

Advanced Analytics

+
+ + + + + + + + + + + + + + +
+ + )} )} diff --git a/frontend/src/index.css b/frontend/src/index.css index c9bde90..c99520c 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -157,6 +157,24 @@ body { letter-spacing: 0.5px; } +.stat-card .stat-info-icon { + display: inline-block; + width: 14px; + height: 14px; + line-height: 14px; + font-size: 10px; + background-color: var(--text-light); + color: var(--card-background); + border-radius: 50%; + margin-left: 4px; + cursor: help; + vertical-align: middle; +} + +.stat-card[title]:hover { + cursor: help; +} + /* Grids */ .grid { display: grid; diff --git a/frontend/src/types/stats.ts b/frontend/src/types/stats.ts index 37bc9ea..891ab27 100644 --- a/frontend/src/types/stats.ts +++ b/frontend/src/types/stats.ts @@ -28,6 +28,20 @@ export interface BattingStats { plateAppearances: number; totalBases: number; extraBaseHits: number; + // Advanced Sabermetric Stats + war: number | null; + woba: number | null; + wrcPlus: number | null; + hardHitPct: number | null; + barrelPct: number | null; + avgExitVelocity: number | null; + avgLaunchAngle: number | null; + sprintSpeed: number | null; + xba: number | null; + xslg: number | null; + xwoba: number | null; + kPct: number | null; + bbPct: number | null; } export interface PitchingStats { @@ -56,6 +70,21 @@ export interface PitchingStats { hPer9: number; completeGames: number; shutouts: number; + // Advanced Sabermetric Stats + war: number | null; + fip: number | null; + xfip: number | null; + siera: number | null; + kPct: number | null; + bbPct: number | null; + gbPct: number | null; + fbPct: number | null; + hardHitPctAgainst: number | null; + avgExitVelocityAgainst: number | null; + xera: number | null; + avgSpinRate: number | null; + whiffPct: number | null; + chasePct: number | null; } export interface PageResponse { From 91e7366a8f2af908c558219310d22921aaea8bd6 Mon Sep 17 00:00:00 2001 From: Carter Grove Date: Fri, 6 Feb 2026 03:46:14 +0000 Subject: [PATCH 2/7] Add gWAR (Grove WAR) calculation system with Baseball Savant integration Implements a transparent, simplified WAR metric with documented methodology: - gWAR calculation from batting (wRAA), baserunning (wSB), fielding (OAA), positional adjustment, and replacement level components - MLB Stats API integration for official WAR, wOBA, FIP sabermetrics - Baseball Savant integration for OAA, expected stats (xBA, xSLG, xwOBA), exit velocity, barrel%, and sprint speed - API endpoints for gWAR leaderboards and player breakdown - Scheduled jobs for daily sabermetrics sync and weekly Statcast sync Co-Authored-By: Claude Opus 4.5 --- .../api/controller/PlayerController.java | 36 ++ .../com/mlbstats/api/dto/BattingStatsDto.java | 19 +- .../mlbstats/api/dto/GwarBreakdownDto.java | 79 +++++ .../mlbstats/api/dto/PitchingStatsDto.java | 11 +- .../api/service/PlayerApiService.java | 88 +++++ .../common/config/RestClientConfig.java | 13 + .../domain/constants/LeagueConstants.java | 66 ++++ .../constants/LeagueConstantsRepository.java | 12 + .../domain/gwar/GwarCalculationService.java | 282 ++++++++++++++++ .../mlbstats/domain/gwar/GwarComponents.java | 38 +++ .../domain/stats/PlayerBattingStats.java | 25 ++ .../stats/PlayerBattingStatsRepository.java | 7 + .../domain/stats/PlayerPitchingStats.java | 10 + .../stats/PlayerPitchingStatsRepository.java | 4 + .../client/BaseballSavantClient.java | 314 ++++++++++++++++++ .../ingestion/client/MlbApiClient.java | 64 ++++ .../client/dto/ExpectedStatsResponse.java | 50 +++ .../client/dto/SabermetricsResponse.java | 84 +++++ .../client/dto/SeasonAdvancedResponse.java | 79 +++++ .../ingestion/mapper/StatsMapper.java | 112 +++++++ .../scheduler/IngestionScheduler.java | 69 ++++ .../service/OaaIngestionService.java | 90 +++++ .../service/SabermetricsIngestionService.java | 212 ++++++++++++ .../service/StatcastIngestionService.java | 111 +++++++ .../migration/V14__gwar_and_sabermetrics.sql | 69 ++++ .../gwar/GwarCalculationServiceTest.java | 258 ++++++++++++++ .../ingestion/mapper/StatsMapperTest.java | 172 ++++++++++ .../SabermetricsIngestionServiceTest.java | 200 +++++++++++ docs/GWAR_METHODOLOGY.md | 263 +++++++++++++++ 29 files changed, 2833 insertions(+), 4 deletions(-) create mode 100644 backend/src/main/java/com/mlbstats/api/dto/GwarBreakdownDto.java create mode 100644 backend/src/main/java/com/mlbstats/domain/constants/LeagueConstants.java create mode 100644 backend/src/main/java/com/mlbstats/domain/constants/LeagueConstantsRepository.java create mode 100644 backend/src/main/java/com/mlbstats/domain/gwar/GwarCalculationService.java create mode 100644 backend/src/main/java/com/mlbstats/domain/gwar/GwarComponents.java create mode 100644 backend/src/main/java/com/mlbstats/ingestion/client/BaseballSavantClient.java create mode 100644 backend/src/main/java/com/mlbstats/ingestion/client/dto/ExpectedStatsResponse.java create mode 100644 backend/src/main/java/com/mlbstats/ingestion/client/dto/SabermetricsResponse.java create mode 100644 backend/src/main/java/com/mlbstats/ingestion/client/dto/SeasonAdvancedResponse.java create mode 100644 backend/src/main/java/com/mlbstats/ingestion/service/OaaIngestionService.java create mode 100644 backend/src/main/java/com/mlbstats/ingestion/service/SabermetricsIngestionService.java create mode 100644 backend/src/main/java/com/mlbstats/ingestion/service/StatcastIngestionService.java create mode 100644 backend/src/main/resources/db/migration/V14__gwar_and_sabermetrics.sql create mode 100644 backend/src/test/java/com/mlbstats/domain/gwar/GwarCalculationServiceTest.java create mode 100644 backend/src/test/java/com/mlbstats/ingestion/mapper/StatsMapperTest.java create mode 100644 backend/src/test/java/com/mlbstats/ingestion/service/SabermetricsIngestionServiceTest.java create mode 100644 docs/GWAR_METHODOLOGY.md diff --git a/backend/src/main/java/com/mlbstats/api/controller/PlayerController.java b/backend/src/main/java/com/mlbstats/api/controller/PlayerController.java index 1a41e16..59a1a95 100644 --- a/backend/src/main/java/com/mlbstats/api/controller/PlayerController.java +++ b/backend/src/main/java/com/mlbstats/api/controller/PlayerController.java @@ -319,4 +319,40 @@ public ResponseEntity comparePlayers( return ResponseEntity.ok(playerApiService.comparePlayerStats(playerIds, seasonList, careerMode)); } + + // ================================================================================ + // gWAR (Grove WAR) Endpoints + // ================================================================================ + + @GetMapping("/leaders/gwar/batting") + @Operation(summary = "Get batting gWAR leaders", description = "Returns top position players by gWAR (Grove WAR) for a season") + public ResponseEntity> getBattingGwarLeaders( + @RequestParam(required = false) Integer season, + @RequestParam(defaultValue = "10") int limit) { + return ResponseEntity.ok(playerApiService.getTopBattingGwar(season, limit)); + } + + @GetMapping("/leaders/gwar/pitching") + @Operation(summary = "Get pitching gWAR leaders", description = "Returns top pitchers by gWAR (Grove WAR) for a season") + public ResponseEntity> getPitchingGwarLeaders( + @RequestParam(required = false) Integer season, + @RequestParam(defaultValue = "10") int limit) { + return ResponseEntity.ok(playerApiService.getTopPitchingGwar(season, limit)); + } + + @GetMapping("/leaders/oaa") + @Operation(summary = "Get OAA leaders", description = "Returns top fielders by Outs Above Average for a season") + public ResponseEntity> getOaaLeaders( + @RequestParam(required = false) Integer season, + @RequestParam(defaultValue = "10") int limit) { + return ResponseEntity.ok(playerApiService.getTopOaa(season, limit)); + } + + @GetMapping("/{id}/gwar-breakdown") + @Operation(summary = "Get gWAR breakdown", description = "Returns detailed breakdown of gWAR components for a player") + public ResponseEntity getGwarBreakdown( + @PathVariable Long id, + @RequestParam(required = false) Integer season) { + return ResponseEntity.ok(playerApiService.getGwarBreakdown(id, season)); + } } diff --git a/backend/src/main/java/com/mlbstats/api/dto/BattingStatsDto.java b/backend/src/main/java/com/mlbstats/api/dto/BattingStatsDto.java index 562d114..2c26341 100644 --- a/backend/src/main/java/com/mlbstats/api/dto/BattingStatsDto.java +++ b/backend/src/main/java/com/mlbstats/api/dto/BattingStatsDto.java @@ -44,7 +44,15 @@ public record BattingStatsDto( BigDecimal xslg, BigDecimal xwoba, BigDecimal kPct, - BigDecimal bbPct + BigDecimal bbPct, + // gWAR (Grove WAR) fields + BigDecimal gwar, + BigDecimal gwarBatting, + BigDecimal gwarBaserunning, + BigDecimal gwarFielding, + BigDecimal gwarPositional, + BigDecimal gwarReplacement, + Integer oaa ) { public static BattingStatsDto fromEntity(PlayerBattingStats stats) { return new BattingStatsDto( @@ -86,7 +94,14 @@ public static BattingStatsDto fromEntity(PlayerBattingStats stats) { stats.getXslg(), stats.getXwoba(), stats.getKPct(), - stats.getBbPct() + stats.getBbPct(), + stats.getGwar(), + stats.getGwarBatting(), + stats.getGwarBaserunning(), + stats.getGwarFielding(), + stats.getGwarPositional(), + stats.getGwarReplacement(), + stats.getOaa() ); } } diff --git a/backend/src/main/java/com/mlbstats/api/dto/GwarBreakdownDto.java b/backend/src/main/java/com/mlbstats/api/dto/GwarBreakdownDto.java new file mode 100644 index 0000000..f9faf3b --- /dev/null +++ b/backend/src/main/java/com/mlbstats/api/dto/GwarBreakdownDto.java @@ -0,0 +1,79 @@ +package com.mlbstats.api.dto; + +import java.math.BigDecimal; + +/** + * DTO for displaying a detailed breakdown of gWAR components. + * All component values are in runs above average/replacement. + */ +public record GwarBreakdownDto( + PlayerDto player, + Integer season, + BigDecimal gwar, + BigDecimal officialWar, + // Component breakdown (in runs) + BigDecimal batting, + BigDecimal baserunning, + BigDecimal fielding, + BigDecimal positional, + BigDecimal replacement, + // Additional context + String position, + Integer oaa, + String methodologyUrl +) { + public static final String METHODOLOGY_URL = "/docs/GWAR_METHODOLOGY.md"; + + public static GwarBreakdownDto forBatter( + PlayerDto player, + Integer season, + BigDecimal gwar, + BigDecimal officialWar, + BigDecimal batting, + BigDecimal baserunning, + BigDecimal fielding, + BigDecimal positional, + BigDecimal replacement, + String position, + Integer oaa + ) { + return new GwarBreakdownDto( + player, + season, + gwar, + officialWar, + batting, + baserunning, + fielding, + positional, + replacement, + position, + oaa, + METHODOLOGY_URL + ); + } + + public static GwarBreakdownDto forPitcher( + PlayerDto player, + Integer season, + BigDecimal gwar, + BigDecimal officialWar, + BigDecimal pitching, + BigDecimal replacement + ) { + return new GwarBreakdownDto( + player, + season, + gwar, + officialWar, + pitching, // pitching runs stored in batting slot + null, // no baserunning + null, // no fielding for pitchers + null, // no positional for pitchers + replacement, + "P", + null, + METHODOLOGY_URL + ); + } +} diff --git a/backend/src/main/java/com/mlbstats/api/dto/PitchingStatsDto.java b/backend/src/main/java/com/mlbstats/api/dto/PitchingStatsDto.java index 58f7ac4..c95828d 100644 --- a/backend/src/main/java/com/mlbstats/api/dto/PitchingStatsDto.java +++ b/backend/src/main/java/com/mlbstats/api/dto/PitchingStatsDto.java @@ -44,7 +44,11 @@ public record PitchingStatsDto( BigDecimal xera, Integer avgSpinRate, BigDecimal whiffPct, - BigDecimal chasePct + BigDecimal chasePct, + // gWAR (Grove WAR) fields + BigDecimal gwar, + BigDecimal gwarPitching, + BigDecimal gwarReplacement ) { public static PitchingStatsDto fromEntity(PlayerPitchingStats stats) { return new PitchingStatsDto( @@ -86,7 +90,10 @@ public static PitchingStatsDto fromEntity(PlayerPitchingStats stats) { stats.getXera(), stats.getAvgSpinRate(), stats.getWhiffPct(), - stats.getChasePct() + stats.getChasePct(), + stats.getGwar(), + stats.getGwarPitching(), + stats.getGwarReplacement() ); } } diff --git a/backend/src/main/java/com/mlbstats/api/service/PlayerApiService.java b/backend/src/main/java/com/mlbstats/api/service/PlayerApiService.java index ac4dfbf..6e06eaf 100644 --- a/backend/src/main/java/com/mlbstats/api/service/PlayerApiService.java +++ b/backend/src/main/java/com/mlbstats/api/service/PlayerApiService.java @@ -591,4 +591,92 @@ public List getTopWhiffPct(Integer season, java.math.BigDecima .map(PitchingStatsDto::fromEntity) .toList(); } + + // ================================================================================ + // gWAR (Grove WAR) Leaderboards + // ================================================================================ + + @Cacheable(value = CacheConfig.LEADERBOARDS, key = "'batting_gwar_' + #season + '_' + #limit") + public List getTopBattingGwar(Integer season, int limit) { + if (season == null) { + season = DateUtils.getCurrentSeason(); + } + return battingStatsRepository.findTopGwar(season).stream() + .limit(limit) + .map(BattingStatsDto::fromEntity) + .toList(); + } + + @Cacheable(value = CacheConfig.LEADERBOARDS, key = "'pitching_gwar_' + #season + '_' + #limit") + public List getTopPitchingGwar(Integer season, int limit) { + if (season == null) { + season = DateUtils.getCurrentSeason(); + } + return pitchingStatsRepository.findTopGwar(season).stream() + .limit(limit) + .map(PitchingStatsDto::fromEntity) + .toList(); + } + + @Cacheable(value = CacheConfig.LEADERBOARDS, key = "'oaa_' + #season + '_' + #limit") + public List getTopOaa(Integer season, int limit) { + if (season == null) { + season = DateUtils.getCurrentSeason(); + } + return battingStatsRepository.findTopOaa(season).stream() + .limit(limit) + .map(BattingStatsDto::fromEntity) + .toList(); + } + + public GwarBreakdownDto getGwarBreakdown(Long playerId, Integer season) { + if (season == null) { + season = DateUtils.getCurrentSeason(); + } + + Player player = playerRepository.findById(playerId) + .orElseThrow(() -> new ResourceNotFoundException("Player", playerId)); + + // Check batting stats first + List battingStats = battingStatsRepository.findByPlayerIdAndSeason(playerId, season); + if (!battingStats.isEmpty() && battingStats.get(0).getGwar() != null) { + PlayerBattingStats stats = battingStats.get(0); + return GwarBreakdownDto.forBatter( + PlayerDto.fromEntity(player), + season, + stats.getGwar(), + stats.getWar(), + stats.getGwarBatting(), + stats.getGwarBaserunning(), + stats.getGwarFielding(), + stats.getGwarPositional(), + stats.getGwarReplacement(), + player.getPosition(), + stats.getOaa() + ); + } + + // Check pitching stats + List pitchingStats = pitchingStatsRepository.findByPlayerIdAndSeason(playerId, season); + if (!pitchingStats.isEmpty() && pitchingStats.get(0).getGwar() != null) { + PlayerPitchingStats stats = pitchingStats.get(0); + return GwarBreakdownDto.forPitcher( + PlayerDto.fromEntity(player), + season, + stats.getGwar(), + stats.getWar(), + stats.getGwarPitching(), + stats.getGwarReplacement() + ); + } + + // No gWAR data available + return GwarBreakdownDto.forBatter( + PlayerDto.fromEntity(player), + season, + null, null, null, null, null, null, null, + player.getPosition(), + null + ); + } } diff --git a/backend/src/main/java/com/mlbstats/common/config/RestClientConfig.java b/backend/src/main/java/com/mlbstats/common/config/RestClientConfig.java index 36b4277..d9ce043 100644 --- a/backend/src/main/java/com/mlbstats/common/config/RestClientConfig.java +++ b/backend/src/main/java/com/mlbstats/common/config/RestClientConfig.java @@ -27,4 +27,17 @@ public RestClient mlbApiRestClient() { .defaultHeader("Accept", "application/json") .build(); } + + @Bean + public RestClient baseballSavantRestClient() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(timeout); + factory.setReadTimeout(60000); // Longer timeout for CSV downloads + + return RestClient.builder() + .requestFactory(factory) + .defaultHeader("Accept", "text/csv") + .defaultHeader("User-Agent", "MLB-Stats-App/1.0") + .build(); + } } diff --git a/backend/src/main/java/com/mlbstats/domain/constants/LeagueConstants.java b/backend/src/main/java/com/mlbstats/domain/constants/LeagueConstants.java new file mode 100644 index 0000000..fe30a9c --- /dev/null +++ b/backend/src/main/java/com/mlbstats/domain/constants/LeagueConstants.java @@ -0,0 +1,66 @@ +package com.mlbstats.domain.constants; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * Stores season-specific league constants used for gWAR calculations. + * These values change each season based on league-wide offensive environment. + */ +@Entity +@Table(name = "league_constants") +@Getter +@Setter +@NoArgsConstructor +public class LeagueConstants { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private Integer season; + + /** + * League average wOBA - used as baseline for wRAA calculation + */ + @Column(name = "lg_woba", precision = 5, scale = 4, nullable = false) + private BigDecimal lgWoba; + + /** + * wOBA scale - converts wOBA to runs above average + */ + @Column(name = "woba_scale", precision = 5, scale = 4, nullable = false) + private BigDecimal wobaScale; + + /** + * League average runs per plate appearance + */ + @Column(name = "lg_r_per_pa", precision = 6, scale = 5, nullable = false) + private BigDecimal lgRPerPa; + + /** + * FIP constant for the season (used to scale FIP to ERA) + */ + @Column(name = "fip_constant", precision = 4, scale = 2, nullable = false) + private BigDecimal fipConstant; + + /** + * Runs per win (typically around 10) + */ + @Column(name = "runs_per_win", precision = 4, scale = 2) + private BigDecimal runsPerWin = new BigDecimal("10.0"); + + @Column(name = "created_at") + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/mlbstats/domain/constants/LeagueConstantsRepository.java b/backend/src/main/java/com/mlbstats/domain/constants/LeagueConstantsRepository.java new file mode 100644 index 0000000..6fe790e --- /dev/null +++ b/backend/src/main/java/com/mlbstats/domain/constants/LeagueConstantsRepository.java @@ -0,0 +1,12 @@ +package com.mlbstats.domain.constants; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface LeagueConstantsRepository extends JpaRepository { + + Optional findBySeason(Integer season); +} diff --git a/backend/src/main/java/com/mlbstats/domain/gwar/GwarCalculationService.java b/backend/src/main/java/com/mlbstats/domain/gwar/GwarCalculationService.java new file mode 100644 index 0000000..08588bf --- /dev/null +++ b/backend/src/main/java/com/mlbstats/domain/gwar/GwarCalculationService.java @@ -0,0 +1,282 @@ +package com.mlbstats.domain.gwar; + +import com.mlbstats.domain.constants.LeagueConstants; +import com.mlbstats.domain.constants.LeagueConstantsRepository; +import com.mlbstats.domain.stats.PlayerBattingStats; +import com.mlbstats.domain.stats.PlayerPitchingStats; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.Map; + +/** + * Calculates gWAR (Grove WAR) - a transparent, simplified WAR metric. + * + *

gWAR Formula for Position Players:

+ *
+ * gWAR = (Batting + Baserunning + Fielding + Positional + Replacement) / Runs_Per_Win
+ *
+ * Where:
+ * - Batting (wRAA) = ((wOBA - lgwOBA) / wOBAScale) × PA
+ * - Baserunning (wSB) = (SB × 0.2) + (CS × -0.41)
+ * - Fielding = OAA × 0.9 (from Baseball Savant)
+ * - Positional = Position adjustment × (games / 162)
+ * - Replacement = PA × (20.5 / 600)
+ * 
+ * + *

gWAR Formula for Pitchers:

+ *
+ * gWAR = (Pitching + Replacement) / Runs_Per_Win
+ *
+ * Where:
+ * - Pitching = ((lgFIP - FIP) / 9) × IP
+ * - Replacement = IP × (5.5 / 200)
+ * 
+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class GwarCalculationService { + + private final LeagueConstantsRepository constantsRepo; + + // Positional adjustment runs per 162 games (from FanGraphs methodology) + private static final Map POSITIONAL_ADJUSTMENTS = Map.ofEntries( + Map.entry("C", bd("12.5")), + Map.entry("SS", bd("7.5")), + Map.entry("2B", bd("3.0")), + Map.entry("CF", bd("2.5")), + Map.entry("3B", bd("2.5")), + Map.entry("LF", bd("-7.5")), + Map.entry("RF", bd("-7.5")), + Map.entry("1B", bd("-12.5")), + Map.entry("DH", bd("-17.5")) + ); + + // Stolen base run values + private static final BigDecimal SB_RUN_VALUE = bd("0.2"); + private static final BigDecimal CS_RUN_VALUE = bd("-0.41"); + + // OAA to runs conversion factor + private static final BigDecimal OAA_TO_RUNS = bd("0.9"); + + // Replacement level constants + private static final BigDecimal BATTER_REPLACEMENT_RUNS_PER_PA = bd("20.5").divide(bd("600"), 6, RoundingMode.HALF_UP); + private static final BigDecimal PITCHER_REPLACEMENT_RUNS_PER_IP = bd("5.5").divide(bd("200"), 6, RoundingMode.HALF_UP); + + // League average FIP (approximate, used when calculating pitcher runs) + private static final BigDecimal LEAGUE_AVG_FIP = bd("4.00"); + + /** + * Calculates and applies gWAR to a batter's stats. + * + * @param stats The batting stats entity to update + * @param position The player's primary position (C, 1B, 2B, SS, 3B, LF, CF, RF, DH) + */ + public void calculateAndApply(PlayerBattingStats stats, String position) { + LeagueConstants lc = getConstants(stats.getSeason()); + if (lc == null) { + log.warn("No league constants for season {}, skipping gWAR calculation", stats.getSeason()); + return; + } + + // Batting: wRAA = ((wOBA - lgwOBA) / wOBAScale) × PA + BigDecimal batting = calculateWraa(stats.getWoba(), stats.getPlateAppearances(), lc); + + // Baserunning: wSB = (SB × 0.2) + (CS × -0.41) + BigDecimal baserunning = calculateWsb(stats.getStolenBases(), stats.getCaughtStealing()); + + // Fielding: OAA × 0.9 + BigDecimal fielding = calculateFielding(stats.getOaa()); + + // Positional adjustment (prorated by games played) + BigDecimal positional = calculatePositional(position, stats.getGamesPlayed()); + + // Replacement level runs + BigDecimal replacement = calculateBatterReplacement(stats.getPlateAppearances()); + + // Total runs above replacement + BigDecimal totalRuns = batting.add(baserunning).add(fielding).add(positional).add(replacement); + + // Convert to wins + BigDecimal gwar = totalRuns.divide(lc.getRunsPerWin(), 1, RoundingMode.HALF_UP); + + // Apply to entity + stats.setGwar(gwar); + stats.setGwarBatting(batting.setScale(1, RoundingMode.HALF_UP)); + stats.setGwarBaserunning(baserunning.setScale(1, RoundingMode.HALF_UP)); + stats.setGwarFielding(fielding.setScale(1, RoundingMode.HALF_UP)); + stats.setGwarPositional(positional.setScale(1, RoundingMode.HALF_UP)); + stats.setGwarReplacement(replacement.setScale(1, RoundingMode.HALF_UP)); + + log.debug("Calculated gWAR for player {}: {} (bat={}, br={}, fld={}, pos={}, rep={})", + stats.getPlayer() != null ? stats.getPlayer().getFullName() : "unknown", + gwar, batting, baserunning, fielding, positional, replacement); + } + + /** + * Calculates and applies gWAR to a pitcher's stats. + * + * @param stats The pitching stats entity to update + */ + public void calculateAndApply(PlayerPitchingStats stats) { + LeagueConstants lc = getConstants(stats.getSeason()); + if (lc == null) { + log.warn("No league constants for season {}, skipping gWAR calculation", stats.getSeason()); + return; + } + + // Pitching runs = ((lgFIP - FIP) / 9) × IP + BigDecimal pitching = calculatePitchingRuns(stats.getFip(), stats.getInningsPitched(), lc); + + // Replacement level runs + BigDecimal replacement = calculatePitcherReplacement(stats.getInningsPitched()); + + // Total runs above replacement + BigDecimal totalRuns = pitching.add(replacement); + + // Convert to wins + BigDecimal gwar = totalRuns.divide(lc.getRunsPerWin(), 1, RoundingMode.HALF_UP); + + // Apply to entity + stats.setGwar(gwar); + stats.setGwarPitching(pitching.setScale(1, RoundingMode.HALF_UP)); + stats.setGwarReplacement(replacement.setScale(1, RoundingMode.HALF_UP)); + + log.debug("Calculated gWAR for pitcher {}: {} (pitch={}, rep={})", + stats.getPlayer() != null ? stats.getPlayer().getFullName() : "unknown", + gwar, pitching, replacement); + } + + /** + * Calculates wRAA (weighted Runs Above Average) from batting. + * Formula: ((wOBA - lgwOBA) / wOBAScale) × PA + */ + private BigDecimal calculateWraa(BigDecimal woba, Integer plateAppearances, LeagueConstants lc) { + if (woba == null || plateAppearances == null || plateAppearances == 0) { + return BigDecimal.ZERO; + } + + BigDecimal wobaDiff = woba.subtract(lc.getLgWoba()); + BigDecimal runsPerPa = wobaDiff.divide(lc.getWobaScale(), 6, RoundingMode.HALF_UP); + return runsPerPa.multiply(new BigDecimal(plateAppearances)); + } + + /** + * Calculates wSB (weighted Stolen Base runs). + * Formula: (SB × 0.2) + (CS × -0.41) + */ + private BigDecimal calculateWsb(Integer stolenBases, Integer caughtStealing) { + BigDecimal sb = stolenBases != null ? new BigDecimal(stolenBases) : BigDecimal.ZERO; + BigDecimal cs = caughtStealing != null ? new BigDecimal(caughtStealing) : BigDecimal.ZERO; + + return sb.multiply(SB_RUN_VALUE).add(cs.multiply(CS_RUN_VALUE)); + } + + /** + * Calculates fielding runs from OAA. + * Formula: OAA × 0.9 + */ + private BigDecimal calculateFielding(Integer oaa) { + if (oaa == null) { + return BigDecimal.ZERO; + } + return new BigDecimal(oaa).multiply(OAA_TO_RUNS); + } + + /** + * Calculates positional adjustment runs. + * Formula: Position adjustment × (games / 162) + */ + private BigDecimal calculatePositional(String position, Integer gamesPlayed) { + if (position == null || gamesPlayed == null || gamesPlayed == 0) { + return BigDecimal.ZERO; + } + + // Normalize position to standard abbreviation + String normalizedPosition = normalizePosition(position); + BigDecimal adjustment = POSITIONAL_ADJUSTMENTS.getOrDefault(normalizedPosition, BigDecimal.ZERO); + + // Prorate by games played + BigDecimal gamesFraction = new BigDecimal(gamesPlayed).divide(bd("162"), 4, RoundingMode.HALF_UP); + return adjustment.multiply(gamesFraction); + } + + /** + * Calculates replacement level runs for batters. + * Formula: PA × (20.5 / 600) + */ + private BigDecimal calculateBatterReplacement(Integer plateAppearances) { + if (plateAppearances == null || plateAppearances == 0) { + return BigDecimal.ZERO; + } + return new BigDecimal(plateAppearances).multiply(BATTER_REPLACEMENT_RUNS_PER_PA); + } + + /** + * Calculates pitching runs above average. + * Formula: ((lgFIP - FIP) / 9) × IP + */ + private BigDecimal calculatePitchingRuns(BigDecimal fip, BigDecimal inningsPitched, LeagueConstants lc) { + if (fip == null || inningsPitched == null || inningsPitched.compareTo(BigDecimal.ZERO) == 0) { + return BigDecimal.ZERO; + } + + // Use FIP constant + 3.2 as approximate league average FIP + BigDecimal lgFip = lc.getFipConstant().add(bd("0.85")); + BigDecimal fipDiff = lgFip.subtract(fip); + BigDecimal runsPerInning = fipDiff.divide(bd("9"), 6, RoundingMode.HALF_UP); + return runsPerInning.multiply(inningsPitched); + } + + /** + * Calculates replacement level runs for pitchers. + * Formula: IP × (5.5 / 200) + */ + private BigDecimal calculatePitcherReplacement(BigDecimal inningsPitched) { + if (inningsPitched == null || inningsPitched.compareTo(BigDecimal.ZERO) == 0) { + return BigDecimal.ZERO; + } + return inningsPitched.multiply(PITCHER_REPLACEMENT_RUNS_PER_IP); + } + + /** + * Gets league constants for a season. + */ + private LeagueConstants getConstants(Integer season) { + return constantsRepo.findBySeason(season).orElse(null); + } + + /** + * Normalizes position strings to standard abbreviations. + */ + private String normalizePosition(String position) { + if (position == null) { + return null; + } + + String upper = position.toUpperCase().trim(); + + return switch (upper) { + case "CATCHER", "C" -> "C"; + case "FIRST BASEMAN", "FIRST BASE", "1B" -> "1B"; + case "SECOND BASEMAN", "SECOND BASE", "2B" -> "2B"; + case "SHORTSTOP", "SS" -> "SS"; + case "THIRD BASEMAN", "THIRD BASE", "3B" -> "3B"; + case "LEFT FIELDER", "LEFT FIELD", "LF" -> "LF"; + case "CENTER FIELDER", "CENTER FIELD", "CF" -> "CF"; + case "RIGHT FIELDER", "RIGHT FIELD", "RF" -> "RF"; + case "DESIGNATED HITTER", "DH" -> "DH"; + case "OUTFIELDER", "OF" -> "CF"; // Default OF to CF (neutral) + case "INFIELDER", "IF" -> "SS"; // Default IF to SS (average IF) + default -> upper; + }; + } + + private static BigDecimal bd(String val) { + return new BigDecimal(val); + } +} diff --git a/backend/src/main/java/com/mlbstats/domain/gwar/GwarComponents.java b/backend/src/main/java/com/mlbstats/domain/gwar/GwarComponents.java new file mode 100644 index 0000000..0dde344 --- /dev/null +++ b/backend/src/main/java/com/mlbstats/domain/gwar/GwarComponents.java @@ -0,0 +1,38 @@ +package com.mlbstats.domain.gwar; + +import java.math.BigDecimal; + +/** + * Record representing the components of a gWAR calculation. + * All values are in runs above average/replacement except gwar which is in wins. + */ +public record GwarComponents( + BigDecimal gwar, + BigDecimal batting, + BigDecimal baserunning, + BigDecimal fielding, + BigDecimal positional, + BigDecimal replacement +) { + + /** + * Creates empty components (all zeros). + */ + public static GwarComponents empty() { + return new GwarComponents( + BigDecimal.ZERO, + BigDecimal.ZERO, + BigDecimal.ZERO, + BigDecimal.ZERO, + BigDecimal.ZERO, + BigDecimal.ZERO + ); + } + + /** + * Returns the sum of all run components. + */ + public BigDecimal totalRuns() { + return batting.add(baserunning).add(fielding).add(positional).add(replacement); + } +} diff --git a/backend/src/main/java/com/mlbstats/domain/stats/PlayerBattingStats.java b/backend/src/main/java/com/mlbstats/domain/stats/PlayerBattingStats.java index 4c8b48f..10ce0f9 100644 --- a/backend/src/main/java/com/mlbstats/domain/stats/PlayerBattingStats.java +++ b/backend/src/main/java/com/mlbstats/domain/stats/PlayerBattingStats.java @@ -144,6 +144,31 @@ public class PlayerBattingStats { @Column(name = "bb_pct", precision = 4, scale = 1) private BigDecimal bbPct; + // gWAR (Grove WAR) components + @Column(precision = 4, scale = 1) + private BigDecimal gwar; + + @Column(name = "gwar_batting", precision = 5, scale = 1) + private BigDecimal gwarBatting; + + @Column(name = "gwar_baserunning", precision = 5, scale = 1) + private BigDecimal gwarBaserunning; + + @Column(name = "gwar_fielding", precision = 5, scale = 1) + private BigDecimal gwarFielding; + + @Column(name = "gwar_positional", precision = 5, scale = 1) + private BigDecimal gwarPositional; + + @Column(name = "gwar_replacement", precision = 5, scale = 1) + private BigDecimal gwarReplacement; + + /** + * Outs Above Average from Baseball Savant - used for fielding component of gWAR + */ + @Column + private Integer oaa; + @Column(name = "created_at") private LocalDateTime createdAt; diff --git a/backend/src/main/java/com/mlbstats/domain/stats/PlayerBattingStatsRepository.java b/backend/src/main/java/com/mlbstats/domain/stats/PlayerBattingStatsRepository.java index 96385e5..8423242 100644 --- a/backend/src/main/java/com/mlbstats/domain/stats/PlayerBattingStatsRepository.java +++ b/backend/src/main/java/com/mlbstats/domain/stats/PlayerBattingStatsRepository.java @@ -72,4 +72,11 @@ Optional findByPlayerIdAndTeamIdAndSeasonAndGameType( @Query("SELECT pbs FROM PlayerBattingStats pbs JOIN FETCH pbs.player JOIN FETCH pbs.team WHERE pbs.season = :season AND pbs.plateAppearances >= :minPa AND pbs.barrelPct IS NOT NULL ORDER BY pbs.barrelPct DESC") List findTopBarrelPct(@Param("season") Integer season, @Param("minPa") Integer minPa); + + // gWAR Leaderboards + @Query("SELECT pbs FROM PlayerBattingStats pbs JOIN FETCH pbs.player JOIN FETCH pbs.team WHERE pbs.season = :season AND pbs.gwar IS NOT NULL ORDER BY pbs.gwar DESC") + List findTopGwar(@Param("season") Integer season); + + @Query("SELECT pbs FROM PlayerBattingStats pbs JOIN FETCH pbs.player JOIN FETCH pbs.team WHERE pbs.season = :season AND pbs.oaa IS NOT NULL ORDER BY pbs.oaa DESC") + List findTopOaa(@Param("season") Integer season); } diff --git a/backend/src/main/java/com/mlbstats/domain/stats/PlayerPitchingStats.java b/backend/src/main/java/com/mlbstats/domain/stats/PlayerPitchingStats.java index aec4ea0..27a97e3 100644 --- a/backend/src/main/java/com/mlbstats/domain/stats/PlayerPitchingStats.java +++ b/backend/src/main/java/com/mlbstats/domain/stats/PlayerPitchingStats.java @@ -142,6 +142,16 @@ public class PlayerPitchingStats { @Column(name = "chase_pct", precision = 4, scale = 1) private BigDecimal chasePct; + // gWAR (Grove WAR) components + @Column(precision = 4, scale = 1) + private BigDecimal gwar; + + @Column(name = "gwar_pitching", precision = 5, scale = 1) + private BigDecimal gwarPitching; + + @Column(name = "gwar_replacement", precision = 5, scale = 1) + private BigDecimal gwarReplacement; + @Column(name = "created_at") private LocalDateTime createdAt; diff --git a/backend/src/main/java/com/mlbstats/domain/stats/PlayerPitchingStatsRepository.java b/backend/src/main/java/com/mlbstats/domain/stats/PlayerPitchingStatsRepository.java index afd1fc5..dbea271 100644 --- a/backend/src/main/java/com/mlbstats/domain/stats/PlayerPitchingStatsRepository.java +++ b/backend/src/main/java/com/mlbstats/domain/stats/PlayerPitchingStatsRepository.java @@ -67,4 +67,8 @@ Optional findByPlayerIdAndTeamIdAndSeasonAndGameType( @Query("SELECT pps FROM PlayerPitchingStats pps JOIN FETCH pps.player JOIN FETCH pps.team WHERE pps.season = :season AND pps.inningsPitched >= :minInnings AND pps.whiffPct IS NOT NULL ORDER BY pps.whiffPct DESC") List findTopWhiffPct(@Param("season") Integer season, @Param("minInnings") BigDecimal minInnings); + + // gWAR Leaderboards + @Query("SELECT pps FROM PlayerPitchingStats pps JOIN FETCH pps.player JOIN FETCH pps.team WHERE pps.season = :season AND pps.gwar IS NOT NULL ORDER BY pps.gwar DESC") + List findTopGwar(@Param("season") Integer season); } diff --git a/backend/src/main/java/com/mlbstats/ingestion/client/BaseballSavantClient.java b/backend/src/main/java/com/mlbstats/ingestion/client/BaseballSavantClient.java new file mode 100644 index 0000000..7ea41ab --- /dev/null +++ b/backend/src/main/java/com/mlbstats/ingestion/client/BaseballSavantClient.java @@ -0,0 +1,314 @@ +package com.mlbstats.ingestion.client; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +import java.io.BufferedReader; +import java.io.StringReader; +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.Map; + +/** + * Client for fetching data from Baseball Savant. + * Retrieves OAA (Outs Above Average) and expected stats from the Statcast leaderboards. + */ +@Slf4j +@Component +public class BaseballSavantClient { + + private final RestClient restClient; + + // Baseball Savant CSV endpoints + private static final String OAA_LEADERBOARD_URL = + "https://baseballsavant.mlb.com/leaderboard/outs_above_average?type=Fielder&year={year}&min=q&csv=true"; + + private static final String EXPECTED_STATS_URL = + "https://baseballsavant.mlb.com/leaderboard/expected_statistics?type=batter&year={year}&min=q&csv=true"; + + private static final String STATCAST_RUNNING_URL = + "https://baseballsavant.mlb.com/leaderboard/sprint_speed?year={year}&min=q&csv=true"; + + public BaseballSavantClient(RestClient baseballSavantRestClient) { + this.restClient = baseballSavantRestClient; + } + + /** + * Fetches OAA (Outs Above Average) for all qualified fielders. + * + * @param season Season year + * @return Map of MLB player ID to OAA value + */ + public Map getOaaByPlayerId(Integer season) { + log.info("Fetching OAA leaderboard from Baseball Savant for {}", season); + try { + String csv = restClient.get() + .uri(OAA_LEADERBOARD_URL, season) + .retrieve() + .body(String.class); + + return parseOaaCsv(csv); + } catch (RestClientException e) { + log.error("Failed to fetch OAA leaderboard: {}", e.getMessage()); + return Map.of(); + } + } + + /** + * Fetches expected statistics (xBA, xSLG, xwOBA) for all qualified batters. + * + * @param season Season year + * @return Map of MLB player ID to expected stats data + */ + public Map getExpectedStatsByPlayerId(Integer season) { + log.info("Fetching expected stats leaderboard from Baseball Savant for {}", season); + try { + String csv = restClient.get() + .uri(EXPECTED_STATS_URL, season) + .retrieve() + .body(String.class); + + return parseExpectedStatsCsv(csv); + } catch (RestClientException e) { + log.error("Failed to fetch expected stats leaderboard: {}", e.getMessage()); + return Map.of(); + } + } + + /** + * Fetches sprint speed for all qualified runners. + * + * @param season Season year + * @return Map of MLB player ID to sprint speed (ft/sec) + */ + public Map getSprintSpeedByPlayerId(Integer season) { + log.info("Fetching sprint speed leaderboard from Baseball Savant for {}", season); + try { + String csv = restClient.get() + .uri(STATCAST_RUNNING_URL, season) + .retrieve() + .body(String.class); + + return parseSprintSpeedCsv(csv); + } catch (RestClientException e) { + log.error("Failed to fetch sprint speed leaderboard: {}", e.getMessage()); + return Map.of(); + } + } + + // ================================================================================ + // CSV PARSING + // ================================================================================ + + /** + * Parses OAA CSV response. + * Expected columns: player_id, last_name, first_name, fielding_runs_outs_above_average, ... + */ + private Map parseOaaCsv(String csv) { + Map result = new HashMap<>(); + if (csv == null || csv.isBlank()) return result; + + try (BufferedReader reader = new BufferedReader(new StringReader(csv))) { + String header = reader.readLine(); + if (header == null) return result; + + // Find column indices + String[] columns = parseCsvLine(header); + int playerIdIdx = findColumnIndex(columns, "player_id"); + int oaaIdx = findColumnIndex(columns, "fielding_runs_outs_above_average", "outs_above_average", "oaa"); + + if (playerIdIdx < 0 || oaaIdx < 0) { + log.warn("Could not find required columns in OAA CSV. Header: {}", header); + return result; + } + + String line; + while ((line = reader.readLine()) != null) { + try { + String[] values = parseCsvLine(line); + if (values.length > Math.max(playerIdIdx, oaaIdx)) { + Integer playerId = parseInteger(values[playerIdIdx]); + Integer oaa = parseInteger(values[oaaIdx]); + if (playerId != null && oaa != null) { + result.put(playerId, oaa); + } + } + } catch (Exception e) { + log.trace("Skipping malformed OAA row: {}", line); + } + } + } catch (Exception e) { + log.error("Error parsing OAA CSV: {}", e.getMessage()); + } + + log.debug("Parsed {} OAA entries from Baseball Savant", result.size()); + return result; + } + + /** + * Parses expected stats CSV response. + */ + private Map parseExpectedStatsCsv(String csv) { + Map result = new HashMap<>(); + if (csv == null || csv.isBlank()) return result; + + try (BufferedReader reader = new BufferedReader(new StringReader(csv))) { + String header = reader.readLine(); + if (header == null) return result; + + String[] columns = parseCsvLine(header); + int playerIdIdx = findColumnIndex(columns, "player_id"); + int xbaIdx = findColumnIndex(columns, "est_ba", "xba"); + int xslgIdx = findColumnIndex(columns, "est_slg", "xslg"); + int xwobaIdx = findColumnIndex(columns, "est_woba", "xwoba"); + int evIdx = findColumnIndex(columns, "avg_hit_speed", "exit_velocity_avg"); + int laIdx = findColumnIndex(columns, "avg_hit_angle", "launch_angle_avg"); + int barrelPctIdx = findColumnIndex(columns, "brl_percent", "barrel_batted_rate"); + int hardHitPctIdx = findColumnIndex(columns, "hard_hit_percent", "hard_hit_rate"); + + if (playerIdIdx < 0) { + log.warn("Could not find player_id column in expected stats CSV"); + return result; + } + + String line; + while ((line = reader.readLine()) != null) { + try { + String[] values = parseCsvLine(line); + if (values.length > playerIdIdx) { + Integer playerId = parseInteger(values[playerIdIdx]); + if (playerId != null) { + ExpectedStatsData data = new ExpectedStatsData( + playerId, + safeGet(values, xbaIdx), + safeGet(values, xslgIdx), + safeGet(values, xwobaIdx), + safeGet(values, evIdx), + safeGet(values, laIdx), + safeGet(values, barrelPctIdx), + safeGet(values, hardHitPctIdx), + null // sprint speed comes from separate endpoint + ); + result.put(playerId, data); + } + } + } catch (Exception e) { + log.trace("Skipping malformed expected stats row: {}", line); + } + } + } catch (Exception e) { + log.error("Error parsing expected stats CSV: {}", e.getMessage()); + } + + log.debug("Parsed {} expected stats entries from Baseball Savant", result.size()); + return result; + } + + /** + * Parses sprint speed CSV response. + */ + private Map parseSprintSpeedCsv(String csv) { + Map result = new HashMap<>(); + if (csv == null || csv.isBlank()) return result; + + try (BufferedReader reader = new BufferedReader(new StringReader(csv))) { + String header = reader.readLine(); + if (header == null) return result; + + String[] columns = parseCsvLine(header); + int playerIdIdx = findColumnIndex(columns, "player_id"); + int speedIdx = findColumnIndex(columns, "hp_to_1b", "sprint_speed"); + + if (playerIdIdx < 0 || speedIdx < 0) { + log.warn("Could not find required columns in sprint speed CSV"); + return result; + } + + String line; + while ((line = reader.readLine()) != null) { + try { + String[] values = parseCsvLine(line); + if (values.length > Math.max(playerIdIdx, speedIdx)) { + Integer playerId = parseInteger(values[playerIdIdx]); + BigDecimal speed = parseDecimal(values[speedIdx]); + if (playerId != null && speed != null) { + result.put(playerId, speed); + } + } + } catch (Exception e) { + log.trace("Skipping malformed sprint speed row: {}", line); + } + } + } catch (Exception e) { + log.error("Error parsing sprint speed CSV: {}", e.getMessage()); + } + + log.debug("Parsed {} sprint speed entries from Baseball Savant", result.size()); + return result; + } + + // ================================================================================ + // UTILITY METHODS + // ================================================================================ + + private String[] parseCsvLine(String line) { + // Simple CSV parsing - handles quoted fields + return line.split(",(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)", -1); + } + + private int findColumnIndex(String[] columns, String... names) { + for (int i = 0; i < columns.length; i++) { + String col = columns[i].toLowerCase().trim().replace("\"", ""); + for (String name : names) { + if (col.equals(name.toLowerCase())) { + return i; + } + } + } + return -1; + } + + private BigDecimal safeGet(String[] values, int index) { + if (index < 0 || index >= values.length) return null; + return parseDecimal(values[index]); + } + + private Integer parseInteger(String value) { + if (value == null || value.isBlank()) return null; + try { + return Integer.parseInt(value.trim().replace("\"", "")); + } catch (NumberFormatException e) { + return null; + } + } + + private BigDecimal parseDecimal(String value) { + if (value == null || value.isBlank()) return null; + try { + return new BigDecimal(value.trim().replace("\"", "").replace("%", "")); + } catch (NumberFormatException e) { + return null; + } + } + + // ================================================================================ + // DATA CLASSES + // ================================================================================ + + /** + * Expected stats data from Baseball Savant. + */ + public record ExpectedStatsData( + Integer playerId, + BigDecimal xba, + BigDecimal xslg, + BigDecimal xwoba, + BigDecimal avgExitVelocity, + BigDecimal avgLaunchAngle, + BigDecimal barrelPct, + BigDecimal hardHitPct, + BigDecimal sprintSpeed + ) {} +} diff --git a/backend/src/main/java/com/mlbstats/ingestion/client/MlbApiClient.java b/backend/src/main/java/com/mlbstats/ingestion/client/MlbApiClient.java index f4d855c..50b1d19 100644 --- a/backend/src/main/java/com/mlbstats/ingestion/client/MlbApiClient.java +++ b/backend/src/main/java/com/mlbstats/ingestion/client/MlbApiClient.java @@ -170,4 +170,68 @@ public LinescoreResponse getLinescore(Integer gamePk) { return null; } } + + /** + * Fetches sabermetric stats for a player. + * + * @param playerId MLB player ID + * @param season Season year + * @param group Stats group: "hitting" or "pitching" + * @return Sabermetrics response containing WAR, wOBA, FIP, etc. + */ + public SabermetricsResponse getPlayerSabermetrics(Integer playerId, Integer season, String group) { + log.debug("Fetching sabermetrics for player {} season {} group {}", playerId, season, group); + try { + return restClient.get() + .uri("/people/{id}/stats?stats=sabermetrics&season={season}&group={group}", + playerId, season, group) + .retrieve() + .body(SabermetricsResponse.class); + } catch (RestClientException e) { + log.warn("Failed to fetch sabermetrics for player {}: {}", playerId, e.getMessage()); + return null; + } + } + + public SabermetricsResponse getBattingSabermetrics(Integer playerId, Integer season) { + return getPlayerSabermetrics(playerId, season, "hitting"); + } + + public SabermetricsResponse getPitchingSabermetrics(Integer playerId, Integer season) { + return getPlayerSabermetrics(playerId, season, "pitching"); + } + + /** + * Fetches expected stats for a player (xBA, xSLG, xwOBA). + */ + public ExpectedStatsResponse getPlayerExpectedStats(Integer playerId, Integer season, String group) { + log.debug("Fetching expected stats for player {} season {} group {}", playerId, season, group); + try { + return restClient.get() + .uri("/people/{id}/stats?stats=expectedStatistics&season={season}&group={group}", + playerId, season, group) + .retrieve() + .body(ExpectedStatsResponse.class); + } catch (RestClientException e) { + log.warn("Failed to fetch expected stats for player {}: {}", playerId, e.getMessage()); + return null; + } + } + + /** + * Fetches season advanced stats for a player (BABIP, K%, BB%, etc.). + */ + public SeasonAdvancedResponse getPlayerSeasonAdvanced(Integer playerId, Integer season, String group) { + log.debug("Fetching season advanced stats for player {} season {} group {}", playerId, season, group); + try { + return restClient.get() + .uri("/people/{id}/stats?stats=seasonAdvanced&season={season}&group={group}", + playerId, season, group) + .retrieve() + .body(SeasonAdvancedResponse.class); + } catch (RestClientException e) { + log.warn("Failed to fetch season advanced stats for player {}: {}", playerId, e.getMessage()); + return null; + } + } } diff --git a/backend/src/main/java/com/mlbstats/ingestion/client/dto/ExpectedStatsResponse.java b/backend/src/main/java/com/mlbstats/ingestion/client/dto/ExpectedStatsResponse.java new file mode 100644 index 0000000..1fc2496 --- /dev/null +++ b/backend/src/main/java/com/mlbstats/ingestion/client/dto/ExpectedStatsResponse.java @@ -0,0 +1,50 @@ +package com.mlbstats.ingestion.client.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +import java.util.List; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class ExpectedStatsResponse { + + private List stats; + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class StatGroup { + private TypeData type; + private GroupData group; + private List splits; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class TypeData { + private String displayName; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class GroupData { + private String displayName; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class StatSplit { + private String season; + private ExpectedStatData stat; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ExpectedStatData { + // Expected batting/pitching stats (xBA, xSLG, xwOBA) + private String avg; // xBA + private String slg; // xSLG + private String woba; // xwOBA + private String wobaCon; + } +} diff --git a/backend/src/main/java/com/mlbstats/ingestion/client/dto/SabermetricsResponse.java b/backend/src/main/java/com/mlbstats/ingestion/client/dto/SabermetricsResponse.java new file mode 100644 index 0000000..b6982fc --- /dev/null +++ b/backend/src/main/java/com/mlbstats/ingestion/client/dto/SabermetricsResponse.java @@ -0,0 +1,84 @@ +package com.mlbstats.ingestion.client.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SabermetricsResponse { + + private List stats; + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class StatGroup { + private TypeData type; + private GroupData group; + private List splits; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class TypeData { + private String displayName; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class GroupData { + private String displayName; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class StatSplit { + private String season; + private TeamData team; + private SabermetricData stat; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class TeamData { + private Integer id; + private String name; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class SabermetricData { + // Batting sabermetrics + private BigDecimal woba; + private BigDecimal wRaa; + private BigDecimal wRc; + private BigDecimal wRcPlus; + private BigDecimal rar; + private BigDecimal war; + private BigDecimal batting; + private BigDecimal fielding; + private BigDecimal baseRunning; + private BigDecimal positional; + private BigDecimal wLeague; + private BigDecimal replacement; + private BigDecimal spd; + private BigDecimal ubr; + private BigDecimal wGdp; + private BigDecimal wSb; + + // Pitching sabermetrics + private BigDecimal fip; + private BigDecimal xfip; + private BigDecimal fipMinus; + private BigDecimal ra9War; + private BigDecimal eraMinus; + private BigDecimal pli; + private BigDecimal inli; + private BigDecimal gmli; + private BigDecimal exli; + private BigDecimal sd; + private BigDecimal md; + } +} diff --git a/backend/src/main/java/com/mlbstats/ingestion/client/dto/SeasonAdvancedResponse.java b/backend/src/main/java/com/mlbstats/ingestion/client/dto/SeasonAdvancedResponse.java new file mode 100644 index 0000000..9978e1b --- /dev/null +++ b/backend/src/main/java/com/mlbstats/ingestion/client/dto/SeasonAdvancedResponse.java @@ -0,0 +1,79 @@ +package com.mlbstats.ingestion.client.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +import java.util.List; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SeasonAdvancedResponse { + + private List stats; + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class StatGroup { + private TypeData type; + private GroupData group; + private List splits; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class TypeData { + private String displayName; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class GroupData { + private String displayName; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class StatSplit { + private String season; + private TeamData team; + private AdvancedStatData stat; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class TeamData { + private Integer id; + private String name; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class AdvancedStatData { + // Common advanced stats + private String babip; + private String iso; + private Integer qualityStarts; + + // Batting advanced + private String walksPerPlateAppearance; // BB% + private String strikeoutsPerPlateAppearance; // K% + private String walksPerStrikeout; + + // Pitching advanced + private String strikeoutsPer9; + private String baseOnBallsPer9; + private String hitsPer9; + private String homeRunsPer9; + private String strikesoutsToWalks; + private String whiffPercentage; + private String flyBallPercentage; // FB% + private String strikeoutsMinusWalksPercentage; + + // Batted ball data (for calculating GB%) + private Integer groundOuts; + private Integer flyOuts; + private Integer popOuts; + private Integer lineOuts; + private Integer ballsInPlay; + } +} diff --git a/backend/src/main/java/com/mlbstats/ingestion/mapper/StatsMapper.java b/backend/src/main/java/com/mlbstats/ingestion/mapper/StatsMapper.java index d0bf80c..ff094f9 100644 --- a/backend/src/main/java/com/mlbstats/ingestion/mapper/StatsMapper.java +++ b/backend/src/main/java/com/mlbstats/ingestion/mapper/StatsMapper.java @@ -4,10 +4,14 @@ import com.mlbstats.domain.stats.PlayerBattingStats; import com.mlbstats.domain.stats.PlayerPitchingStats; import com.mlbstats.domain.team.Team; +import com.mlbstats.ingestion.client.dto.ExpectedStatsResponse; +import com.mlbstats.ingestion.client.dto.SabermetricsResponse; +import com.mlbstats.ingestion.client.dto.SeasonAdvancedResponse; import com.mlbstats.ingestion.client.dto.StatsResponse; import org.springframework.stereotype.Component; import java.math.BigDecimal; +import java.math.RoundingMode; @Component public class StatsMapper { @@ -188,4 +192,112 @@ private BigDecimal parseDecimal(String value) { return null; } } + + // ================================================================================ + // SABERMETRICS MAPPING + // ================================================================================ + + /** + * Applies sabermetric stats (WAR, wOBA, wRC+) to batting stats. + */ + public void applySabermetrics(PlayerBattingStats stats, SabermetricsResponse.SabermetricData saber) { + if (saber == null) return; + + stats.setWar(saber.getWar()); + stats.setWoba(saber.getWoba()); + if (saber.getWRcPlus() != null) { + stats.setWrcPlus(saber.getWRcPlus().intValue()); + } + } + + /** + * Applies sabermetric stats (WAR, FIP, xFIP) to pitching stats. + */ + public void applySabermetrics(PlayerPitchingStats stats, SabermetricsResponse.SabermetricData saber) { + if (saber == null) return; + + stats.setWar(saber.getWar()); + stats.setFip(saber.getFip()); + stats.setXfip(saber.getXfip()); + } + + /** + * Applies expected stats (xBA, xSLG, xwOBA) to batting stats. + */ + public void applyExpectedStats(PlayerBattingStats stats, ExpectedStatsResponse.ExpectedStatData expected) { + if (expected == null) return; + + stats.setXba(parseDecimal(expected.getAvg())); + stats.setXslg(parseDecimal(expected.getSlg())); + stats.setXwoba(parseDecimal(expected.getWoba())); + } + + /** + * Applies season advanced stats (BABIP, K%, BB%) to batting stats. + */ + public void applySeasonAdvanced(PlayerBattingStats stats, SeasonAdvancedResponse.AdvancedStatData advanced) { + if (advanced == null) return; + + stats.setBabip(parseDecimal(advanced.getBabip())); + + // Convert decimal rates to percentages (e.g., 0.222 -> 22.2) + BigDecimal kPct = parseDecimalToPercent(advanced.getStrikeoutsPerPlateAppearance()); + BigDecimal bbPct = parseDecimalToPercent(advanced.getWalksPerPlateAppearance()); + + if (kPct != null) stats.setKPct(kPct); + if (bbPct != null) stats.setBbPct(bbPct); + } + + /** + * Applies season advanced stats (QS, whiff%, GB%, FB%) to pitching stats. + */ + public void applySeasonAdvanced(PlayerPitchingStats stats, SeasonAdvancedResponse.AdvancedStatData advanced) { + if (advanced == null) return; + + stats.setQualityStarts(advanced.getQualityStarts()); + + // Convert decimal rates to percentages + BigDecimal whiffPct = parseDecimalToPercent(advanced.getWhiffPercentage()); + BigDecimal fbPct = parseDecimalToPercent(advanced.getFlyBallPercentage()); + + if (whiffPct != null) stats.setWhiffPct(whiffPct); + if (fbPct != null) stats.setFbPct(fbPct); + + // Calculate GB% from batted ball data + BigDecimal gbPct = calculateGroundBallPct(advanced); + if (gbPct != null) stats.setGbPct(gbPct); + } + + /** + * Parses a decimal string and converts to percentage. + * E.g., "0.222" -> 22.2 + */ + private BigDecimal parseDecimalToPercent(String value) { + BigDecimal decimal = parseDecimal(value); + if (decimal == null) return null; + return decimal.multiply(new BigDecimal("100")).setScale(1, RoundingMode.HALF_UP); + } + + /** + * Calculates ground ball percentage from batted ball data. + * GB% = groundOuts / (groundOuts + flyOuts + lineOuts + popOuts) * 100 + */ + private BigDecimal calculateGroundBallPct(SeasonAdvancedResponse.AdvancedStatData advanced) { + Integer groundOuts = advanced.getGroundOuts(); + Integer flyOuts = advanced.getFlyOuts(); + Integer lineOuts = advanced.getLineOuts(); + Integer popOuts = advanced.getPopOuts(); + + if (groundOuts == null || flyOuts == null || lineOuts == null || popOuts == null) { + return null; + } + + int total = groundOuts + flyOuts + lineOuts + popOuts; + if (total == 0) return null; + + return new BigDecimal(groundOuts) + .divide(new BigDecimal(total), 4, RoundingMode.HALF_UP) + .multiply(new BigDecimal("100")) + .setScale(1, RoundingMode.HALF_UP); + } } diff --git a/backend/src/main/java/com/mlbstats/ingestion/scheduler/IngestionScheduler.java b/backend/src/main/java/com/mlbstats/ingestion/scheduler/IngestionScheduler.java index 0028a36..d64cfc5 100644 --- a/backend/src/main/java/com/mlbstats/ingestion/scheduler/IngestionScheduler.java +++ b/backend/src/main/java/com/mlbstats/ingestion/scheduler/IngestionScheduler.java @@ -3,7 +3,10 @@ import com.mlbstats.common.util.DateUtils; import com.mlbstats.ingestion.service.GameIngestionService; import com.mlbstats.ingestion.service.IngestionOrchestrator; +import com.mlbstats.ingestion.service.OaaIngestionService; import com.mlbstats.ingestion.service.RosterIngestionService; +import com.mlbstats.ingestion.service.SabermetricsIngestionService; +import com.mlbstats.ingestion.service.StatcastIngestionService; import com.mlbstats.ingestion.service.StatsIngestionService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -22,6 +25,9 @@ public class IngestionScheduler { private final GameIngestionService gameIngestionService; private final RosterIngestionService rosterIngestionService; private final StatsIngestionService statsIngestionService; + private final SabermetricsIngestionService sabermetricsIngestionService; + private final OaaIngestionService oaaIngestionService; + private final StatcastIngestionService statcastIngestionService; @Value("${mlb.ingestion.enabled:false}") private boolean ingestionEnabled; @@ -86,4 +92,67 @@ public void weeklyRosterSync() { log.error("Weekly roster sync failed", e); } } + + /** + * Daily: Sabermetrics sync at 6:30 AM (after stats sync) + * Fetches WAR, wOBA, FIP from MLB API and calculates gWAR + */ + @Scheduled(cron = "0 30 6 * * *") + public void dailySabermetricsSync() { + if (!ingestionEnabled) { + log.debug("Scheduled ingestion is disabled"); + return; + } + + log.info("Starting daily sabermetrics sync"); + try { + int season = DateUtils.getCurrentSeason(); + sabermetricsIngestionService.syncAllPlayerSabermetrics(season); + log.info("Daily sabermetrics sync completed"); + } catch (Exception e) { + log.error("Daily sabermetrics sync failed", e); + } + } + + /** + * Weekly: OAA (Outs Above Average) sync on Sunday at 7 AM + * Fetches OAA from Baseball Savant and recalculates gWAR + */ + @Scheduled(cron = "0 0 7 * * SUN") + public void weeklyOaaSync() { + if (!ingestionEnabled) { + log.debug("Scheduled ingestion is disabled"); + return; + } + + log.info("Starting weekly OAA sync"); + try { + int season = DateUtils.getCurrentSeason(); + oaaIngestionService.syncOaaForSeason(season); + log.info("Weekly OAA sync completed"); + } catch (Exception e) { + log.error("Weekly OAA sync failed", e); + } + } + + /** + * Weekly: Statcast expected stats sync on Sunday at 7:30 AM + * Fetches xBA, xSLG, xwOBA, exit velocity, barrel% from Baseball Savant + */ + @Scheduled(cron = "0 30 7 * * SUN") + public void weeklyStatcastSync() { + if (!ingestionEnabled) { + log.debug("Scheduled ingestion is disabled"); + return; + } + + log.info("Starting weekly Statcast sync"); + try { + int season = DateUtils.getCurrentSeason(); + statcastIngestionService.syncAllStatcastData(season); + log.info("Weekly Statcast sync completed"); + } catch (Exception e) { + log.error("Weekly Statcast sync failed", e); + } + } } diff --git a/backend/src/main/java/com/mlbstats/ingestion/service/OaaIngestionService.java b/backend/src/main/java/com/mlbstats/ingestion/service/OaaIngestionService.java new file mode 100644 index 0000000..1543431 --- /dev/null +++ b/backend/src/main/java/com/mlbstats/ingestion/service/OaaIngestionService.java @@ -0,0 +1,90 @@ +package com.mlbstats.ingestion.service; + +import com.mlbstats.domain.gwar.GwarCalculationService; +import com.mlbstats.domain.player.Player; +import com.mlbstats.domain.player.PlayerRepository; +import com.mlbstats.domain.stats.PlayerBattingStats; +import com.mlbstats.domain.stats.PlayerBattingStatsRepository; +import com.mlbstats.ingestion.client.BaseballSavantClient; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Service for ingesting OAA (Outs Above Average) data from Baseball Savant. + * OAA is used as the fielding component of gWAR calculations. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class OaaIngestionService { + + private final BaseballSavantClient savantClient; + private final PlayerRepository playerRepo; + private final PlayerBattingStatsRepository battingRepo; + private final GwarCalculationService gwarService; + + /** + * Syncs OAA data for all qualified fielders in a season. + * After setting OAA, recalculates gWAR for affected players. + * + * @param season Season year + * @return Number of players updated + */ + @Transactional + public int syncOaaForSeason(Integer season) { + log.info("Starting OAA sync for season {}", season); + + Map oaaByMlbId = savantClient.getOaaByPlayerId(season); + if (oaaByMlbId.isEmpty()) { + log.warn("No OAA data received from Baseball Savant for season {}", season); + return 0; + } + + int updated = 0; + + for (Map.Entry entry : oaaByMlbId.entrySet()) { + Integer mlbId = entry.getKey(); + Integer oaa = entry.getValue(); + + Optional playerOpt = playerRepo.findByMlbId(mlbId); + if (playerOpt.isEmpty()) { + log.trace("Player with MLB ID {} not found in database", mlbId); + continue; + } + + Player player = playerOpt.get(); + List statsList = battingRepo.findByPlayerIdAndSeason(player.getId(), season); + + for (PlayerBattingStats stats : statsList) { + stats.setOaa(oaa); + + // Recalculate gWAR with updated fielding component + gwarService.calculateAndApply(stats, player.getPosition()); + + battingRepo.save(stats); + updated++; + } + } + + log.info("OAA sync completed for season {}: {} player-stats updated", season, updated); + return updated; + } + + /** + * Gets OAA for a specific player without persisting. + * + * @param mlbId MLB player ID + * @param season Season year + * @return OAA value if available, null otherwise + */ + public Integer getPlayerOaa(Integer mlbId, Integer season) { + Map oaaData = savantClient.getOaaByPlayerId(season); + return oaaData.get(mlbId); + } +} diff --git a/backend/src/main/java/com/mlbstats/ingestion/service/SabermetricsIngestionService.java b/backend/src/main/java/com/mlbstats/ingestion/service/SabermetricsIngestionService.java new file mode 100644 index 0000000..55f316c --- /dev/null +++ b/backend/src/main/java/com/mlbstats/ingestion/service/SabermetricsIngestionService.java @@ -0,0 +1,212 @@ +package com.mlbstats.ingestion.service; + +import com.mlbstats.domain.gwar.GwarCalculationService; +import com.mlbstats.domain.player.Player; +import com.mlbstats.domain.player.PlayerRepository; +import com.mlbstats.domain.stats.PlayerBattingStats; +import com.mlbstats.domain.stats.PlayerBattingStatsRepository; +import com.mlbstats.domain.stats.PlayerPitchingStats; +import com.mlbstats.domain.stats.PlayerPitchingStatsRepository; +import com.mlbstats.ingestion.client.MlbApiClient; +import com.mlbstats.ingestion.client.dto.ExpectedStatsResponse; +import com.mlbstats.ingestion.client.dto.SabermetricsResponse; +import com.mlbstats.ingestion.client.dto.SeasonAdvancedResponse; +import com.mlbstats.ingestion.mapper.StatsMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Service for ingesting sabermetric statistics from MLB Stats API. + * Fetches official WAR, wOBA, FIP, and other advanced stats, then calculates gWAR. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SabermetricsIngestionService { + + private final MlbApiClient mlbApiClient; + private final PlayerRepository playerRepository; + private final PlayerBattingStatsRepository battingRepo; + private final PlayerPitchingStatsRepository pitchingRepo; + private final StatsMapper statsMapper; + private final GwarCalculationService gwarService; + + /** + * Syncs sabermetrics for all players with stats in a given season. + */ + @Transactional + public int syncAllPlayerSabermetrics(Integer season) { + log.info("Starting sabermetrics sync for season {}", season); + + // Get all players with batting or pitching stats for this season + List battingStats = battingRepo.findTopWar(season); + List pitchingStats = pitchingRepo.findTopWar(season); + + AtomicInteger count = new AtomicInteger(0); + + // Process batters + battingStats.stream() + .map(PlayerBattingStats::getPlayer) + .distinct() + .forEach(player -> { + if (syncPlayerBattingSabermetrics(player, season)) { + count.incrementAndGet(); + } + }); + + // Process pitchers + pitchingStats.stream() + .map(PlayerPitchingStats::getPlayer) + .distinct() + .forEach(player -> { + if (syncPlayerPitchingSabermetrics(player, season)) { + count.incrementAndGet(); + } + }); + + log.info("Sabermetrics sync completed for season {}: {} players updated", season, count.get()); + return count.get(); + } + + /** + * Syncs batting sabermetrics for a single player. + */ + @Transactional + public boolean syncPlayerBattingSabermetrics(Player player, Integer season) { + try { + // Fetch sabermetrics from MLB API + SabermetricsResponse saberResponse = mlbApiClient.getBattingSabermetrics(player.getMlbId(), season); + ExpectedStatsResponse expectedResponse = mlbApiClient.getPlayerExpectedStats(player.getMlbId(), season, "hitting"); + SeasonAdvancedResponse advancedResponse = mlbApiClient.getPlayerSeasonAdvanced(player.getMlbId(), season, "hitting"); + + // Extract data from responses + SabermetricsResponse.SabermetricData saberData = extractBattingData(saberResponse); + ExpectedStatsResponse.ExpectedStatData expectedData = extractExpectedData(expectedResponse); + SeasonAdvancedResponse.AdvancedStatData advancedData = extractAdvancedData(advancedResponse); + + // Find existing batting stats + List statsList = battingRepo.findByPlayerIdAndSeason(player.getId(), season); + + for (PlayerBattingStats stats : statsList) { + // Apply sabermetrics + statsMapper.applySabermetrics(stats, saberData); + statsMapper.applyExpectedStats(stats, expectedData); + statsMapper.applySeasonAdvanced(stats, advancedData); + + // Calculate gWAR + gwarService.calculateAndApply(stats, player.getPosition()); + + battingRepo.save(stats); + } + + log.debug("Updated batting sabermetrics for {} ({})", player.getFullName(), season); + return !statsList.isEmpty(); + + } catch (Exception e) { + log.warn("Failed to sync batting sabermetrics for player {}: {}", player.getMlbId(), e.getMessage()); + return false; + } + } + + /** + * Syncs pitching sabermetrics for a single player. + */ + @Transactional + public boolean syncPlayerPitchingSabermetrics(Player player, Integer season) { + try { + // Fetch sabermetrics from MLB API + SabermetricsResponse saberResponse = mlbApiClient.getPitchingSabermetrics(player.getMlbId(), season); + SeasonAdvancedResponse advancedResponse = mlbApiClient.getPlayerSeasonAdvanced(player.getMlbId(), season, "pitching"); + + // Extract data from responses + SabermetricsResponse.SabermetricData saberData = extractPitchingData(saberResponse); + SeasonAdvancedResponse.AdvancedStatData advancedData = extractAdvancedData(advancedResponse); + + // Find existing pitching stats + List statsList = pitchingRepo.findByPlayerIdAndSeason(player.getId(), season); + + for (PlayerPitchingStats stats : statsList) { + // Apply sabermetrics + statsMapper.applySabermetrics(stats, saberData); + statsMapper.applySeasonAdvanced(stats, advancedData); + + // Calculate gWAR + gwarService.calculateAndApply(stats); + + pitchingRepo.save(stats); + } + + log.debug("Updated pitching sabermetrics for {} ({})", player.getFullName(), season); + return !statsList.isEmpty(); + + } catch (Exception e) { + log.warn("Failed to sync pitching sabermetrics for player {}: {}", player.getMlbId(), e.getMessage()); + return false; + } + } + + /** + * Syncs all sabermetrics for a single player. + */ + @Transactional + public void syncPlayerSabermetrics(Player player, Integer season) { + syncPlayerBattingSabermetrics(player, season); + syncPlayerPitchingSabermetrics(player, season); + } + + // ================================================================================ + // EXTRACTION HELPERS + // ================================================================================ + + private SabermetricsResponse.SabermetricData extractBattingData(SabermetricsResponse response) { + if (response == null || response.getStats() == null || response.getStats().isEmpty()) { + return null; + } + + return response.getStats().stream() + .filter(g -> g.getSplits() != null && !g.getSplits().isEmpty()) + .flatMap(g -> g.getSplits().stream()) + .filter(s -> s.getStat() != null) + .map(SabermetricsResponse.StatSplit::getStat) + .findFirst() + .orElse(null); + } + + private SabermetricsResponse.SabermetricData extractPitchingData(SabermetricsResponse response) { + // Same logic as batting - structure is identical + return extractBattingData(response); + } + + private ExpectedStatsResponse.ExpectedStatData extractExpectedData(ExpectedStatsResponse response) { + if (response == null || response.getStats() == null || response.getStats().isEmpty()) { + return null; + } + + return response.getStats().stream() + .filter(g -> g.getSplits() != null && !g.getSplits().isEmpty()) + .flatMap(g -> g.getSplits().stream()) + .filter(s -> s.getStat() != null) + .map(ExpectedStatsResponse.StatSplit::getStat) + .findFirst() + .orElse(null); + } + + private SeasonAdvancedResponse.AdvancedStatData extractAdvancedData(SeasonAdvancedResponse response) { + if (response == null || response.getStats() == null || response.getStats().isEmpty()) { + return null; + } + + return response.getStats().stream() + .filter(g -> g.getSplits() != null && !g.getSplits().isEmpty()) + .flatMap(g -> g.getSplits().stream()) + .filter(s -> s.getStat() != null) + .map(SeasonAdvancedResponse.StatSplit::getStat) + .findFirst() + .orElse(null); + } +} diff --git a/backend/src/main/java/com/mlbstats/ingestion/service/StatcastIngestionService.java b/backend/src/main/java/com/mlbstats/ingestion/service/StatcastIngestionService.java new file mode 100644 index 0000000..0413ae0 --- /dev/null +++ b/backend/src/main/java/com/mlbstats/ingestion/service/StatcastIngestionService.java @@ -0,0 +1,111 @@ +package com.mlbstats.ingestion.service; + +import com.mlbstats.domain.player.Player; +import com.mlbstats.domain.player.PlayerRepository; +import com.mlbstats.domain.stats.PlayerBattingStats; +import com.mlbstats.domain.stats.PlayerBattingStatsRepository; +import com.mlbstats.ingestion.client.BaseballSavantClient; +import com.mlbstats.ingestion.client.BaseballSavantClient.ExpectedStatsData; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Service for ingesting Statcast expected stats from Baseball Savant. + * Populates xBA, xSLG, xwOBA, exit velocity, barrel%, hard hit%, and sprint speed. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class StatcastIngestionService { + + private final BaseballSavantClient savantClient; + private final PlayerRepository playerRepo; + private final PlayerBattingStatsRepository battingRepo; + + /** + * Syncs expected stats for all qualified batters in a season. + * + * @param season Season year + * @return Number of players updated + */ + @Transactional + public int syncExpectedStats(Integer season) { + log.info("Starting expected stats sync for season {}", season); + + Map statsByMlbId = savantClient.getExpectedStatsByPlayerId(season); + Map sprintByMlbId = savantClient.getSprintSpeedByPlayerId(season); + + if (statsByMlbId.isEmpty()) { + log.warn("No expected stats data received from Baseball Savant for season {}", season); + return 0; + } + + int updated = 0; + + for (Map.Entry entry : statsByMlbId.entrySet()) { + Integer mlbId = entry.getKey(); + ExpectedStatsData data = entry.getValue(); + + Optional playerOpt = playerRepo.findByMlbId(mlbId); + if (playerOpt.isEmpty()) { + log.trace("Player with MLB ID {} not found in database", mlbId); + continue; + } + + Player player = playerOpt.get(); + List statsList = battingRepo.findByPlayerIdAndSeason(player.getId(), season); + + // Get sprint speed if available + BigDecimal sprintSpeed = sprintByMlbId.get(mlbId); + + for (PlayerBattingStats stats : statsList) { + applyExpectedStats(stats, data, sprintSpeed); + battingRepo.save(stats); + updated++; + } + } + + log.info("Expected stats sync completed for season {}: {} player-stats updated", season, updated); + return updated; + } + + /** + * Applies expected stats data to a batting stats entity. + */ + private void applyExpectedStats(PlayerBattingStats stats, ExpectedStatsData data, BigDecimal sprintSpeed) { + if (data == null) return; + + // xStats + if (data.xba() != null) stats.setXba(data.xba()); + if (data.xslg() != null) stats.setXslg(data.xslg()); + if (data.xwoba() != null) stats.setXwoba(data.xwoba()); + + // Statcast metrics + if (data.avgExitVelocity() != null) stats.setAvgExitVelocity(data.avgExitVelocity()); + if (data.avgLaunchAngle() != null) stats.setAvgLaunchAngle(data.avgLaunchAngle()); + if (data.barrelPct() != null) stats.setBarrelPct(data.barrelPct()); + if (data.hardHitPct() != null) stats.setHardHitPct(data.hardHitPct()); + + // Sprint speed (from separate endpoint) + if (sprintSpeed != null) stats.setSprintSpeed(sprintSpeed); + } + + /** + * Syncs all Statcast data for a season. + * This includes expected stats and sprint speed. + * + * @param season Season year + * @return Number of players updated + */ + @Transactional + public int syncAllStatcastData(Integer season) { + return syncExpectedStats(season); + } +} diff --git a/backend/src/main/resources/db/migration/V14__gwar_and_sabermetrics.sql b/backend/src/main/resources/db/migration/V14__gwar_and_sabermetrics.sql new file mode 100644 index 0000000..18be05b --- /dev/null +++ b/backend/src/main/resources/db/migration/V14__gwar_and_sabermetrics.sql @@ -0,0 +1,69 @@ +-- gWAR (Grove WAR) and Sabermetrics Enhancement +-- Adds gWAR calculation columns and league constants for transparent WAR calculations +-- See issue #170 + +-- ============================================================================= +-- LEAGUE CONSTANTS TABLE +-- Stores season-specific constants needed for gWAR calculations +-- ============================================================================= + +CREATE TABLE league_constants ( + id BIGSERIAL PRIMARY KEY, + season INTEGER NOT NULL UNIQUE, + lg_woba DECIMAL(5,4) NOT NULL, + woba_scale DECIMAL(5,4) NOT NULL, + lg_r_per_pa DECIMAL(6,5) NOT NULL, + fip_constant DECIMAL(4,2) NOT NULL, + runs_per_win DECIMAL(4,2) DEFAULT 10.0, + created_at TIMESTAMP DEFAULT NOW() +); + +-- Seed recent years with league constants +-- Source: FanGraphs guts! constants +INSERT INTO league_constants (season, lg_woba, woba_scale, lg_r_per_pa, fip_constant) VALUES +(2024, 0.3100, 1.177, 0.1120, 3.15), +(2023, 0.3180, 1.169, 0.1100, 3.10), +(2022, 0.3100, 1.157, 0.1060, 3.15), +(2021, 0.3180, 1.153, 0.1110, 3.13), +(2020, 0.3200, 1.167, 0.1100, 3.16); + +-- ============================================================================= +-- BATTING STATS: gWAR COMPONENTS +-- ============================================================================= + +-- gWAR total for position players +ALTER TABLE player_batting_stats ADD COLUMN gwar DECIMAL(4,1); + +-- gWAR components (stored in runs above average) +ALTER TABLE player_batting_stats ADD COLUMN gwar_batting DECIMAL(5,1); +ALTER TABLE player_batting_stats ADD COLUMN gwar_baserunning DECIMAL(5,1); +ALTER TABLE player_batting_stats ADD COLUMN gwar_fielding DECIMAL(5,1); +ALTER TABLE player_batting_stats ADD COLUMN gwar_positional DECIMAL(5,1); +ALTER TABLE player_batting_stats ADD COLUMN gwar_replacement DECIMAL(5,1); + +-- OAA (Outs Above Average) from Baseball Savant - used for fielding component +ALTER TABLE player_batting_stats ADD COLUMN oaa INTEGER; + +-- ============================================================================= +-- PITCHING STATS: gWAR COMPONENTS +-- ============================================================================= + +-- gWAR total for pitchers +ALTER TABLE player_pitching_stats ADD COLUMN gwar DECIMAL(4,1); + +-- gWAR components (stored in runs above average) +ALTER TABLE player_pitching_stats ADD COLUMN gwar_pitching DECIMAL(5,1); +ALTER TABLE player_pitching_stats ADD COLUMN gwar_replacement DECIMAL(5,1); + +-- ============================================================================= +-- LEADERBOARD INDEXES +-- ============================================================================= + +-- Batting gWAR leaderboard +CREATE INDEX idx_batting_gwar ON player_batting_stats(season, gwar DESC NULLS LAST); + +-- Pitching gWAR leaderboard +CREATE INDEX idx_pitching_gwar ON player_pitching_stats(season, gwar DESC NULLS LAST); + +-- OAA leaderboard +CREATE INDEX idx_batting_oaa ON player_batting_stats(season, oaa DESC NULLS LAST); diff --git a/backend/src/test/java/com/mlbstats/domain/gwar/GwarCalculationServiceTest.java b/backend/src/test/java/com/mlbstats/domain/gwar/GwarCalculationServiceTest.java new file mode 100644 index 0000000..51342ce --- /dev/null +++ b/backend/src/test/java/com/mlbstats/domain/gwar/GwarCalculationServiceTest.java @@ -0,0 +1,258 @@ +package com.mlbstats.domain.gwar; + +import com.mlbstats.domain.constants.LeagueConstants; +import com.mlbstats.domain.constants.LeagueConstantsRepository; +import com.mlbstats.domain.player.Player; +import com.mlbstats.domain.stats.PlayerBattingStats; +import com.mlbstats.domain.stats.PlayerPitchingStats; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class GwarCalculationServiceTest { + + @Mock + private LeagueConstantsRepository constantsRepo; + + private GwarCalculationService gwarService; + + private LeagueConstants leagueConstants; + + @BeforeEach + void setUp() { + gwarService = new GwarCalculationService(constantsRepo); + + // Set up 2024 league constants (approximating FanGraphs values) + leagueConstants = new LeagueConstants(); + leagueConstants.setSeason(2024); + leagueConstants.setLgWoba(new BigDecimal("0.310")); + leagueConstants.setWobaScale(new BigDecimal("1.177")); + leagueConstants.setLgRPerPa(new BigDecimal("0.112")); + leagueConstants.setFipConstant(new BigDecimal("3.15")); + leagueConstants.setRunsPerWin(new BigDecimal("10.0")); + + lenient().when(constantsRepo.findBySeason(2024)).thenReturn(Optional.of(leagueConstants)); + } + + @Test + void calculateAndApply_shouldCalculateGwarForBatter() { + // Create a star batter (MVP-caliber stats like Ohtani 2024) + PlayerBattingStats stats = createBattingStats(2024, 636, 50, 10, 5); + stats.setWoba(new BigDecimal("0.430")); // Elite wOBA + stats.setOaa(5); // Above average fielder + + gwarService.calculateAndApply(stats, "DH"); + + // Verify gWAR is calculated + assertThat(stats.getGwar()).isNotNull(); + assertThat(stats.getGwarBatting()).isNotNull(); + assertThat(stats.getGwarBaserunning()).isNotNull(); + assertThat(stats.getGwarFielding()).isNotNull(); + assertThat(stats.getGwarPositional()).isNotNull(); + assertThat(stats.getGwarReplacement()).isNotNull(); + + // For an elite batter, gWAR should be positive and significant + assertThat(stats.getGwar()).isGreaterThan(BigDecimal.ZERO); + + // Batting component should be positive for above-league wOBA + assertThat(stats.getGwarBatting()).isGreaterThan(BigDecimal.ZERO); + + // Baserunning should reflect SB/CS: 50 SB * 0.2 + 10 CS * -0.41 = 10 - 4.1 = 5.9 + assertThat(stats.getGwarBaserunning()).isEqualByComparingTo("5.9"); + + // Fielding = OAA * 0.9 = 5 * 0.9 = 4.5 + assertThat(stats.getGwarFielding()).isEqualByComparingTo("4.5"); + + // DH positional adjustment is negative (-17.5 prorated) + assertThat(stats.getGwarPositional()).isLessThan(BigDecimal.ZERO); + + // Replacement level is always positive + assertThat(stats.getGwarReplacement()).isGreaterThan(BigDecimal.ZERO); + } + + @Test + void calculateAndApply_shouldHandleZeroPa() { + PlayerBattingStats stats = createBattingStats(2024, 0, 0, 0, 0); + + gwarService.calculateAndApply(stats, "SS"); + + assertThat(stats.getGwar()).isEqualByComparingTo("0.0"); + assertThat(stats.getGwarBatting()).isEqualByComparingTo("0.0"); + assertThat(stats.getGwarBaserunning()).isEqualByComparingTo("0.0"); + assertThat(stats.getGwarReplacement()).isEqualByComparingTo("0.0"); + } + + @Test + void calculateAndApply_shouldHandleNullWoba() { + PlayerBattingStats stats = createBattingStats(2024, 400, 20, 5, 120); + stats.setWoba(null); + + gwarService.calculateAndApply(stats, "CF"); + + assertThat(stats.getGwar()).isNotNull(); + // Batting should be 0 if wOBA is null + assertThat(stats.getGwarBatting()).isEqualByComparingTo("0.0"); + } + + @Test + void calculateAndApply_shouldHandleNullOaa() { + PlayerBattingStats stats = createBattingStats(2024, 400, 20, 5, 120); + stats.setWoba(new BigDecimal("0.350")); + stats.setOaa(null); + + gwarService.calculateAndApply(stats, "2B"); + + assertThat(stats.getGwar()).isNotNull(); + // Fielding should be 0 if OAA is null + assertThat(stats.getGwarFielding()).isEqualByComparingTo("0.0"); + } + + @Test + void calculateAndApply_shouldApplyCorrectPositionalAdjustment() { + PlayerBattingStats stats = createBattingStats(2024, 600, 20, 5, 150); + stats.setWoba(new BigDecimal("0.320")); + + // Catcher gets +12.5 per 162 games + gwarService.calculateAndApply(stats, "C"); + BigDecimal catcherPositional = stats.getGwarPositional(); + + // Prorated: 150/162 * 12.5 ≈ 11.6 + assertThat(catcherPositional).isGreaterThan(new BigDecimal("11.0")); + + // DH gets -17.5 per 162 games + gwarService.calculateAndApply(stats, "DH"); + BigDecimal dhPositional = stats.getGwarPositional(); + + // Prorated: 150/162 * -17.5 ≈ -16.2 + assertThat(dhPositional).isLessThan(new BigDecimal("-16.0")); + } + + @Test + void calculateAndApply_shouldCalculateGwarForPitcher() { + PlayerPitchingStats stats = createPitchingStats(2024, new BigDecimal("180.0")); + stats.setFip(new BigDecimal("2.50")); // Elite FIP + + gwarService.calculateAndApply(stats); + + assertThat(stats.getGwar()).isNotNull(); + assertThat(stats.getGwarPitching()).isNotNull(); + assertThat(stats.getGwarReplacement()).isNotNull(); + + // Elite pitcher should have positive gWAR + assertThat(stats.getGwar()).isGreaterThan(BigDecimal.ZERO); + + // Below-league FIP should give positive pitching runs + assertThat(stats.getGwarPitching()).isGreaterThan(BigDecimal.ZERO); + + // Replacement level is always positive + assertThat(stats.getGwarReplacement()).isGreaterThan(BigDecimal.ZERO); + } + + @Test + void calculateAndApply_shouldHandleZeroInnings() { + PlayerPitchingStats stats = createPitchingStats(2024, BigDecimal.ZERO); + stats.setFip(new BigDecimal("4.00")); + + gwarService.calculateAndApply(stats); + + assertThat(stats.getGwar()).isEqualByComparingTo("0.0"); + assertThat(stats.getGwarPitching()).isEqualByComparingTo("0.0"); + assertThat(stats.getGwarReplacement()).isEqualByComparingTo("0.0"); + } + + @Test + void calculateAndApply_shouldHandleNullFip() { + PlayerPitchingStats stats = createPitchingStats(2024, new BigDecimal("150.0")); + stats.setFip(null); + + gwarService.calculateAndApply(stats); + + assertThat(stats.getGwar()).isNotNull(); + // Pitching should be 0 if FIP is null + assertThat(stats.getGwarPitching()).isEqualByComparingTo("0.0"); + } + + @Test + void calculateAndApply_shouldSkipIfNoLeagueConstants() { + // Need to set up specific mock for this test since setUp uses 2024 + when(constantsRepo.findBySeason(2020)).thenReturn(Optional.empty()); + + PlayerBattingStats stats = createBattingStats(2020, 500, 20, 5, 140); + stats.setWoba(new BigDecimal("0.350")); + + gwarService.calculateAndApply(stats, "SS"); + + // Should not throw, but gWAR should remain null + assertThat(stats.getGwar()).isNull(); + } + + @Test + void calculateAndApply_shouldUseLeagueConstants() { + // Verify that the setup works correctly + PlayerBattingStats stats = createBattingStats(2024, 500, 20, 5, 140); + stats.setWoba(new BigDecimal("0.350")); + + gwarService.calculateAndApply(stats, "SS"); + + // Should calculate gWAR with 2024 constants + assertThat(stats.getGwar()).isNotNull(); + } + + @Test + void calculateAndApply_shouldNormalizePositions() { + PlayerBattingStats stats = createBattingStats(2024, 600, 20, 5, 150); + stats.setWoba(new BigDecimal("0.320")); + + // Test various position formats + gwarService.calculateAndApply(stats, "Shortstop"); + BigDecimal ssPositional = stats.getGwarPositional(); + + gwarService.calculateAndApply(stats, "SS"); + BigDecimal ssAbbrevPositional = stats.getGwarPositional(); + + assertThat(ssPositional).isEqualByComparingTo(ssAbbrevPositional); + } + + // ================================================================================ + // HELPER METHODS + // ================================================================================ + + private PlayerBattingStats createBattingStats(int season, int pa, int sb, int cs, int games) { + Player player = new Player(); + player.setId(1L); + player.setMlbId(660271); + player.setFullName("Shohei Ohtani"); + + PlayerBattingStats stats = new PlayerBattingStats(); + stats.setPlayer(player); + stats.setSeason(season); + stats.setPlateAppearances(pa); + stats.setStolenBases(sb); + stats.setCaughtStealing(cs); + stats.setGamesPlayed(games); + return stats; + } + + private PlayerPitchingStats createPitchingStats(int season, BigDecimal ip) { + Player player = new Player(); + player.setId(2L); + player.setMlbId(592789); + player.setFullName("Blake Snell"); + + PlayerPitchingStats stats = new PlayerPitchingStats(); + stats.setPlayer(player); + stats.setSeason(season); + stats.setInningsPitched(ip); + return stats; + } +} diff --git a/backend/src/test/java/com/mlbstats/ingestion/mapper/StatsMapperTest.java b/backend/src/test/java/com/mlbstats/ingestion/mapper/StatsMapperTest.java new file mode 100644 index 0000000..901964c --- /dev/null +++ b/backend/src/test/java/com/mlbstats/ingestion/mapper/StatsMapperTest.java @@ -0,0 +1,172 @@ +package com.mlbstats.ingestion.mapper; + +import com.mlbstats.domain.player.Player; +import com.mlbstats.domain.stats.PlayerBattingStats; +import com.mlbstats.domain.stats.PlayerPitchingStats; +import com.mlbstats.domain.team.Team; +import com.mlbstats.ingestion.client.dto.ExpectedStatsResponse; +import com.mlbstats.ingestion.client.dto.SabermetricsResponse; +import com.mlbstats.ingestion.client.dto.SeasonAdvancedResponse; +import com.mlbstats.ingestion.client.dto.StatsResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; + +class StatsMapperTest { + + private StatsMapper statsMapper; + private Player player; + private Team team; + + @BeforeEach + void setUp() { + statsMapper = new StatsMapper(); + + player = new Player(); + player.setId(1L); + player.setMlbId(660271); + player.setFullName("Shohei Ohtani"); + + team = new Team(); + team.setId(1L); + team.setMlbId(119); + team.setName("Los Angeles Dodgers"); + } + + @Test + void applySabermetrics_shouldSetBattingWARAndWoba() { + PlayerBattingStats stats = new PlayerBattingStats(); + stats.setPlayer(player); + stats.setTeam(team); + stats.setSeason(2024); + + SabermetricsResponse.SabermetricData saber = new SabermetricsResponse.SabermetricData(); + saber.setWar(new BigDecimal("8.9")); + saber.setWoba(new BigDecimal("0.431")); + saber.setWRcPlus(new BigDecimal("180")); + + statsMapper.applySabermetrics(stats, saber); + + assertThat(stats.getWar()).isEqualByComparingTo("8.9"); + assertThat(stats.getWoba()).isEqualByComparingTo("0.431"); + assertThat(stats.getWrcPlus()).isEqualTo(180); + } + + @Test + void applySabermetrics_shouldSetPitchingWARAndFIP() { + PlayerPitchingStats stats = new PlayerPitchingStats(); + stats.setPlayer(player); + stats.setTeam(team); + stats.setSeason(2024); + + SabermetricsResponse.SabermetricData saber = new SabermetricsResponse.SabermetricData(); + saber.setWar(new BigDecimal("6.0")); + saber.setFip(new BigDecimal("2.49")); + saber.setXfip(new BigDecimal("2.83")); + + statsMapper.applySabermetrics(stats, saber); + + assertThat(stats.getWar()).isEqualByComparingTo("6.0"); + assertThat(stats.getFip()).isEqualByComparingTo("2.49"); + assertThat(stats.getXfip()).isEqualByComparingTo("2.83"); + } + + @Test + void applyExpectedStats_shouldSetXbaXslgXwoba() { + PlayerBattingStats stats = new PlayerBattingStats(); + stats.setPlayer(player); + stats.setTeam(team); + stats.setSeason(2024); + + ExpectedStatsResponse.ExpectedStatData expected = new ExpectedStatsResponse.ExpectedStatData(); + expected.setAvg(".314"); + expected.setSlg(".660"); + expected.setWoba(".438"); + + statsMapper.applyExpectedStats(stats, expected); + + assertThat(stats.getXba()).isEqualByComparingTo("0.314"); + assertThat(stats.getXslg()).isEqualByComparingTo("0.660"); + assertThat(stats.getXwoba()).isEqualByComparingTo("0.438"); + } + + @Test + void applySeasonAdvanced_shouldSetBattingAdvancedStats() { + PlayerBattingStats stats = new PlayerBattingStats(); + stats.setPlayer(player); + stats.setTeam(team); + stats.setSeason(2024); + + SeasonAdvancedResponse.AdvancedStatData advanced = new SeasonAdvancedResponse.AdvancedStatData(); + advanced.setBabip(".336"); + advanced.setWalksPerPlateAppearance(".111"); + advanced.setStrikeoutsPerPlateAppearance(".222"); + + statsMapper.applySeasonAdvanced(stats, advanced); + + assertThat(stats.getBabip()).isEqualByComparingTo("0.336"); + assertThat(stats.getBbPct()).isEqualByComparingTo("11.1"); + assertThat(stats.getKPct()).isEqualByComparingTo("22.2"); + } + + @Test + void applySeasonAdvanced_shouldSetPitchingAdvancedStats() { + PlayerPitchingStats stats = new PlayerPitchingStats(); + stats.setPlayer(player); + stats.setTeam(team); + stats.setSeason(2024); + + SeasonAdvancedResponse.AdvancedStatData advanced = new SeasonAdvancedResponse.AdvancedStatData(); + advanced.setQualityStarts(22); + advanced.setWhiffPercentage(".319"); + advanced.setFlyBallPercentage(".247"); + advanced.setGroundOuts(169); + advanced.setFlyOuts(96); + advanced.setLineOuts(40); + advanced.setPopOuts(34); + advanced.setBallsInPlay(481); + + statsMapper.applySeasonAdvanced(stats, advanced); + + assertThat(stats.getQualityStarts()).isEqualTo(22); + assertThat(stats.getWhiffPct()).isEqualByComparingTo("31.9"); + assertThat(stats.getFbPct()).isEqualByComparingTo("24.7"); + // GB% = groundOuts / (groundOuts + flyOuts + lineOuts + popOuts) * 100 + // = 169 / 339 * 100 = 49.9 + assertThat(stats.getGbPct()).isEqualByComparingTo("49.9"); + } + + @Test + void applySabermetrics_shouldHandleNullInput() { + PlayerBattingStats stats = new PlayerBattingStats(); + stats.setPlayer(player); + + statsMapper.applySabermetrics(stats, null); + + assertThat(stats.getWar()).isNull(); + assertThat(stats.getWoba()).isNull(); + } + + @Test + void applyExpectedStats_shouldHandleNullInput() { + PlayerBattingStats stats = new PlayerBattingStats(); + stats.setPlayer(player); + + statsMapper.applyExpectedStats(stats, null); + + assertThat(stats.getXba()).isNull(); + } + + @Test + void applySeasonAdvanced_shouldHandleNullInput() { + PlayerBattingStats stats = new PlayerBattingStats(); + stats.setPlayer(player); + + statsMapper.applySeasonAdvanced(stats, null); + + assertThat(stats.getBabip()).isNull(); + } +} diff --git a/backend/src/test/java/com/mlbstats/ingestion/service/SabermetricsIngestionServiceTest.java b/backend/src/test/java/com/mlbstats/ingestion/service/SabermetricsIngestionServiceTest.java new file mode 100644 index 0000000..99e08b7 --- /dev/null +++ b/backend/src/test/java/com/mlbstats/ingestion/service/SabermetricsIngestionServiceTest.java @@ -0,0 +1,200 @@ +package com.mlbstats.ingestion.service; + +import com.mlbstats.BaseIntegrationTest; +import com.mlbstats.domain.constants.LeagueConstants; +import com.mlbstats.domain.constants.LeagueConstantsRepository; +import com.mlbstats.domain.gwar.GwarCalculationService; +import com.mlbstats.domain.player.Player; +import com.mlbstats.domain.stats.PlayerBattingStats; +import com.mlbstats.domain.stats.PlayerBattingStatsRepository; +import com.mlbstats.domain.stats.PlayerPitchingStats; +import com.mlbstats.domain.stats.PlayerPitchingStatsRepository; +import com.mlbstats.ingestion.client.MlbApiClient; +import com.mlbstats.ingestion.client.dto.SabermetricsResponse; +import com.mlbstats.ingestion.mapper.StatsMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +class SabermetricsIngestionServiceTest extends BaseIntegrationTest { + + @MockitoBean + private MlbApiClient mlbApiClient; + + @Autowired + private SabermetricsIngestionService sabermetricsIngestionService; + + @Autowired + private PlayerBattingStatsRepository battingRepo; + + @Autowired + private PlayerPitchingStatsRepository pitchingRepo; + + @Autowired + private LeagueConstantsRepository constantsRepo; + + @Autowired + private StatsMapper statsMapper; + + @Autowired + private GwarCalculationService gwarService; + + private Player testPlayer; + private PlayerBattingStats testBattingStats; + + @BeforeEach + void setUp() { + // Create league constants for 2024 + LeagueConstants lc = new LeagueConstants(); + lc.setSeason(2024); + lc.setLgWoba(new BigDecimal("0.310")); + lc.setWobaScale(new BigDecimal("1.177")); + lc.setLgRPerPa(new BigDecimal("0.112")); + lc.setFipConstant(new BigDecimal("3.15")); + lc.setRunsPerWin(new BigDecimal("10.0")); + constantsRepo.save(lc); + + // Create test player and team + testPlayer = createTestPlayer(660271, "Shohei Ohtani", "DH"); + + // Create batting stats + testBattingStats = new PlayerBattingStats(); + testBattingStats.setPlayer(testPlayer); + testBattingStats.setTeam(createTestTeam(119, "Los Angeles Dodgers", "LAD")); + testBattingStats.setSeason(2024); + testBattingStats.setGameType("R"); + testBattingStats.setPlateAppearances(636); + testBattingStats.setGamesPlayed(159); + testBattingStats.setStolenBases(50); + testBattingStats.setCaughtStealing(10); + testBattingStats = battingRepo.save(testBattingStats); + } + + @Test + void syncPlayerBattingSabermetrics_shouldApplyWarAndWoba() { + // Mock MLB API response + SabermetricsResponse response = createMockSabermetricsResponse( + new BigDecimal("8.9"), // WAR + new BigDecimal("0.431"), // wOBA + new BigDecimal("180") // wRC+ + ); + when(mlbApiClient.getBattingSabermetrics(660271, 2024)).thenReturn(response); + when(mlbApiClient.getPlayerExpectedStats(anyInt(), anyInt(), eq("hitting"))).thenReturn(null); + when(mlbApiClient.getPlayerSeasonAdvanced(anyInt(), anyInt(), eq("hitting"))).thenReturn(null); + + boolean updated = sabermetricsIngestionService.syncPlayerBattingSabermetrics(testPlayer, 2024); + + assertThat(updated).isTrue(); + + PlayerBattingStats stats = battingRepo.findById(testBattingStats.getId()).orElseThrow(); + assertThat(stats.getWar()).isEqualByComparingTo("8.9"); + assertThat(stats.getWoba()).isEqualByComparingTo("0.431"); + assertThat(stats.getWrcPlus()).isEqualTo(180); + + // gWAR should also be calculated + assertThat(stats.getGwar()).isNotNull(); + assertThat(stats.getGwarBatting()).isNotNull(); + } + + @Test + void syncPlayerBattingSabermetrics_shouldHandleApiError() { + when(mlbApiClient.getBattingSabermetrics(660271, 2024)).thenReturn(null); + when(mlbApiClient.getPlayerExpectedStats(anyInt(), anyInt(), eq("hitting"))).thenReturn(null); + when(mlbApiClient.getPlayerSeasonAdvanced(anyInt(), anyInt(), eq("hitting"))).thenReturn(null); + + boolean updated = sabermetricsIngestionService.syncPlayerBattingSabermetrics(testPlayer, 2024); + + // Should return true (stats exist) but WAR should remain null + assertThat(updated).isTrue(); + + PlayerBattingStats stats = battingRepo.findById(testBattingStats.getId()).orElseThrow(); + assertThat(stats.getWar()).isNull(); + } + + @Test + void syncPlayerPitchingSabermetrics_shouldApplyFipAndWar() { + // Create test pitcher + Player pitcher = createTestPlayer(592789, "Blake Snell", "P"); + + PlayerPitchingStats pitchingStats = new PlayerPitchingStats(); + pitchingStats.setPlayer(pitcher); + pitchingStats.setTeam(createTestTeam(137, "San Francisco Giants", "SF")); + pitchingStats.setSeason(2024); + pitchingStats.setGameType("R"); + pitchingStats.setInningsPitched(new BigDecimal("180.0")); + pitchingStats = pitchingRepo.save(pitchingStats); + + // Mock API response + SabermetricsResponse response = createMockPitchingSabermetricsResponse( + new BigDecimal("5.5"), // WAR + new BigDecimal("2.85"), // FIP + new BigDecimal("3.10") // xFIP + ); + when(mlbApiClient.getPitchingSabermetrics(592789, 2024)).thenReturn(response); + when(mlbApiClient.getPlayerSeasonAdvanced(anyInt(), anyInt(), eq("pitching"))).thenReturn(null); + + boolean updated = sabermetricsIngestionService.syncPlayerPitchingSabermetrics(pitcher, 2024); + + assertThat(updated).isTrue(); + + PlayerPitchingStats stats = pitchingRepo.findById(pitchingStats.getId()).orElseThrow(); + assertThat(stats.getWar()).isEqualByComparingTo("5.5"); + assertThat(stats.getFip()).isEqualByComparingTo("2.85"); + assertThat(stats.getXfip()).isEqualByComparingTo("3.10"); + + // gWAR should be calculated + assertThat(stats.getGwar()).isNotNull(); + assertThat(stats.getGwarPitching()).isNotNull(); + } + + // ================================================================================ + // HELPER METHODS + // ================================================================================ + + private SabermetricsResponse createMockSabermetricsResponse(BigDecimal war, BigDecimal woba, BigDecimal wrcPlus) { + SabermetricsResponse response = new SabermetricsResponse(); + + SabermetricsResponse.SabermetricData data = new SabermetricsResponse.SabermetricData(); + data.setWar(war); + data.setWoba(woba); + data.setWRcPlus(wrcPlus); + + SabermetricsResponse.StatSplit split = new SabermetricsResponse.StatSplit(); + split.setSeason("2024"); + split.setStat(data); + + SabermetricsResponse.StatGroup group = new SabermetricsResponse.StatGroup(); + group.setSplits(List.of(split)); + + response.setStats(List.of(group)); + return response; + } + + private SabermetricsResponse createMockPitchingSabermetricsResponse(BigDecimal war, BigDecimal fip, BigDecimal xfip) { + SabermetricsResponse response = new SabermetricsResponse(); + + SabermetricsResponse.SabermetricData data = new SabermetricsResponse.SabermetricData(); + data.setWar(war); + data.setFip(fip); + data.setXfip(xfip); + + SabermetricsResponse.StatSplit split = new SabermetricsResponse.StatSplit(); + split.setSeason("2024"); + split.setStat(data); + + SabermetricsResponse.StatGroup group = new SabermetricsResponse.StatGroup(); + group.setSplits(List.of(split)); + + response.setStats(List.of(group)); + return response; + } +} diff --git a/docs/GWAR_METHODOLOGY.md b/docs/GWAR_METHODOLOGY.md new file mode 100644 index 0000000..d1fe1b0 --- /dev/null +++ b/docs/GWAR_METHODOLOGY.md @@ -0,0 +1,263 @@ +# gWAR (Grove WAR) Methodology + +**gWAR** is our transparent, simplified Wins Above Replacement metric. Unlike fWAR (FanGraphs) and bWAR (Baseball-Reference), gWAR uses publicly documented formulas with verifiable calculations. + +## Overview + +gWAR measures a player's total value in wins above a replacement-level player. It combines: +- **Batting** contribution (wRAA) +- **Baserunning** contribution (wSB) +- **Fielding** contribution (OAA-based) +- **Positional** adjustment +- **Replacement** level adjustment + +All components are measured in **runs above average/replacement**, then converted to wins using a runs-per-win factor (typically ~10 runs = 1 win). + +--- + +## Position Player Formula + +``` +gWAR = (Batting + Baserunning + Fielding + Positional + Replacement) / Runs_Per_Win +``` + +### Batting (wRAA - Weighted Runs Above Average) + +``` +Batting = ((wOBA - lgwOBA) / wOBAScale) × PA +``` + +| Variable | Description | Source | +|----------|-------------|--------| +| wOBA | Player's weighted on-base average | MLB Stats API | +| lgwOBA | League average wOBA for the season | `league_constants` table | +| wOBAScale | Scaling factor (~1.15-1.18) | `league_constants` table | +| PA | Plate appearances | MLB Stats API | + +**Example**: A player with .380 wOBA, 600 PA in a .310 lgwOBA environment: +``` +wRAA = ((0.380 - 0.310) / 1.177) × 600 = 35.7 runs +``` + +### Baserunning (wSB - Weighted Stolen Bases) + +``` +Baserunning = (SB × 0.2) + (CS × -0.41) +``` + +A simplified baserunning metric using stolen bases and caught stealing. + +**Example**: 30 SB, 5 CS: +``` +wSB = (30 × 0.2) + (5 × -0.41) = 6.0 - 2.05 = 3.95 runs +``` + +### Fielding + +``` +Fielding = OAA × 0.9 +``` + +| Variable | Description | Source | +|----------|-------------|--------| +| OAA | Outs Above Average | Baseball Savant | +| 0.9 | OAA to runs conversion factor | Research estimate | + +OAA is considered one of the most predictive fielding metrics available. The 0.9 factor converts OAA to approximate run value. + +**Example**: +10 OAA: +``` +Fielding = 10 × 0.9 = 9.0 runs +``` + +### Positional Adjustment + +``` +Positional = Position_Adjustment × (Games / 162) +``` + +| Position | Adjustment (per 162 games) | +|----------|---------------------------| +| C | +12.5 | +| SS | +7.5 | +| 2B | +3.0 | +| CF | +2.5 | +| 3B | +2.5 | +| LF | -7.5 | +| RF | -7.5 | +| 1B | -12.5 | +| DH | -17.5 | + +**Example**: Shortstop, 150 games: +``` +Positional = 7.5 × (150/162) = 6.9 runs +``` + +### Replacement Level + +``` +Replacement = PA × (20.5 / 600) +``` + +This represents the value added above a freely available replacement-level player. The factor (20.5/600) means a full-time player (600 PA) adds 20.5 runs over replacement. + +**Example**: 550 PA: +``` +Replacement = 550 × (20.5/600) = 18.8 runs +``` + +--- + +## Pitcher Formula + +``` +gWAR = (Pitching + Replacement) / Runs_Per_Win +``` + +### Pitching Runs + +``` +Pitching = ((lgFIP - FIP) / 9) × IP +``` + +| Variable | Description | Source | +|----------|-------------|--------| +| FIP | Fielding Independent Pitching | MLB Stats API | +| lgFIP | League average FIP (~4.00) | Calculated from FIP constant | +| IP | Innings pitched | MLB Stats API | + +**Example**: 2.80 FIP, 180 IP, 4.00 lgFIP: +``` +Pitching = ((4.00 - 2.80) / 9) × 180 = 24.0 runs +``` + +### Pitcher Replacement Level + +``` +Replacement = IP × (5.5 / 200) +``` + +A full-time starter (200 IP) adds 5.5 runs over replacement level. + +**Example**: 170 IP: +``` +Replacement = 170 × (5.5/200) = 4.7 runs +``` + +--- + +## Data Sources + +| Metric | Source | Update Frequency | +|--------|--------|------------------| +| WAR (official) | MLB Stats API - sabermetrics | Daily | +| wOBA, wRC+ | MLB Stats API - sabermetrics | Daily | +| FIP, xFIP | MLB Stats API - sabermetrics | Daily | +| OAA | Baseball Savant CSV | Weekly | +| xBA, xSLG, xwOBA | Baseball Savant CSV | Weekly | +| Exit velocity, barrel% | Baseball Savant CSV | Weekly | +| Sprint speed | Baseball Savant CSV | Weekly | +| League constants | FanGraphs (annual) | Pre-season | + +--- + +## Comparison with Official WAR + +| Aspect | gWAR | fWAR | bWAR | +|--------|------|------|------| +| Fielding metric | OAA | UZR | DRS | +| Baserunning | wSB (simplified) | BsR | BsR | +| Pitching basis | FIP | FIP | RA9 | +| Transparency | Fully documented | Partially documented | Complex | +| Reproducibility | 100% | ~95% | ~90% | + +--- + +## Limitations + +1. **Simplified baserunning**: gWAR only uses SB/CS, not advanced metrics like BsR or EQBRR +2. **No defensive spectrum**: Position changes within a season aren't tracked +3. **Single-position**: Uses primary position, not positional splits +4. **No league adjustments**: Doesn't account for AL/NL differences +5. **No park factors**: All runs are treated equally regardless of ballpark + +--- + +## Example Calculations + +### Example 1: Elite Position Player + +Player: 2024 MVP-caliber hitter +- 636 PA, 159 games, DH +- .430 wOBA +- 50 SB, 10 CS +- +5 OAA (as RF before moving to DH) + +``` +Batting = ((0.430 - 0.310) / 1.177) × 636 = 64.8 runs +Baserun = (50 × 0.2) + (10 × -0.41) = 5.9 runs +Fielding = 5 × 0.9 = 4.5 runs +Position = -17.5 × (159/162) = -17.2 runs +Replace = 636 × (20.5/600) = 21.7 runs + +Total = 64.8 + 5.9 + 4.5 - 17.2 + 21.7 = 79.7 runs +gWAR = 79.7 / 10 = 8.0 WAR +``` + +### Example 2: Elite Starting Pitcher + +Player: Cy Young candidate +- 180 IP +- 2.50 FIP + +``` +Pitching = ((4.00 - 2.50) / 9) × 180 = 30.0 runs +Replace = 180 × (5.5/200) = 5.0 runs + +Total = 30.0 + 5.0 = 35.0 runs +gWAR = 35.0 / 10 = 3.5 WAR +``` + +--- + +## API Endpoints + +### Get gWAR Leaderboard + +``` +GET /api/players/leaders/gwar/batting?season=2024&limit=10 +GET /api/players/leaders/gwar/pitching?season=2024&limit=10 +``` + +### Get Player gWAR Breakdown + +``` +GET /api/players/{id}/gwar-breakdown?season=2024 +``` + +Response: +```json +{ + "player": { "id": 123, "name": "Shohei Ohtani" }, + "season": 2024, + "gwar": 8.0, + "officialWar": 8.9, + "batting": 64.8, + "baserunning": 5.9, + "fielding": 4.5, + "positional": -17.2, + "replacement": 21.7, + "position": "DH", + "oaa": 5, + "methodologyUrl": "/docs/GWAR_METHODOLOGY.md" +} +``` + +--- + +## References + +- [FanGraphs WAR Overview](https://library.fangraphs.com/misc/war/) +- [Baseball Savant Outs Above Average](https://baseballsavant.mlb.com/leaderboard/outs_above_average) +- [wOBA and wRAA](https://library.fangraphs.com/offense/woba/) +- [Positional Adjustments](https://library.fangraphs.com/misc/war/positional-adjustment/) From d9ec4a0aec05eaf74073460acdd2695f7096a467 Mon Sep 17 00:00:00 2001 From: Carter Grove Date: Fri, 6 Feb 2026 03:53:39 +0000 Subject: [PATCH 3/7] Fix code review issues: remove unused constant, add 2025/2026 league constants - Remove unused LEAGUE_AVG_FIP constant from GwarCalculationService - Add estimated 2025 and 2026 league constants to V14 migration Co-Authored-By: Claude Opus 4.5 --- .../java/com/mlbstats/domain/gwar/GwarCalculationService.java | 3 --- .../main/resources/db/migration/V14__gwar_and_sabermetrics.sql | 3 +++ 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/src/main/java/com/mlbstats/domain/gwar/GwarCalculationService.java b/backend/src/main/java/com/mlbstats/domain/gwar/GwarCalculationService.java index 08588bf..aa1ac7f 100644 --- a/backend/src/main/java/com/mlbstats/domain/gwar/GwarCalculationService.java +++ b/backend/src/main/java/com/mlbstats/domain/gwar/GwarCalculationService.java @@ -67,9 +67,6 @@ public class GwarCalculationService { private static final BigDecimal BATTER_REPLACEMENT_RUNS_PER_PA = bd("20.5").divide(bd("600"), 6, RoundingMode.HALF_UP); private static final BigDecimal PITCHER_REPLACEMENT_RUNS_PER_IP = bd("5.5").divide(bd("200"), 6, RoundingMode.HALF_UP); - // League average FIP (approximate, used when calculating pitcher runs) - private static final BigDecimal LEAGUE_AVG_FIP = bd("4.00"); - /** * Calculates and applies gWAR to a batter's stats. * diff --git a/backend/src/main/resources/db/migration/V14__gwar_and_sabermetrics.sql b/backend/src/main/resources/db/migration/V14__gwar_and_sabermetrics.sql index 18be05b..249d1a1 100644 --- a/backend/src/main/resources/db/migration/V14__gwar_and_sabermetrics.sql +++ b/backend/src/main/resources/db/migration/V14__gwar_and_sabermetrics.sql @@ -20,7 +20,10 @@ CREATE TABLE league_constants ( -- Seed recent years with league constants -- Source: FanGraphs guts! constants +-- Note: 2025/2026 values are estimates based on recent trends; update when official data available INSERT INTO league_constants (season, lg_woba, woba_scale, lg_r_per_pa, fip_constant) VALUES +(2026, 0.3120, 1.180, 0.1130, 3.14), +(2025, 0.3110, 1.178, 0.1125, 3.14), (2024, 0.3100, 1.177, 0.1120, 3.15), (2023, 0.3180, 1.169, 0.1100, 3.10), (2022, 0.3100, 1.157, 0.1060, 3.15), From b4c9daed974913d78ee70d4ced20ef98ebbaed49 Mon Sep 17 00:00:00 2001 From: Carter Grove Date: Fri, 6 Feb 2026 04:03:00 +0000 Subject: [PATCH 4/7] Address GitHub Copilot code review feedback - Use fixed lgFIP (4.00) for consistent cross-season gWAR calculations - Add gameType='R' filter to gWAR/OAA leaderboard queries for regular season only - Fix chicken-and-egg issue in sabermetrics sync by using findBySeasonRegularSeason - Remove misleading @Transactional from self-called methods - Add documentation for CSV parsing and parseDecimal format assumptions - Add gWAR fields to frontend TypeScript interfaces (BattingStats, PitchingStats) - Document two-way player limitation in getGwarBreakdown method Co-Authored-By: Claude Opus 4.5 --- .../api/service/PlayerApiService.java | 9 +++++++- .../domain/gwar/GwarCalculationService.java | 6 +++-- .../stats/PlayerBattingStatsRepository.java | 7 ++++-- .../stats/PlayerPitchingStatsRepository.java | 5 +++- .../client/BaseballSavantClient.java | 23 ++++++++++++++++++- .../service/SabermetricsIngestionService.java | 12 ++++++---- frontend/src/types/stats.ts | 12 ++++++++++ 7 files changed, 62 insertions(+), 12 deletions(-) diff --git a/backend/src/main/java/com/mlbstats/api/service/PlayerApiService.java b/backend/src/main/java/com/mlbstats/api/service/PlayerApiService.java index 6e06eaf..93ee7ef 100644 --- a/backend/src/main/java/com/mlbstats/api/service/PlayerApiService.java +++ b/backend/src/main/java/com/mlbstats/api/service/PlayerApiService.java @@ -629,6 +629,13 @@ public List getTopOaa(Integer season, int limit) { .toList(); } + /** + * Gets the gWAR breakdown for a player in a given season. + *

+ * Note: For two-way players (e.g., Shohei Ohtani) who have both batting and pitching + * stats, this method returns the batting breakdown. A future enhancement could return + * both or use the player's primary position to determine which to prioritize. + */ public GwarBreakdownDto getGwarBreakdown(Long playerId, Integer season) { if (season == null) { season = DateUtils.getCurrentSeason(); @@ -637,7 +644,7 @@ public GwarBreakdownDto getGwarBreakdown(Long playerId, Integer season) { Player player = playerRepository.findById(playerId) .orElseThrow(() -> new ResourceNotFoundException("Player", playerId)); - // Check batting stats first + // Check batting stats first (for two-way players, batting is returned) List battingStats = battingStatsRepository.findByPlayerIdAndSeason(playerId, season); if (!battingStats.isEmpty() && battingStats.get(0).getGwar() != null) { PlayerBattingStats stats = battingStats.get(0); diff --git a/backend/src/main/java/com/mlbstats/domain/gwar/GwarCalculationService.java b/backend/src/main/java/com/mlbstats/domain/gwar/GwarCalculationService.java index aa1ac7f..46c19ed 100644 --- a/backend/src/main/java/com/mlbstats/domain/gwar/GwarCalculationService.java +++ b/backend/src/main/java/com/mlbstats/domain/gwar/GwarCalculationService.java @@ -222,8 +222,10 @@ private BigDecimal calculatePitchingRuns(BigDecimal fip, BigDecimal inningsPitch return BigDecimal.ZERO; } - // Use FIP constant + 3.2 as approximate league average FIP - BigDecimal lgFip = lc.getFipConstant().add(bd("0.85")); + // Use a fixed league-average FIP of ~4.00 for consistency across seasons. + // While lgFIP varies slightly by year (typically 3.95-4.05), using a fixed value + // simplifies the calculation and provides stable cross-season comparisons. + BigDecimal lgFip = bd("4.00"); BigDecimal fipDiff = lgFip.subtract(fip); BigDecimal runsPerInning = fipDiff.divide(bd("9"), 6, RoundingMode.HALF_UP); return runsPerInning.multiply(inningsPitched); diff --git a/backend/src/main/java/com/mlbstats/domain/stats/PlayerBattingStatsRepository.java b/backend/src/main/java/com/mlbstats/domain/stats/PlayerBattingStatsRepository.java index 8423242..968f4d6 100644 --- a/backend/src/main/java/com/mlbstats/domain/stats/PlayerBattingStatsRepository.java +++ b/backend/src/main/java/com/mlbstats/domain/stats/PlayerBattingStatsRepository.java @@ -74,9 +74,12 @@ Optional findByPlayerIdAndTeamIdAndSeasonAndGameType( List findTopBarrelPct(@Param("season") Integer season, @Param("minPa") Integer minPa); // gWAR Leaderboards - @Query("SELECT pbs FROM PlayerBattingStats pbs JOIN FETCH pbs.player JOIN FETCH pbs.team WHERE pbs.season = :season AND pbs.gwar IS NOT NULL ORDER BY pbs.gwar DESC") + @Query("SELECT pbs FROM PlayerBattingStats pbs JOIN FETCH pbs.player JOIN FETCH pbs.team WHERE pbs.season = :season AND pbs.gameType = 'R' AND pbs.gwar IS NOT NULL ORDER BY pbs.gwar DESC") List findTopGwar(@Param("season") Integer season); - @Query("SELECT pbs FROM PlayerBattingStats pbs JOIN FETCH pbs.player JOIN FETCH pbs.team WHERE pbs.season = :season AND pbs.oaa IS NOT NULL ORDER BY pbs.oaa DESC") + @Query("SELECT pbs FROM PlayerBattingStats pbs JOIN FETCH pbs.player JOIN FETCH pbs.team WHERE pbs.season = :season AND pbs.gameType = 'R' AND pbs.oaa IS NOT NULL ORDER BY pbs.oaa DESC") List findTopOaa(@Param("season") Integer season); + + @Query("SELECT pbs FROM PlayerBattingStats pbs JOIN FETCH pbs.player JOIN FETCH pbs.team WHERE pbs.season = :season AND pbs.gameType = 'R'") + List findBySeasonRegularSeason(@Param("season") Integer season); } diff --git a/backend/src/main/java/com/mlbstats/domain/stats/PlayerPitchingStatsRepository.java b/backend/src/main/java/com/mlbstats/domain/stats/PlayerPitchingStatsRepository.java index dbea271..e9f7fa7 100644 --- a/backend/src/main/java/com/mlbstats/domain/stats/PlayerPitchingStatsRepository.java +++ b/backend/src/main/java/com/mlbstats/domain/stats/PlayerPitchingStatsRepository.java @@ -69,6 +69,9 @@ Optional findByPlayerIdAndTeamIdAndSeasonAndGameType( List findTopWhiffPct(@Param("season") Integer season, @Param("minInnings") BigDecimal minInnings); // gWAR Leaderboards - @Query("SELECT pps FROM PlayerPitchingStats pps JOIN FETCH pps.player JOIN FETCH pps.team WHERE pps.season = :season AND pps.gwar IS NOT NULL ORDER BY pps.gwar DESC") + @Query("SELECT pps FROM PlayerPitchingStats pps JOIN FETCH pps.player JOIN FETCH pps.team WHERE pps.season = :season AND pps.gameType = 'R' AND pps.gwar IS NOT NULL ORDER BY pps.gwar DESC") List findTopGwar(@Param("season") Integer season); + + @Query("SELECT pps FROM PlayerPitchingStats pps JOIN FETCH pps.player JOIN FETCH pps.team WHERE pps.season = :season AND pps.gameType = 'R'") + List findBySeasonRegularSeason(@Param("season") Integer season); } diff --git a/backend/src/main/java/com/mlbstats/ingestion/client/BaseballSavantClient.java b/backend/src/main/java/com/mlbstats/ingestion/client/BaseballSavantClient.java index 7ea41ab..d915b73 100644 --- a/backend/src/main/java/com/mlbstats/ingestion/client/BaseballSavantClient.java +++ b/backend/src/main/java/com/mlbstats/ingestion/client/BaseballSavantClient.java @@ -253,8 +253,13 @@ private Map parseSprintSpeedCsv(String csv) { // UTILITY METHODS // ================================================================================ + /** + * Parses a CSV line using a regex pattern. + * Handles standard quoted fields but may not handle all edge cases + * (e.g., escaped quotes within fields like "Player ""Nickname"" Name"). + * Baseball Savant's CSV output uses standard formatting, so this is sufficient. + */ private String[] parseCsvLine(String line) { - // Simple CSV parsing - handles quoted fields return line.split(",(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)", -1); } @@ -284,6 +289,22 @@ private Integer parseInteger(String value) { } } + /** + * Parses a numeric value from Baseball Savant CSV. + *

+ * This method: + *

    + *
  • Trims whitespace and removes surrounding quotes
  • + *
  • Strips any '%' characters from the value
  • + *
  • Parses the remaining text as a BigDecimal without scaling
  • + *
+ *

+ * Baseball Savant returns percentages already in 0-100 form (e.g., "15.3" or "15.3%" + * both represent 15.3%). Values are parsed as-is without conversion. + * + * @param value The string value from the CSV + * @return The parsed BigDecimal, or null if parsing fails + */ private BigDecimal parseDecimal(String value) { if (value == null || value.isBlank()) return null; try { diff --git a/backend/src/main/java/com/mlbstats/ingestion/service/SabermetricsIngestionService.java b/backend/src/main/java/com/mlbstats/ingestion/service/SabermetricsIngestionService.java index 55f316c..81a28f1 100644 --- a/backend/src/main/java/com/mlbstats/ingestion/service/SabermetricsIngestionService.java +++ b/backend/src/main/java/com/mlbstats/ingestion/service/SabermetricsIngestionService.java @@ -38,14 +38,16 @@ public class SabermetricsIngestionService { /** * Syncs sabermetrics for all players with stats in a given season. + * All operations run in a single transaction. If any player sync fails, + * it is logged and skipped, but does not roll back the entire batch. */ @Transactional public int syncAllPlayerSabermetrics(Integer season) { log.info("Starting sabermetrics sync for season {}", season); - // Get all players with batting or pitching stats for this season - List battingStats = battingRepo.findTopWar(season); - List pitchingStats = pitchingRepo.findTopWar(season); + // Get all players with batting or pitching stats for this season (regular season only) + List battingStats = battingRepo.findBySeasonRegularSeason(season); + List pitchingStats = pitchingRepo.findBySeasonRegularSeason(season); AtomicInteger count = new AtomicInteger(0); @@ -75,8 +77,8 @@ public int syncAllPlayerSabermetrics(Integer season) { /** * Syncs batting sabermetrics for a single player. + * Note: When called from syncAllPlayerSabermetrics, runs within the parent transaction. */ - @Transactional public boolean syncPlayerBattingSabermetrics(Player player, Integer season) { try { // Fetch sabermetrics from MLB API @@ -115,8 +117,8 @@ public boolean syncPlayerBattingSabermetrics(Player player, Integer season) { /** * Syncs pitching sabermetrics for a single player. + * Note: When called from syncAllPlayerSabermetrics, runs within the parent transaction. */ - @Transactional public boolean syncPlayerPitchingSabermetrics(Player player, Integer season) { try { // Fetch sabermetrics from MLB API diff --git a/frontend/src/types/stats.ts b/frontend/src/types/stats.ts index 891ab27..90c8c49 100644 --- a/frontend/src/types/stats.ts +++ b/frontend/src/types/stats.ts @@ -42,6 +42,14 @@ export interface BattingStats { xwoba: number | null; kPct: number | null; bbPct: number | null; + // gWAR (Grove WAR) fields + gwar: number | null; + gwarBatting: number | null; + gwarBaserunning: number | null; + gwarFielding: number | null; + gwarPositional: number | null; + gwarReplacement: number | null; + oaa: number | null; } export interface PitchingStats { @@ -85,6 +93,10 @@ export interface PitchingStats { avgSpinRate: number | null; whiffPct: number | null; chasePct: number | null; + // gWAR (Grove WAR) fields + gwar: number | null; + gwarPitching: number | null; + gwarReplacement: number | null; } export interface PageResponse { From 74db7bec5a568ccfc4215dd94ffd85a77d93204c Mon Sep 17 00:00:00 2001 From: Carter Grove Date: Fri, 6 Feb 2026 04:14:56 +0000 Subject: [PATCH 5/7] Add gWAR display to frontend UI 1. Leaderboards page: - Add WAR, gWAR, OAA categories for batting leaderboards - Add WAR, gWAR categories for pitching leaderboards 2. Player profile (PlayerStats component): - Add gWAR Breakdown section showing all components - Display gWAR, batting, baserunning, fielding, positional, replacement - Add OAA (Outs Above Average) stat card - Add pitching gWAR breakdown for pitchers 3. Career stats table: - Add WAR and gWAR columns 4. API service: - Add getBattingGwarLeaders, getPitchingGwarLeaders - Add getOaaLeaders, getWarLeaders, getPitchingWarLeaders Co-Authored-By: Claude Opus 4.5 --- .../src/components/player/CareerStats.tsx | 17 +++++++ .../src/components/player/PlayerStats.tsx | 36 ++++++++++++-- frontend/src/pages/LeaderboardsPage.tsx | 47 +++++++++++++++++-- frontend/src/services/api.ts | 31 ++++++++++++ 4 files changed, 124 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/player/CareerStats.tsx b/frontend/src/components/player/CareerStats.tsx index 95f5a22..a9f9217 100644 --- a/frontend/src/components/player/CareerStats.tsx +++ b/frontend/src/components/player/CareerStats.tsx @@ -10,6 +10,11 @@ function formatAvg(value: number | null): string { return value.toFixed(3).replace(/^0/, ''); } +function formatWar(value: number | null): string { + if (value === null || value === undefined) return '--'; + return value.toFixed(1); +} + function CareerStats({ stats }: CareerStatsProps) { if (stats.length <= 1) return null; @@ -49,6 +54,18 @@ function CareerStats({ stats }: CareerStatsProps) { className: 'number', render: (s: BattingStats) => formatAvg(s.slg), }, + { + key: 'war', + header: 'WAR', + className: 'number', + render: (s: BattingStats) => formatWar(s.war), + }, + { + key: 'gwar', + header: 'gWAR', + className: 'number', + render: (s: BattingStats) => formatWar(s.gwar), + }, ]; return ( diff --git a/frontend/src/components/player/PlayerStats.tsx b/frontend/src/components/player/PlayerStats.tsx index ab7cd8d..a5b6235 100644 --- a/frontend/src/components/player/PlayerStats.tsx +++ b/frontend/src/components/player/PlayerStats.tsx @@ -32,12 +32,12 @@ function formatWar(value: number | null): string { } function hasAdvancedBattingStats(stats: BattingStats): boolean { - return stats.war != null || stats.woba != null || stats.wrcPlus != null || - stats.avgExitVelocity != null || stats.barrelPct != null; + return stats.war != null || stats.gwar != null || stats.woba != null || stats.wrcPlus != null || + stats.avgExitVelocity != null || stats.barrelPct != null || stats.oaa != null; } function hasAdvancedPitchingStats(stats: PitchingStats): boolean { - return stats.war != null || stats.fip != null || stats.xfip != null || + return stats.war != null || stats.gwar != null || stats.fip != null || stats.xfip != null || stats.whiffPct != null || stats.xera != null; } @@ -102,6 +102,22 @@ function PlayerStats({ battingStats, pitchingStats }: PlayerStatsProps) {

+ + {/* gWAR Breakdown */} + {latestBatting.gwar != null && ( + <> +

gWAR Breakdown (Grove WAR)

+
+ + + + + + + +
+ + )} )} @@ -146,7 +162,7 @@ function PlayerStats({ battingStats, pitchingStats }: PlayerStatsProps) { {hasAdvancedPitchingStats(latestPitching) && ( <>

Advanced Analytics

-
+
@@ -162,6 +178,18 @@ function PlayerStats({ battingStats, pitchingStats }: PlayerStatsProps) {
+ + {/* gWAR Breakdown for Pitchers */} + {latestPitching.gwar != null && ( + <> +

gWAR Breakdown (Grove WAR)

+
+ + + +
+ + )} )} diff --git a/frontend/src/pages/LeaderboardsPage.tsx b/frontend/src/pages/LeaderboardsPage.tsx index 1a1b7da..ead4e5f 100644 --- a/frontend/src/pages/LeaderboardsPage.tsx +++ b/frontend/src/pages/LeaderboardsPage.tsx @@ -14,11 +14,17 @@ import { getEraLeaders, getSavesLeaders, getWhipLeaders, + getBattingGwarLeaders, + getPitchingGwarLeaders, + getOaaLeaders, + getWarLeaders, + getPitchingWarLeaders, } from '../services/api'; type LeaderboardCategory = | 'home-runs' | 'batting-average' | 'rbi' | 'runs' | 'hits' | 'stolen-bases' | 'ops' - | 'wins' | 'strikeouts' | 'era' | 'saves' | 'whip'; + | 'war' | 'gwar' | 'oaa' + | 'wins' | 'strikeouts' | 'era' | 'saves' | 'whip' | 'pitching-war' | 'pitching-gwar'; interface CategoryConfig { label: string; @@ -113,10 +119,45 @@ const categories: Record = { statKey: 'whip', format: (v) => v.toFixed(2), }, + 'war': { + label: 'WAR', + type: 'batting', + fetch: getWarLeaders, + statKey: 'war', + format: (v) => v?.toFixed(1) ?? '--', + }, + 'gwar': { + label: 'gWAR', + type: 'batting', + fetch: getBattingGwarLeaders, + statKey: 'gwar', + format: (v) => v?.toFixed(1) ?? '--', + }, + 'oaa': { + label: 'OAA', + type: 'batting', + fetch: getOaaLeaders, + statKey: 'oaa', + format: (v) => String(v ?? '--'), + }, + 'pitching-war': { + label: 'WAR', + type: 'pitching', + fetch: getPitchingWarLeaders, + statKey: 'war', + format: (v) => v?.toFixed(1) ?? '--', + }, + 'pitching-gwar': { + label: 'gWAR', + type: 'pitching', + fetch: getPitchingGwarLeaders, + statKey: 'gwar', + format: (v) => v?.toFixed(1) ?? '--', + }, }; -const battingCategories: LeaderboardCategory[] = ['home-runs', 'batting-average', 'rbi', 'runs', 'hits', 'stolen-bases', 'ops']; -const pitchingCategories: LeaderboardCategory[] = ['wins', 'strikeouts', 'era', 'saves', 'whip']; +const battingCategories: LeaderboardCategory[] = ['home-runs', 'batting-average', 'rbi', 'runs', 'hits', 'stolen-bases', 'ops', 'war', 'gwar', 'oaa']; +const pitchingCategories: LeaderboardCategory[] = ['wins', 'strikeouts', 'era', 'saves', 'whip', 'pitching-war', 'pitching-gwar']; function getDefaultSeason(): number { const now = new Date(); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 994e7b1..cc6b3f7 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -225,6 +225,37 @@ export async function getWhipLeaders(season?: number, limit = 10): Promise(`${API_BASE}/players/leaders/whip?${params}`); } +// gWAR (Grove WAR) Leaders +export async function getBattingGwarLeaders(season?: number, limit = 10): Promise { + const params = new URLSearchParams({ limit: String(limit) }); + if (season) params.set('season', String(season)); + return fetchJson(`${API_BASE}/players/leaders/gwar/batting?${params}`); +} + +export async function getPitchingGwarLeaders(season?: number, limit = 10): Promise { + const params = new URLSearchParams({ limit: String(limit) }); + if (season) params.set('season', String(season)); + return fetchJson(`${API_BASE}/players/leaders/gwar/pitching?${params}`); +} + +export async function getOaaLeaders(season?: number, limit = 10): Promise { + const params = new URLSearchParams({ limit: String(limit) }); + if (season) params.set('season', String(season)); + return fetchJson(`${API_BASE}/players/leaders/oaa?${params}`); +} + +export async function getWarLeaders(season?: number, limit = 10): Promise { + const params = new URLSearchParams({ limit: String(limit) }); + if (season) params.set('season', String(season)); + return fetchJson(`${API_BASE}/players/leaders/war/batting?${params}`); +} + +export async function getPitchingWarLeaders(season?: number, limit = 10): Promise { + const params = new URLSearchParams({ limit: String(limit) }); + if (season) params.set('season', String(season)); + return fetchJson(`${API_BASE}/players/leaders/war/pitching?${params}`); +} + export async function comparePlayerStats( players: PlayerSelection[], mode: 'season' | 'career' From 5db2abd48460063f6b0e1499716d540ef9673146 Mon Sep 17 00:00:00 2001 From: Carter Grove Date: Fri, 6 Feb 2026 04:22:25 +0000 Subject: [PATCH 6/7] Add sabermetrics sync to admin UI - Add SABERMETRICS to SyncJobType enum - Add tracked sabermetrics sync to IngestionOrchestrator - Add /api/ingestion/sabermetrics endpoint - Add freshness thresholds for sabermetrics (24h fresh, 3d stale) - Add triggerSabermetricsSync to frontend API - Wire up sabermetrics sync button in AdminPage Co-Authored-By: Claude Opus 4.5 --- .../api/controller/IngestionController.java | 14 ++++++++++ .../com/mlbstats/domain/sync/SyncJobType.java | 3 +- .../service/IngestionOrchestrator.java | 28 +++++++++++++++++++ .../ingestion/service/SyncJobService.java | 5 ++++ frontend/src/pages/AdminPage.tsx | 4 +++ frontend/src/services/api.ts | 7 ++++- 6 files changed, 59 insertions(+), 2 deletions(-) diff --git a/backend/src/main/java/com/mlbstats/api/controller/IngestionController.java b/backend/src/main/java/com/mlbstats/api/controller/IngestionController.java index 4044f21..9eb1d21 100644 --- a/backend/src/main/java/com/mlbstats/api/controller/IngestionController.java +++ b/backend/src/main/java/com/mlbstats/api/controller/IngestionController.java @@ -191,6 +191,20 @@ public ResponseEntity syncLinescores( return ResponseEntity.ok(SyncJobDto.fromEntity(job)); } + @PostMapping("/sabermetrics") + @Operation(summary = "Sync sabermetrics", description = "Synchronizes WAR, wOBA, FIP, expected stats, and calculates gWAR") + public ResponseEntity syncSabermetrics( + @RequestParam(required = false) Integer season, + @AuthenticationPrincipal OAuth2User principal) { + + if (season == null) { + season = DateUtils.getCurrentSeason(); + } + AppUser user = getUserFromPrincipal(principal); + SyncJob job = orchestrator.createAndRunTrackedSabermetricsSync(season, TriggerType.MANUAL, user); + return ResponseEntity.ok(SyncJobDto.fromEntity(job)); + } + // ===== Legacy Untracked Endpoints ===== @PostMapping("/players/incomplete") diff --git a/backend/src/main/java/com/mlbstats/domain/sync/SyncJobType.java b/backend/src/main/java/com/mlbstats/domain/sync/SyncJobType.java index 53230b2..80eb1a0 100644 --- a/backend/src/main/java/com/mlbstats/domain/sync/SyncJobType.java +++ b/backend/src/main/java/com/mlbstats/domain/sync/SyncJobType.java @@ -8,7 +8,8 @@ public enum SyncJobType { STATS("Stats"), STANDINGS("Standings"), BOX_SCORES("Box Scores"), - LINESCORES("Linescores"); + LINESCORES("Linescores"), + SABERMETRICS("Sabermetrics"); private final String displayName; diff --git a/backend/src/main/java/com/mlbstats/ingestion/service/IngestionOrchestrator.java b/backend/src/main/java/com/mlbstats/ingestion/service/IngestionOrchestrator.java index 8b0c5f2..49908a9 100644 --- a/backend/src/main/java/com/mlbstats/ingestion/service/IngestionOrchestrator.java +++ b/backend/src/main/java/com/mlbstats/ingestion/service/IngestionOrchestrator.java @@ -26,6 +26,7 @@ public class IngestionOrchestrator { private final StandingsIngestionService standingsIngestionService; private final BoxScoreIngestionService boxScoreIngestionService; private final LinescoreIngestionService linescoreIngestionService; + private final SabermetricsIngestionService sabermetricsIngestionService; private final SyncJobService syncJobService; @Caching(evict = { @@ -374,6 +375,33 @@ public SyncJob createAndRunTrackedLinescoresSync(int season, TriggerType trigger return job; } + @Async + @Caching(evict = { + @CacheEvict(value = CacheConfig.LEADERBOARDS, allEntries = true), + @CacheEvict(value = CacheConfig.PLAYERS, allEntries = true) + }) + public void runTrackedSabermetricsSync(Long jobId, int season) { + log.info("Starting tracked sabermetrics sync for season {} (job {})", season, jobId); + try { + syncJobService.startJob(jobId); + syncJobService.updateProgress(jobId, 0, 1, "Syncing sabermetrics and calculating gWAR..."); + + int count = sabermetricsIngestionService.syncAllPlayerSabermetrics(season); + + syncJobService.completeJob(jobId, count, 0, 0); + log.info("Tracked sabermetrics sync completed (job {})", jobId); + } catch (Exception e) { + log.error("Tracked sabermetrics sync failed (job {})", jobId, e); + syncJobService.failJob(jobId, e.getMessage()); + } + } + + public SyncJob createAndRunTrackedSabermetricsSync(int season, TriggerType trigger, AppUser user) { + SyncJob job = syncJobService.createJob(SyncJobType.SABERMETRICS, season, trigger, user); + runTrackedSabermetricsSync(job.getId(), season); + return job; + } + // Legacy untracked methods for backward compatibility @Caching(evict = { diff --git a/backend/src/main/java/com/mlbstats/ingestion/service/SyncJobService.java b/backend/src/main/java/com/mlbstats/ingestion/service/SyncJobService.java index 4f446e5..530bd51 100644 --- a/backend/src/main/java/com/mlbstats/ingestion/service/SyncJobService.java +++ b/backend/src/main/java/com/mlbstats/ingestion/service/SyncJobService.java @@ -247,6 +247,11 @@ private FreshnessLevel getFreshnessLevel(SyncJobType type, Duration age) { if (hours < 24) yield FreshnessLevel.STALE; yield FreshnessLevel.CRITICAL; } + case SABERMETRICS -> { + if (hours < 24) yield FreshnessLevel.FRESH; + if (days < 3) yield FreshnessLevel.STALE; + yield FreshnessLevel.CRITICAL; + } case FULL_SYNC -> { if (hours < 24) yield FreshnessLevel.FRESH; if (days < 7) yield FreshnessLevel.STALE; diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 4d3a5d9..bdf877d 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -10,6 +10,7 @@ import { triggerStandingsSync, triggerBoxScoresSync, triggerLinescoresSync, + triggerSabermetricsSync, getUsers, updateUserRole, AdminUser, @@ -134,6 +135,9 @@ function AdminPage() { case 'LINESCORES': job = await triggerLinescoresSync(); break; + case 'SABERMETRICS': + job = await triggerSabermetricsSync(); + break; default: return; } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index cc6b3f7..0a3a41f 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -332,7 +332,7 @@ export async function getGameCounts(options: { } // Sync Job Types -export type SyncJobType = 'FULL_SYNC' | 'TEAMS' | 'ROSTERS' | 'GAMES' | 'STATS' | 'STANDINGS' | 'BOX_SCORES' | 'LINESCORES'; +export type SyncJobType = 'FULL_SYNC' | 'TEAMS' | 'ROSTERS' | 'GAMES' | 'STATS' | 'STANDINGS' | 'BOX_SCORES' | 'LINESCORES' | 'SABERMETRICS'; export type SyncJobStatus = 'PENDING' | 'RUNNING' | 'COMPLETED' | 'FAILED' | 'CANCELLED'; export type TriggerType = 'MANUAL' | 'SCHEDULED'; export type FreshnessLevel = 'FRESH' | 'STALE' | 'CRITICAL'; @@ -465,6 +465,11 @@ export async function triggerLinescoresSync(season?: number): Promise { return postJson(`${API_BASE}/ingestion/linescores${params}`); } +export async function triggerSabermetricsSync(season?: number): Promise { + const params = season ? `?season=${season}` : ''; + return postJson(`${API_BASE}/ingestion/sabermetrics${params}`); +} + // Data Manager export interface SeasonData { season: number; From 9541429425a2854ad822c96b209ad5ad95c6cd85 Mon Sep 17 00:00:00 2001 From: Carter Grove Date: Fri, 6 Feb 2026 04:25:04 +0000 Subject: [PATCH 7/7] Add SABERMETRICS to sync_jobs job_type constraint Flyway migration to allow SABERMETRICS as a valid job_type in the sync_jobs table. Co-Authored-By: Claude Opus 4.5 --- .../db/migration/V15__add_sabermetrics_job_type.sql | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 backend/src/main/resources/db/migration/V15__add_sabermetrics_job_type.sql diff --git a/backend/src/main/resources/db/migration/V15__add_sabermetrics_job_type.sql b/backend/src/main/resources/db/migration/V15__add_sabermetrics_job_type.sql new file mode 100644 index 0000000..25f6cbe --- /dev/null +++ b/backend/src/main/resources/db/migration/V15__add_sabermetrics_job_type.sql @@ -0,0 +1,6 @@ +-- Add SABERMETRICS to the valid_job_type check constraint + +ALTER TABLE sync_jobs DROP CONSTRAINT valid_job_type; + +ALTER TABLE sync_jobs ADD CONSTRAINT valid_job_type + CHECK (job_type IN ('FULL_SYNC', 'TEAMS', 'ROSTERS', 'GAMES', 'STATS', 'STANDINGS', 'BOX_SCORES', 'LINESCORES', 'SABERMETRICS'));