126 lines
4.1 KiB
JavaScript
126 lines
4.1 KiB
JavaScript
/**
|
||
* 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 },
|
||
};
|