diff --git a/src/services/heroPropService.js b/src/services/heroPropService.js index 2a4155d..bbea9c3 100644 --- a/src/services/heroPropService.js +++ b/src/services/heroPropService.js @@ -74,35 +74,41 @@ async function pickHeroProp(deps = {}) { } } - // HERO RULE v2 (Model Train, step 5): the highest EV among reads that pass the - // TAKEABLE gate, A/B grades only. A huge model-vs-line gap on a -900 line is - // trivia; the hero is the best OPPORTUNITY at a price you'd actually take. + // HERO RULE v3 (hero-ranking-fix, 2026-07-29). Rank by the CHAMPION's p_win among + // TAKEABLE-priced A/B reads — the highest-confidence read at a price you'd take. + // + // - Why not ev_pct (the v2 key): ev_pct is NULL on served grades, and + // `Number(null) === 0` made `Number.isFinite(Number(g.ev_pct))` TRUE, so every + // prop tied at EV 0 and the "top read" was really the FIRST takeable A/B prop in + // cache order — arbitrary, dressed as ranked. The `num()` guard below kills that. + // - p_win is the only signal with a *promising* (not proven) edge (the champion's + // takeable-MLB-over CLV survived the skew audit). It is used for RANKING ONLY, + // server-side; it is never exposed (toHero omits it; the route strips it too). + // - TAKEABLE filter is MANDATORY: raw p_win crowns -300 chalk, which is not the + // product. Floor = the project's `isTakeable` band (-160..+200), same definition + // the takeable-edge proof/audit used. + // - NO BACKFILL: nothing qualifies → honest empty state, never a weak recent read. const { isTakeable } = require('../config/valueEngine'); - let hero = null, heroEv = -Infinity; + // Strict null: `Number(null) === 0` is the exact fabrication this hero fell to. + const num = (v) => (v == null || v === '' ? null : (Number.isFinite(Number(v)) ? Number(v) : null)); + let hero = null; let heroP = -Infinity; for (const { g, sport } of all) { if (!isAB(g.grade) || !candidate(g)) continue; - if (!Number.isFinite(Number(g.ev_pct))) continue; // need a real EV to rank - if (!isTakeable(g.book_odds)) continue; // promoted surface → takeable only - if (Number(g.ev_pct) > heroEv) { heroEv = Number(g.ev_pct); hero = { g, sport }; } + const p = num(g.p_win); + if (p == null) continue; // no champion p_win → cannot rank + const price = num(g.book_odds) ?? num(g.gradedAt && g.gradedAt.odds); + if (price == null || !isTakeable(price)) continue; // takeable price only — excludes chalk + if (p > heroP) { heroP = p; hero = { g, sport }; } } if (hero) { - const gap = candidate(hero.g) ? Math.abs(Number(hero.g.projection) - Number(hero.g.line)) : null; + const gap = Math.abs(Number(hero.g.projection) - Number(hero.g.line)); return toHero(hero.g, hero.sport, gap, false); } - // Empty slate → the MOST RECENT real graded read (any grade), by timestamp. - let recent = null, recentTs = ''; - for (const { g, sport } of all) { - const ts = (g.gradedAt && g.gradedAt.timestamp) || ''; - if (ts && ts > recentTs) { recentTs = ts; recent = { g, sport }; } - } - if (recent) { - const gp = candidate(recent.g) ? Math.abs(Number(recent.g.projection) - Number(recent.g.line)) : null; - return toHero(recent.g, recent.sport, gp, true); - } - - // Truly nothing cached — the card hides. Never a fabricated fallback. - return { available: false }; + // HONEST EMPTY STATE — no takeable high-p_win read qualifies (or the slate is empty). + // Deliberately NO recent-read backfill: a weak read dressed as today's hero is exactly + // the dishonesty this order removes. The card self-hides. + return { available: false, reason: 'no_qualifying_read' }; } module.exports = { pickHeroProp, __internals: { isAB, candidate, DEFAULT_SPORTS } }; diff --git a/tests/unit/heroPropService.test.js b/tests/unit/heroPropService.test.js index be5eea0..06e6a72 100644 --- a/tests/unit/heroPropService.test.js +++ b/tests/unit/heroPropService.test.js @@ -1,76 +1,85 @@ 'use strict'; -// Hero rule v2 (Model Train, step 5): highest ev_pct among reads passing the -// TAKEABLE gate, A/B grades only. Empty slate → most recent real read. +// Hero rule v3 (hero-ranking-fix, 2026-07-29): rank by the CHAMPION's p_win among +// TAKEABLE-priced A/B reads. No ev_pct (NULL on served grades). No backfill — nothing +// qualifies → honest empty state. const { pickHeroProp } = require('../../src/services/heroPropService'); function cacheFrom(map) { return async (key) => (key in map ? map[key] : null); } -// A graded read carries ev_pct + book_odds (the v2 ranking inputs). const grade = (o) => ({ player_name: o.player, stat_type: o.stat || 'hits', line: o.line ?? 1.5, projection: o.proj ?? 2.0, direction: o.dir || 'over', grade: o.grade, book: o.book || 'dk', - ev_pct: o.ev, book_odds: o.odds ?? -120, value: o.value ?? null, + p_win: o.pwin, ev_pct: o.ev ?? null, book_odds: o.odds ?? -120, value: o.value ?? null, + fair_odds: o.fair ?? null, model_odds: o.model ?? null, gradedAt: { line: o.line ?? 1.5, odds: o.odds ?? -120, timestamp: o.ts || '2026-07-17T19:00:00Z' }, }); -describe('pickHeroProp — v2 (EV among takeable A/B)', () => { - test('picks the HIGHEST ev_pct among takeable A/B reads', async () => { +describe('pickHeroProp — v3 (champion p_win among takeable A/B)', () => { + test('picks the HIGHEST p_win among takeable A/B reads', async () => { const cacheGet = cacheFrom({ 'snapshot:mlb:latest': { grades: [ - grade({ player: 'LowEV', grade: 'A', ev: 3.1, odds: -120 }), - grade({ player: 'HighEV', grade: 'B', ev: 8.4, odds: +110 }), + grade({ player: 'LowP', grade: 'A', pwin: 0.55, odds: -120 }), + grade({ player: 'HighP', grade: 'B', pwin: 0.71, odds: 110 }), ] } }); const hero = await pickHeroProp({ cacheGet, sports: ['mlb'] }); expect(hero.available).toBe(true); - expect(hero.player).toBe('HighEV'); - expect(hero.ev_pct).toBe(8.4); + expect(hero.player).toBe('HighP'); + expect(hero.p_win).toBeUndefined(); // p_win is RANKING-ONLY, never exposed on the hero }); - test('a HUGE EV on an un-takeable price (-900) is NOT the hero (trivia, not opportunity)', async () => { + test('un-takeable chalk is EXCLUDED even with the highest p_win (raw p_win crowns chalk)', async () => { const cacheGet = cacheFrom({ 'snapshot:mlb:latest': { grades: [ - grade({ player: 'Chalk', grade: 'A', ev: 20, odds: -900 }), // un-takeable - grade({ player: 'Takeable', grade: 'B', ev: 5, odds: -130 }), // in band + grade({ player: 'Chalk', grade: 'A', pwin: 0.90, odds: -300 }), // outside -160..+200 + grade({ player: 'Takeable', grade: 'B', pwin: 0.60, odds: -130 }), ] } }); const hero = await pickHeroProp({ cacheGet, sports: ['mlb'] }); expect(hero.player).toBe('Takeable'); }); - test('C/D/F grades are ineligible even with high EV', async () => { + test('C/D/F grades ineligible even with high p_win', async () => { const cacheGet = cacheFrom({ 'snapshot:mlb:latest': { grades: [ - grade({ player: 'HighEV_C', grade: 'C', ev: 12, odds: -110 }), - grade({ player: 'RealA', grade: 'A', ev: 4, odds: -110 }), + grade({ player: 'HighP_C', grade: 'C', pwin: 0.80, odds: -110 }), + grade({ player: 'RealA', grade: 'A', pwin: 0.60, odds: -110 }), ] } }); const hero = await pickHeroProp({ cacheGet, sports: ['mlb'] }); expect(hero.player).toBe('RealA'); }); - test('exposes the value triplet + ev/value on the hero', async () => { + test('Number(null)===0 GUARD: a null-p_win prop never ranks (no arbitrary hero)', async () => { + // The v2 bug: Number(null)===0 → every null-EV prop tied at 0 → the first one won. const cacheGet = cacheFrom({ 'snapshot:mlb:latest': { grades: [ - { player_name: 'Trip', stat_type: 'hits', line: 1.5, projection: 2.1, direction: 'over', - grade: 'A', book: 'dk', ev_pct: 6.2, value: true, takeable: true, - book_odds: -145, fair_odds: -132, model_odds: -110, - gradedAt: { line: 1.5, odds: -145, timestamp: '2026-07-17T19:00:00Z' } }, + grade({ player: 'NullP', grade: 'A', pwin: null, odds: -120 }), // must NOT win + grade({ player: 'RealP', grade: 'A', pwin: 0.58, odds: -120 }), + ] } }); + const hero = await pickHeroProp({ cacheGet, sports: ['mlb'] }); + expect(hero.player).toBe('RealP'); + // and when EVERY prop has null p_win → honest empty state, not an arbitrary pick + const allNull = cacheFrom({ 'snapshot:mlb:latest': { grades: [grade({ player: 'X', grade: 'A', pwin: null, odds: -120 })] } }); + expect((await pickHeroProp({ cacheGet: allNull, sports: ['mlb'] })).available).toBe(false); + }); + + test('exposes book/fair/model odds CONTEXT on the hero (display, not ranking)', async () => { + const cacheGet = cacheFrom({ 'snapshot:mlb:latest': { grades: [ + grade({ player: 'Trip', grade: 'A', pwin: 0.64, odds: -145, fair: -132, model: -110 }), ] } }); const hero = await pickHeroProp({ cacheGet, sports: ['mlb'] }); expect(hero.book_odds).toBe(-145); expect(hero.fair_odds).toBe(-132); expect(hero.model_odds).toBe(-110); - expect(hero.ev_pct).toBe(6.2); - expect(hero.value).toBe(true); }); - test('no takeable A/B EV read → most recent real read (fallback)', async () => { + test('no takeable A/B p_win read → HONEST EMPTY STATE, never a backfilled recent read', async () => { const cacheGet = cacheFrom({ 'snapshot:mlb:latest': { grades: [ - grade({ player: 'OnlyChalk', grade: 'A', ev: 9, odds: -800, ts: '2026-07-17T19:00:00Z' }), + grade({ player: 'OnlyChalk', grade: 'A', pwin: 0.9, odds: -800 }), // un-takeable ] } }); const hero = await pickHeroProp({ cacheGet, sports: ['mlb'] }); - expect(hero.available).toBe(true); - expect(hero.is_recent).toBe(true); - expect(hero.player).toBe('OnlyChalk'); + expect(hero.available).toBe(false); + expect(hero.reason).toBe('no_qualifying_read'); + expect(hero.is_recent).toBeUndefined(); // NO recent-read backfill }); test('nothing cached → { available:false }', async () => { const hero = await pickHeroProp({ cacheGet: cacheFrom({}), sports: ['mlb'] }); - expect(hero).toEqual({ available: false }); + expect(hero.available).toBe(false); }); }); diff --git a/web/src/components/LiveHeroProp.tsx b/web/src/components/LiveHeroProp.tsx index 03310ee..1bad317 100644 --- a/web/src/components/LiveHeroProp.tsx +++ b/web/src/components/LiveHeroProp.tsx @@ -7,13 +7,13 @@ import PriceTriplet from './vyndr/PriceTriplet'; /** * Daily hero prop card (Truth-Everywhere Part 2, item 5). * - * A live RULE, not a hand-picked example: the graded prop where VYNDR disagrees - * MOST with the market — largest |model - consensus| gap, A/B grades only — - * fetched from /api/hero-prop (which reads the pre-graded snapshot). The card - * shows the disagreement explicitly (the book's number vs ours) with the real - * grade timestamp. Empty slate → the most recent real graded read. Nothing - * cached → the card hides. There is NO static fallback — the old Jokic - * "Example" card is gone. + * A live RULE, not a hand-picked example (hero-ranking-fix, 2026-07-29): the + * HIGHEST-CONFIDENCE read at a takeable price — the champion's top p_win among + * A/B, takeable-priced props — fetched from /api/hero-prop (which reads the + * pre-graded snapshot). The card shows the model's number vs the book's, with the + * real grade timestamp — a high-confidence read, NOT a claim of proven edge / +EV / + * best bet (no CLV/ROI/edge number is shown). Nothing qualifies → the card HIDES + * (honest empty state — no backfilled weak read, no static "Example" fallback). */ type Hero = {