diff --git a/src/services/projectionChallenger.js b/src/services/projectionChallenger.js index 449d1b9..39210d3 100644 --- a/src/services/projectionChallenger.js +++ b/src/services/projectionChallenger.js @@ -57,6 +57,13 @@ 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) { if (!teamAbbr) return null; @@ -179,13 +186,19 @@ 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). const tradedRung = isNum(line) ? Math.max(1, Math.ceil(line)) : null; - const fairProb = num(grade && grade.fair_prob); // de-vigged, graded direction + 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 ladderOut = rungs.map((r) => ({ rung: r.rung, p_at_least: r.p_at_least, - book_implied: (tradedRung != null && r.rung === tradedRung && direction === 'over' && fairProb != null) - ? Math.round(fairProb * 1000) / 1000 : null, + book_implied: (tradedRung != null && r.rung === tradedRung) ? bookOverImplied : null, })); const pOverLine = tradedRung != null ? dist.round3(dist.nbSurvival(nb.r, nb.p, tradedRung)) : null; @@ -194,7 +207,7 @@ function projectProp({ proj_point: point, proj_line: line, proj_p_over_line: pOverLine, - proj_book_implied: (direction === 'over' && fairProb != null) ? Math.round(fairProb * 1000) / 1000 : null, + proj_book_implied: bookOverImplied, proj_distribution: { family: 'negative_binomial', r: dist.round3(nb.r), p: dist.round3(nb.p), diff --git a/tests/unit/projectionChallenger.test.js b/tests/unit/projectionChallenger.test.js index 3ce96fd..3ae91a5 100644 --- a/tests/unit/projectionChallenger.test.js +++ b/tests/unit/projectionChallenger.test.js @@ -76,7 +76,8 @@ 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', () => { - const grade = { stat_type: 'hits', line: 0.5, direction: 'over', season_avg: 0.9, fair_prob: 0.62, team: 'NYY' }; + // 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'); @@ -85,11 +86,19 @@ describe('projectProp — full object, never abstains, plausible', () => { 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 for comparison - expect(p.proj_ladder.find((r) => r.rung === 1).book_implied).toBeCloseTo(0.62, 3); + // 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: [] });