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:
@@ -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: {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user