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