455 lines
11 KiB
TypeScript
455 lines
11 KiB
TypeScript
import type { Game, Team } from './apiResponseTypes'
|
|
import type { Player } from './playersService'
|
|
import { MODERN_STAT_ERA_START, SITE_URL, avg, obp, ops, 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
|
|
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
|
|
}
|
|
|
|
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(`${SITE_URL}/api/v3/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])
|
|
}
|
|
|
|
async function fetchLegacyPitchingStatsBySeasonAndPlayerId(seasonNumber: number, playerId: number, isRegularSeason: boolean): Promise<PitchingStat | undefined> {
|
|
const response = await fetch(`${SITE_URL}/api/v3/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(`${SITE_URL}/api/v3/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 function aggregatePitchingStats(pitchingStats: PitchingStat[]): PitchingStat {
|
|
let 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,
|
|
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
|
|
}
|
|
|
|
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,
|
|
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)
|
|
}
|
|
}
|
|
|
|
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,
|
|
|
|
// 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),
|
|
|
|
// 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,
|
|
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"]
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|