Generalize the no-edge guard: suppress by the BOOK'S PRICE, not a stat whitelist
Follow-up to the rare-event under fix — the whitelist (doubles/triples/HR/SB) was fragile: the same juiced-under problem exists for steals, blocks, and any other low-frequency market, and a new stat would slip through. The real signal is the book's own price. The doubles unders were priced -625 to -1100 — laying 6-11x to win 1x on an ~82% event, with no value the model could recover. So the PRIMARY guard is now stat/sport-agnostic: analyzeViaEngine1 refuses any read whose graded-side odds are past the juice floor (JUICE_ODDS_FLOOR, default -400, env-tunable). That catches every version of this — steals, blocks, anything — and it also keeps the public record honest (those -800 "wins" hit ~82% of the time and would inflate the hit rate, the same class as the projection-0 degradation). The structural rare-event rules stay as the BACKUP for props with no odds (list also expanded cross-sport: + steals, blocks). Normal + longshot prices (-110, -250, +600) are preserved. 16 tests cover both layers. Reported: the doubles projection was REAL per-player (not a fallback); the fix is the price guard, not a bigger list. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,8 +18,13 @@
|
|||||||
* touching grade logic. CommonJS so it's unit-testable + requireable everywhere.
|
* 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.
|
// Low-frequency counting stats (across sports) where a 0.5 under is
|
||||||
const RARE_EVENT_STATS = ['doubles', 'triples', 'home_runs', 'stolen_bases'];
|
// 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;
|
// 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).
|
// 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);
|
&& !(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 = {
|
module.exports = {
|
||||||
RARE_EVENT_STATS,
|
RARE_EVENT_STATS,
|
||||||
RARE_EVENT_LINE_MAX,
|
RARE_EVENT_LINE_MAX,
|
||||||
isRareEventStat,
|
isRareEventStat,
|
||||||
isSuppressedRareUnder,
|
isSuppressedRareUnder,
|
||||||
isSuppressedRareOver,
|
isSuppressedRareOver,
|
||||||
|
JUICE_ODDS_FLOOR,
|
||||||
|
parseAmerican,
|
||||||
|
gradedSideOdds,
|
||||||
|
isTooJuiced,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
const { computeFeaturesForProp } = require('./computeFeatures');
|
const { computeFeaturesForProp } = require('./computeFeatures');
|
||||||
const engine1 = require('./engine1');
|
const engine1 = require('./engine1');
|
||||||
const { toLegacyShape } = require('../../utils/gradeAdapter');
|
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
|
// Map an error code from computeFeaturesForProp.meta.errors into a human
|
||||||
// sentence the user will see in reasoning.summary.
|
// sentence the user will see in reasoning.summary.
|
||||||
@@ -384,9 +384,18 @@ function buildIntelFields(features = {}, opts = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function analyzeViaEngine1(rawProp = {}) {
|
async function analyzeViaEngine1(rawProp = {}) {
|
||||||
// Betting-logic audit — suppress the juiced UNDER up front (no compute spent):
|
// Betting-logic audit — the GENERAL no-edge guard (stat/sport-agnostic): the
|
||||||
// a 0.5-line under on a rare counting stat (doubles/triples/HR/SB) is never a
|
// book's own price is the truth. A side priced past the juice floor (e.g.
|
||||||
// takeable edge. (The OVER is gated on the projection below.)
|
// 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)) {
|
if (isSuppressedRareUnder(rawProp.stat_type, rawProp.line, rawProp.direction)) {
|
||||||
return suppressedRareResult(rawProp, 'rare_event_under',
|
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.`);
|
`No read — a ${rawProp.line} under on ${rawProp.stat_type} is a juiced rare-event market, not a takeable edge.`);
|
||||||
|
|||||||
@@ -9,9 +9,12 @@
|
|||||||
const cfg = require('../../src/config/rareEventMarkets');
|
const cfg = require('../../src/config/rareEventMarkets');
|
||||||
|
|
||||||
describe('rareEventMarkets config', () => {
|
describe('rareEventMarkets config', () => {
|
||||||
test('the rare stats + threshold are config-driven (tunable)', () => {
|
test('the rare stats + threshold are config-driven (tunable, cross-sport)', () => {
|
||||||
expect(cfg.RARE_EVENT_STATS).toEqual(['doubles', 'triples', 'home_runs', 'stolen_bases']);
|
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.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', () => {
|
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 ───────────────────────────────────────────
|
// ── analyzeViaEngine1 integration ───────────────────────────────────────────
|
||||||
const mockComputeReturn = { current: null };
|
const mockComputeReturn = { current: null };
|
||||||
jest.mock('../../src/services/intelligence/computeFeatures', () => ({
|
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' });
|
const out = await analyzeViaEngine1({ player: 'X', stat_type: 'home_runs', line: 1.5, direction: 'under' });
|
||||||
expect(out.grade).toBe('B');
|
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 ─────────────────────────
|
// ── board layer: collectBreadth drops no-model rows ─────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user