e96b0dbb6d
The live fingerprint showed proj_book_implied null on every real row: grades carry book_odds/locked_odds (e.g. -264) but NOT a de-vigged fair_prob, so keying the book comparison off fair_prob yielded null. The book ODDS are exactly "the book's implied probability" the handicapper test needs. Now proj_book_implied + the traded rung's book_implied derive from americanToImplied(book_odds), expressed on the OVER basis (under props → 1 - implied) so it's directly comparable to our P(≥rung). Vigged (a known offset the ledger measures both sides of). proj-v1 suites 24/24. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
147 lines
7.7 KiB
JavaScript
147 lines
7.7 KiB
JavaScript
/* proj-v1 — matchup read + orchestration. Champion untouched; never abstains. */
|
|
const pc = require('../../src/services/projectionChallenger');
|
|
const mr = require('../../src/services/projection/matchupRead');
|
|
|
|
// A sinker-heavy, high-whiff arsenal.
|
|
const SINKER_ARM = mr.classifyArsenal([
|
|
{ type: 'SI', usage_pct: 45, whiff_pct: 12, hard_hit_pct: 40 },
|
|
{ type: 'SL', usage_pct: 30, whiff_pct: 34, hard_hit_pct: 30 },
|
|
{ type: 'CH', usage_pct: 25, whiff_pct: 30, hard_hit_pct: 32 },
|
|
]);
|
|
// A fastball-heavy, low-whiff (pitch-to-contact) arsenal.
|
|
const FB_ARM = mr.classifyArsenal([
|
|
{ type: 'FF', usage_pct: 65, whiff_pct: 16, hard_hit_pct: 44 },
|
|
{ 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 (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', () => {
|
|
expect(SINKER_ARM.sinker_pct).toBeCloseTo(0.45, 2);
|
|
expect(SINKER_ARM.breaking_pct).toBeCloseTo(0.30, 2);
|
|
expect(SINKER_ARM.whiff_tendency).toBeGreaterThan(FB_ARM.whiff_tendency);
|
|
});
|
|
it('absent mix → null (no matchup contribution, honest)', () => {
|
|
expect(mr.classifyArsenal(null)).toBeNull();
|
|
expect(mr.classifyArsenal([])).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('matchup direction (two-sided, coarse repertoire-vs-profile)', () => {
|
|
it('a groundy sinker arm SUPPRESSES a fly-ball hitter\'s power (HR mult < 1)', () => {
|
|
const m = mr.matchupMultiplier({ arsenal: SINKER_ARM, hitter: mr.hitterProfile(flyBat), statType: 'home_runs' });
|
|
expect(m.multiplier).toBeLessThan(1);
|
|
expect(m.components.some((c) => c.label === 'air_suppression')).toBe(true);
|
|
});
|
|
it('a high-whiff arsenal SUPPRESSES a whiff-prone hitter\'s hits (hits mult < 1)', () => {
|
|
const m = mr.matchupMultiplier({ arsenal: SINKER_ARM, hitter: mr.hitterProfile(whiffBat), statType: 'hits' });
|
|
expect(m.multiplier).toBeLessThan(1);
|
|
});
|
|
it('the SAME whiff pressure moves a STRIKEOUT prop the other way (mult > 1)', () => {
|
|
const m = mr.matchupMultiplier({ arsenal: SINKER_ARM, hitter: mr.hitterProfile(whiffBat), statType: 'strikeouts' });
|
|
expect(m.multiplier).toBeGreaterThan(1);
|
|
});
|
|
it('missing either side → neutral 1.0 (never fabricates a matchup)', () => {
|
|
expect(mr.matchupMultiplier({ arsenal: null, hitter: mr.hitterProfile(flyBat), statType: 'hits' }).multiplier).toBe(1);
|
|
expect(mr.matchupMultiplier({ arsenal: SINKER_ARM, hitter: null, statType: 'hits' }).multiplier).toBe(1);
|
|
});
|
|
it('the total matchup lean is bounded', () => {
|
|
const m = mr.matchupMultiplier({ arsenal: SINKER_ARM, hitter: mr.hitterProfile(whiffBat), statType: 'total_bases' });
|
|
expect(Math.abs(m.multiplier - 1)).toBeLessThanOrEqual(mr.TOTAL_MAX + 1e-9);
|
|
});
|
|
});
|
|
|
|
describe('park RELATIVE to own exposure (Phase B fix)', () => {
|
|
it('baseline is the mean park factor over home(own)/away(opp) games', () => {
|
|
// NYY home park (parkBase) + away games; just assert it resolves a number
|
|
// from ≥3 venues and stays near 1.0 (park factors are ~1.0).
|
|
const log = [
|
|
{ isHome: true, opponent: 'Boston Red Sox', stat: {} },
|
|
{ isHome: false, opponent: 'Boston Red Sox', stat: {} },
|
|
{ isHome: false, opponent: 'Houston Astros', stat: {} },
|
|
{ isHome: true, opponent: 'Tampa Bay Rays', stat: {} },
|
|
];
|
|
const b = pc.parkBaselineFromLogs(log, 'NYY');
|
|
if (b != null) { expect(b).toBeGreaterThan(0.7); expect(b).toBeLessThan(1.3); }
|
|
});
|
|
it('too few resolvable venues → null (park contributes nothing, not a raw multiply)', () => {
|
|
expect(pc.parkBaselineFromLogs([{ isHome: true, stat: {} }], 'NYY')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('projectProp — full object, never abstains, plausible', () => {
|
|
const hitLog = (vals) => vals.map((h) => ({ isHome: true, opponent: 'Boston Red Sox', stat: { hits: h } }));
|
|
|
|
it('emits distribution + full ladder + point + factor breakdown', () => {
|
|
// book_odds -150 → implied 0.6 (the book's number the grade actually carries)
|
|
const grade = { stat_type: 'hits', line: 0.5, direction: 'over', season_avg: 0.9, book_odds: -150, team: 'NYY' };
|
|
const p = pc.projectProp({ grade, gameLog: hitLog([1, 0, 2, 1, 1, 0, 1, 2, 1, 0]) });
|
|
expect(p.proj_version).toBe('proj-v1');
|
|
expect(p.proj_distribution.family).toBe('negative_binomial');
|
|
expect(p.proj_ladder.length).toBeGreaterThanOrEqual(3);
|
|
expect(p.proj_ladder[0].p_at_least).toBeGreaterThanOrEqual(p.proj_ladder[1].p_at_least);
|
|
expect(p.proj_factors.breakdown.map((f) => f.label)).toEqual(
|
|
expect.arrayContaining(['park_relative', 'weather', 'platoon', 'matchup']),
|
|
);
|
|
// traded rung (ceil 0.5 = 1) carries the BOOK-implied (from odds) for comparison
|
|
expect(p.proj_ladder.find((r) => r.rung === 1).book_implied).toBeCloseTo(0.6, 3);
|
|
expect(p.proj_book_implied).toBeCloseTo(0.6, 3);
|
|
expect(p.proj_p_over_line).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('book implied is expressed on the OVER basis for an under prop', () => {
|
|
// under at -150 (implied 0.6 the ball stays under) → over-basis implied 0.4
|
|
const grade = { stat_type: 'hits', line: 1.5, direction: 'under', season_avg: 0.9, book_odds: -150, team: 'NYY' };
|
|
const p = pc.projectProp({ grade, gameLog: hitLog([1, 0, 2, 1, 1, 0, 1, 2, 1, 0]) });
|
|
expect(p.proj_book_implied).toBeCloseTo(0.4, 3);
|
|
});
|
|
|
|
it('NEVER abstains — an empty game log still projects (wide, from the prior)', () => {
|
|
const grade = { stat_type: 'hits', line: 0.5, direction: 'over', season_avg: 0.9, team: 'NYY' };
|
|
const p = pc.projectProp({ grade, gameLog: [] });
|
|
expect(p.proj_point).not.toBeNull();
|
|
expect(p.proj_ladder[0].p_at_least).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('PLAUSIBILITY: a .300-ish hitter does not project 3.0 hits', () => {
|
|
const grade = { stat_type: 'hits', line: 1.5, direction: 'over', season_avg: 0.95, team: 'NYY' };
|
|
const p = pc.projectProp({ grade, gameLog: hitLog([1, 1, 2, 0, 1, 1, 1, 0, 2, 1]) });
|
|
expect(p.proj_point).toBeLessThan(2.0);
|
|
expect(p.proj_point).toBeGreaterThan(0.5);
|
|
});
|
|
|
|
it('the combined non-form multiplier is bounded (no absurd Coors swing)', () => {
|
|
const grade = { stat_type: 'home_runs', line: 0.5, direction: 'over', season_avg: 0.2, team: 'COL' };
|
|
const p = pc.projectProp({
|
|
grade, gameLog: hitLog([0, 0, 1, 0, 0, 1, 0, 0, 0, 1]),
|
|
tonightParkFactor: 1.3, weatherMod: 1.1, platoonMult: 1.1, arsenal: FB_ARM,
|
|
batterRow: flyBat,
|
|
});
|
|
expect(Math.abs(p.proj_factors.combined_multiplier - 1)).toBeLessThanOrEqual(pc.COMBINED_MAX + 1e-9);
|
|
});
|
|
|
|
it('non-batting stat → not modeled (returns null projection object)', () => {
|
|
expect(pc.projectProp({ grade: { stat_type: 'pitcher_strikeouts', line: 5.5, direction: 'over' } })).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('attachProjection — champion byte-identical', () => {
|
|
it('adds proj-v1 fields, never mutates p_win / grade / other challengers', async () => {
|
|
const grades = [{
|
|
player: 'Slugger', playerId: null, stat_type: 'hits', line: 0.5, direction: 'over',
|
|
season_avg: 0.9, fair_prob: 0.6, team: 'NYY', p_win: 0.58, grade: 'B',
|
|
p_win_challenger: 0.6, p_win_contact: 0.61,
|
|
}];
|
|
const out = await pc.attachProjection(grades, {});
|
|
expect(out[0].p_win).toBe(0.58);
|
|
expect(out[0].grade).toBe('B');
|
|
expect(out[0].p_win_challenger).toBe(0.6);
|
|
expect(out[0].p_win_contact).toBe(0.61);
|
|
expect(out[0].proj_version).toBe('proj-v1');
|
|
expect(out[0].proj_point).not.toBeNull(); // projected even with no game log
|
|
});
|
|
});
|