diff --git a/src/services/adapters/statcastAdapter.js b/src/services/adapters/statcastAdapter.js index c3d6bba..762372f 100644 --- a/src/services/adapters/statcastAdapter.js +++ b/src/services/adapters/statcastAdapter.js @@ -66,6 +66,12 @@ const FEEDS = Object.freeze({ // carries only each pitcher's primary pitch, which left velo at 53% coverage // and unusable as an archetype axis. pitch_velo: (y) => `${BASE}/pitch-arsenals?year=${y}&min=1&type=avg_speed&hand=&csv=true`, + // FIELDING — Outs Above Average, per fielder, with his team. Free, same host, + // no key. This is the ONLY defence signal available to us: nothing in the + // other six feeds measures fielding at all, and the obvious substitutes + // (a team's pitchers' hits-allowed) conflate pitching WITH defence and would + // validate the wrong skill. Measured live 2026-08-03: 513 fielders. + fielding_oaa: (y) => `${BASE}/leaderboard/outs_above_average?type=Fielder&startYear=${y}&endYear=${y}&split=no&team=&range=year&min=1&pos=&roles=&viz=hide&csv=true`, }); /** statsapi — free, no key. Two calls that complete the Tier-A joins. @@ -285,10 +291,11 @@ async function fetchSeason(season = DEFAULT_SEASON, opts = {}) { } }; - const [bd, bbb, pd, pbb, mv, ars, velo] = await Promise.all([ + const [bd, bbb, pd, pbb, mv, ars, velo, oaa] = await Promise.all([ get('batter_discipline'), get('batter_batted_ball'), get('pitcher_discipline'), get('pitcher_batted_ball'), get('pitch_movement'), get('pitch_arsenal'), get('pitch_velo'), + get('fielding_oaa'), ]); const [hands, roles] = await Promise.all([fetchHandedness(season, opts), fetchRoles(season, opts)]); @@ -301,15 +308,78 @@ async function fetchSeason(season = DEFAULT_SEASON, opts = {}) { pitchMix: indexArsenal(ars, indexPitchMix(mv), indexVelo(velo)), handedness: hands, roles, + fielding: indexFielding(oaa), + teamDefense: aggregateTeamDefense(oaa), counts: { batter_discipline: bd.length, batter_batted_ball: bbb.length, pitcher_discipline: pd.length, pitcher_batted_ball: pbb.length, pitch_movement: mv.length, pitch_arsenal: ars.length, pitch_velo: velo.length, - handedness: hands.size, pitcher_roles: roles.size, + handedness: hands.size, pitcher_roles: roles.size, fielding_oaa: oaa.length, }, }; } +/** + * Per-fielder OAA → Map(mlbam id → { oaa, runs_prevented, success_diff, pos }). + * + * UNKNOWN IS NOT ZERO, and it bites unusually hard here: an OAA of 0 is a REAL + * measurement meaning "exactly average", while a missing row means we have no + * read on that fielder. Coercing absence to 0 would silently assert that every + * unmeasured fielder is league-average — the single most common defensive + * profile — which is a fabricated fact, not a neutral default. + */ +function indexFielding(rows) { + const out = new Map(); + for (const r of rows || []) { + const id = numOrNull(r.player_id); + if (id == null) continue; + out.set(Number(id), { + oaa: numOrNull(r.outs_above_average), + runs_prevented: numOrNull(r.fielding_runs_prevented), + // "-4%" → -4. The success-rate columns arrive percent-formatted. + success_diff: numOrNull(String(r.diff_success_rate_formatted || '').replace('%', '')), + pos: r.primary_pos_formatted || null, + team: r.display_team_name || null, + }); + } + return out; +} + +/** + * TEAM defence → Map(team nickname → { oaa_sum, oaa_mean, success_diff_mean, fielders }). + * + * A batter's prop is conditioned on the DEFENCE BEHIND THE PITCHER HE FACES, so + * team level is the unit that matters. Summed OAA is the team's total outs + * converted above average; the mean is carried too because a team with more + * measured fielders would otherwise look better merely for being measured more. + * A team with no measured fielders is ABSENT, never 0. + */ +function aggregateTeamDefense(rows) { + const acc = new Map(); + for (const r of rows || []) { + const team = r.display_team_name; + const oaa = numOrNull(r.outs_above_average); + if (!team || oaa == null) continue; + const cur = acc.get(team) || { oaa_sum: 0, fielders: 0, diff_sum: 0, diff_n: 0 }; + cur.oaa_sum += oaa; + cur.fielders += 1; + const d = numOrNull(String(r.diff_success_rate_formatted || '').replace('%', '')); + if (d != null) { cur.diff_sum += d; cur.diff_n += 1; } + acc.set(team, cur); + } + const out = new Map(); + for (const [team, v] of acc) { + if (v.fielders < 3) continue; // too thin to call a team defence + out.set(team, { + oaa_sum: v.oaa_sum, + oaa_mean: v.oaa_sum / v.fielders, + success_diff_mean: v.diff_n > 0 ? v.diff_sum / v.diff_n : null, + fielders: v.fielders, + }); + } + return out; +} + /** statsapi handedness → Map(id → {bats, throws, position}). ONE call, and it * carried 1,316/1,316 in the live probe. */ async function fetchHandedness(season, opts = {}) { @@ -357,6 +427,7 @@ async function fetchJson(url, opts = {}) { module.exports = { fetchSeason, + indexFielding, aggregateTeamDefense, DEFAULT_SEASON, FEEDS, __internals: { diff --git a/src/services/statcastAggregateService.js b/src/services/statcastAggregateService.js index 9b84727..30a36a5 100644 --- a/src/services/statcastAggregateService.js +++ b/src/services/statcastAggregateService.js @@ -255,6 +255,32 @@ async function refreshSeason(opts = {}) { summary.written += batch.length; } + // ── TEAM DEFENCE ────────────────────────────────────────────────────── + // Written DATED, so point-in-time is available the first time it is needed + // rather than retrofitted after a wrong answer (the statcast_aggregates + // lesson). Best-effort like every other side-write: a defence failure must + // never fail the mechanism refresh. + try { + const td = feeds.teamDefense; + const asOfD = (opts.asOfDate || started).slice(0, 10); + if (td && td.size > 0) { + const rows = [...td.entries()].map(([team, v]) => ({ + as_of_date: asOfD, sport, season, team, + oaa_sum: v.oaa_sum, oaa_mean: v.oaa_mean, + success_diff_mean: v.success_diff_mean, fielders: v.fielders, + source: 'statcast_oaa', + })); + const { error } = await sb.from('team_defense') + .upsert(rows, { onConflict: 'as_of_date,sport,season,team' }); + if (error) summary.team_defense_error = error.message; + else summary.team_defense_written = rows.length; + } else { + summary.team_defense_written = 0; + } + } catch (e) { + summary.team_defense_error = e.message; + } + // ── POINT-IN-TIME RETENTION ─────────────────────────────────────────── // `statcast_aggregates` is upserted in place, so it holds exactly ONE as-of // date and every prior version is destroyed. That silently makes any backtest