/* 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', () => { // fair_prob 0.55 = the de-vigged fair the grade carries (NOT raw book) const grade = { stat_type: 'hits', line: 0.5, direction: 'over', season_avg: 0.9, fair_prob: 0.55, 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.1'); 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 carries DE-VIGGED FAIR (not raw book) for the comparison expect(p.proj_ladder.find((r) => r.rung === 1).book_implied).toBeCloseTo(0.55, 3); expect(p.proj_book_implied).toBeCloseTo(0.55, 3); expect(p.proj_factors.book_implied_basis).toBe('fair_multiplicative'); expect(p.proj_p_over_line).toBeGreaterThan(0); }); it('book implied is de-vigged FAIR on the OVER basis for an under prop', () => { // under prop, fair_prob 0.6 (fair the ball stays under) → over-basis fair 0.4 const grade = { stat_type: 'hits', line: 1.5, direction: 'under', season_avg: 0.9, fair_prob: 0.6, 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); expect(p.proj_factors.book_implied_basis).toBe('fair_multiplicative'); }); it('HONEST-NULL when fair is uncomputable — never a raw-book fallback', () => { // one-sided market: book_odds present but NO fair_prob → book_implied null 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_book_implied).toBeNull(); // NOT 0.6 from raw book expect(p.proj_ladder.find((r) => r.rung === 1).book_implied).toBeNull(); expect(p.proj_factors.book_implied_basis).toBe('none'); }); it('flags the longshot de-vig caveat where multiplicative bias is largest', () => { const grade = { stat_type: 'home_runs', line: 0.5, direction: 'over', season_avg: 0.2, fair_prob: 0.18, team: 'NYY' }; const p = pc.projectProp({ grade, gameLog: hitLog([0, 0, 1, 0, 0, 1, 0, 0, 0, 1]) }); expect(p.proj_factors.longshot_devig_caveat).toBe(true); }); 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.1'); expect(out[0].proj_point).not.toBeNull(); // projected even with no game log }); }); describe('hits-v1 — the challenger rides alongside, and reads the RIGHT takeable axis', () => { // A real log: at-bat counts AND hits, which the negative-binomial path never // asks for. Most recent LAST (statsapi order). const abLog = (pairs) => pairs.map(([ab, h]) => ({ isHome: true, opponent: 'Boston Red Sox', stat: { atBats: ab, hits: h } })); const LOG = abLog([[4, 1], [3, 0], [5, 2], [4, 1], [4, 0], [3, 1], [4, 2], [4, 1], [3, 1], [4, 0]]); it('emits proj_hits_p_over WITHOUT disturbing the current ladder or the champion', () => { const grade = { stat_type: 'hits', line: 0.5, direction: 'over', season_avg: 0.9, fair_prob: 0.55, team: 'NYY', book: 'draftkings', over_odds: -150, under_odds: 130 }; const withHits = pc.projectProp({ grade, gameLog: LOG }); // The ladder value is computed from the SAME log by the SAME code path as // before this challenger existed — hits-v1 is additive, never substitutive. expect(withHits.proj_hits_p_over).toBeGreaterThan(0); expect(withHits.proj_hits_p_over).toBeLessThanOrEqual(1); expect(withHits.proj_p_over_line).not.toBeNull(); expect(withHits.proj_distribution.family).toBe('negative_binomial'); expect(withHits.proj_hits_meta.version).toBe('hits-v1'); expect(withHits.proj_hits_meta.family).toBe('binomial_over_empirical_at_bats'); }); it('MODELS a -300 one-sided hits market — takeable by identity, outside the promotion band', () => { // The whole reason the three questions were disambiguated. A -300 hits-over // from a real book is a bet you can place: steep juice is a PRICE, not a // disqualification, and one-sidedness is normal baseball market structure. const grade = { stat_type: 'hits', line: 0.5, direction: 'over', season_avg: 0.9, team: 'NYY', book: 'betrivers', over_odds: -300, under_odds: null }; const p = pc.projectProp({ grade, gameLog: LOG }); expect(p.proj_hits_p_over).not.toBeNull(); // MODELLED, not thrown away const m = p.proj_hits_meta.market; expect(m.market_takeable).toBe(true); // identity: yes expect(m.within_promotion_band).toBe(false); // price band: no expect(m.one_sided).toBe(true); // normal, not an error expect(m.price_filtered).toBe(false); // no price-shape gate ran }); it('a longshot +1300 hits market is still modelled — recording is not crowning', () => { const grade = { stat_type: 'hits', line: 2.5, direction: 'over', season_avg: 0.9, team: 'NYY', book: 'fanduel', over_odds: 1300, under_odds: -2000 }; const p = pc.projectProp({ grade, gameLog: LOG }); expect(p.proj_hits_p_over).not.toBeNull(); expect(p.proj_hits_meta.market.market_takeable).toBe(true); expect(p.proj_hits_meta.market.within_promotion_band).toBe(false); }); it('never reads the deprecated `takeable` mirror', () => { const src = require('fs').readFileSync(require.resolve('../../src/services/projection/binomialHits'), 'utf8') + require('fs').readFileSync(require.resolve('../../src/services/projectionChallenger'), 'utf8'); // The disambiguated names only. `takeable:` as a bare field read would be // the ambiguous mirror the S-prior commit deprecated. expect(/\btakeable\s*:/.test(src.replace(/market_takeable\s*:/g, ''))).toBe(false); }); it('is NULL on a non-hits prop — never a fabricated 0', () => { const grade = { stat_type: 'total_bases', line: 1.5, direction: 'over', season_avg: 1.4, team: 'NYY', book: 'draftkings', over_odds: -110 }; const p = pc.projectProp({ grade, gameLog: LOG }); expect(p.proj_hits_p_over).toBeNull(); expect(p.proj_hits_meta).toBeNull(); }); it('abstains honestly when the at-bat inputs are underivable, keeping the ladder', () => { // A log with hits but no at-bat counts — exactly what the NB path runs on. const noAb = [1, 0, 2, 1, 1, 0, 1].map((h) => ({ isHome: true, opponent: 'Boston Red Sox', stat: { hits: h } })); const grade = { stat_type: 'hits', line: 0.5, direction: 'over', season_avg: 0.9, team: 'NYY', book: 'draftkings', over_odds: -140 }; const p = pc.projectProp({ grade, gameLog: noAb }); expect(p.proj_hits_p_over).toBeNull(); // no read, not a zero expect(p.proj_hits_meta.reason).toBe('inputs_underivable'); expect(p.proj_p_over_line).not.toBeNull(); // the ladder still stands }); });