From 7a925f43ebdecad48a8302ee0b70efa0d536fd3d Mon Sep 17 00:00:00 2001 From: Kev Date: Sun, 19 Jul 2026 02:43:30 -0400 Subject: [PATCH] Model Train arc 1 (engine): de-vig + EV + takeable/value gates + hero v2 + triplet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- BACKEND_HANDOFF.md | 23 ++++ src/config/valueEngine.js | 49 ++++++++ src/services/heroPropService.js | 25 +++- .../intelligence/analyzeViaEngine1.js | 45 ++++++-- src/utils/devig.js | 81 +++++++++++++ tests/unit/devig.test.js | 55 +++++++++ tests/unit/heroPropService.test.js | 109 ++++++++---------- tests/unit/valueEngine.test.js | 83 +++++++++++++ 8 files changed, 399 insertions(+), 71 deletions(-) create mode 100644 src/config/valueEngine.js create mode 100644 src/utils/devig.js create mode 100644 tests/unit/devig.test.js create mode 100644 tests/unit/valueEngine.test.js diff --git a/BACKEND_HANDOFF.md b/BACKEND_HANDOFF.md index 90894aa..291502f 100644 --- a/BACKEND_HANDOFF.md +++ b/BACKEND_HANDOFF.md @@ -273,3 +273,26 @@ match `^[a-z0-9_]{3,20}$` (400); a handle owned by another user → 409. Frontend: `/u/[handle]` (OPEN route, server shell + client record + OG card, Node runtime) via proxies `web/src/app/api/profiles/me|[handle]`. + +## Value Engine fields (Model Train arc 1, 2026-07-19) +Every graded read (snapshot `grades:{sport}`, `/api/snapshot/:sport`, `/api/hero-prop`, +`/api/scan`) now carries — all OPTIONAL + self-hiding when absent: +- `ev_pct` (number) — expected value % at the graded side's ACTUAL price + (model prob × decimal − 1). The ranking signal on boards + hero, replacing raw + |model−consensus|. +- `value` (bool) — the read passes BOTH gates: takeable price AND ev_pct ≥ + VALUE_EV_THRESHOLD. Grade = read quality; `value` = "the price pays you." An A + without `value` is honest (right read, price gone). +- `takeable` (bool) — the graded-side price is inside the promotable band + (TAKEABLE_ODDS_CEILING −160 .. +200). +- **The value triplet** — `book_odds` · `fair_odds` · `model_odds` (all American): + the book's price, the de-vigged FAIR price (two-way multiplicative de-vig; + present only when BOTH sides were priced — else absent, never faked), and the + model's implied price. This is the story to render: "book −145 · vig-free −132 + · model −110". Also `fair_prob`, `overround`, `devig_method`. +- Suppression reasons ride on refused reads: `suppressed` + `suppressed_reason` + (`juiced_no_edge` / `rare_event_under`) + `reasoning.summary` (branded "No + read" copy) — for the visible-refusal moment (Model Train step 10). + +DESIGN: the triplet + VALUE marker + refusal copy are Session-2 design surfaces +(reveal card, board row, hero). Backend ships the fields; rendering is Design's. diff --git a/src/config/valueEngine.js b/src/config/valueEngine.js new file mode 100644 index 0000000..1fb143e --- /dev/null +++ b/src/config/valueEngine.js @@ -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, +}; diff --git a/src/services/heroPropService.js b/src/services/heroPropService.js index 4dfa108..2a4155d 100644 --- a/src/services/heroPropService.js +++ b/src/services/heroPropService.js @@ -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 = ''; diff --git a/src/services/intelligence/analyzeViaEngine1.js b/src/services/intelligence/analyzeViaEngine1.js index e62986b..76486a1 100644 --- a/src/services/intelligence/analyzeViaEngine1.js +++ b/src/services/intelligence/analyzeViaEngine1.js @@ -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; } diff --git a/src/utils/devig.js b/src/utils/devig.js new file mode 100644 index 0000000..f686938 --- /dev/null +++ b/src/utils/devig.js @@ -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, +}; diff --git a/tests/unit/devig.test.js b/tests/unit/devig.test.js new file mode 100644 index 0000000..0a8ef31 --- /dev/null +++ b/tests/unit/devig.test.js @@ -0,0 +1,55 @@ +'use strict'; + +const devig = require('../../src/utils/devig'); + +describe('de-vig engine (step 1)', () => { + test('american → implied prob (favorite + dog + break-even)', () => { + expect(devig.americanToImpliedProb(-110)).toBeCloseTo(0.5238, 3); + expect(devig.americanToImpliedProb(+150)).toBeCloseTo(0.4, 3); + expect(devig.americanToImpliedProb(+100)).toBeCloseTo(0.5, 3); + expect(devig.americanToImpliedProb(0)).toBeNull(); + expect(devig.americanToImpliedProb(null)).toBeNull(); + }); + + test('prob → fair american is the inverse', () => { + expect(devig.impliedProbToAmerican(0.5)).toBe(100); // +100 at 50% + expect(devig.impliedProbToAmerican(0.6)).toBe(-150); + expect(devig.impliedProbToAmerican(0.4)).toBe(150); + expect(devig.impliedProbToAmerican(0)).toBeNull(); + expect(devig.impliedProbToAmerican(1)).toBeNull(); + }); + + test('two-way de-vig strips the vig; fair probs sum to 1', () => { + // -110 / -110: each implies .5238, sum 1.0476 (4.76% vig) → fair .5 / .5 + const d = devig.devigTwoWay(-110, -110); + expect(d.method).toBe('multiplicative'); + expect(d.overround).toBeCloseTo(0.048, 2); + expect(d.over.fair_prob).toBeCloseTo(0.5, 3); + expect(d.under.fair_prob).toBeCloseTo(0.5, 3); + expect(d.over.fair_prob + d.under.fair_prob).toBeCloseTo(1, 6); + }); + + test('a juiced book price de-vigs to a fairer number (the story)', () => { + // book -145 over vs +115 under + const d = devig.devigTwoWay(-145, +115); + expect(d.over.fair_prob + d.under.fair_prob).toBeCloseTo(1, 6); + // the fair over price is less juiced than the -145 book price + expect(d.over.fair_odds).toBeGreaterThan(-145); + }); + + test('only one side priced → fair UNAVAILABLE (never faked)', () => { + expect(devig.devigTwoWay(-110, null)).toBeNull(); + expect(devig.devigTwoWay(null, -110)).toBeNull(); + expect(devig.devigTwoWay(undefined, undefined)).toBeNull(); + }); + + test('EV% at the actual price from the model probability', () => { + // model 55% on a +100 line: 0.55*2 - 1 = +10% + expect(devig.evPct(0.55, +100)).toBeCloseTo(10, 1); + // model 52.38% on -110 (= break-even): ~0 EV + expect(devig.evPct(0.5238, -110)).toBeCloseTo(0, 0); + // model 50% on -110 → negative (you pay the vig) + expect(devig.evPct(0.5, -110)).toBeLessThan(0); + expect(devig.evPct(null, -110)).toBeNull(); + }); +}); diff --git a/tests/unit/heroPropService.test.js b/tests/unit/heroPropService.test.js index 1aab5ef..be5eea0 100644 --- a/tests/unit/heroPropService.test.js +++ b/tests/unit/heroPropService.test.js @@ -1,85 +1,76 @@ 'use strict'; -// Item 5 (Truth-Everywhere Part 2) — the daily hero prop is a deterministic -// live RULE: largest |projection - line| gap among A/B grades. Empty slate → -// most recent real read. Nothing → hidden. +// 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. -const { pickHeroProp, __internals } = require('../../src/services/heroPropService'); +const { pickHeroProp } = require('../../src/services/heroPropService'); -function cacheFrom(map) { - return async (key) => (key in map ? map[key] : null); -} +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, line: o.line, projection: o.proj, + 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', - gradedAt: { line: o.line, odds: -110, timestamp: o.ts || '2026-07-17T19:00:00Z' }, + ev_pct: o.ev, book_odds: o.odds ?? -120, value: o.value ?? null, + gradedAt: { line: o.line ?? 1.5, odds: o.odds ?? -120, timestamp: o.ts || '2026-07-17T19:00:00Z' }, }); -describe('pickHeroProp', () => { - test('picks the LARGEST |projection - line| gap among A/B grades', async () => { - const cacheGet = cacheFrom({ - 'snapshot:mlb:latest': { grades: [ - grade({ player: 'Small Gap', stat: 'hits', line: 0.5, proj: 0.6, grade: 'A' }), // gap .1 - grade({ player: 'Big Gap', stat: 'strikeouts', line: 6.5, proj: 9.0, grade: 'B' }), // gap 2.5 - ] }, - }); +describe('pickHeroProp — v2 (EV among takeable A/B)', () => { + test('picks the HIGHEST ev_pct 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 }), + ] } }); const hero = await pickHeroProp({ cacheGet, sports: ['mlb'] }); expect(hero.available).toBe(true); - expect(hero.player).toBe('Big Gap'); - expect(hero.gap).toBe(2.5); - expect(hero.is_recent).toBe(false); - expect(hero.graded_at).toBeTruthy(); // real timestamp - expect(hero.line).toBe(6.5); // the book number - expect(hero.projection).toBe(9.0); // the model number + expect(hero.player).toBe('HighEV'); + expect(hero.ev_pct).toBe(8.4); }); - test('C/D/F grades are NOT eligible (conviction gate)', async () => { - const cacheGet = cacheFrom({ - 'snapshot:mlb:latest': { grades: [ - grade({ player: 'Huge Gap C', stat: 'hits', line: 0.5, proj: 3.0, grade: 'C' }), // gap 2.5 but C - grade({ player: 'Real A', stat: 'hits', line: 0.5, proj: 0.9, grade: 'A' }), // gap .4 - ] }, - }); + test('a HUGE EV on an un-takeable price (-900) is NOT the hero (trivia, not opportunity)', 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 + ] } }); const hero = await pickHeroProp({ cacheGet, sports: ['mlb'] }); - expect(hero.player).toBe('Real A'); // the C is excluded despite a bigger gap + expect(hero.player).toBe('Takeable'); }); - test('a prop with no projection or no line is not a candidate (never gap on 0)', async () => { - const cacheGet = cacheFrom({ - 'snapshot:mlb:latest': { grades: [ - { player_name: 'No Proj', stat_type: 'hits', line: 0.5, projection: 0, grade: 'A', gradedAt: { timestamp: '2026-07-17T19:00:00Z' } }, - grade({ player: 'Valid', stat: 'hits', line: 1.5, proj: 2.2, grade: 'B' }), - ] }, - }); + test('C/D/F grades are ineligible even with high EV', 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 }), + ] } }); const hero = await pickHeroProp({ cacheGet, sports: ['mlb'] }); - expect(hero.player).toBe('Valid'); + expect(hero.player).toBe('RealA'); }); - test('empty A/B slate → MOST RECENT real graded read (any grade), flagged', async () => { - const cacheGet = cacheFrom({ - 'snapshot:mlb:latest': { grades: [ - grade({ player: 'Older C', stat: 'hits', line: 0.5, proj: 0.4, grade: 'C', ts: '2026-07-17T14:00:00Z' }), - grade({ player: 'Newer C', stat: 'hits', line: 0.5, proj: 0.3, grade: 'C', ts: '2026-07-17T19:00:00Z' }), - ] }, - }); + test('exposes the value triplet + ev/value on the hero', async () => { + 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' } }, + ] } }); + 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 () => { + const cacheGet = cacheFrom({ 'snapshot:mlb:latest': { grades: [ + grade({ player: 'OnlyChalk', grade: 'A', ev: 9, odds: -800, ts: '2026-07-17T19:00:00Z' }), + ] } }); const hero = await pickHeroProp({ cacheGet, sports: ['mlb'] }); expect(hero.available).toBe(true); expect(hero.is_recent).toBe(true); - expect(hero.player).toBe('Newer C'); // most recent by timestamp + expect(hero.player).toBe('OnlyChalk'); }); - test('nothing cached → { available:false } (card hides, no fabrication)', async () => { - const hero = await pickHeroProp({ cacheGet: cacheFrom({}), sports: ['mlb', 'nba'] }); + test('nothing cached → { available:false }', async () => { + const hero = await pickHeroProp({ cacheGet: cacheFrom({}), sports: ['mlb'] }); expect(hero).toEqual({ available: false }); }); - - test('picks across sports (max gap wins regardless of sport)', async () => { - const cacheGet = cacheFrom({ - 'snapshot:mlb:latest': { grades: [grade({ player: 'MLB', stat: 'hits', line: 0.5, proj: 0.9, grade: 'A' })] }, // .4 - 'snapshot:wnba:latest': { grades: [grade({ player: 'WNBA', stat: 'points', line: 18.5, proj: 24.0, grade: 'B' })] }, // 5.5 - }); - const hero = await pickHeroProp({ cacheGet, sports: ['mlb', 'wnba'] }); - expect(hero.player).toBe('WNBA'); - expect(hero.sport).toBe('wnba'); - }); }); diff --git a/tests/unit/valueEngine.test.js b/tests/unit/valueEngine.test.js new file mode 100644 index 0000000..fb215a1 --- /dev/null +++ b/tests/unit/valueEngine.test.js @@ -0,0 +1,83 @@ +'use strict'; + +// Model Train — value engine: takeable gate, value flag, and the end-to-end +// wiring in analyzeViaEngine1 (de-vig + EV + triplet + flags). + +const ve = require('../../src/config/valueEngine'); + +describe('valueEngine config (steps 3-4)', () => { + test('takeable band is -160..+200 by default', () => { + expect(ve.isTakeable(-110)).toBe(true); + expect(ve.isTakeable(-160)).toBe(true); // ceiling inclusive + expect(ve.isTakeable(+200)).toBe(true); // max inclusive + expect(ve.isTakeable(-200)).toBe(false); // too chalky + expect(ve.isTakeable(+250)).toBe(false); // too long + expect(ve.isTakeable(null)).toBe(false); + }); + test('value = takeable AND ev above threshold', () => { + expect(ve.isValue(-120, 5)).toBe(true); // takeable + 5% EV + expect(ve.isValue(-120, 1)).toBe(false); // takeable but EV below 2% + expect(ve.isValue(-900, 20)).toBe(false); // huge EV but not takeable + }); +}); + +// ── analyzeViaEngine1 value fields ────────────────────────────────────────── +const mockCompute = { current: null }; +jest.mock('../../src/services/intelligence/computeFeatures', () => ({ + computeFeaturesForProp: async () => mockCompute.current, +})); +jest.mock('../../src/services/intelligence/engine1', () => ({ + gradeProp: () => ({ grade: 'B', confidence: 0.55, top_factors: [], all_factors: [] }), +})); +const mockPOver = { current: 0.6 }; +jest.mock('../../src/services/intelligence/probabilityEstimator', () => ({ + estimateProbability: () => ({ p_over: mockPOver.current }), +})); +const { analyzeViaEngine1 } = require('../../src/services/intelligence/analyzeViaEngine1'); + +beforeEach(() => { + mockCompute.current = { + features: { l5_avg: 1.4, l20_avg: 1.3 }, trap: {}, consistency: { consistency: 'reliable', score: 0.7 }, + prop: { line: 0.5, direction: 'over' }, meta: { sport: 'mlb', gameLogs: [{ hits: 1 }], errors: [] }, + }; + mockPOver.current = 0.6; // model P(over) = 60% +}); + +describe('analyzeViaEngine1 — de-vig + EV + triplet (steps 1,2,6)', () => { + test('computes the value triplet, EV, and flags from both-sided odds', async () => { + // book: over -130 / under +110. model P(over) 0.60. + const out = await analyzeViaEngine1({ + player: 'X', stat_type: 'hits', line: 0.5, direction: 'over', over_odds: -130, under_odds: 110, + }); + expect(out.grade).toBe('B'); // still a real read + expect(out.book_odds).toBe(-130); // book price + expect(typeof out.fair_odds).toBe('number'); // de-vigged fair price present (both sides) + expect(out.model_odds).toBe(-150); // impliedProbToAmerican(0.60) + expect(out.p_win).toBe(0.6); + // EV at -130 with p 0.60: 0.60*(1+100/130) - 1 = +6.15% + expect(out.ev_pct).toBeGreaterThan(5); + expect(out.takeable).toBe(true); // -130 in band + expect(out.value).toBe(true); // takeable + EV > 2% + expect(out.devig_method).toBe('multiplicative'); + }); + + test('one-sided odds → fair UNAVAILABLE, but book price + EV still ship', async () => { + const out = await analyzeViaEngine1({ + player: 'X', stat_type: 'hits', line: 0.5, direction: 'over', over_odds: -130, // no under_odds + }); + expect(out.book_odds).toBe(-130); + expect(out.fair_odds).toBeUndefined(); // never faked from one side + expect(out.ev_pct).toBeGreaterThan(0); // EV from model + actual price + expect(out.takeable).toBe(true); + }); + + test('a chalky price is not takeable → not value even at positive EV', async () => { + mockPOver.current = 0.92; + const out = await analyzeViaEngine1({ + player: 'X', stat_type: 'hits', line: 0.5, direction: 'over', over_odds: -600, under_odds: 400, + }); + // -600 is past the JUICE_ODDS_FLOOR (-400) → refused before we even get here + expect(out.grade).toBeNull(); + expect(out.suppressed_reason).toBe('juiced_no_edge'); + }); +});