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>
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Value engine config (Model Train, steps 3-4).
|
||||
*
|
||||
* Product doctrine (Kev): VYNDR PROMOTES bets people actually take — roughly the
|
||||
* -160 to +200 band — that ALSO carry a genuine vig-free edge. Not
|
||||
* plus-money-only, not heavy chalk.
|
||||
*
|
||||
* The TAKEABLE gate applies to PROMOTED surfaces only (daily hero, featured /
|
||||
* top-of-board, future alerts). The full board still shows every graded read.
|
||||
* Parlay Lab is EXEMPT (juiced legs combine into takeable payouts).
|
||||
* JUICE_ODDS_FLOOR (-400, in rareEventMarkets) stays the absolute refusal
|
||||
* backstop UNDERNEATH this — those reads are never graded at all.
|
||||
*
|
||||
* All thresholds are env-tunable.
|
||||
*/
|
||||
|
||||
// The promotable price band. Ceiling = most-juiced favorite we'll promote;
|
||||
// max = longest dog we'll promote.
|
||||
const TAKEABLE_ODDS_CEILING = Number(process.env.TAKEABLE_ODDS_CEILING || -160);
|
||||
const TAKEABLE_ODDS_MAX = Number(process.env.TAKEABLE_ODDS_MAX || 200);
|
||||
|
||||
// A read is VALUE only when its EV clears this (a real edge, not rounding).
|
||||
const VALUE_EV_THRESHOLD = Number(process.env.VALUE_EV_THRESHOLD || 2);
|
||||
|
||||
/** Is an American price inside the takeable band (default -160..+200)?
|
||||
* Strict on input — null/''/undefined are NOT takeable (Number(null) === 0
|
||||
* would otherwise land a missing price inside the band). */
|
||||
function isTakeable(american) {
|
||||
if (american == null || american === '') return false;
|
||||
const a = Number(american);
|
||||
if (!Number.isFinite(a)) return false;
|
||||
return a >= TAKEABLE_ODDS_CEILING && a <= TAKEABLE_ODDS_MAX;
|
||||
}
|
||||
|
||||
/** A read carries VALUE when it's takeable AND its EV clears the threshold —
|
||||
* "the price pays you to take it", distinct from grade ("read quality"). */
|
||||
function isValue(american, evPct) {
|
||||
return isTakeable(american) && Number.isFinite(evPct) && evPct >= VALUE_EV_THRESHOLD;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
TAKEABLE_ODDS_CEILING,
|
||||
TAKEABLE_ODDS_MAX,
|
||||
VALUE_EV_THRESHOLD,
|
||||
isTakeable,
|
||||
isValue,
|
||||
};
|
||||
@@ -36,6 +36,14 @@ function toHero(g, sport, gap, isRecent) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -66,14 +74,21 @@ async function pickHeroProp(deps = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// The hero: largest |projection - line| among A/B candidates.
|
||||
let hero = null, heroGap = -1;
|
||||
// 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;
|
||||
const gap = Math.abs(Number(g.projection) - Number(g.line));
|
||||
if (gap > heroGap) { heroGap = gap; hero = { g, sport }; }
|
||||
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);
|
||||
}
|
||||
if (hero) return toHero(hero.g, hero.sport, heroGap, false);
|
||||
|
||||
// Empty slate → the MOST RECENT real graded read (any grade), by timestamp.
|
||||
let recent = null, recentTs = '';
|
||||
|
||||
@@ -496,24 +496,55 @@ async function analyzeViaEngine1(rawProp = {}) {
|
||||
}
|
||||
} catch { /* the ladder is additive — never breaks the read */ }
|
||||
|
||||
// Session 62 (A1-S1) — QUARTER-KELLY. Real probability (quantile estimator
|
||||
// over the actual game logs) × real book odds, or nothing. Never derived
|
||||
// from confidence, never a default vig.
|
||||
// Session 62 (A1-S1) — QUARTER-KELLY + Model Train (steps 1-6): de-vig, EV,
|
||||
// the value triplet, and the takeable/value flags. Real probability (quantile
|
||||
// estimator over the actual game logs) × real book odds, or nothing — never
|
||||
// derived from confidence, never a default vig.
|
||||
try {
|
||||
const { estimateProbability } = require('./probabilityEstimator');
|
||||
const { devigTwoWay, evPct, impliedProbToAmerican } = require('../../utils/devig');
|
||||
const { isTakeable, isValue } = require('../../config/valueEngine');
|
||||
|
||||
const dir = String(prop.direction || 'over').toLowerCase();
|
||||
const est = estimateProbability({ gameLogs: meta.gameLogs, line: prop.line, statType: rawProp.stat_type, features });
|
||||
const pWin = String(prop.direction || 'over').toLowerCase() === 'under'
|
||||
const pWin = dir === 'under'
|
||||
? (Number.isFinite(est.p_over) ? 1 - est.p_over : null)
|
||||
: (Number.isFinite(est.p_over) ? est.p_over : null);
|
||||
if (pWin != null) legacy.p_win = Math.round(pWin * 1000) / 1000;
|
||||
const sideOdds = String(prop.direction || 'over').toLowerCase() === 'under'
|
||||
? rawProp.under_odds : rawProp.over_odds;
|
||||
|
||||
const sideOdds = dir === 'under' ? rawProp.under_odds : rawProp.over_odds;
|
||||
|
||||
// Quarter-Kelly sizing (unchanged).
|
||||
if (pWin != null && sideOdds != null) {
|
||||
const { quarterKelly } = require('../../utils/kelly');
|
||||
const k = quarterKelly(pWin, sideOdds);
|
||||
if (k) legacy.kelly = { ...k, odds: String(sideOdds) };
|
||||
}
|
||||
} catch { /* sizing is additive — absent beats wrong */ }
|
||||
|
||||
// The VALUE TRIPLET (step 6): book price · fair (de-vigged) price · model
|
||||
// price. Two-way de-vig needs BOTH sides; one side missing → fair absent.
|
||||
if (sideOdds != null) legacy.book_odds = Number(sideOdds);
|
||||
const dv = devigTwoWay(rawProp.over_odds, rawProp.under_odds);
|
||||
if (dv) {
|
||||
const fair = dir === 'under' ? dv.under : dv.over;
|
||||
legacy.fair_prob = fair.fair_prob;
|
||||
legacy.fair_odds = fair.fair_odds;
|
||||
legacy.devig_method = dv.method;
|
||||
legacy.overround = dv.overround;
|
||||
}
|
||||
if (pWin != null) legacy.model_odds = impliedProbToAmerican(pWin);
|
||||
|
||||
// EV at the ACTUAL price (step 2) + the takeable/value flags (steps 3-4).
|
||||
// `takeable` is a property of the price alone; `value` also needs the edge.
|
||||
if (sideOdds != null) legacy.takeable = isTakeable(sideOdds);
|
||||
if (pWin != null && sideOdds != null) {
|
||||
const ev = evPct(pWin, sideOdds);
|
||||
if (ev != null) {
|
||||
legacy.ev_pct = ev;
|
||||
legacy.value = isValue(sideOdds, ev);
|
||||
}
|
||||
}
|
||||
} catch { /* the value layer is additive — absent beats wrong */ }
|
||||
|
||||
return legacy;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* De-vig engine (Model Train, step 1) — the foundation for EV + value.
|
||||
*
|
||||
* A two-sided prop price carries the book's vig (the "overround"): the two
|
||||
* sides' implied probabilities sum to MORE than 1. De-vigging strips that back
|
||||
* out to FAIR probabilities that sum to 1, and converts them back to a fair
|
||||
* (vig-free) price per side.
|
||||
*
|
||||
* METHOD: multiplicative / proportional de-vig — each side's implied prob is
|
||||
* divided by the total. It's the standard, distribution-free two-way method
|
||||
* (a.k.a. "normalized implied probability"). Simple, transparent, and correct
|
||||
* for two-way markets; we note it in the stored `method` field.
|
||||
*
|
||||
* If only one side is priced, fair values are UNAVAILABLE (null) — never faked.
|
||||
*/
|
||||
|
||||
const round3 = (n) => Math.round(n * 1000) / 1000;
|
||||
|
||||
/** American odds → implied probability (WITH the vig). Null on bad input. */
|
||||
function americanToImpliedProb(american) {
|
||||
const a = Number(american);
|
||||
if (!Number.isFinite(a) || a === 0) return null;
|
||||
return a > 0 ? 100 / (a + 100) : (-a) / ((-a) + 100);
|
||||
}
|
||||
|
||||
/** American odds → decimal odds (total return multiple incl. stake). */
|
||||
function americanToDecimal(american) {
|
||||
const a = Number(american);
|
||||
if (!Number.isFinite(a) || a === 0) return null;
|
||||
return a > 0 ? 1 + a / 100 : 1 + 100 / (-a);
|
||||
}
|
||||
|
||||
/** Probability → fair American odds. Null outside (0,1). */
|
||||
function impliedProbToAmerican(p) {
|
||||
if (!Number.isFinite(p) || p <= 0 || p >= 1) return null;
|
||||
// Even money (50%) is +100 by convention → favorites (p > .5) go negative.
|
||||
return p > 0.5 ? -Math.round((p / (1 - p)) * 100) : Math.round(((1 - p) / p) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-way de-vig. Given BOTH sides' American odds, return fair prob + fair price
|
||||
* per side plus the overround. Null when a side's odds are missing/invalid —
|
||||
* the caller marks fair as unavailable rather than inventing it.
|
||||
*/
|
||||
function devigTwoWay(overOdds, underOdds) {
|
||||
const po = americanToImpliedProb(overOdds);
|
||||
const pu = americanToImpliedProb(underOdds);
|
||||
if (po == null || pu == null) return null;
|
||||
const sum = po + pu; // > 1 by the vig
|
||||
if (!(sum > 0)) return null;
|
||||
const fairOver = po / sum;
|
||||
const fairUnder = pu / sum;
|
||||
return {
|
||||
method: 'multiplicative',
|
||||
overround: round3(sum - 1),
|
||||
over: { fair_prob: round3(fairOver), fair_odds: impliedProbToAmerican(fairOver) },
|
||||
under: { fair_prob: round3(fairUnder), fair_odds: impliedProbToAmerican(fairUnder) },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Expected value (%) of staking one unit on `american` when the model gives the
|
||||
* side probability `modelProb`. EV per unit = p·decimal − 1. Positive = the bet
|
||||
* is +EV at the price you'd actually pay (the vig is already baked into the
|
||||
* actual price, so this is the true "does it pay you" measure). Null on bad input.
|
||||
*/
|
||||
function evPct(modelProb, american) {
|
||||
const dec = americanToDecimal(american);
|
||||
if (!Number.isFinite(modelProb) || modelProb <= 0 || dec == null) return null;
|
||||
return Math.round((modelProb * dec - 1) * 1000) / 10; // one decimal place
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
americanToImpliedProb,
|
||||
americanToDecimal,
|
||||
impliedProbToAmerican,
|
||||
devigTwoWay,
|
||||
evPct,
|
||||
};
|
||||
Reference in New Issue
Block a user