Sessions 5-7a: 955 tests, deployment ready

This commit is contained in:
Kev
2026-06-08 18:35:13 -04:00
parent 06b82624a2
commit 1fa04dc776
371 changed files with 49366 additions and 955 deletions
@@ -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 },
};