diff --git a/src/config/rareEventMarkets.js b/src/config/rareEventMarkets.js index ee324b8..d09991a 100644 --- a/src/config/rareEventMarkets.js +++ b/src/config/rareEventMarkets.js @@ -18,8 +18,13 @@ * touching grade logic. CommonJS so it's unit-testable + requireable everywhere. */ -// Low-frequency counting stats where a 0.5 under is structurally a bad bet. -const RARE_EVENT_STATS = ['doubles', 'triples', 'home_runs', 'stolen_bases']; +// Low-frequency counting stats (across sports) where a 0.5 under is +// structurally a bad bet. This list is only the BACKUP for props with no odds — +// the primary, stat-agnostic guard is the juice floor below. +const RARE_EVENT_STATS = [ + 'doubles', 'triples', 'home_runs', 'stolen_bases', // MLB + 'steals', 'blocks', // NBA / WNBA +]; // The under is suppressed only at/below this line (0.5 is the juiced rare line; // a 1.5+ line is a different market where an under can be a real read). @@ -56,10 +61,45 @@ function isSuppressedRareOver(statType, line, direction, projection) { && !(Number.isFinite(proj) && proj > ln); } +// ── The juice floor (the GENERAL, stat-agnostic rule) ─────────────────────── +// The book's own price is the ground truth of "no takeable edge": a side priced +// worse than this is a heavy favorite where the vig has already eaten any edge, +// regardless of stat or sport. The rare-event doubles unders that started this +// were priced -625 to -1100 — laying 6-11x to win 1x on an ~82% event, with no +// value the model could recover. This catches EVERY version of the problem +// (steals, blocks, any market) without a per-stat whitelist. Tunable via env. +const JUICE_ODDS_FLOOR = Number(process.env.JUICE_ODDS_FLOOR || -400); + +function parseAmerican(odds) { + if (odds == null) return null; + const n = Number(String(odds).replace('+', '')); + return Number.isFinite(n) ? n : null; +} + +/** American odds on the SIDE being graded (under_odds for under, else over). */ +function gradedSideOdds(prop) { + const dir = String((prop && prop.direction) || '').toLowerCase(); + const raw = dir === 'under' + ? (prop && (prop.under_odds ?? prop.under)) ?? null + : (prop && (prop.over_odds ?? prop.over)) ?? null; + return parseAmerican(raw); +} + +/** True when the graded side is too juiced to carry a takeable edge. Odds + * absent → false (can't judge from price; the rare-event rule is the backup). */ +function isTooJuiced(prop) { + const o = gradedSideOdds(prop); + return o != null && o <= JUICE_ODDS_FLOOR; +} + module.exports = { RARE_EVENT_STATS, RARE_EVENT_LINE_MAX, isRareEventStat, isSuppressedRareUnder, isSuppressedRareOver, + JUICE_ODDS_FLOOR, + parseAmerican, + gradedSideOdds, + isTooJuiced, }; diff --git a/src/services/intelligence/analyzeViaEngine1.js b/src/services/intelligence/analyzeViaEngine1.js index 21e1a09..e62986b 100644 --- a/src/services/intelligence/analyzeViaEngine1.js +++ b/src/services/intelligence/analyzeViaEngine1.js @@ -16,7 +16,7 @@ const { computeFeaturesForProp } = require('./computeFeatures'); const engine1 = require('./engine1'); const { toLegacyShape } = require('../../utils/gradeAdapter'); -const { isSuppressedRareUnder, isSuppressedRareOver } = require('../../config/rareEventMarkets'); +const { isSuppressedRareUnder, isSuppressedRareOver, isTooJuiced, gradedSideOdds } = require('../../config/rareEventMarkets'); // Map an error code from computeFeaturesForProp.meta.errors into a human // sentence the user will see in reasoning.summary. @@ -384,9 +384,18 @@ function buildIntelFields(features = {}, opts = {}) { } async function analyzeViaEngine1(rawProp = {}) { - // Betting-logic audit — suppress the juiced UNDER up front (no compute spent): - // a 0.5-line under on a rare counting stat (doubles/triples/HR/SB) is never a - // takeable edge. (The OVER is gated on the projection below.) + // Betting-logic audit — the GENERAL no-edge guard (stat/sport-agnostic): the + // book's own price is the truth. A side priced past the juice floor (e.g. + // those doubles unders at -625 to -1100) has no takeable edge, so refuse it up + // front. This is what makes the fix robust instead of a fragile whitelist. + if (isTooJuiced(rawProp)) { + return suppressedRareResult(rawProp, 'juiced_no_edge', + `No read — the book prices this side at ${gradedSideOdds(rawProp)}; the vig has eaten any edge.`); + } + + // Backup for props with NO odds — the structural rare-event rule. Suppress the + // juiced UNDER on a 0.5-line rare counting stat (doubles/triples/HR/SB/steals/ + // blocks). (The OVER is gated on the projection below.) if (isSuppressedRareUnder(rawProp.stat_type, rawProp.line, rawProp.direction)) { return suppressedRareResult(rawProp, 'rare_event_under', `No read — a ${rawProp.line} under on ${rawProp.stat_type} is a juiced rare-event market, not a takeable edge.`); diff --git a/tests/unit/rareEventSuppression.test.js b/tests/unit/rareEventSuppression.test.js index 17cd266..6f1e955 100644 --- a/tests/unit/rareEventSuppression.test.js +++ b/tests/unit/rareEventSuppression.test.js @@ -9,9 +9,12 @@ const cfg = require('../../src/config/rareEventMarkets'); describe('rareEventMarkets config', () => { - test('the rare stats + threshold are config-driven (tunable)', () => { - expect(cfg.RARE_EVENT_STATS).toEqual(['doubles', 'triples', 'home_runs', 'stolen_bases']); + test('the rare stats + threshold are config-driven (tunable, cross-sport)', () => { + expect(cfg.RARE_EVENT_STATS).toEqual( + expect.arrayContaining(['doubles', 'triples', 'home_runs', 'stolen_bases', 'steals', 'blocks']), + ); expect(cfg.RARE_EVENT_LINE_MAX).toBe(0.5); + expect(cfg.JUICE_ODDS_FLOOR).toBe(-400); // the general guard's default }); test('UNDER on a rare 0.5 market is suppressed', () => { @@ -32,6 +35,32 @@ describe('rareEventMarkets config', () => { }); }); +describe('juice floor — the GENERAL, stat-agnostic guard', () => { + test('graded-side odds are read from the graded direction', () => { + expect(cfg.gradedSideOdds({ direction: 'under', under_odds: -800, over_odds: 120 })).toBe(-800); + expect(cfg.gradedSideOdds({ direction: 'over', under_odds: -800, over_odds: 120 })).toBe(120); + expect(cfg.parseAmerican('+600')).toBe(600); + expect(cfg.parseAmerican(null)).toBeNull(); + }); + + test('a side priced past the floor is too juiced (any stat)', () => { + expect(cfg.isTooJuiced({ direction: 'under', under_odds: -800 })).toBe(true); // the doubles case + expect(cfg.isTooJuiced({ direction: 'under', under_odds: -1100 })).toBe(true); + expect(cfg.isTooJuiced({ direction: 'under', under_odds: -400 })).toBe(true); // == floor + }); + + test('normal + longshot prices are NOT juiced (legit markets preserved)', () => { + expect(cfg.isTooJuiced({ direction: 'under', under_odds: -110 })).toBe(false); + expect(cfg.isTooJuiced({ direction: 'under', under_odds: -250 })).toBe(false); + expect(cfg.isTooJuiced({ direction: 'over', over_odds: 600 })).toBe(false); // a real longshot over + }); + + test('no odds → cannot judge from price (falls back to the structural rule)', () => { + expect(cfg.isTooJuiced({ direction: 'under' })).toBe(false); + expect(cfg.isTooJuiced({ direction: 'under', under_odds: null })).toBe(false); + }); +}); + // ── analyzeViaEngine1 integration ─────────────────────────────────────────── const mockComputeReturn = { current: null }; jest.mock('../../src/services/intelligence/computeFeatures', () => ({ @@ -97,6 +126,19 @@ describe('analyzeViaEngine1 — rare-event suppression', () => { const out = await analyzeViaEngine1({ player: 'X', stat_type: 'home_runs', line: 1.5, direction: 'under' }); expect(out.grade).toBe('B'); }); + + test('JUICE GUARD — a heavily-juiced side is refused for ANY stat, via price', async () => { + // hits is NOT a rare-event stat, but the book pricing it -800 means no edge. + const out = await analyzeViaEngine1({ player: 'X', stat_type: 'hits', line: 1.5, direction: 'under', under_odds: -800 }); + expect(out.grade).toBeNull(); + expect(out.suppressed_reason).toBe('juiced_no_edge'); + }); + + test('JUICE GUARD — a normally-priced play still grades', async () => { + mockComputeReturn.current = feat(1.4, 0.5, 'over'); + const out = await analyzeViaEngine1({ player: 'X', stat_type: 'hits', line: 0.5, direction: 'over', over_odds: -130 }); + expect(out.grade).toBe('B'); // -130 is fine → real read + }); }); // ── board layer: collectBreadth drops no-model rows ─────────────────────────