86d123945c
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
138 lines
4.9 KiB
JavaScript
138 lines
4.9 KiB
JavaScript
/**
|
|
* Normal CDF using rational approximation (Abramowitz & Stegun).
|
|
*/
|
|
function normalCDF(x, mean = 0, stddev = 1) {
|
|
if (stddev <= 0) return x >= mean ? 1 : 0;
|
|
const z = (x - mean) / stddev;
|
|
const t = 1 / (1 + 0.2316419 * Math.abs(z));
|
|
const d = 0.3989422804014327; // 1/sqrt(2*pi)
|
|
const p = d * Math.exp(-z * z / 2) *
|
|
(t * (0.3193815 + t * (-0.3565638 + t * (1.781478 + t * (-1.8212560 + t * 1.3302744)))));
|
|
return z > 0 ? 1 - p : p;
|
|
}
|
|
|
|
/**
|
|
* Calculate model probability for a prop line using normal distribution.
|
|
* @param {number} mean - Projected mean
|
|
* @param {number} stddev - Standard deviation
|
|
* @param {number} line - The prop line
|
|
* @param {string} direction - 'over' or 'under'
|
|
* @returns {number} Probability 0-1
|
|
*/
|
|
function calculateModelProbability(mean, stddev, line, direction) {
|
|
if (stddev <= 0) {
|
|
if (direction === 'over') return mean > line ? 1 : 0;
|
|
return mean < line ? 1 : 0;
|
|
}
|
|
|
|
const cdf = normalCDF(line, mean, stddev);
|
|
return direction === 'over' ? 1 - cdf : cdf;
|
|
}
|
|
|
|
/**
|
|
* Convert American odds to implied probability.
|
|
* @param {number} odds - American odds (e.g. -110, +150)
|
|
* @returns {number} Implied probability 0-1
|
|
*/
|
|
function americanToImplied(odds) {
|
|
if (odds < 0) return Math.abs(odds) / (Math.abs(odds) + 100);
|
|
return 100 / (odds + 100);
|
|
}
|
|
|
|
/**
|
|
* Compare model probability to book implied probability.
|
|
*
|
|
* EDGE IS RETIRED AS A DECISION (2026-08-01). `value_detected: edge > 0` used to
|
|
* 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) {
|
|
const bookImplied = americanToImplied(bookOdds);
|
|
const edge = modelProb - bookImplied;
|
|
|
|
return {
|
|
model_prob: Math.round(modelProb * 1000) / 1000,
|
|
book_implied: Math.round(bookImplied * 1000) / 1000,
|
|
// DIAGNOSTIC ONLY — never a ranking, gate or quality signal.
|
|
edge: Math.round(edge * 1000) / 1000,
|
|
value_detected: null,
|
|
value_basis: 'retired:edge_does_not_predict',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Rank the rungs of an alt-line ladder by the model-vs-price gap.
|
|
*
|
|
* ⚠️ THIS MODULE HAS NO CALLERS (verified 2026-08-01) — it is unwired, like
|
|
* 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) {
|
|
if (!prop || !oddsData || oddsData.length === 0) return null;
|
|
|
|
const { projected_mean, projected_stddev } = prop;
|
|
const direction = prop.direction || 'over';
|
|
|
|
const evaluated = oddsData.map(alt => {
|
|
const modelProb = calculateModelProbability(projected_mean, projected_stddev, alt.line, direction);
|
|
const comparison = compareToBookImplied(modelProb, alt.odds);
|
|
|
|
return {
|
|
line: alt.line,
|
|
odds: alt.odds,
|
|
book: alt.book,
|
|
model_probability: comparison.model_prob,
|
|
book_implied: comparison.book_implied,
|
|
edge: comparison.edge,
|
|
};
|
|
});
|
|
|
|
if (evaluated.length === 0) return null;
|
|
|
|
// No value FILTER: a negative gap is a real observation about a rung, not a
|
|
// 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 {
|
|
ranking_basis: 'price_gap_diagnostic_unvalidated',
|
|
top_by_price_gap: top.line,
|
|
odds: top.odds,
|
|
book: top.book,
|
|
model_probability: top.model_probability,
|
|
book_implied: top.book_implied,
|
|
edge: top.edge,
|
|
ranked_lines: ranked,
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
scanAltLines,
|
|
calculateModelProbability,
|
|
compareToBookImplied,
|
|
normalCDF,
|
|
americanToImplied,
|
|
};
|