7a925f43eb
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>
84 lines
3.9 KiB
JavaScript
84 lines
3.9 KiB
JavaScript
'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');
|
|
});
|
|
});
|