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,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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user