316b79733e
1. matchupRead fly-ball signal: the batter metrics `gb_pct_bb`/`fb_ld_pct` are MISLABELED — they're exit velocities by batted-ball type (Judge fb_ld_pct = 100.3 mph, not a rate), not ground/fly RATES. Switched fly-ball lean to avg_launch_angle (league p10/p50/p90 = 7.1/13.9/20.1°), the correct signal. 2. Absolute rate now fits the FULL season (recency-weighted), not a 20-game window: the window under-sampled rare stats — Judge HR projected 0.11 vs his 0.28 season rate (a fake -32pt edge). Now point=0.27 (matches season); the last-5-2x recency lean is preserved. Post-fix induction (real statsapi logs + real statcast): Judge HR 0.27 (P>=1 0.235 vs book 0.42 -> flags the juiced over), Judge TB P>=2 0.548 vs 0.48 (+6.8pt), thin-hot 3-game P>=1 0.726 / P>=3 0.164 (credible low, thin high), .300 hitter != 3.0. proj-v1 suites 23/23. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
159 lines
7.6 KiB
JavaScript
159 lines
7.6 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* proj-v1 MATCHUP READ — coarse repertoire-vs-profile (Phase 2).
|
||
*
|
||
* This is the input the book DOESN'T use. Books price generic L/R handedness
|
||
* splits; they do not read THIS pitcher's arsenal against THIS hitter's profile.
|
||
*
|
||
* ── HONEST FIDELITY (Phase 0.4) ──────────────────────────────────────────
|
||
* We have RICH pitcher repertoire (statcast pitch_mix: per-pitch type, usage%,
|
||
* whiff%, hard_hit%). We do NOT have hitter pitch-type performance (no "damage
|
||
* vs sliders"). So TRUE repertoire-vs-profile is impossible today. This is the
|
||
* COARSE version: bucket the arsenal (fastball / sinker / breaking / offspeed
|
||
* heavy, + its whiff & hard-hit tendency) and read it against the hitter's
|
||
* AGGREGATE profile (whiff%, chase%, ground-ball vs fly-ball lean, hard-hit).
|
||
* It beats the generic L/R split; it is not pitch-type-damage matching. A
|
||
* hitter pitch-type feed would unlock the true version — see the roadmap.
|
||
*
|
||
* Every rule below is DERIVED from what the feeds carry and DOCUMENTED so the
|
||
* number's meaning is knowable later; matchupRead.test.js locks each direction.
|
||
* The output is a small, bounded rate MULTIPLIER per stat plus a per-component
|
||
* breakdown (so the terminal can show its work).
|
||
*/
|
||
|
||
const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
|
||
const num = (v) => (isNum(v) ? v : (v == null || v === '' ? null : (Number.isFinite(Number(v)) ? Number(v) : null)));
|
||
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
||
|
||
// Pitch-type families (statcast `type` codes).
|
||
const FASTBALL = new Set(['FF', 'FC']); // 4-seam, cutter (true rising/hard)
|
||
const SINKER = new Set(['SI', 'FT']); // sinker / 2-seam (ground-ball inducing)
|
||
const BREAKING = new Set(['SL', 'CU', 'ST', 'SV', 'KC', 'SC']);
|
||
const OFFSPEED = new Set(['CH', 'FS', 'FO']);
|
||
|
||
/**
|
||
* classifyArsenal(pitch_mix) — usage-weighted buckets + tendency. Returns null
|
||
* when the mix is absent (honest-absent → no matchup contribution).
|
||
*/
|
||
function classifyArsenal(pitchMix) {
|
||
if (!Array.isArray(pitchMix) || !pitchMix.length) return null;
|
||
let total = 0; const b = { fastball: 0, sinker: 0, breaking: 0, offspeed: 0 };
|
||
let whiffW = 0; let hardW = 0;
|
||
for (const pt of pitchMix) {
|
||
const use = num(pt && pt.usage_pct);
|
||
const type = String(pt && pt.type || '').toUpperCase();
|
||
if (use == null || use <= 0) continue;
|
||
total += use;
|
||
if (FASTBALL.has(type)) b.fastball += use;
|
||
else if (SINKER.has(type)) b.sinker += use;
|
||
else if (BREAKING.has(type)) b.breaking += use;
|
||
else if (OFFSPEED.has(type)) b.offspeed += use;
|
||
const w = num(pt.whiff_pct); if (w != null) whiffW += use * w;
|
||
const h = num(pt.hard_hit_pct); if (h != null) hardW += use * h;
|
||
}
|
||
if (total <= 0) return null;
|
||
return {
|
||
fastball_pct: b.fastball / total,
|
||
sinker_pct: b.sinker / total,
|
||
breaking_pct: b.breaking / total,
|
||
offspeed_pct: b.offspeed / total,
|
||
whiff_tendency: whiffW / total, // arsenal-wide whiff% (swing-and-miss stuff)
|
||
hard_hit_tendency: hardW / total, // contact quality the arsenal allows
|
||
};
|
||
}
|
||
|
||
/**
|
||
* hitterProfile(batterRow) — the aggregate tendencies we DO have.
|
||
*
|
||
* FLY-BALL lean comes from avg_launch_angle (league p10/p50/p90 = 7.1/13.9/20.1°
|
||
* verified in prod). NOTE: the metrics blob's `gb_pct_bb` / `fb_ld_pct` are
|
||
* mislabeled — they are EXIT VELOCITIES by batted-ball type (Judge fb_ld_pct =
|
||
* 100.3 mph, not a rate), NOT ground/fly RATES. Launch angle is the correct,
|
||
* unambiguous air-vs-ground signal. (Caught in the proj-v1 sanity induction.)
|
||
*/
|
||
function hitterProfile(row) {
|
||
if (!row) return null;
|
||
const m = row.metrics || {};
|
||
return {
|
||
whiff: num(row.whiff_pct) ?? num(m.whiff_pct),
|
||
chase: num(row.chase_pct) ?? num(m.chase_pct),
|
||
hard_hit: num(row.hard_hit_pct) ?? num(m.hard_hit_pct),
|
||
launch_angle: num(row.avg_launch_angle) ?? num(m.avg_launch_angle), // ° — higher = more air
|
||
};
|
||
}
|
||
|
||
// League anchors (2026 percentiles, verified in prod) — the neutral point each
|
||
// tendency is measured against, so "high"/"low" means relative to the league.
|
||
const ANCHOR = Object.freeze({
|
||
arsenal_whiff: 22, hitter_whiff: 24, hard_hit: 38, launch_angle: 13.9,
|
||
});
|
||
// Max single-component lean and total matchup lean — coarse read, small effect.
|
||
const COMP_MAX = 0.05;
|
||
const TOTAL_MAX = Number(process.env.PROJ_MATCHUP_MAX || 0.10);
|
||
|
||
const z = (v, anchor, scale) => (v == null ? 0 : clamp((v - anchor) / scale, -1, 1));
|
||
|
||
/**
|
||
* matchupMultiplier({ arsenal, hitter, statType }) — per-stat rate multiplier
|
||
* (≈1.0 neutral) + a documented per-component breakdown. Coarse, bounded, and
|
||
* ABSENT (multiplier 1, components []) when either side is missing.
|
||
*/
|
||
function matchupMultiplier({ arsenal, hitter, statType } = {}) {
|
||
const stat = String(statType || '').toLowerCase();
|
||
const none = { multiplier: 1, components: [], reason: 'insufficient_matchup_data' };
|
||
if (!arsenal || !hitter) return none;
|
||
|
||
const comps = [];
|
||
const add = (label, lean) => { if (lean) comps.push({ label, lean: Math.round(lean * 1000) / 1000 }); };
|
||
|
||
// (1) WHIFF axis — a high-whiff arsenal vs a whiff-prone hitter suppresses
|
||
// balls in play (fewer hits), and raises strikeouts. DERIVED: arsenal
|
||
// whiff tendency × hitter whiff, both league-relative.
|
||
const arsWhiff = z(arsenal.whiff_tendency, ANCHOR.arsenal_whiff, 8);
|
||
const hitWhiff = z(hitter.whiff, ANCHOR.hitter_whiff, 8);
|
||
const whiffPress = clamp(arsWhiff * (0.5 + 0.5 * Math.max(0, hitWhiff)), -1, 1); // stuff bites vulnerable hitters harder
|
||
|
||
// (2) GROUND/AIR axis — a sinker/GB arsenal keeps the ball down, suppressing a
|
||
// fly-ball hitter's POWER (HR/TB) but yielding weak contact. A fastball/
|
||
// fly-ball arsenal lets a fly-ball hitter elevate. DERIVED: sinker share
|
||
// vs hitter fly lean.
|
||
const groundy = clamp(arsenal.sinker_pct - 0.20, -0.5, 0.6) / 0.6; // >20% sinker = groundy
|
||
const flyBat = z(hitter.launch_angle, ANCHOR.launch_angle, 6); // p90 20° → ~+1, p10 7° → ~-1
|
||
const airSuppress = clamp(groundy * Math.max(0, flyBat), -1, 1); // groundy arm vs fly bat → power down
|
||
|
||
// (3) HARD-CONTACT axis — an arsenal that allows hard contact, faced by a
|
||
// hard-hit hitter, lifts extra-base outcomes.
|
||
const arsHard = z(arsenal.hard_hit_tendency, ANCHOR.hard_hit, 6);
|
||
const hitHard = z(hitter.hard_hit, ANCHOR.hard_hit, 6);
|
||
const hardLift = clamp((arsHard + hitHard) / 2, -1, 1);
|
||
|
||
let lean = 0;
|
||
if (stat === 'hits') {
|
||
add('whiff_vs_stuff', -COMP_MAX * whiffPress); lean += -COMP_MAX * whiffPress;
|
||
add('contact_quality', 0.4 * COMP_MAX * hardLift); lean += 0.4 * COMP_MAX * hardLift;
|
||
} else if (stat === 'strikeouts') {
|
||
add('whiff_vs_stuff', COMP_MAX * whiffPress); lean += COMP_MAX * whiffPress; // K prop moves WITH whiff pressure
|
||
} else if (stat === 'home_runs' || stat === 'total_bases' || stat === 'doubles' || stat === 'triples') {
|
||
add('air_suppression', -COMP_MAX * airSuppress); lean += -COMP_MAX * airSuppress;
|
||
add('hard_contact', COMP_MAX * hardLift); lean += COMP_MAX * hardLift;
|
||
add('whiff_vs_stuff', -0.4 * COMP_MAX * whiffPress); lean += -0.4 * COMP_MAX * whiffPress;
|
||
} else {
|
||
return none; // unmapped stat → no matchup lean
|
||
}
|
||
|
||
const capped = clamp(lean, -TOTAL_MAX, TOTAL_MAX);
|
||
return {
|
||
multiplier: Math.round((1 + capped) * 1000) / 1000,
|
||
components: comps,
|
||
capped: capped !== lean,
|
||
reason: comps.length ? null : 'matchup_neutral',
|
||
};
|
||
}
|
||
|
||
module.exports = {
|
||
classifyArsenal, hitterProfile, matchupMultiplier,
|
||
COMP_MAX, TOTAL_MAX, ANCHOR,
|
||
__internals: { FASTBALL, SINKER, BREAKING, OFFSPEED, z },
|
||
};
|