proj-v1 book-implied: raw odds → de-vigged FAIR (fix self-flattering basis)
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user