Add gWAR (Grove WAR) calculation system with Baseball Savant integration#178
Add gWAR (Grove WAR) calculation system with Baseball Savant integration#178
Conversation
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This pull request implements gWAR (Grove WAR), a transparent and simplified Wins Above Replacement metric for baseball statistics. The implementation integrates with MLB Stats API for official sabermetrics (WAR, wOBA, FIP, xFIP) and Baseball Savant for Statcast data (OAA, expected stats, exit velocity metrics). The system includes automatic calculation of gWAR components, API endpoints for leaderboards and detailed breakdowns, and scheduled jobs for daily/weekly data synchronization.
Changes:
- Introduces gWAR calculation engine with transparent methodology for position players and pitchers
- Adds integration with Baseball Savant CSV exports for OAA and Statcast expected statistics
- Implements scheduled ingestion services for daily sabermetrics and weekly Statcast data synchronization
- Adds comprehensive API endpoints for gWAR leaderboards and component breakdowns with extensive documentation
Reviewed changes
Copilot reviewed 33 out of 33 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| frontend/src/types/stats.ts | Adds advanced sabermetric stat types (WAR, wOBA, FIP, Statcast metrics) to TypeScript interfaces |
| frontend/src/index.css | Adds CSS styling for stat tooltip info icons |
| frontend/src/components/player/PlayerStats.tsx | Displays advanced analytics section with tooltips for batting and pitching stats |
| frontend/src/components/common/StatCard.tsx | Adds tooltip support to stat cards |
| docs/GWAR_METHODOLOGY.md | Comprehensive documentation of gWAR calculation methodology and formulas |
| backend/.../SabermetricsIngestionServiceTest.java | Integration tests for sabermetrics ingestion with gWAR calculation |
| backend/.../StatsMapperTest.java | Unit tests for mapping API responses to stats entities |
| backend/.../GwarCalculationServiceTest.java | Unit tests for gWAR calculation logic with various scenarios |
| backend/.../V14__gwar_and_sabermetrics.sql | Database migration adding league constants table and gWAR columns |
| backend/.../StatcastIngestionService.java | Service for ingesting expected stats from Baseball Savant |
| backend/.../SabermetricsIngestionService.java | Service for ingesting official sabermetrics from MLB API and calculating gWAR |
| backend/.../OaaIngestionService.java | Service for ingesting OAA (Outs Above Average) from Baseball Savant |
| backend/.../IngestionScheduler.java | Adds scheduled jobs for daily sabermetrics and weekly Statcast syncs |
| backend/.../StatsMapper.java | Adds mapping methods for sabermetrics, expected stats, and advanced stats |
| backend/.../SeasonAdvancedResponse.java | DTO for MLB API season advanced stats response |
| backend/.../SabermetricsResponse.java | DTO for MLB API sabermetrics response (WAR, wOBA, FIP) |
| backend/.../ExpectedStatsResponse.java | DTO for MLB API expected stats response (xBA, xSLG, xwOBA) |
| backend/.../MlbApiClient.java | Adds methods to fetch sabermetrics and expected stats from MLB API |
| backend/.../BaseballSavantClient.java | New client for fetching and parsing Baseball Savant CSV exports |
| backend/.../PlayerPitchingStatsRepository.java | Adds queries for advanced pitching stat and gWAR leaderboards |
| backend/.../PlayerBattingStatsRepository.java | Adds queries for advanced batting stat and gWAR leaderboards |
| backend/.../PlayerPitchingStats.java | Adds advanced sabermetric and gWAR component fields |
| backend/.../PlayerBattingStats.java | Adds advanced sabermetric and gWAR component fields |
| backend/.../GwarComponents.java | Record class for gWAR component breakdown |
| backend/.../GwarCalculationService.java | Core service implementing gWAR calculation formulas |
| backend/.../LeagueConstantsRepository.java | Repository for league constants used in gWAR calculations |
| backend/.../LeagueConstants.java | Entity storing season-specific league constants (wOBA, FIP constant, etc.) |
| backend/.../RestClientConfig.java | Adds RestClient bean for Baseball Savant HTTP requests |
| backend/.../PlayerApiService.java | Adds service methods for advanced stat and gWAR leaderboards |
| backend/.../PitchingStatsDto.java | Adds advanced sabermetric and gWAR fields to DTO |
| backend/.../GwarBreakdownDto.java | New DTO for detailed gWAR component breakdown API response |
| backend/.../BattingStatsDto.java | Adds advanced sabermetric and gWAR fields to DTO |
| backend/.../PlayerController.java | Adds API endpoints for advanced stat leaderboards and gWAR breakdowns |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Use FIP constant + 3.2 as approximate league average FIP | ||
| BigDecimal lgFip = lc.getFipConstant().add(bd("0.85")); |
There was a problem hiding this comment.
The calculation of league average FIP is inconsistent between documentation and implementation. The documentation states lgFIP is "~4.00" and uses it directly in examples, but the code calculates it as FIP constant + 0.85 (e.g., 3.15 + 0.85 = 4.00 for 2024). While this happens to work out correctly for 2024, it creates a discrepancy for other seasons. For example, 2023 would be 3.10 + 0.85 = 3.95, not 4.00. Consider either:
- Using a fixed lgFIP value of 4.00 for all seasons (if that's the intention)
- Updating the documentation to clarify that lgFIP = FIP constant + 0.85
- Storing lgFIP as a separate constant in the league_constants table
| // 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 | |
| BigDecimal lgFip = bd("4.00"); |
There was a problem hiding this comment.
Fixed. Changed to use a fixed lgFIP of 4.00 for consistency across seasons. Added a clarifying comment explaining that while lgFIP varies slightly by year (typically 3.95-4.05), using a fixed value simplifies the calculation and provides stable cross-season comparisons.
| @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<PlayerPitchingStats> 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<PlayerPitchingStats> 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<PlayerPitchingStats> 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") |
There was a problem hiding this comment.
The leaderboard queries (findTopWar, findTopFip, findTopGwar, etc.) don't filter by gameType, which means they could include stats from different game types (e.g., Regular season 'R', Postseason 'P', Spring Training 'S'). Consider adding 'AND pps.gameType = 'R'' to these queries to ensure leaderboards only show regular season stats, which is the typical convention for leaderboards. Other leaderboard queries in the codebase don't have this filter either, so this should be addressed consistently across all leaderboard methods.
| @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<PlayerPitchingStats> 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<PlayerPitchingStats> 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<PlayerPitchingStats> 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.inningsPitched >= :minInnings AND pps.xfip IS NOT NULL AND pps.gameType = 'R' ORDER BY pps.xfip ASC") | |
| List<PlayerPitchingStats> 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 AND pps.gameType = 'R' ORDER BY pps.xera ASC") | |
| List<PlayerPitchingStats> 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 AND pps.gameType = 'R' ORDER BY pps.whiffPct DESC") | |
| List<PlayerPitchingStats> 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 AND pps.gameType = 'R' ORDER BY pps.gwar DESC") |
There was a problem hiding this comment.
Fixed. Added AND pps.gameType = 'R' filter to the findTopGwar query to ensure leaderboards only show regular season stats. This aligns with the typical convention for leaderboards.
| List<PlayerBattingStats> battingStats = battingRepo.findTopWar(season); | ||
| List<PlayerPitchingStats> pitchingStats = pitchingRepo.findTopWar(season); |
There was a problem hiding this comment.
The method uses findTopWar(season) to get players with stats, but this will only return players who already have WAR values set (non-null). This creates a chicken-and-egg problem: on the first run, no players will have WAR values yet, so the query will return an empty list. Consider using a query that returns all players with stats for the season, regardless of whether they have WAR values. For example, use a method like findBySeason(season) or findBySeasonAndGameType(season, "R") instead.
| List<PlayerBattingStats> battingStats = battingRepo.findTopWar(season); | |
| List<PlayerPitchingStats> pitchingStats = pitchingRepo.findTopWar(season); | |
| List<PlayerBattingStats> battingStats = battingRepo.findBySeason(season); | |
| List<PlayerPitchingStats> pitchingStats = pitchingRepo.findBySeason(season); |
There was a problem hiding this comment.
Fixed. Added a new findBySeasonRegularSeason method to both repositories that returns all players with stats for the season (filtered to regular season). Changed syncAllPlayerSabermetrics to use this method instead of findTopWar, which resolves the chicken-and-egg problem on first run.
| private BigDecimal parseDecimal(String value) { | ||
| if (value == null || value.isBlank()) return null; | ||
| try { | ||
| return new BigDecimal(value.trim().replace("\"", "").replace("%", "")); |
There was a problem hiding this comment.
The parseDecimal method strips the "%" character from values, but Baseball Savant CSV files may return percentages in different formats (e.g., "15.3%" vs "15.3" vs "0.153"). After stripping "%", the value "15.3%" would become "15.3", which is correct if percentages are already in 0-100 format. However, if Baseball Savant returns percentages as decimals (0.153), this would be incorrect. Verify the actual format of percentage values from Baseball Savant and ensure consistent handling. Consider documenting the expected format or adding validation.
| private BigDecimal parseDecimal(String value) { | |
| if (value == null || value.isBlank()) return null; | |
| try { | |
| return new BigDecimal(value.trim().replace("\"", "").replace("%", "")); | |
| /** | |
| * Parse a numeric value from Baseball Savant. | |
| * <p> | |
| * This method: | |
| * <ul> | |
| * <li>Trims whitespace and removes surrounding quotes.</li> | |
| * <li>Strips any '%' characters from the value.</li> | |
| * <li>Parses the remaining text as a {@link BigDecimal} without scaling.</li> | |
| * </ul> | |
| * <p> | |
| * Assumptions: | |
| * <ul> | |
| * <li>Percentage fields from Baseball Savant are already expressed in 0–100 form | |
| * (e.g. {@code "15.3"} or {@code "15.3%"} are both treated as {@code 15.3}).</li> | |
| * <li>Values like {@code "0.153"} are interpreted literally as {@code 0.153}; | |
| * this method does <b>not</b> convert between 0–1 and 0–100 representations.</li> | |
| * </ul> | |
| * If a value looks like a fractional percentage (between 0 and 1) but does not contain | |
| * a '%' sign, a warning is logged to aid in detecting format changes in the CSV feed. | |
| */ | |
| private BigDecimal parseDecimal(String value) { | |
| if (value == null || value.isBlank()) return null; | |
| try { | |
| String trimmed = value.trim().replace("\"", ""); | |
| boolean hasPercent = trimmed.contains("%"); | |
| String numericPart = trimmed.replace("%", ""); | |
| BigDecimal result = new BigDecimal(numericPart); | |
| // Log potentially ambiguous "percentage-like" values that are in 0–1 form | |
| // but do not explicitly contain a '%' sign. | |
| if (!hasPercent | |
| && numericPart.contains(".") | |
| && result.compareTo(BigDecimal.ZERO) >= 0 | |
| && result.compareTo(BigDecimal.ONE) <= 0) { | |
| log.warn("Parsed value '{}' as decimal {} without '%' sign. " + | |
| "If this is intended to represent a percentage in 0–100 space, " + | |
| "it should be provided as such (e.g. '15.3' or '15.3%').", | |
| value, result); | |
| } | |
| return result; |
There was a problem hiding this comment.
Fixed. Added comprehensive Javadoc to the parseDecimal method documenting the expected format from Baseball Savant: percentages are returned in 0-100 form (e.g., "15.3" or "15.3%" both represent 15.3%). Values are parsed as-is without conversion.
| private String[] parseCsvLine(String line) { | ||
| // Simple CSV parsing - handles quoted fields | ||
| return line.split(",(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)", -1); |
There was a problem hiding this comment.
The CSV parsing regex pattern may not handle all edge cases correctly, particularly nested quotes or escaped quotes within fields. For example, a field like "Player ""Nickname"" Name" might not parse correctly. Consider using a dedicated CSV parsing library like Apache Commons CSV or OpenCSV for more robust CSV handling, especially since Baseball Savant data can contain complex field values with quotes and commas.
There was a problem hiding this comment.
Acknowledged. Added documentation to the parseCsvLine method noting that the regex handles standard quoted fields but may not handle all edge cases (e.g., escaped quotes within fields). Baseball Savant's CSV output uses standard formatting, so the current implementation is sufficient. Adding a CSV library would introduce additional dependencies for minimal benefit in this use case.
| @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<PlayerBattingStats> 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<PlayerBattingStats> 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<PlayerBattingStats> 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") |
There was a problem hiding this comment.
The leaderboard queries (findTopWar, findTopWoba, findTopGwar, etc.) don't filter by gameType, which means they could include stats from different game types (e.g., Regular season 'R', Postseason 'P', Spring Training 'S'). Consider adding 'AND pbs.gameType = 'R'' to these queries to ensure leaderboards only show regular season stats, which is the typical convention for leaderboards. Other leaderboard queries in the codebase don't have this filter either, so this should be addressed consistently across all leaderboard methods.
| @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<PlayerBattingStats> 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<PlayerBattingStats> 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<PlayerBattingStats> 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.plateAppearances >= :minPa AND pbs.avgExitVelocity IS NOT NULL ORDER BY pbs.avgExitVelocity DESC") | |
| List<PlayerBattingStats> 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.gameType = 'R' AND pbs.plateAppearances >= :minPa AND pbs.barrelPct IS NOT NULL ORDER BY pbs.barrelPct DESC") | |
| List<PlayerBattingStats> 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.gameType = 'R' AND pbs.gwar IS NOT NULL ORDER BY pbs.gwar DESC") | |
| List<PlayerBattingStats> 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.gameType = 'R' AND pbs.oaa IS NOT NULL ORDER BY pbs.oaa DESC") |
There was a problem hiding this comment.
Fixed. Added AND pbs.gameType = 'R' filter to both findTopGwar and findTopOaa queries to ensure leaderboards only show regular season stats.
| // 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; | ||
| } |
There was a problem hiding this comment.
The BattingStats interface is missing the gWAR (Grove WAR) fields that were added to BattingStatsDto in the backend. The backend DTO includes gwar, gwarBatting, gwarBaserunning, gwarFielding, gwarPositional, gwarReplacement, and oaa fields. These should be added to the TypeScript interface to maintain type consistency between frontend and backend. For example:
// 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;There was a problem hiding this comment.
Fixed. Added the gWAR fields to the TypeScript BattingStats interface: gwar, gwarBatting, gwarBaserunning, gwarFielding, gwarPositional, gwarReplacement, and oaa. This maintains type consistency between frontend and backend.
| // 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; | ||
| } |
There was a problem hiding this comment.
The PitchingStats interface is missing the gWAR (Grove WAR) fields that were added to PitchingStatsDto in the backend. The backend DTO includes gwar, gwarPitching, and gwarReplacement fields. These should be added to the TypeScript interface to maintain type consistency between frontend and backend. For example:
// gWAR (Grove WAR) fields
gwar: number | null;
gwarPitching: number | null;
gwarReplacement: number | null;There was a problem hiding this comment.
Fixed. Added the gWAR fields to the TypeScript PitchingStats interface: gwar, gwarPitching, and gwarReplacement. This maintains type consistency between frontend and backend.
| // Check batting stats first | ||
| List<PlayerBattingStats> 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<PlayerPitchingStats> 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() | ||
| ); | ||
| } |
There was a problem hiding this comment.
When a player has both batting and pitching stats for a season, this method will only return the batting gWAR breakdown, even if the player is primarily a pitcher. Consider checking the player's position or adding logic to determine which stats to prioritize. For example, two-way players like Shohei Ohtani might have both batting and pitching stats, and the choice of which to display should be based on the player's primary position or provide both options in the API response.
There was a problem hiding this comment.
Acknowledged. Added Javadoc to the getGwarBreakdown method documenting this limitation: for two-way players (e.g., Shohei Ohtani), the batting breakdown is returned. Also updated the inline comment to clarify that batting is checked first intentionally. A future enhancement could return both breakdowns or use the player's primary position to determine which to prioritize.
| @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<PlayerBattingStats> 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<PlayerPitchingStats> 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; | ||
| } | ||
| } |
There was a problem hiding this comment.
The syncPlayerBattingSabermetrics and syncPlayerPitchingSabermetrics methods are annotated with @transactional and are called from within syncAllPlayerSabermetrics, which is also @transactional. This creates nested transactions. Since both methods are in the same class and called via this, Spring's proxy-based AOP won't create separate transactions. This means all database operations will be in a single large transaction. If one player's sync fails and throws an exception, the entire sync for all players could be rolled back. Consider either:
- Removing @transactional from the nested methods
- Using REQUIRES_NEW propagation for per-player transactions
- Moving these methods to a separate service class to enable proper transaction boundaries
There was a problem hiding this comment.
Fixed. Removed the @Transactional annotations from syncPlayerBattingSabermetrics and syncPlayerPitchingSabermetrics methods since they're called via this and Spring's proxy-based AOP won't intercept self-calls anyway. Added documentation to clarify that when called from syncAllPlayerSabermetrics, these methods run within the parent transaction. The single-transaction behavior is actually appropriate here since we want atomic updates within a sync batch.
Resolve merge conflicts in gWAR implementation files, keeping gWAR additions (gwar fields, repository queries, API service methods). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
Flyway migration to allow SABERMETRICS as a valid job_type in the sync_jobs table. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Summary
gWAR Formula
New Files
domain/constants/LeagueConstants.java- Season-specific constants for calculationsdomain/gwar/GwarCalculationService.java- Core gWAR calculation logicingestion/client/BaseballSavantClient.java- Baseball Savant CSV integrationingestion/service/SabermetricsIngestionService.java- MLB API sabermetrics syncingestion/service/OaaIngestionService.java- OAA sync from Baseball Savantingestion/service/StatcastIngestionService.java- Expected stats syncapi/dto/GwarBreakdownDto.java- Detailed gWAR component breakdowndb/migration/V14__gwar_and_sabermetrics.sql- Schema additionsdocs/GWAR_METHODOLOGY.md- Full methodology documentationNew API Endpoints
GET /api/players/leaders/gwar/batting- Batting gWAR leaderboardGET /api/players/leaders/gwar/pitching- Pitching gWAR leaderboardGET /api/players/leaders/oaa- OAA leaderboardGET /api/players/{id}/gwar-breakdown- Detailed gWAR componentsTest plan
GwarCalculationServiceTest(11 tests) validates gWAR calculationsSabermetricsIngestionServiceTest(3 tests) validates ingestion flowStatsMapperTest(8 tests) validates sabermetrics mapping🤖 Generated with Claude Code