sba-website/src/services/fieldingStatsService.ts

276 lines
9.9 KiB
TypeScript

import type { Game, Team } from './apiResponseTypes'
import type { Player } from './playersService'
import { MODERN_STAT_ERA_START, SITE_URL, isNotUndefined } from './utilities'
// TODO make a stats object that has properties for current regular season, current post season,
// last 4 games, historical seasons, career totals
// could split into batting/pitching/fielding so only necessary ones are called to save time
export interface FieldingStat {
game: Game | 'TOT'
player: Player
team: Team
pos: 'P' | 'C' | '1B' | '2B' | '3B' | 'SS' | 'LF' | 'CF' | 'RF' | 'TOT'
xCheckCount: number
hit: number
error: number
stolenBaseCheckCount: number
stolenBaseCount: number
caughtStealingCount: number
passedBallCount: number
winProbabilityAdded: number
weightedFieldingPercent: number
caughtStealingPercent: number
}
interface FieldingStatRaw {
game: Game | 'TOT'
player: Player
team: Team
pos: 'P' | 'C' | '1B' | '2B' | '3B' | 'SS' | 'LF' | 'CF' | 'RF' | 'TOT'
'x-ch': number
hit: number
error: number
'sb-ch': number
sb: number
cs: number
pb: number
wpa: number
'wf%': number
'cs%': number | ''
}
interface LegacyFieldingStat {
player: Player
team: Team
pos: 'P' | 'C' | '1B' | '2B' | '3B' | 'SS' | 'LF' | 'CF' | 'RF' | 'TOT' | 'PH'
xch: number
xhit: number
error: number
pb: number
sbc: number
csc: number
}
export async function fetchFieldingStatsBySeasonAndPlayerId(seasonNumber: number, playerId: number, isRegularSeason: boolean): Promise<FieldingStat[]> {
// different endpoint for pre-modern stats (/plays) era
if (seasonNumber < MODERN_STAT_ERA_START) {
return await fetchLegacyFieldingStatsBySeasonAndPlayerId(seasonNumber, playerId, isRegularSeason)
}
const response = await fetch(`${SITE_URL}/api/v3/plays/fielding?season=${seasonNumber}&player_id=${playerId}&group_by=playerposition&s_type=${isRegularSeason ? 'regular' : 'post'}`)
const fieldingStatsResponse: {
count: number
stats: FieldingStatRaw[]
} = await response.json()
if (fieldingStatsResponse.count === 0) return []
return fieldingStatsResponse.stats.map(normalizeFieldingStat)
}
export async function fetchFieldingStatsBySeasonAndTeamId(seasonNumber: number, teamId: number, isRegularSeason: boolean): Promise<FieldingStat[]> {
// different endpoint for pre-modern stats (/plays) era
if (seasonNumber < MODERN_STAT_ERA_START) {
return []
}
// TODO might want to make this playerpositionteam grouping (currently does not exist)
const response = await fetch(`${SITE_URL}/api/v3/plays/fielding?season=${seasonNumber}&team_id=${teamId}&group_by=playerposition&s_type=${isRegularSeason ? 'regular' : 'post'}`)
const fieldingStatsResponse: {
count: number
stats: FieldingStatRaw[]
} = await response.json()
if (fieldingStatsResponse.count === 0) return []
return fieldingStatsResponse.stats.map(normalizeFieldingStat)
}
export async function fetchFieldingStatsBySeason(seasonNumber: number, isRegularSeason: boolean): Promise<FieldingStat[]> {
// different endpoint for pre-modern stats (/plays) era
if (seasonNumber < MODERN_STAT_ERA_START) {
return []
}
const response = await fetch(`${SITE_URL}/api/v3/plays/fielding?season=${seasonNumber}&limit=10000&group_by=playerposition&s_type=${isRegularSeason ? 'regular' : 'post'}`)
const fieldingStatsResponse: {
count: number
stats: FieldingStatRaw[]
} = await response.json()
if (fieldingStatsResponse.count === 0) return []
return fieldingStatsResponse.stats.map(normalizeFieldingStat)
}
async function fetchLegacyFieldingStatsBySeasonAndPlayerId(seasonNumber: number, playerId: number, isRegularSeason: boolean): Promise<FieldingStat[]> {
const response = await fetch(`${SITE_URL}/api/v3/fieldingstats/totals?season=${seasonNumber}&player_id=${playerId}&s_type=${isRegularSeason ? 'regular' : 'post'}`)
const legacyFieldingStatsResponse: {
count: number
stats: LegacyFieldingStat[]
} = await response.json()
if (legacyFieldingStatsResponse.count === 0) return []
return legacyFieldingStatsResponse.stats
.map(makeModernFieldingStatFromLegacy)
.filter(isNotUndefined)
}
export async function fetchFieldingStatsForLastFourGamesBySeasonAndPlayerId(seasonNumber: number, playerId: number): Promise<FieldingStat[]> {
const response = await fetch(`${SITE_URL}/api/v3/plays/fielding?season=${seasonNumber}&player_id=${playerId}&group_by=playerpositiongame&limit=4&s_type=regular&sort=newest`)
const fieldingStatsResponse: {
count: number
stats: FieldingStatRaw[]
} = await response.json()
if (fieldingStatsResponse.count > 4) {
throw new Error(`fieldingStatsService.fetchFieldingStatsForLastFourBySeasonAndPlayerId - Expected at most 4 games, return contained ${fieldingStatsResponse.count}`)
}
return fieldingStatsResponse.stats.map(normalizeFieldingStat)
}
export function aggregateFieldingStats(fieldingStats: FieldingStat[]): FieldingStat[] {
const fieldingStatsByPosition = fieldingStats.reduce((statsByPos: { [key: string]: FieldingStat }, stat: FieldingStat) => {
if (!statsByPos[stat.pos]) {
statsByPos[stat.pos] = {
game: 'TOT',
player: stat.player,
team: stat.team,
pos: stat.pos,
xCheckCount: stat.xCheckCount,
hit: stat.hit,
error: stat.error,
stolenBaseCheckCount: stat.stolenBaseCheckCount,
stolenBaseCount: stat.stolenBaseCount,
caughtStealingCount: stat.caughtStealingCount,
passedBallCount: stat.passedBallCount,
winProbabilityAdded: stat.winProbabilityAdded,
weightedFieldingPercent: stat.weightedFieldingPercent,
caughtStealingPercent: stat.caughtStealingPercent
}
} else {
statsByPos[stat.pos].xCheckCount += stat.xCheckCount
statsByPos[stat.pos].hit += stat.hit
statsByPos[stat.pos].error += stat.error
statsByPos[stat.pos].stolenBaseCheckCount += stat.stolenBaseCheckCount
statsByPos[stat.pos].stolenBaseCount += stat.stolenBaseCount
statsByPos[stat.pos].caughtStealingCount += stat.caughtStealingCount
statsByPos[stat.pos].passedBallCount += stat.passedBallCount
statsByPos[stat.pos].winProbabilityAdded += stat.winProbabilityAdded
statsByPos[stat.pos].weightedFieldingPercent = weightedFieldingPercentage({
xChecks: statsByPos[stat.pos].xCheckCount,
hits: statsByPos[stat.pos].hit,
errors: statsByPos[stat.pos].error
})
statsByPos[stat.pos].caughtStealingPercent = caughtStealingPercentage({
stolenBaseAttempts: statsByPos[stat.pos].stolenBaseCheckCount,
caughtStealingCount: statsByPos[stat.pos].caughtStealingCount
})
}
return statsByPos
}, {})
return Object.values(fieldingStatsByPosition)
}
export function totaledFieldingStats(fieldingStats: FieldingStat[]): FieldingStat | undefined {
if (fieldingStats.length === 0) return undefined
const totalStat: FieldingStat = {
game: 'TOT',
player: fieldingStats[0].player,
team: fieldingStats[0].team,
pos: 'TOT',
xCheckCount: 0,
hit: 0,
error: 0,
stolenBaseCheckCount: 0,
stolenBaseCount: 0,
caughtStealingCount: 0,
passedBallCount: 0,
winProbabilityAdded: 0,
weightedFieldingPercent: 0,
caughtStealingPercent: 0
}
fieldingStats.forEach(stat => {
totalStat.xCheckCount += stat.xCheckCount
totalStat.hit += stat.hit
totalStat.error += stat.error
totalStat.stolenBaseCheckCount += stat.stolenBaseCheckCount
totalStat.stolenBaseCount += stat.stolenBaseCount
totalStat.caughtStealingCount += stat.caughtStealingCount
totalStat.passedBallCount += stat.passedBallCount
totalStat.winProbabilityAdded += stat.winProbabilityAdded
totalStat.weightedFieldingPercent = weightedFieldingPercentage({
xChecks: totalStat.xCheckCount,
hits: totalStat.hit,
errors: totalStat.error
})
totalStat.caughtStealingPercent = caughtStealingPercentage({
stolenBaseAttempts: totalStat.stolenBaseCheckCount,
caughtStealingCount: totalStat.caughtStealingCount
})
})
return totalStat
}
function makeModernFieldingStatFromLegacy(legacyStat: LegacyFieldingStat): FieldingStat | undefined {
// filter out "PH" fielding stats here which used to occur when position/xchecks were loosely checked/observed
if (legacyStat.pos === 'PH') return undefined
return {
game: 'TOT',
player: legacyStat.player,
team: legacyStat.team,
pos: legacyStat.pos,
xCheckCount: legacyStat.xch,
hit: legacyStat.xhit,
error: legacyStat.error,
stolenBaseCheckCount: legacyStat.sbc,
stolenBaseCount: legacyStat.sbc - legacyStat.csc,
caughtStealingCount: legacyStat.csc,
passedBallCount: legacyStat.pb,
winProbabilityAdded: 0,
weightedFieldingPercent: weightedFieldingPercentage({ xChecks: legacyStat.xch, hits: legacyStat.xhit, errors: legacyStat.error }),
caughtStealingPercent: caughtStealingPercentage({ stolenBaseAttempts: legacyStat.sbc, caughtStealingCount: legacyStat.csc })
}
}
function normalizeFieldingStat(rawStat: FieldingStatRaw): FieldingStat {
return {
game: rawStat.game,
player: rawStat.player,
team: rawStat.team,
pos: rawStat.pos,
xCheckCount: rawStat['x-ch'],
hit: rawStat.hit,
error: rawStat.error,
stolenBaseCheckCount: rawStat['sb-ch'],
stolenBaseCount: rawStat.sb,
caughtStealingCount: rawStat.cs,
passedBallCount: rawStat.pb,
winProbabilityAdded: rawStat.wpa,
weightedFieldingPercent: rawStat['wf%'],
caughtStealingPercent: rawStat['cs%'] === '' ? NaN : rawStat['cs%']
}
}
export function weightedFieldingPercentage(stat: { xChecks: number, hits: number, errors: number }): number {
if (stat.xChecks === 0) return NaN
return (stat.xChecks - stat.errors * 0.5 - stat.hits * 0.75) / stat.xChecks
}
export function caughtStealingPercentage(stat: { stolenBaseAttempts: number, caughtStealingCount: number }): number {
if (stat.stolenBaseAttempts === 0) return NaN
return stat.caughtStealingCount / stat.stolenBaseAttempts
}