Files
vyndr/src/services/intelligence/probabilityEstimator.js
T

126 lines
4.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 },
};