Files
vyndr/src/services/heroPropService.js
T
builtbykev 7a925f43eb Model Train arc 1 (engine): de-vig + EV + takeable/value gates + hero v2 + triplet
Steps 1-6 — make "real opportunities at takeable prices" the engine, not a filter.

1. DE-VIG (src/utils/devig.js): two-way multiplicative de-vig strips the vig and
   returns fair prob + fair price per side + the overround. One side missing →
   fair UNAVAILABLE (null), never faked. Method noted in code + the `devig_method`
   field.
2. EV (devig.evPct): ev_pct = model prob × decimal − 1 at the graded side's
   ACTUAL price. This is the ranking signal now, replacing raw |model−consensus|.
3. TAKEABLE gate (src/config/valueEngine.js, TAKEABLE_ODDS_CEILING −160 .. +200,
   env-tunable): promoted surfaces only (hero/featured/alerts). The full board
   still shows everything; Parlay Lab exempt; JUICE_ODDS_FLOOR (−400) stays the
   absolute backstop underneath. Strict null-guard (Number(null)===0 would have
   made a missing price "takeable").
4. VALUE flag: passes BOTH gates (takeable AND ev_pct ≥ VALUE_EV_THRESHOLD).
   Grade = read quality; value = the price pays you. Shipped in payloads.
5. HERO v2 (heroPropService): highest ev_pct among takeable A/B reads — a huge
   gap on a −900 line is trivia, not an opportunity.
6. VALUE TRIPLET: book_odds · fair_odds · model_odds on every read (snapshot,
   hero, scan — they all spread the grade). Handoff documents the fields; the
   rendering is Session-2 Design's job.

All wired in analyzeViaEngine1's existing p_win/kelly block (real quantile
probability × real book odds, or nothing). 33 new tests; suite 276/3306 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 02:43:30 -04:00

109 lines
4.5 KiB
JavaScript

'use strict';
/**
* heroPropService (Truth-Everywhere Part 2, item 5) — the DAILY HERO PROP.
*
* The landing card is a live rule, not a hand-picked example: the graded prop
* where VYNDR disagrees MOST with the market — the largest |model - consensus|
* gap — gated to A/B grades (conviction, not noise). Highest-confidence would
* just be the model agreeing loudly with an undisputed number; the disagreement
* is the read that makes a stranger argue.
*
* Deterministic, no curation. Reads the pre-graded snapshot caches (no grading,
* no API credits). Empty slate → the MOST RECENT real graded read with its real
* date. Never a hand-written fallback. Truly nothing cached → { available:false }
* and the card hides.
*/
const DEFAULT_SPORTS = ['mlb', 'wnba', 'nba', 'soccer'];
// A/B tiers only — the conviction gate.
const isAB = (g) => /^[AB]/.test(String(g || '').trim().toUpperCase());
function toHero(g, sport, gap, isRecent) {
const at = g.gradedAt || {};
return {
available: true,
is_recent: !!isRecent, // true = the empty-slate "most recent real read"
sport,
player: g.player_name || g.player || null,
stat_type: g.stat_type || g.stat || null,
line: g.line ?? at.line ?? null, // the book's number (consensus)
direction: g.direction || 'over',
projection: g.projection ?? null, // VYNDR's number (model)
grade: g.grade || null,
book: g.book || null,
graded_at: at.timestamp || null, // real timestamp — "graded 2:14 PM"
gap: gap == null ? null : Math.round(gap * 100) / 100,
team: g.team || null,
reasoning: (g.reasoning && g.reasoning.summary) || null, // blurred paywall teaser
// Model Train (steps 2/4/6) — EV ranking, the value flag, and the value
// triplet (book price · fair de-vigged price · model price).
ev_pct: g.ev_pct ?? null,
value: g.value ?? null,
takeable: g.takeable ?? null,
book_odds: g.book_odds ?? (at.odds != null ? Number(at.odds) : null),
fair_odds: g.fair_odds ?? null,
model_odds: g.model_odds ?? null,
};
}
// A prop is a valid hero candidate only with a real projection AND a real line
// (both required to compute an honest gap; a missing value never counts as 0).
function candidate(g) {
const line = Number(g && g.line);
const proj = Number(g && g.projection);
return Number.isFinite(line) && line > 0 && Number.isFinite(proj) && proj > 0;
}
async function pickHeroProp(deps = {}) {
const cacheGet = deps.cacheGet || require('../utils/redis').cacheGet;
const sports = deps.sports || DEFAULT_SPORTS;
const all = [];
for (const sport of sports) {
let grades = null;
const snap = await cacheGet(`snapshot:${sport}:latest`);
if (snap && Array.isArray(snap.grades)) grades = snap.grades;
else {
const env = await cacheGet(`grades:${sport}`);
if (env && Array.isArray(env.grades)) grades = env.grades;
}
for (const g of grades || []) {
if (!g || !g.grade || g.insufficient_data) continue;
all.push({ g, sport });
}
}
// 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.
const { isTakeable } = require('../config/valueEngine');
let hero = null, heroEv = -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 }; }
}
if (hero) {
const gap = candidate(hero.g) ? Math.abs(Number(hero.g.projection) - Number(hero.g.line)) : null;
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 };
}
module.exports = { pickHeroProp, __internals: { isAB, candidate, DEFAULT_SPORTS } };