Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,20 @@ public ResponseEntity<SyncJobDto> 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<SyncJobDto> 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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -319,4 +319,40 @@ public ResponseEntity<PlayerComparisonDto> 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<List<BattingStatsDto>> 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<List<PitchingStatsDto>> 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<List<BattingStatsDto>> 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<GwarBreakdownDto> getGwarBreakdown(
@PathVariable Long id,
@RequestParam(required = false) Integer season) {
return ResponseEntity.ok(playerApiService.getGwarBreakdown(id, season));
}
}
19 changes: 17 additions & 2 deletions backend/src/main/java/com/mlbstats/api/dto/BattingStatsDto.java
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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()
);
}
}
Original file line number Diff line number Diff line change
@@ -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
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -591,4 +591,99 @@ public List<PitchingStatsDto> 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<BattingStatsDto> 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<PitchingStatsDto> 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<BattingStatsDto> getTopOaa(Integer season, int limit) {
if (season == null) {
season = DateUtils.getCurrentSeason();
}
return battingStatsRepository.findTopOaa(season).stream()
.limit(limit)
.map(BattingStatsDto::fromEntity)
.toList();
}

/**
* Gets the gWAR breakdown for a player in a given season.
* <p>
* 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();
}

Player player = playerRepository.findById(playerId)
.orElseThrow(() -> new ResourceNotFoundException("Player", playerId));

// Check batting stats first (for two-way players, batting is returned)
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()
);
}

// No gWAR data available
return GwarBreakdownDto.forBatter(
PlayerDto.fromEntity(player),
season,
null, null, null, null, null, null, null,
player.getPosition(),
null
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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<LeagueConstants, Long> {

Optional<LeagueConstants> findBySeason(Integer season);
}
Loading
Loading