Skip to content

Add gWAR (Grove WAR) calculation system with Baseball Savant integration#178

Open
grovecj wants to merge 8 commits intomainfrom
feature/advanced-sabermetrics-94
Open

Add gWAR (Grove WAR) calculation system with Baseball Savant integration#178
grovecj wants to merge 8 commits intomainfrom
feature/advanced-sabermetrics-94

Conversation

@grovecj
Copy link
Owner

@grovecj grovecj commented Feb 6, 2026

Summary

  • Implements gWAR (Grove WAR) - a transparent, simplified WAR metric with fully documented methodology
  • Integrates MLB Stats API for official sabermetrics (WAR, wOBA, FIP, xFIP)
  • Integrates Baseball Savant for Statcast data (OAA, xBA, xSLG, xwOBA, exit velocity, barrel%, sprint speed)
  • Adds API endpoints for gWAR leaderboards and detailed player breakdowns
  • Adds scheduled jobs for daily sabermetrics sync and weekly Statcast sync

gWAR Formula

gWAR = (Batting + Baserunning + Fielding + Positional + Replacement) / 10

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

New Files

  • domain/constants/LeagueConstants.java - Season-specific constants for calculations
  • domain/gwar/GwarCalculationService.java - Core gWAR calculation logic
  • ingestion/client/BaseballSavantClient.java - Baseball Savant CSV integration
  • ingestion/service/SabermetricsIngestionService.java - MLB API sabermetrics sync
  • ingestion/service/OaaIngestionService.java - OAA sync from Baseball Savant
  • ingestion/service/StatcastIngestionService.java - Expected stats sync
  • api/dto/GwarBreakdownDto.java - Detailed gWAR component breakdown
  • db/migration/V14__gwar_and_sabermetrics.sql - Schema additions
  • docs/GWAR_METHODOLOGY.md - Full methodology documentation

New API Endpoints

  • GET /api/players/leaders/gwar/batting - Batting gWAR leaderboard
  • GET /api/players/leaders/gwar/pitching - Pitching gWAR leaderboard
  • GET /api/players/leaders/oaa - OAA leaderboard
  • GET /api/players/{id}/gwar-breakdown - Detailed gWAR components

Test plan

  • All 141 existing tests pass
  • New GwarCalculationServiceTest (11 tests) validates gWAR calculations
  • New SabermetricsIngestionServiceTest (3 tests) validates ingestion flow
  • New StatsMapperTest (8 tests) validates sabermetrics mapping
  • Manual verification of gWAR calculation against known player stats
  • Verify Baseball Savant CSV parsing with real data

🤖 Generated with Claude Code

grovecj and others added 2 commits February 1, 2026 02:05
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>
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 225 to 226
// Use FIP constant + 3.2 as approximate league average FIP
BigDecimal lgFip = lc.getFipConstant().add(bd("0.85"));
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Using a fixed lgFIP value of 4.00 for all seasons (if that's the intention)
  2. Updating the documentation to clarify that lgFIP = FIP constant + 0.85
  3. Storing lgFIP as a separate constant in the league_constants table
Suggested change
// 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");

Copilot uses AI. Check for mistakes.
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 62 to 72
@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")
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
@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")

Copilot uses AI. Check for mistakes.
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 47 to 48
List<PlayerBattingStats> battingStats = battingRepo.findTopWar(season);
List<PlayerPitchingStats> pitchingStats = pitchingRepo.findTopWar(season);
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
List<PlayerBattingStats> battingStats = battingRepo.findTopWar(season);
List<PlayerPitchingStats> pitchingStats = pitchingRepo.findTopWar(season);
List<PlayerBattingStats> battingStats = battingRepo.findBySeason(season);
List<PlayerPitchingStats> pitchingStats = pitchingRepo.findBySeason(season);

Copilot uses AI. Check for mistakes.
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +287 to +290
private BigDecimal parseDecimal(String value) {
if (value == null || value.isBlank()) return null;
try {
return new BigDecimal(value.trim().replace("\"", "").replace("%", ""));
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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 0100 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 01 and 0100 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;

Copilot uses AI. Check for mistakes.
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 256 to 258
private String[] parseCsvLine(String line) {
// Simple CSV parsing - handles quoted fields
return line.split(",(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)", -1);
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 70 to 80
@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")
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
@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")

Copilot uses AI. Check for mistakes.
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Added AND pbs.gameType = 'R' filter to both findTopGwar and findTopOaa queries to ensure leaderboards only show regular season stats.

Comment on lines 31 to 45
// 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;
}
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copilot uses AI. Check for mistakes.
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 73 to 88
// 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;
}
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copilot uses AI. Check for mistakes.
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Added the gWAR fields to the TypeScript PitchingStats interface: gwar, gwarPitching, and gwarReplacement. This maintains type consistency between frontend and backend.

Comment on lines 640 to 671
// 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()
);
}
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 79 to 151
@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;
}
}
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Removing @transactional from the nested methods
  2. Using REQUIRES_NEW propagation for per-player transactions
  3. Moving these methods to a separate service class to enable proper transaction boundaries

Copilot uses AI. Check for mistakes.
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

grovecj and others added 5 commits February 6, 2026 03:57
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants