Files
vyndr/src/services/altLineScanner.js
T
builtbykev 86d123945c 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
2026-08-01 01:24:55 -04:00

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,
};