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:
@@ -41,9 +41,19 @@ function americanToImplied(odds) {
|
||||
|
||||
/**
|
||||
* Compare model probability to book implied probability.
|
||||
* @param {number} modelProb - Model-calculated probability
|
||||
* @param {number} bookOdds - American odds from the book
|
||||
* @returns {object} { model_prob, book_implied, edge, value_detected }
|
||||
*
|
||||
* 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);
|
||||
@@ -52,16 +62,32 @@ function compareToBookImplied(modelProb, bookOdds) {
|
||||
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: edge > 0,
|
||||
value_detected: null,
|
||||
value_basis: 'retired:edge_does_not_predict',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan alternate lines for A-grade props to find optimal value.
|
||||
* @param {object} prop - { player, stat, projected_mean, projected_stddev, grade }
|
||||
* @param {Array} oddsData - Array of { line, odds, book } from alt markets
|
||||
* @returns {object|null} Best alt line with edge, or null
|
||||
* 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;
|
||||
@@ -80,24 +106,25 @@ function scanAltLines(prop, oddsData) {
|
||||
model_probability: comparison.model_prob,
|
||||
book_implied: comparison.book_implied,
|
||||
edge: comparison.edge,
|
||||
value_detected: comparison.value_detected,
|
||||
};
|
||||
});
|
||||
|
||||
const withValue = evaluated.filter(e => e.value_detected);
|
||||
if (withValue.length === 0) return null;
|
||||
if (evaluated.length === 0) return null;
|
||||
|
||||
withValue.sort((a, b) => b.edge - a.edge);
|
||||
const optimal = withValue[0];
|
||||
// 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 {
|
||||
optimal_line: optimal.line,
|
||||
odds: optimal.odds,
|
||||
book: optimal.book,
|
||||
model_probability: optimal.model_probability,
|
||||
book_implied: optimal.book_implied,
|
||||
edge: optimal.edge,
|
||||
all_value_lines: withValue,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user