07626de3de
Hits was diagnosed as a family mismatch: 84% of hits rows trade at 0.5, so the stat rides on P(0), and a negative binomial has unbounded support and no notion of opportunity at all. hits-v1 models it as the bounded conversion it is -- N ~ the player's empirical at-bat distribution, hits|N ~ Binomial(N,q), with the multiplier scaling q (conversion) and never N (opportunity). STEP 0 confirmed the inputs before the model existed: 30/30 real ledger players, 100% combined-input coverage. Every read goes through knownRate -- a row with no atBats is dropped, never counted as a 0-at-bat game. It FIRES: 158/159 hits props (99.4%) on the live production snapshot, through the real attachProjection path. Scoping by book IDENTITY rather than price shape kept 94 out-of-promotion-band props on the board, 93 of them modelled -- 59% that a price rule would have deleted. And it LOST. Point-in-time replay (game log truncated strictly before each row's game_date, real grade-time multiplier), hits-only, direction-aligned, n=242: resolution champion 0.195 / ladder 0.048 / hits-v1 0.026. Paired bootstrap on the same rows: hits-v1 - ladder = -0.022, CI95 excluding zero. Not promoted. The value is in what it eliminates. The family was wrong AND the mean was not the constraint -- hits-v1 moved the line-0.5 mean 0.554 -> 0.581 toward a 0.598 base rate while resolution fell. What is left is per-prop discrimination: the ladder's inputs, not its distribution. The pre-registered fallback is recorded as WRONG rather than deleted. It said hits might be genuinely low-resolution for anyone; the champion scores 0.276 on the identical 189 rows, so there is real signal and the ceiling claim was the comfortable reading, not the honest one. Its own control refuted it, and that control was already in hand when the branch was written. hits-v1 stays wired as a challenger writing its own ledger columns so the forward accrual can confirm the backtest. Champion, ladder, ranking, calibration, reference ruler and the four accruing verdicts are byte-identical -- the diff has zero deleted lines. Tests 4,156 green (332 suites); web build exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
231 lines
13 KiB
JavaScript
231 lines
13 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', () => {
|
|
// 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
|
|
});
|
|
});
|