Rank on p_win: challenger instrument + retire edge from decisions
MEASURED BASIS (n=200 settled MLB rows): corr(p_win, outcome) = +0.26; corr(edge, outcome) = -0.010 incumbent ruler / -0.022 consensus ruler. Subtracting the market destroys the signal under BOTH rulers, so a quantity that does not predict must not rank, gate or decide. CHALLENGER-FIRST -- live ordering is byte-identical. rankGrades (the incumbent, grade-first with edge as its 4th key) is untouched and tested as untouched. NEW: rankByForecast -- takeable-gated p_win -> grade -> confidence -> stable order, with NO edge term anywhere. p_win LEADS and the letter follows, deliberately: the letter measured r ~ 0.005 and is inverted (B 52.4% < C 56.9%) while p_win measures +0.26, so leading with the letter would sort by the weaker signal and use the stronger one only to break ties. Recorded in the code: isotonic calibration is a MONOTONE transform, so ranking on raw vs calibrated p_win gives the SAME ORDER. Calibration matters when p_win is displayed or thresholded; it cannot change a ranking. Nothing here needs the calibrated value. rankingDelta + GET /api/internal/ranking-delta measure how far the board would move before any flip. The endpoint reports p_win coverage alongside the delta -- if p_win is absent the challenger degrades to grade order and the delta UNDERSTATES, which is worth saying rather than reporting a clean zero. forecast_rank is stamped on snapshot grades BEFORE stripModelPrice, so every tier gets the correct order without the paid values (the topGradedService precedent -- an ordinal can travel where the magnitude cannot). Additive only: nothing sorts by it yet. RETIRED AS DECISIONS (not rankings, so done now): - altLineScanner.compareToBookImplied no longer returns value_detected: edge > 0. Edge is still COMPUTED and returned -- losing the record would be worse than mis-using it -- but the verdict is an honest null with value_basis: 'retired:edge_does_not_predict'. - scanAltLines no longer filters to edge>0 or calls the survivor "optimal". The whole ladder is returned ranked and labelled 'price_gap_diagnostic_unvalidated'. The module has ZERO callers (verified) -- unwired like mlbGrader.js, left in place and made honest. An honest asymmetry recorded there: ranking props AGAINST EACH OTHER must not use edge, but choosing between RUNGS OF THE SAME PROP is inherently price-relative -- ranking rungs by model probability alone would always pick the lowest line, since P(over 0.5) > P(over 2.5) by construction. So the gap stays the rung key, explicitly labelled unvalidated. Two superseded tests updated to stronger properties. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
This commit is contained in:
@@ -556,4 +556,49 @@ router.get('/propline-verify', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/internal/ranking-delta (Order: rank on p_win — CHALLENGER-FIRST)
|
||||||
|
*
|
||||||
|
* Reads the live snapshot and reports how far the board WOULD move if the
|
||||||
|
* ranking instrument changed from `rankGrades` (grade-first, edge as 4th key)
|
||||||
|
* to `rankByForecast` (p_win-first, no edge term). Changes nothing — the live
|
||||||
|
* ordering is untouched until this delta is reviewed.
|
||||||
|
*
|
||||||
|
* ?sports=mlb,wnba ?top=10
|
||||||
|
*/
|
||||||
|
router.get('/ranking-delta', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { cacheGet } = require('../utils/redis');
|
||||||
|
const { rankingDelta } = require('../utils/gradeRanking');
|
||||||
|
const sports = String(req.query.sports || 'mlb,wnba')
|
||||||
|
.split(',').map((x) => x.trim().toLowerCase()).filter(Boolean).slice(0, 6);
|
||||||
|
const topN = Math.max(1, Math.min(50, parseInt(req.query.top, 10) || 10));
|
||||||
|
|
||||||
|
const out = {};
|
||||||
|
for (const sport of sports) {
|
||||||
|
let grades = null;
|
||||||
|
const snap = await cacheGet(`snapshot:${sport}:latest`);
|
||||||
|
if (snap && Array.isArray(snap.grades)) grades = snap.grades;
|
||||||
|
else {
|
||||||
|
const env = await cacheGet(`grades:${sport}`);
|
||||||
|
if (env && Array.isArray(env.grades)) grades = env.grades;
|
||||||
|
}
|
||||||
|
if (!grades || grades.length === 0) { out[sport] = { note: 'no cached grades' }; continue; }
|
||||||
|
const withPWin = grades.filter((g) => g && g.p_win != null).length;
|
||||||
|
out[sport] = {
|
||||||
|
graded: grades.length,
|
||||||
|
with_p_win: withPWin,
|
||||||
|
// Honest: if p_win is absent the challenger degrades to grade order and
|
||||||
|
// the delta understates. Say so rather than reporting a clean zero.
|
||||||
|
p_win_coverage_pct: grades.length ? Math.round((1000 * withPWin) / grades.length) / 10 : null,
|
||||||
|
...rankingDelta(grades, topN),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
res.set('Cache-Control', 'no-store');
|
||||||
|
return res.json({ ok: true, live_ordering_unchanged: true, per_sport: out });
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json({ ok: false, error: err && err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
+22
-1
@@ -20,6 +20,7 @@ const { indexRosterLogs, attachLast10Dots } = require('../services/last10Dots');
|
|||||||
// viewer. This endpoint is PUBLIC, so the Session-66 gate on /api/analyze was
|
// viewer. This endpoint is PUBLIC, so the Session-66 gate on /api/analyze was
|
||||||
// being bypassed here on every graded row. Same layer as the CLV gate.
|
// being bypassed here on every graded row. Same layer as the CLV gate.
|
||||||
const { stripModelPrice, gateItemizedGrades, liveLockedSummary, freeSample, entitledToItemizedGrades } = require('../utils/snapshotGating');
|
const { stripModelPrice, gateItemizedGrades, liveLockedSummary, freeSample, entitledToItemizedGrades } = require('../utils/snapshotGating');
|
||||||
|
const { rankByForecast, gradeKey } = require('../utils/gradeRanking');
|
||||||
const { resolveTierFromRequest } = require('../utils/requestTier');
|
const { resolveTierFromRequest } = require('../utils/requestTier');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -108,7 +109,27 @@ router.get('/:sport', async (req, res) => {
|
|||||||
// The earlier resolution-flip freed settled grades, which made the free tier a
|
// The earlier resolution-flip freed settled grades, which made the free tier a
|
||||||
// ONE-DAY-DELAYED FEED of the whole product. Order still matters: strip the model
|
// ONE-DAY-DELAYED FEED of the whole product. Order still matters: strip the model
|
||||||
// PRICE first (S67), then withhold judgment on EVERY itemized grade.
|
// PRICE first (S67), then withhold judgment on EVERY itemized grade.
|
||||||
const gate = (grades) => gateItemizedGrades(stripModelPrice(grades, tier), tier);
|
// FORECAST RANK (Order: rank on p_win, 2026-08-01) — stamped BEFORE the
|
||||||
|
// model-price strip, so every tier receives the CORRECT ORDER without the
|
||||||
|
// paid values. Same precedent as topGradedService: `p_win` is stripped for
|
||||||
|
// unentitled callers, so a client cannot rank on it; an ordinal can travel
|
||||||
|
// where the magnitude cannot.
|
||||||
|
//
|
||||||
|
// ADDITIVE ONLY IN THIS ORDER. Nothing sorts by it yet — the live ordering
|
||||||
|
// is byte-identical until the flip is reviewed against the recorded delta.
|
||||||
|
// It leaks ordering, not magnitude, which is the same trade already made
|
||||||
|
// and accepted for the top-graded board.
|
||||||
|
const stampForecastRank = (grades) => {
|
||||||
|
if (!Array.isArray(grades) || grades.length === 0) return grades;
|
||||||
|
const ranked = rankByForecast(grades);
|
||||||
|
const pos = new Map();
|
||||||
|
ranked.forEach((g, i) => pos.set(gradeKey(g), i + 1));
|
||||||
|
return grades.map((g) => {
|
||||||
|
const r = pos.get(gradeKey(g));
|
||||||
|
return r == null ? g : { ...g, forecast_rank: r };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const gate = (grades) => gateItemizedGrades(stripModelPrice(stampForecastRank(grades), tier), tier);
|
||||||
// Free proof, none of it itemizing the nightly slate:
|
// Free proof, none of it itemizing the nightly slate:
|
||||||
// - the tease: AGGREGATE count + tier shape, computed from the ungated rows and
|
// - the tease: AGGREGATE count + tier shape, computed from the ungated rows and
|
||||||
// never joined back to one, so nobody can tell WHICH prop is the A
|
// never joined back to one, so nobody can tell WHICH prop is the A
|
||||||
|
|||||||
@@ -41,9 +41,19 @@ function americanToImplied(odds) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Compare model probability to book implied probability.
|
* Compare model probability to book implied probability.
|
||||||
* @param {number} modelProb - Model-calculated probability
|
*
|
||||||
* @param {number} bookOdds - American odds from the book
|
* EDGE IS RETIRED AS A DECISION (2026-08-01). `value_detected: edge > 0` used to
|
||||||
* @returns {object} { model_prob, book_implied, edge, value_detected }
|
* declare that a line had value. It cannot: measured on n=200 settled MLB rows,
|
||||||
|
* corr(edge, outcome) = -0.010 under the incumbent ruler and -0.022 under the
|
||||||
|
* consensus ruler, while corr(p_win, outcome) = +0.26. A quantity that does not
|
||||||
|
* predict the outcome must not decide anything the user sees.
|
||||||
|
*
|
||||||
|
* `edge` is STILL COMPUTED AND RETURNED — losing the record would be worse than
|
||||||
|
* mis-using it, and it stays in the ledger as a diagnostic. What is gone is the
|
||||||
|
* verdict derived from it. `value_detected` is now null with an explicit reason,
|
||||||
|
* so a caller that reads it gets an honest absence instead of a false boolean.
|
||||||
|
*
|
||||||
|
* @returns {object} { model_prob, book_implied, edge, value_detected, value_basis }
|
||||||
*/
|
*/
|
||||||
function compareToBookImplied(modelProb, bookOdds) {
|
function compareToBookImplied(modelProb, bookOdds) {
|
||||||
const bookImplied = americanToImplied(bookOdds);
|
const bookImplied = americanToImplied(bookOdds);
|
||||||
@@ -52,16 +62,32 @@ function compareToBookImplied(modelProb, bookOdds) {
|
|||||||
return {
|
return {
|
||||||
model_prob: Math.round(modelProb * 1000) / 1000,
|
model_prob: Math.round(modelProb * 1000) / 1000,
|
||||||
book_implied: Math.round(bookImplied * 1000) / 1000,
|
book_implied: Math.round(bookImplied * 1000) / 1000,
|
||||||
|
// DIAGNOSTIC ONLY — never a ranking, gate or quality signal.
|
||||||
edge: Math.round(edge * 1000) / 1000,
|
edge: Math.round(edge * 1000) / 1000,
|
||||||
value_detected: edge > 0,
|
value_detected: null,
|
||||||
|
value_basis: 'retired:edge_does_not_predict',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scan alternate lines for A-grade props to find optimal value.
|
* Rank the rungs of an alt-line ladder by the model-vs-price gap.
|
||||||
* @param {object} prop - { player, stat, projected_mean, projected_stddev, grade }
|
*
|
||||||
* @param {Array} oddsData - Array of { line, odds, book } from alt markets
|
* ⚠️ THIS MODULE HAS NO CALLERS (verified 2026-08-01) — it is unwired, like
|
||||||
* @returns {object|null} Best alt line with edge, or null
|
* mlbGrader.js. Left in place, made honest, not deleted.
|
||||||
|
*
|
||||||
|
* EDGE IS NO LONGER A VERDICT HERE. This used to `filter(e => e.value_detected)`
|
||||||
|
* and call the survivor `optimal_line`. Both were quality claims that edge
|
||||||
|
* cannot support (n=200 settled MLB: corr(edge, outcome) = -0.010 / -0.022).
|
||||||
|
*
|
||||||
|
* A HONEST NOTE ON WHY THIS ONE IS DIFFERENT. Ranking props AGAINST EACH OTHER
|
||||||
|
* must not use edge — p_win is the measured predictor. But choosing between
|
||||||
|
* RUNGS OF THE SAME PROP is inherently price-relative: every rung has a
|
||||||
|
* different price, and ranking rungs by model probability alone would always
|
||||||
|
* pick the lowest line (P(over 0.5) > P(over 2.5) by construction). So the gap
|
||||||
|
* is kept as the ordering key here — and labelled as an UNVALIDATED price
|
||||||
|
* diagnostic, because we have no evidence it predicts rung outcomes either.
|
||||||
|
*
|
||||||
|
* @returns {object|null} { ranked_lines, ranking_basis, top_by_price_gap, ... }
|
||||||
*/
|
*/
|
||||||
function scanAltLines(prop, oddsData) {
|
function scanAltLines(prop, oddsData) {
|
||||||
if (!prop || !oddsData || oddsData.length === 0) return null;
|
if (!prop || !oddsData || oddsData.length === 0) return null;
|
||||||
@@ -80,24 +106,25 @@ function scanAltLines(prop, oddsData) {
|
|||||||
model_probability: comparison.model_prob,
|
model_probability: comparison.model_prob,
|
||||||
book_implied: comparison.book_implied,
|
book_implied: comparison.book_implied,
|
||||||
edge: comparison.edge,
|
edge: comparison.edge,
|
||||||
value_detected: comparison.value_detected,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const withValue = evaluated.filter(e => e.value_detected);
|
if (evaluated.length === 0) return null;
|
||||||
if (withValue.length === 0) return null;
|
|
||||||
|
|
||||||
withValue.sort((a, b) => b.edge - a.edge);
|
// No value FILTER: a negative gap is a real observation about a rung, not a
|
||||||
const optimal = withValue[0];
|
// reason to hide it. The whole ladder is returned, ranked, and labelled.
|
||||||
|
const ranked = [...evaluated].sort((a, b) => b.edge - a.edge);
|
||||||
|
const top = ranked[0];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
optimal_line: optimal.line,
|
ranking_basis: 'price_gap_diagnostic_unvalidated',
|
||||||
odds: optimal.odds,
|
top_by_price_gap: top.line,
|
||||||
book: optimal.book,
|
odds: top.odds,
|
||||||
model_probability: optimal.model_probability,
|
book: top.book,
|
||||||
book_implied: optimal.book_implied,
|
model_probability: top.model_probability,
|
||||||
edge: optimal.edge,
|
book_implied: top.book_implied,
|
||||||
all_value_lines: withValue,
|
edge: top.edge,
|
||||||
|
ranked_lines: ranked,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,114 @@ function rankGrades(grades, limit) {
|
|||||||
return limit == null ? out : out.slice(0, Math.max(0, limit));
|
return limit == null ? out : out.slice(0, Math.max(0, limit));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* rankByForecast — THE CHALLENGER instrument (2026-08-01).
|
||||||
|
*
|
||||||
|
* WHY THIS EXISTS, measured on n=200 settled MLB rows:
|
||||||
|
*
|
||||||
|
* corr(p_win, outcome) = +0.26
|
||||||
|
* corr(p_win - fair_prob_v1, outcome) = -0.010
|
||||||
|
* corr(p_win - fair_prob_v2, outcome) = -0.022
|
||||||
|
*
|
||||||
|
* Subtracting the market price DESTROYS the signal, under BOTH rulers. So the
|
||||||
|
* product must rank on the thing that predicts (p_win) and must not rank on
|
||||||
|
* market-relative edge at all. `rankGrades` (the incumbent) keeps edge as its
|
||||||
|
* 4th key; this one has no edge term anywhere.
|
||||||
|
*
|
||||||
|
* ORDER: takeable-gated p_win → grade tier → confidence → stable input order.
|
||||||
|
*
|
||||||
|
* p_win LEADS, grade follows. That inverts the incumbent, and deliberately: the
|
||||||
|
* grade letter measured r ~ 0.005 against outcomes and is INVERTED (B 52.4% <
|
||||||
|
* C 56.9%), while p_win measures +0.26. Leading with the letter would sort the
|
||||||
|
* board by the weaker signal and use the stronger one only to break ties.
|
||||||
|
*
|
||||||
|
* The takeable gate is mandatory and unchanged: raw p_win crowns -300 chalk,
|
||||||
|
* which is not the product.
|
||||||
|
*
|
||||||
|
* ON CALIBRATION: isotonic is a MONOTONE transform, so ranking on raw p_win and
|
||||||
|
* ranking on isotonic-calibrated p_win produce the SAME ORDER. Calibration
|
||||||
|
* matters when p_win is displayed or thresholded — it cannot change a ranking.
|
||||||
|
* Nothing here needs the calibrated value.
|
||||||
|
*/
|
||||||
|
function rankByForecast(grades, limit) {
|
||||||
|
const arr = (Array.isArray(grades) ? grades : []).filter((g) => g && g.grade);
|
||||||
|
const scored = arr.map((g, idx) => ({
|
||||||
|
g,
|
||||||
|
idx,
|
||||||
|
pWin: takeablePWin(g),
|
||||||
|
rank: gradeRankOf(g.grade),
|
||||||
|
conf: strictNum(g.confidence) == null ? -1 : strictNum(g.confidence),
|
||||||
|
}));
|
||||||
|
scored.sort((a, b) => descNullsLast(a.pWin, b.pWin)
|
||||||
|
|| a.rank - b.rank
|
||||||
|
|| b.conf - a.conf
|
||||||
|
|| a.idx - b.idx);
|
||||||
|
const out = scored.map((s) => s.g);
|
||||||
|
return limit == null ? out : out.slice(0, Math.max(0, limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable identity for a grade row, for comparing two orderings. */
|
||||||
|
function gradeKey(g) {
|
||||||
|
if (!g) return '';
|
||||||
|
const player = g.player_name || g.player || '';
|
||||||
|
const stat = g.stat_type || g.stat || '';
|
||||||
|
return `${String(player).toLowerCase()}|${String(stat).toLowerCase()}|${g.line}|${g.direction || ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* rankingDelta — the CHALLENGER-FIRST measurement. How far does the board move
|
||||||
|
* if the instrument changes from `rankGrades` (grade-then-edge) to
|
||||||
|
* `rankByForecast` (p_win-first, no edge)? Pure; changes nothing.
|
||||||
|
*/
|
||||||
|
function rankingDelta(grades, topN = 10) {
|
||||||
|
const incumbent = rankGrades(grades);
|
||||||
|
const challenger = rankByForecast(grades);
|
||||||
|
const posOf = (list) => {
|
||||||
|
const m = new Map();
|
||||||
|
list.forEach((g, i) => m.set(gradeKey(g), i));
|
||||||
|
return m;
|
||||||
|
};
|
||||||
|
const a = posOf(incumbent);
|
||||||
|
const b = posOf(challenger);
|
||||||
|
|
||||||
|
let moved = 0;
|
||||||
|
let sumAbs = 0;
|
||||||
|
let maxMove = 0;
|
||||||
|
const moves = [];
|
||||||
|
for (const [key, i] of a.entries()) {
|
||||||
|
const j = b.get(key);
|
||||||
|
if (j == null) continue;
|
||||||
|
const d = j - i;
|
||||||
|
if (d !== 0) moved += 1;
|
||||||
|
sumAbs += Math.abs(d);
|
||||||
|
if (Math.abs(d) > Math.abs(maxMove)) maxMove = d;
|
||||||
|
moves.push({ key, from: i + 1, to: j + 1, delta: d });
|
||||||
|
}
|
||||||
|
const n = a.size;
|
||||||
|
const topA = new Set(incumbent.slice(0, topN).map(gradeKey));
|
||||||
|
const topB = new Set(challenger.slice(0, topN).map(gradeKey));
|
||||||
|
let overlap = 0;
|
||||||
|
for (const k of topA) if (topB.has(k)) overlap += 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
n,
|
||||||
|
moved,
|
||||||
|
moved_pct: n ? Math.round((1000 * moved) / n) / 10 : null,
|
||||||
|
mean_abs_move: n ? Math.round((10 * sumAbs) / n) / 10 : null,
|
||||||
|
max_move: maxMove,
|
||||||
|
top_n: topN,
|
||||||
|
top_n_overlap: overlap,
|
||||||
|
top_n_overlap_pct: topN ? Math.round((1000 * overlap) / topN) / 10 : null,
|
||||||
|
// The headline for a board: does the #1 read change?
|
||||||
|
incumbent_top: incumbent[0] ? gradeKey(incumbent[0]) : null,
|
||||||
|
challenger_top: challenger[0] ? gradeKey(challenger[0]) : null,
|
||||||
|
top_changed: incumbent[0] && challenger[0] ? gradeKey(incumbent[0]) !== gradeKey(challenger[0]) : null,
|
||||||
|
biggest_movers: moves.sort((x, y) => Math.abs(y.delta) - Math.abs(x.delta)).slice(0, 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
GRADE_RANK, gradeRankOf, strictNum, takeablePWin, descNullsLast, rankGrades,
|
GRADE_RANK, gradeRankOf, strictNum, takeablePWin, descNullsLast, rankGrades,
|
||||||
|
rankByForecast, rankingDelta, gradeKey,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -150,15 +150,25 @@ describe('Intelligence Engine', () => {
|
|||||||
expect(prob).toBeCloseTo(0.5, 1);
|
expect(prob).toBeCloseTo(0.5, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('compareToBookImplied detects value', () => {
|
// SUPERSEDED 2026-08-01. This asserted `value_detected === true` from a
|
||||||
|
// positive edge. Edge does not predict outcomes (n=200 settled MLB rows:
|
||||||
|
// corr -0.010 incumbent ruler / -0.022 consensus, vs corr(p_win) = +0.26),
|
||||||
|
// so it must not decide anything. The stronger property: edge is still
|
||||||
|
// COMPUTED and returned as a diagnostic (losing the record would be worse
|
||||||
|
// than mis-using it), while the verdict is an honest null with a reason.
|
||||||
|
test('compareToBookImplied returns edge as a DIAGNOSTIC and refuses a verdict', () => {
|
||||||
const result = compareToBookImplied(0.60, -110);
|
const result = compareToBookImplied(0.60, -110);
|
||||||
expect(result.model_prob).toBe(0.6);
|
expect(result.model_prob).toBe(0.6);
|
||||||
expect(result.book_implied).toBeCloseTo(0.524, 2);
|
expect(result.book_implied).toBeCloseTo(0.524, 2);
|
||||||
expect(result.value_detected).toBe(true);
|
expect(result.edge).toBeGreaterThan(0); // still recorded
|
||||||
expect(result.edge).toBeGreaterThan(0);
|
expect(result.value_detected).toBeNull(); // never a boolean verdict
|
||||||
|
expect(result.value_basis).toBe('retired:edge_does_not_predict');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('scanAltLines returns optimal line with edge', () => {
|
// SUPERSEDED 2026-08-01: 'optimal' was a quality claim edge cannot support,
|
||||||
|
// and filtering to edge>0 hid rungs. The ladder is now returned whole,
|
||||||
|
// ranked, and labelled as an unvalidated price diagnostic.
|
||||||
|
test('scanAltLines returns the whole ladder ranked, labelled unvalidated', () => {
|
||||||
const prop = { projected_mean: 25, projected_stddev: 5, direction: 'over' };
|
const prop = { projected_mean: 25, projected_stddev: 5, direction: 'over' };
|
||||||
const odds = [
|
const odds = [
|
||||||
{ line: 22.5, odds: -130, book: 'draftkings' },
|
{ line: 22.5, odds: -130, book: 'draftkings' },
|
||||||
@@ -167,8 +177,13 @@ describe('Intelligence Engine', () => {
|
|||||||
];
|
];
|
||||||
const result = scanAltLines(prop, odds);
|
const result = scanAltLines(prop, odds);
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
expect(result.optimal_line).toBeDefined();
|
expect(result.top_by_price_gap).toBeDefined();
|
||||||
expect(result.edge).toBeGreaterThan(0);
|
expect(result.ranking_basis).toBe('price_gap_diagnostic_unvalidated');
|
||||||
|
// every rung survives — a negative gap is an observation, not a reason to hide
|
||||||
|
expect(result.ranked_lines).toHaveLength(odds.length);
|
||||||
|
expect(result.ranked_lines[0].edge).toBeGreaterThanOrEqual(result.ranked_lines[1].edge);
|
||||||
|
// no boolean verdict anywhere in the payload
|
||||||
|
expect(result.ranked_lines.every((r) => r.value_detected === undefined)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ranking instrument — challenger vs incumbent (2026-08-01).
|
||||||
|
*
|
||||||
|
* These lock the reason the challenger exists: measured on n=200 settled MLB
|
||||||
|
* rows, corr(p_win, outcome) = +0.26 while corr(edge, outcome) = -0.010 under
|
||||||
|
* the incumbent ruler and -0.022 under the consensus ruler. A quantity that
|
||||||
|
* does not predict must not rank, gate or decide.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const {
|
||||||
|
rankGrades, rankByForecast, rankingDelta, gradeKey, takeablePWin,
|
||||||
|
} = require('../../src/utils/gradeRanking');
|
||||||
|
|
||||||
|
const g = (player, grade, p_win, odds = -110, extra = {}) => ({
|
||||||
|
player, stat_type: 'hits', line: 1.5, direction: 'over',
|
||||||
|
grade, p_win, book_odds: odds, ...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rankByForecast — the challenger', () => {
|
||||||
|
it('contains NO edge term: edge cannot move the order at all', () => {
|
||||||
|
const a = [g('A', 'B', 0.62, -110, { edge: -99 }), g('B', 'B', 0.55, -110, { edge: +99 })];
|
||||||
|
const b = [g('A', 'B', 0.62, -110, { edge: +99 }), g('B', 'B', 0.55, -110, { edge: -99 })];
|
||||||
|
expect(rankByForecast(a).map((x) => x.player)).toEqual(['A', 'B']);
|
||||||
|
expect(rankByForecast(b).map((x) => x.player)).toEqual(['A', 'B']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leads with p_win, not the grade letter', () => {
|
||||||
|
// The letter measured r ~ 0.005 and is INVERTED; p_win measures +0.26.
|
||||||
|
// A high-p_win C must outrank a low-p_win A.
|
||||||
|
const out = rankByForecast([g('lowPwinA', 'A', 0.51), g('highPwinC', 'C', 0.74)]);
|
||||||
|
expect(out[0].player).toBe('highPwinC');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the takeable gate — raw p_win would crown chalk', () => {
|
||||||
|
const chalk = g('chalk', 'A', 0.93, -300); // untakeable price
|
||||||
|
const real = g('real', 'B', 0.61, -115);
|
||||||
|
expect(takeablePWin(chalk)).toBeNull();
|
||||||
|
expect(rankByForecast([chalk, real])[0].player).toBe('real');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts a missing p_win LAST, never first (Number(null) === 0 guard)', () => {
|
||||||
|
const out = rankByForecast([g('none', 'A', null), g('has', 'C', 0.58)]);
|
||||||
|
expect(out.map((x) => x.player)).toEqual(['has', 'none']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is stable for genuinely tied rows', () => {
|
||||||
|
const rows = [g('first', 'B', 0.6), g('second', 'B', 0.6)];
|
||||||
|
expect(rankByForecast(rows).map((x) => x.player)).toEqual(['first', 'second']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops ungraded rows, like the incumbent', () => {
|
||||||
|
expect(rankByForecast([g('x', null, 0.9), g('y', 'B', 0.5)]).map((r) => r.player)).toEqual(['y']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rankGrades — the incumbent is UNTOUCHED (live ordering byte-identical)', () => {
|
||||||
|
it('still leads with the grade letter and still consults edge', () => {
|
||||||
|
const out = rankGrades([g('lowPwinA', 'A', 0.51), g('highPwinC', 'C', 0.74)]);
|
||||||
|
expect(out[0].player).toBe('lowPwinA'); // grade-first, unchanged
|
||||||
|
});
|
||||||
|
|
||||||
|
it('edge still breaks a true tie in the incumbent', () => {
|
||||||
|
const out = rankGrades([
|
||||||
|
g('lowEdge', 'B', 0.6, -110, { edge: 1 }),
|
||||||
|
g('highEdge', 'B', 0.6, -110, { edge: 9 }),
|
||||||
|
]);
|
||||||
|
expect(out[0].player).toBe('highEdge');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rankingDelta — the challenger-first measurement', () => {
|
||||||
|
it('reports how far the board moves and whether the top read changes', () => {
|
||||||
|
const rows = [g('A', 'A', 0.52), g('B', 'C', 0.77), g('C', 'B', 0.64)];
|
||||||
|
const d = rankingDelta(rows, 3);
|
||||||
|
expect(d.n).toBe(3);
|
||||||
|
expect(d.incumbent_top).toBe(gradeKey(rows[0])); // A-grade leads incumbent
|
||||||
|
expect(d.challenger_top).toBe(gradeKey(rows[1])); // highest p_win leads challenger
|
||||||
|
expect(d.top_changed).toBe(true);
|
||||||
|
expect(d.moved).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports zero movement when both instruments agree', () => {
|
||||||
|
const rows = [g('A', 'A', 0.80), g('B', 'B', 0.60), g('C', 'C', 0.40)];
|
||||||
|
const d = rankingDelta(rows, 3);
|
||||||
|
expect(d.moved).toBe(0);
|
||||||
|
expect(d.top_changed).toBe(false);
|
||||||
|
expect(d.top_n_overlap_pct).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes nothing about the inputs (pure)', () => {
|
||||||
|
const rows = [g('A', 'A', 0.52), g('B', 'C', 0.77)];
|
||||||
|
const snapshot = JSON.stringify(rows);
|
||||||
|
rankingDelta(rows);
|
||||||
|
expect(JSON.stringify(rows)).toBe(snapshot);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user