From 914a0576111dfaec60e9d10fa85089932e97d41d Mon Sep 17 00:00:00 2001 From: Kev Date: Sat, 25 Jul 2026 19:09:02 -0400 Subject: [PATCH] =?UTF-8?q?proj-v1=20book-implied:=20raw=20odds=20?= =?UTF-8?q?=E2=86=92=20de-vigged=20FAIR=20(fix=20self-flattering=20basis)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit proj_book_implied derived from raw book_odds — VIG-INCLUSIVE. A -110/-110 market implies 52.4%/side (104.8% sum); fair is 50%. Comparing our P against raw book overstates the book on both sides, biasing the handicapper test IN OUR FAVOR; on juiced longshots (the Judge HR -18.5pt case) much of that "edge" was vig, not disagreement. Fix (fenced to proj-v1's stored comparison basis): proj_book_implied now derives from DE-VIGGED FAIR via the grade's g.fair_prob — the SAME multiplicative de-vig the triplet uses (utils/devig.js), so the basis matches the product's shown fair. Expressed on the OVER basis (under props → 1 - fair) to match our stored P(≥rung); traded-rung ladder book_implied likewise. HONEST-NULL where fair is uncomputable (one-sided market, ~14%) — NEVER a raw-book fallback (that would recreate the vig bias on a subset and mix two bases in one ledger). proj_factors records book_implied_basis ('fair_multiplicative'|'none'). Phase 0 (prod-verified): fair reachable at store point (g.fair_prob on the grade, no threading); 86% batting coverage; method = multiplicative/proportional. Phase 2 FLAG: multiplicative de-vig mis-splits vig on juiced longshots (favorite- longshot bias), so a longshot fair still carries known method bias — flagged per-row (longshot_devig_caveat); a better de-vig (Shin/power) is a separate item. Phase 3: version bumped proj-v1 → proj-v1.1 so pre-fix (raw-book) and post-fix (fair) rows never silently mix — the projection model is byte-identical, only the basis changed; pre-fix rows can't be recomputed (only the graded side's odds were stored). Champion + arch-v1 + contact-v1 + proj-v1's other columns untouched. proj suites 26/26. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj --- src/services/projectionChallenger.js | 40 ++++++++++++++----------- tests/unit/projectionChallenger.test.js | 37 ++++++++++++++++------- 2 files changed, 50 insertions(+), 27 deletions(-) diff --git a/src/services/projectionChallenger.js b/src/services/projectionChallenger.js index 39210d3..74afa9d 100644 --- a/src/services/projectionChallenger.js +++ b/src/services/projectionChallenger.js @@ -22,7 +22,10 @@ const matchup = require('./projection/matchupRead'); const parkBase = require('./parkBase'); const { NAME_TO_ABBR } = require('./environmentContext'); -const PROJ_VERSION = 'proj-v1'; +// v1.1 — comparison basis changed from raw book_odds to DE-VIGGED FAIR. The +// projection model is byte-identical; the version bump marks the basis so pre-fix +// (raw-book) and post-fix (fair) rows never silently mix in the handicapper test. +const PROJ_VERSION = 'proj-v1.1'; const PRIOR_GAMES = Number(process.env.PROJ_PRIOR_GAMES || 4); const LADDER_MAX = Number(process.env.PROJ_LADDER_MAX || 4); // Combined non-form multiplier bound (Phase B proved <0.12 stacked). A Coors + @@ -57,12 +60,6 @@ const abbrOf = (team) => { return NAME_TO_ABBR[s.toLowerCase()] || null; }; -/** American odds → implied probability (vigged). Null on absent/zero. */ -function americanToImplied(odds) { - const o = num(odds); - if (o == null || o === 0) return null; - return o < 0 ? (-o) / (-o + 100) : 100 / (o + 100); -} /** Park factor (multiplier ~1.0) for a team's home venue, or null. */ function parkFactorFor(teamAbbr) { @@ -186,19 +183,24 @@ function projectProp({ const rungs = dist.ladder(nb, LADDER_MAX); // ── LADDER + book implied (only the traded rung has a book number) ──────── - // The BOOK's implied probability comes from the book ODDS (grades carry - // book_odds, not a de-vigged fair_prob). This is the vigged implied — a known - // constant offset the ledger measures on both sides — expressed on the OVER - // basis so it's directly comparable to our P(≥ rung). + // Compare against DE-VIGGED FAIR (the triplet's multiplicative method, via the + // grade's g.fair_prob), NOT raw book_odds. Raw book is vig-inclusive — a + // -110/-110 market implies 52.4% per side — so comparing our P against raw book + // OVERSTATES the book on both sides and biases the handicapper test in our + // favor. Fair is de-vigged (sums to 1). HONEST-NULL where fair can't be + // computed (one-sided market); NEVER a raw-book fallback (that would recreate + // the vig bias on that subset and mix two bases in one ledger). const tradedRung = isNum(line) ? Math.max(1, Math.ceil(line)) : null; - const bookOdds = num(grade && (grade.book_odds != null ? grade.book_odds : grade.locked_odds)); - const rawImplied = americanToImplied(bookOdds); - const bookOverImplied = rawImplied == null ? null - : Math.round((direction === 'under' ? 1 - rawImplied : rawImplied) * 1000) / 1000; + const fairGraded = num(grade && grade.fair_prob); // de-vigged fair, GRADED side + const fairOver = fairGraded == null ? null + : Math.round((direction === 'under' ? 1 - fairGraded : fairGraded) * 1000) / 1000; + // Phase 2 flag: multiplicative de-vig mis-splits vig on juiced longshots + // (favorite-longshot bias), so a longshot fair still carries known method bias. + const longshotDevigCaveat = fairOver != null && (fairOver <= 0.25 || fairOver >= 0.75); const ladderOut = rungs.map((r) => ({ rung: r.rung, p_at_least: r.p_at_least, - book_implied: (tradedRung != null && r.rung === tradedRung) ? bookOverImplied : null, + book_implied: (tradedRung != null && r.rung === tradedRung) ? fairOver : null, })); const pOverLine = tradedRung != null ? dist.round3(dist.nbSurvival(nb.r, nb.p, tradedRung)) : null; @@ -207,7 +209,7 @@ function projectProp({ proj_point: point, proj_line: line, proj_p_over_line: pOverLine, - proj_book_implied: bookOverImplied, + proj_book_implied: fairOver, proj_distribution: { family: 'negative_binomial', r: dist.round3(nb.r), p: dist.round3(nb.p), @@ -220,6 +222,10 @@ function projectProp({ form_rate: dist.round3(formRate), combined_multiplier: Math.round(M * 1000) / 1000, breakdown: factors, + // Self-documenting comparison basis + the Phase-2 longshot caveat, so the + // ledger knows exactly what proj_book_implied is measured against. + book_implied_basis: fairOver == null ? 'none' : 'fair_multiplicative', + longshot_devig_caveat: longshotDevigCaveat, }, proj_reason: null, }; diff --git a/tests/unit/projectionChallenger.test.js b/tests/unit/projectionChallenger.test.js index 3ae91a5..f89d5f6 100644 --- a/tests/unit/projectionChallenger.test.js +++ b/tests/unit/projectionChallenger.test.js @@ -76,27 +76,44 @@ 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' }; + // 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'); + 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 (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); + // 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 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' }; + 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)', () => { @@ -140,7 +157,7 @@ describe('attachProjection — champion byte-identical', () => { 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_version).toBe('proj-v1.1'); expect(out[0].proj_point).not.toBeNull(); // projected even with no game log }); });