Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* P(Over) — estimated probability that the player goes over the line.
|
||||
*
|
||||
* This is the *quantile-based* probability we surface to users ("73%
|
||||
* chance over") and feed into Engine 2's prompt. It is NOT the implied
|
||||
* probability from the book — that's odds-derived and includes vig. This
|
||||
* one is from the player's actual distribution.
|
||||
*
|
||||
* Formula layers:
|
||||
* 1. Base — empirical frequency of stat > line across the sample
|
||||
* 2. Recency — last 5 games weighted 2× to capture trend
|
||||
* 3. Opponent — bump for weak D, fade for top D (uses 0..1 opp_rank_stat)
|
||||
* 4. Home / away — +1.5% / -1.5%
|
||||
* 5. Consistency — volatile players get pulled toward 0.50
|
||||
*
|
||||
* Clamp at [0.10, 0.95] — we never claim certainty in either direction.
|
||||
*/
|
||||
|
||||
const CV_VOLATILE_THRESHOLD = 0.40;
|
||||
const PROB_FLOOR = 0.10;
|
||||
const PROB_CEIL = 0.95;
|
||||
|
||||
function statFromRow(row, statType) {
|
||||
if (!row) return null;
|
||||
switch (statType) {
|
||||
case 'pts_reb_ast':
|
||||
return (Number(row.points) || 0) + (Number(row.rebounds) || 0) + (Number(row.assists) || 0);
|
||||
case 'pts_reb':
|
||||
return (Number(row.points) || 0) + (Number(row.rebounds) || 0);
|
||||
case 'pts_ast':
|
||||
return (Number(row.points) || 0) + (Number(row.assists) || 0);
|
||||
case 'reb_ast':
|
||||
return (Number(row.rebounds) || 0) + (Number(row.assists) || 0);
|
||||
case 'stl_blk':
|
||||
return (Number(row.steals) || 0) + (Number(row.blocks) || 0);
|
||||
default: {
|
||||
const v = Number(row[statType]);
|
||||
return Number.isFinite(v) ? v : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function frequencyOver(values, line) {
|
||||
const decisive = values.filter((v) => v !== line); // push games don't count
|
||||
if (decisive.length === 0) return null;
|
||||
const over = decisive.filter((v) => v > line).length;
|
||||
return over / decisive.length;
|
||||
}
|
||||
|
||||
function clamp(p) {
|
||||
return Math.max(PROB_FLOOR, Math.min(PROB_CEIL, p));
|
||||
}
|
||||
|
||||
function estimateProbability({ gameLogs = [], line, statType, features = {} } = {}) {
|
||||
if (!Array.isArray(gameLogs) || gameLogs.length === 0 || !Number.isFinite(Number(line))) {
|
||||
return { p_over: null, p_under: null, components: {}, reason: 'insufficient_data' };
|
||||
}
|
||||
const numericLine = Number(line);
|
||||
const values = gameLogs.map((r) => statFromRow(r, statType)).filter((v) => v != null);
|
||||
if (values.length === 0) {
|
||||
return { p_over: null, p_under: null, components: {}, reason: 'no_stat_values' };
|
||||
}
|
||||
|
||||
const base = frequencyOver(values, numericLine);
|
||||
if (base == null) return { p_over: null, p_under: null, components: {}, reason: 'all_pushes' };
|
||||
|
||||
// Recency: last 5 games count 2× in a weighted blend.
|
||||
const recent = values.slice(0, Math.min(5, values.length));
|
||||
const recencyRate = frequencyOver(recent, numericLine);
|
||||
const weighted = recencyRate != null
|
||||
? 0.6 * base + 0.4 * recencyRate
|
||||
: base;
|
||||
|
||||
let p = weighted;
|
||||
|
||||
// Opponent adjustment using 0..1 normalized rank.
|
||||
// opp_rank_stat ≥ 0.70 → weak defense, bump toward over
|
||||
// opp_rank_stat ≤ 0.30 → strong defense, fade
|
||||
const oppAdj = (() => {
|
||||
const r = Number(features.opp_rank_stat);
|
||||
if (!Number.isFinite(r)) return 0;
|
||||
if (r >= 0.70) return +0.03;
|
||||
if (r <= 0.30) return -0.03;
|
||||
return 0;
|
||||
})();
|
||||
p += oppAdj;
|
||||
|
||||
const homeAdj = features.home_away === 1.0 ? +0.015 : features.home_away === 0.0 ? -0.015 : 0;
|
||||
p += homeAdj;
|
||||
|
||||
// Consistency pull: volatile players are uncertain — drag p toward 0.50.
|
||||
const cv = Number(features.l10_stddev) > 0 && Number(features.l20_avg) > 0
|
||||
? Number(features.l10_stddev) / Number(features.l20_avg)
|
||||
: null;
|
||||
const consistencyAdj = (() => {
|
||||
if (!Number.isFinite(cv)) return null;
|
||||
if (cv > CV_VOLATILE_THRESHOLD) {
|
||||
// p' = p * 0.9 + 0.5 * 0.1
|
||||
const before = p;
|
||||
p = p * 0.9 + 0.05;
|
||||
return p - before;
|
||||
}
|
||||
return 0;
|
||||
})();
|
||||
|
||||
const pOver = clamp(p);
|
||||
return {
|
||||
p_over: pOver,
|
||||
p_under: 1 - pOver,
|
||||
components: {
|
||||
base,
|
||||
recency: recencyRate,
|
||||
weighted,
|
||||
opp_adjustment: oppAdj,
|
||||
home_adjustment: homeAdj,
|
||||
consistency_adjustment: consistencyAdj,
|
||||
cv,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
estimateProbability,
|
||||
__internals: { statFromRow, frequencyOver, clamp, CV_VOLATILE_THRESHOLD, PROB_FLOOR, PROB_CEIL },
|
||||
};
|
||||
Reference in New Issue
Block a user