Ingest free Statcast fielding (OAA) as team defence, dated from day one

Defence was the one conditioning category with no derivable proxy: nothing we
ingest measures fielding, and a team's pitchers' hits-allowed conflates
pitching with defence and would validate the wrong skill. Statcast publishes
Outs Above Average free on the same host as the six feeds already pulled --
verified live at 513 fielders -- so there was nothing to decide.

Added as a seventh feed, indexed per fielder and aggregated to team level,
which is the unit a batter's prop actually needs: the defence behind the
pitcher he faces. Summed OAA is the team's outs converted above average; the
mean rides along because a team with more measured fielders would otherwise
look better merely for being measured more, and a team with under three
measured fielders is absent rather than thin.

Unknown is not zero, and it bites unusually hard here: an OAA of 0 is a REAL
reading meaning exactly average, so coercing absence to 0 would assert that
every unmeasured fielder is league-average -- the most common defensive
profile there is, and a fabricated fact rather than a neutral default.

team_defense carries as_of_date in its primary key from the first row.
statcast_aggregates was built upsert-in-place with a single as_of date, which
silently made every backtest leak the games it was predicting and cost a
session to find; this makes point-in-time available before it is needed
instead of after a wrong answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
This commit is contained in:
Kev
2026-08-03 22:03:26 -04:00
parent ac1361486e
commit 010a876b3c
2 changed files with 99 additions and 2 deletions
+73 -2
View File
@@ -66,6 +66,12 @@ const FEEDS = Object.freeze({
// carries only each pitcher's primary pitch, which left velo at 53% coverage // carries only each pitcher's primary pitch, which left velo at 53% coverage
// and unusable as an archetype axis. // and unusable as an archetype axis.
pitch_velo: (y) => `${BASE}/pitch-arsenals?year=${y}&min=1&type=avg_speed&hand=&csv=true`, 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. /** 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('batter_discipline'), get('batter_batted_ball'),
get('pitcher_discipline'), get('pitcher_batted_ball'), get('pitcher_discipline'), get('pitcher_batted_ball'),
get('pitch_movement'), get('pitch_arsenal'), get('pitch_velo'), get('pitch_movement'), get('pitch_arsenal'), get('pitch_velo'),
get('fielding_oaa'),
]); ]);
const [hands, roles] = await Promise.all([fetchHandedness(season, opts), fetchRoles(season, opts)]); 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)), pitchMix: indexArsenal(ars, indexPitchMix(mv), indexVelo(velo)),
handedness: hands, handedness: hands,
roles, roles,
fielding: indexFielding(oaa),
teamDefense: aggregateTeamDefense(oaa),
counts: { counts: {
batter_discipline: bd.length, batter_batted_ball: bbb.length, batter_discipline: bd.length, batter_batted_ball: bbb.length,
pitcher_discipline: pd.length, pitcher_batted_ball: pbb.length, pitcher_discipline: pd.length, pitcher_batted_ball: pbb.length,
pitch_movement: mv.length, pitch_arsenal: ars.length, pitch_velo: velo.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 /** statsapi handedness → Map(id → {bats, throws, position}). ONE call, and it
* carried 1,316/1,316 in the live probe. */ * carried 1,316/1,316 in the live probe. */
async function fetchHandedness(season, opts = {}) { async function fetchHandedness(season, opts = {}) {
@@ -357,6 +427,7 @@ async function fetchJson(url, opts = {}) {
module.exports = { module.exports = {
fetchSeason, fetchSeason,
indexFielding, aggregateTeamDefense,
DEFAULT_SEASON, DEFAULT_SEASON,
FEEDS, FEEDS,
__internals: { __internals: {
+26
View File
@@ -255,6 +255,32 @@ async function refreshSeason(opts = {}) {
summary.written += batch.length; 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 ─────────────────────────────────────────── // ── POINT-IN-TIME RETENTION ───────────────────────────────────────────
// `statcast_aggregates` is upserted in place, so it holds exactly ONE as-of // `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 // date and every prior version is destroyed. That silently makes any backtest