sba-website/src/services/pitchingStatsService.ts

556 lines
14 KiB
TypeScript

import type { Game, Team } from './apiResponseTypes'
import type { Player } from './playersService'
import { MODERN_STAT_ERA_START, API_URL, avg, slg, woba } 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 PitchingStat {
player: Player
team: Team
game: Game | 'TOT'
// stats below
tbf: number
outs: number
games: number
gs: number
win: number
loss: number
hold: number
save: number
bsave: number
ir: number
ir_sc: number
ir_sc_pct: number
ab: number
run: number
e_run: number
hits: number
double: number
triple: number
hr: number
bb: number
so: number
hbp: number
sac: number
ibb: number
gidp: number
sb: number
cs: number
bphr: number
bpfo: number
bp1b: number
bplo: number
wp: number
balk: number
wpa: number
era: number
whip: number
avg: number
obp: number
slg: number
ops: number
woba: number
kPer9: number
bbPer9: number
kPerBB: number
ip: number
}
interface PitchingStatRaw {
player: Player
team: Team
game: Game | 'TOT'
// stats below
tbf: number
outs: number
games: number
gs: number
win: number
loss: number
hold: number
save: number
bsave: number
ir: number
ir_sc: number
ab: number
run: number
e_run: number
hits: number
double: number
triple: number
hr: number
bb: number
so: number
hbp: number
sac: number
ibb: number
gidp: number
sb: number
cs: number
bphr: number
bpfo: number
bp1b: number
bplo: number
wp: number
balk: number
wpa: number
era: number
whip: number
avg: number
obp: number
slg: number
ops: number
woba: number
'k/9': number
'bb/9': number
'k/bb': number
}
interface LegacyPitchingStat {
player: Player
team: Team
ip: number,
hit: number,
run: number,
erun: number,
so: number,
bb: number,
hbp: number,
wp: number,
balk: number,
hr: number,
ir: number,
irs: number,
gs: number,
games: number,
win: number,
loss: number,
hold: number,
sv: number,
bsv: number
}
export async function fetchPitchingStatsBySeasonAndPlayerId(seasonNumber: number, playerId: number, isRegularSeason: boolean): Promise<PitchingStat | undefined> {
// different endpoint for pre-modern stats (/plays) era
if (seasonNumber < MODERN_STAT_ERA_START) {
return await fetchLegacyPitchingStatsBySeasonAndPlayerId(seasonNumber, playerId, isRegularSeason)
}
const response = await fetch(`${API_URL}/plays/pitching?season=${seasonNumber}&player_id=${playerId}&s_type=${isRegularSeason ? 'regular' : 'post'}`)
const pitchingStatsResponse: {
count: number
stats: PitchingStatRaw[]
} = await response.json()
if (pitchingStatsResponse.count === 0) return undefined
if (pitchingStatsResponse.count > 1) {
throw new Error('pitchingStatsService.fetchPitchingStatsBySeasonAndPlayerId - Expected one stat line for season/player, return contained many')
}
return normalizePitchingStat(pitchingStatsResponse.stats[0])
}
export async function fetchPitchingStatsBySeasonAndTeamId(seasonNumber: number, teamId: number, isRegularSeason: boolean): Promise<PitchingStat[]> {
// different endpoint for pre-modern stats (/plays) era
if (seasonNumber < MODERN_STAT_ERA_START) {
return []
}
const response = await fetch(`${API_URL}/plays/pitching?season=${seasonNumber}&team_id=${teamId}&group_by=playerteam&s_type=${isRegularSeason ? 'regular' : 'post'}`)
const pitchingStatsResponse: {
count: number
stats: PitchingStatRaw[]
} = await response.json()
if (pitchingStatsResponse.count === 0) return []
return pitchingStatsResponse.stats.map(normalizePitchingStat)
}
export async function fetchTeamPitchingStatsBySeason(seasonNumber: number): Promise<PitchingStat[]> {
// different endpoint for pre-modern stats (/plays) era
if (seasonNumber < MODERN_STAT_ERA_START) {
return []
}
const response = await fetch(`${API_URL}/plays/pitching?season=${seasonNumber}&group_by=team&s_type=regular`)
const pitchingStatsResponse: {
count: number
stats: PitchingStatRaw[]
} = await response.json()
if (pitchingStatsResponse.count === 0) return []
return pitchingStatsResponse.stats.map(normalizePitchingStat)
}
export async function fetchPitchingStatsBySeason(seasonNumber: number, isRegularSeason: boolean): Promise<PitchingStat[]> {
// different endpoint for pre-modern stats (/plays) era
if (seasonNumber < MODERN_STAT_ERA_START) {
console.warn('pitchingStatsService.fetchPitchingStatsBySeason - Not looking up legacy season stats rn')
return []
}
const response = await fetch(`${API_URL}/plays/pitching?season=${seasonNumber}&group_by=player&limit=1000&s_type=${isRegularSeason ? 'regular' : 'post'}`)
const pitchingStatsResponse: {
count: number
stats: PitchingStatRaw[]
} = await response.json()
if (pitchingStatsResponse.count === 0) return []
return pitchingStatsResponse.stats.map(normalizePitchingStat)
}
async function fetchLegacyPitchingStatsBySeasonAndPlayerId(seasonNumber: number, playerId: number, isRegularSeason: boolean): Promise<PitchingStat | undefined> {
const response = await fetch(`${API_URL}/pitchingstats/totals?season=${seasonNumber}&player_id=${playerId}&s_type=${isRegularSeason ? 'regular' : 'post'}`)
const legacyPitchingStatsResponse: {
count: number
stats: LegacyPitchingStat[]
} = await response.json()
if (legacyPitchingStatsResponse.count === 0) return undefined
if (legacyPitchingStatsResponse.count > 1) {
throw new Error('pitchingStatsService.fetchLegacyPitchingStatsBySeasonAndPlayerId - Expected one stat line for season/player, return contained many')
}
return makeModernPitchingStatFromLegacy(legacyPitchingStatsResponse.stats[0])
}
export async function fetchPitchingStatsForLastFourGamesBySeasonAndPlayerId(seasonNumber: number, playerId: number): Promise<PitchingStat[]> {
const response = await fetch(`${API_URL}/plays/pitching?season=${seasonNumber}&player_id=${playerId}&group_by=playergame&limit=4&s_type=regular&sort=newest`)
const pitchingStatsResponse: {
count: number
stats: PitchingStatRaw[]
} = await response.json()
if (pitchingStatsResponse.count > 4) {
throw new Error(`pitchingStatsService.fetchPitchingStatsForLastFourBySeasonAndPlayerId - Expected at most 4 games, return contained ${pitchingStatsResponse.count}`)
}
return pitchingStatsResponse.stats.map(normalizePitchingStat)
}
export async function fetchPitchingStatsForLastTwoWeeksByTeam(seasonNumber: number, weekNumber: number, teamId: number): Promise<PitchingStat[]> {
// no support for pre-modern games yet
if (seasonNumber < MODERN_STAT_ERA_START) {
return []
}
const response = await fetch(`${API_URL}/plays/pitching?season=${seasonNumber}&week_start=${weekNumber - 1}&week_end=${weekNumber}&team_id=${teamId}&group_by=playergame`)
const pitchingStatsResponse: {
count: number
stats: PitchingStatRaw[]
} = await response.json()
return pitchingStatsResponse.stats.map(normalizePitchingStat)
}
export async function fetchPitchingStatsBySeries(seasonNumber: number, weekNumber: number, homeTeamId: number, awayTeamId: number): Promise<PitchingStat[]> {
// no support for pre-modern games yet
if (seasonNumber < MODERN_STAT_ERA_START) {
return []
}
const response = await fetch(`${API_URL}/plays/pitching?season=${seasonNumber}&week=${weekNumber}&team_id=${homeTeamId}&team_id=${awayTeamId}&group_by=playergame`)
const pitchingStatsResponse: {
count: number
stats: PitchingStatRaw[]
} = await response.json()
return pitchingStatsResponse.stats.map(normalizePitchingStat)
}
export function aggregatePitchingStats(pitchingStats: PitchingStat[]): PitchingStat {
const totalStat: PitchingStat = {
player: pitchingStats[0].player,
team: pitchingStats[0].team,
game: 'TOT',
// stats below
tbf: 0,
outs: 0,
games: 0,
gs: 0,
win: 0,
loss: 0,
hold: 0,
save: 0,
bsave: 0,
ir: 0,
ir_sc: 0,
ir_sc_pct: 0,
ab: 0,
run: 0,
e_run: 0,
hits: 0,
double: 0,
triple: 0,
hr: 0,
bb: 0,
so: 0,
hbp: 0,
sac: 0,
ibb: 0,
gidp: 0,
sb: 0,
cs: 0,
bphr: 0,
bpfo: 0,
bp1b: 0,
bplo: 0,
wp: 0,
balk: 0,
wpa: 0,
era: 0,
whip: 0,
avg: 0,
obp: 0,
slg: 0,
ops: 0,
woba: 0,
kPer9: 0,
bbPer9: 0,
kPerBB: 0,
ip: 0
}
pitchingStats.forEach(stat => {
totalStat.tbf += stat.tbf
totalStat.outs += stat.outs
totalStat.games += stat.games
totalStat.gs += stat.gs
totalStat.win += stat.win
totalStat.loss += stat.loss
totalStat.hold += stat.hold
totalStat.save += stat.save
totalStat.bsave += stat.bsave
totalStat.ir += stat.ir
totalStat.ir_sc += stat.ir_sc
totalStat.ab += stat.ab
totalStat.run += stat.run
totalStat.e_run += stat.e_run
totalStat.hits += stat.hits
totalStat.double += stat.double
totalStat.triple += stat.triple
totalStat.hr += stat.hr
totalStat.bb += stat.bb
totalStat.so += stat.so
totalStat.hbp += stat.hbp
totalStat.sac += stat.sac
totalStat.ibb += stat.ibb
totalStat.gidp += stat.gidp
totalStat.sb += stat.sb
totalStat.cs += stat.cs
totalStat.bphr += stat.bphr
totalStat.bpfo += stat.bpfo
totalStat.bp1b += stat.bp1b
totalStat.bplo += stat.bplo
totalStat.wp += stat.wp
totalStat.balk += stat.balk
totalStat.wpa += stat.wpa
})
// a shim since batting stat has "hit" and pitching stat has "hits" and I want to share functions
// TODO will need to figure out if there are pas for pitching or if there is an easy way to back into it
const totalStatWithHit = { ...totalStat, hit: totalStat.hits }
return {
...totalStat,
ir_sc_pct: irsPercentage(totalStat),
era: eraFromOuts(totalStat),
whip: whipFromOuts(totalStat),
kPer9: kPer9FromOuts(totalStat),
bbPer9: bbPer9FromOuts(totalStat),
kPerBB: kPerBB(totalStat),
avg: avg(totalStatWithHit),
obp: 0, //obp({pa: totalStat.}),
slg: slg(totalStatWithHit),
ops: 0, //ops(totalStat),
woba: woba(totalStatWithHit),
ip: totalStat.outs / 3
}
}
function makeModernPitchingStatFromLegacy(legacyStat: LegacyPitchingStat): PitchingStat {
return {
player: legacyStat.player,
team: legacyStat.team,
game: 'TOT',
// stats below
outs: Math.round(legacyStat.ip * 3),
games: legacyStat.games,
gs: legacyStat.gs,
win: legacyStat.win,
loss: legacyStat.loss,
hold: legacyStat.hold,
save: legacyStat.sv,
bsave: legacyStat.bsv,
ir: legacyStat.ir,
ir_sc: legacyStat.irs,
run: legacyStat.run,
e_run: legacyStat.erun,
hits: legacyStat.hit,
hr: legacyStat.hr,
bb: legacyStat.bb,
so: legacyStat.so,
hbp: legacyStat.hbp,
wp: legacyStat.wp,
balk: legacyStat.balk,
ip: legacyStat.ip,
// calculated stats
era: era({ ip: legacyStat.ip, e_run: legacyStat.erun }),
whip: whip({ ip: legacyStat.ip, bb: legacyStat.bb, hits: legacyStat.hit }),
kPer9: kPer9(legacyStat),
bbPer9: bbPer9(legacyStat),
kPerBB: kPerBB(legacyStat),
ir_sc_pct: irsPercentage({ ir: legacyStat.ir, ir_sc: legacyStat.irs }),
// no easy way to get below values from legacy stats afaik
tbf: 0,
ab: 0,
double: 0,
triple: 0,
sac: 0,
ibb: 0,
gidp: 0,
sb: 0,
cs: 0,
bphr: 0,
bpfo: 0,
bp1b: 0,
bplo: 0,
wpa: 0,
avg: 0,
obp: 0,
slg: 0,
ops: 0,
woba: 0,
}
}
function normalizePitchingStat(rawStat: PitchingStatRaw): PitchingStat {
return {
player: rawStat.player,
team: rawStat.team,
game: rawStat.game,
// stats below
tbf: rawStat.tbf,
outs: rawStat.outs,
games: rawStat.games,
gs: rawStat.gs,
win: rawStat.win,
loss: rawStat.loss,
hold: rawStat.hold,
save: rawStat.save,
bsave: rawStat.bsave,
ir: rawStat.ir,
ir_sc: rawStat.ir_sc,
ir_sc_pct: irsPercentage(rawStat),
ab: rawStat.ab,
run: rawStat.run,
e_run: rawStat.e_run,
hits: rawStat.hits,
double: rawStat.double,
triple: rawStat.triple,
hr: rawStat.hr,
bb: rawStat.bb,
so: rawStat.so,
hbp: rawStat.hbp,
sac: rawStat.sac,
ibb: rawStat.ibb,
gidp: rawStat.gidp,
sb: rawStat.sb,
cs: rawStat.cs,
bphr: rawStat.bphr,
bpfo: rawStat.bpfo,
bp1b: rawStat.bp1b,
bplo: rawStat.bplo,
wp: rawStat.wp,
balk: rawStat.balk,
wpa: rawStat.wpa,
era: rawStat.era,
whip: rawStat.whip,
avg: rawStat.avg,
obp: rawStat.obp,
slg: rawStat.slg,
ops: rawStat.ops,
woba: rawStat.woba,
kPer9: rawStat['k/9'],
bbPer9: rawStat['bb/9'],
kPerBB: rawStat['k/bb'],
ip: rawStat.outs / 3
}
}
function era(stat: { e_run: number, ip: number }): number {
if (stat.ip === 0) return stat.e_run > 0 ? NaN : 0
return stat.e_run * 9 / stat.ip
}
function eraFromOuts(stat: { e_run: number, outs: number }): number {
if (stat.outs === 0) return stat.e_run > 0 ? NaN : 0
return stat.e_run * 27 / stat.outs
}
function whip(stat: { bb: number, hits: number, ip: number }): number {
if (stat.ip === 0) return stat.bb + stat.hits > 0 ? NaN : 0
return (stat.bb + stat.hits) / stat.ip
}
function whipFromOuts(stat: { bb: number, hits: number, outs: number }): number {
if (stat.outs === 0) return stat.bb + stat.hits > 0 ? NaN : 0
return (stat.bb + stat.hits) * 3 / stat.outs
}
function kPer9(stat: { so: number, ip: number }): number {
if (stat.ip === 0) return 0
return stat.so * 9 / stat.ip
}
function kPer9FromOuts(stat: { so: number, outs: number }): number {
if (stat.outs === 0) return 0
return stat.so * 27 / stat.outs
}
function bbPer9(stat: { bb: number, ip: number }): number {
if (stat.ip === 0) return stat.bb > 0 ? NaN : 0
return stat.bb * 9 / stat.ip
}
function bbPer9FromOuts(stat: { bb: number, outs: number }): number {
if (stat.outs === 0) return stat.bb > 0 ? NaN : 0
return stat.bb * 27 / stat.outs
}
function kPerBB(stat: { so: number, bb: number }): number {
if (stat.bb === 0) return 0
return stat.so / stat.bb
}
function irsPercentage(stat: { ir: number, ir_sc: number }): number {
return stat.ir === 0 ? 0 : stat.ir_sc / stat.ir
}