diff --git a/src/services/projection/matchupRead.js b/src/services/projection/matchupRead.js index a098a90..f3b4906 100644 --- a/src/services/projection/matchupRead.js +++ b/src/services/projection/matchupRead.js @@ -64,28 +64,29 @@ function classifyArsenal(pitchMix) { } /** - * hitterProfile(batterRow) — the aggregate tendencies we DO have. gb/fb lean - * comes from gb_pct_bb vs fb_ld_pct in the metrics blob (batted-ball type). + * 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 || {}; - const gb = num(m.gb_pct_bb); - const fb = num(m.fb_ld_pct); - let flyLean = null; - if (gb != null && fb != null && (gb + fb) > 0) flyLean = fb / (gb + fb); // 0..1, high = fly-ball 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), - fly_lean: flyLean, // fraction of batted balls in the air + 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, fly_lean: 0.5, + 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; @@ -118,7 +119,7 @@ function matchupMultiplier({ arsenal, hitter, statType } = {}) { // 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.fly_lean, ANCHOR.fly_lean, 0.2); + 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 diff --git a/src/services/projectionChallenger.js b/src/services/projectionChallenger.js index 8e12819..449d1b9 100644 --- a/src/services/projectionChallenger.js +++ b/src/services/projectionChallenger.js @@ -87,9 +87,14 @@ function parkBaselineFromLogs(gameLog, playerTeamAbbr) { return factors.reduce((a, b) => a + b, 0) / factors.length; } -/** Recency-weighted observed counts (last-5 games weighted 2×, like the champion). */ -function recencyWeighted(gameLog, field, window = 20) { - // gameLog is most-recent-LAST (statsapi order); take the tail as recent. +/** + * Season-with-recency observed counts: the FULL season anchors the absolute rate + * (a 20-game window under-samples rare stats — a slugger's HR drought produced a + * 0.11 point vs a 0.28 season rate, a fake -32pt edge, caught in the induction), + * with the last 5 games weighted 2× for current form (as the champion does). + */ +function recencyWeighted(gameLog, field, window = 200) { + // gameLog is most-recent-LAST (statsapi order); the tail is the recent form. const rows = (gameLog || []).filter((g) => g && g.stat); const recent = rows.slice(-window); const n = recent.length; diff --git a/tests/unit/projectionChallenger.test.js b/tests/unit/projectionChallenger.test.js index 0a8ae64..3ce96fd 100644 --- a/tests/unit/projectionChallenger.test.js +++ b/tests/unit/projectionChallenger.test.js @@ -14,9 +14,9 @@ const FB_ARM = mr.classifyArsenal([ { type: 'FC', usage_pct: 20, whiff_pct: 18, hard_hit_pct: 40 }, { type: 'CU', usage_pct: 15, whiff_pct: 20, hard_hit_pct: 30 }, ]); -// A fly-ball, hard-hit hitter; and a whiff-prone one. -const flyBat = { whiff_pct: 22, chase_pct: 28, hard_hit_pct: 46, metrics: { gb_pct_bb: 30, fb_ld_pct: 55 } }; -const whiffBat = { whiff_pct: 34, chase_pct: 34, hard_hit_pct: 34, metrics: { gb_pct_bb: 45, fb_ld_pct: 40 } }; +// A fly-ball (high launch angle), hard-hit hitter; and a whiff-prone, flatter one. +const flyBat = { whiff_pct: 22, chase_pct: 28, hard_hit_pct: 46, avg_launch_angle: 20 }; +const whiffBat = { whiff_pct: 34, chase_pct: 34, hard_hit_pct: 34, avg_launch_angle: 10 }; describe('arsenal classification', () => { it('buckets usage by pitch family + carries whiff/hard-hit tendency', () => {