const DISTRIBUTION_SHAPES = { points: 'normal', rebounds: 'normal', assists: 'normal', home_runs: 'negative_binomial', stolen_bases: 'negative_binomial', pitcher_strikeouts: 'bimodal_mixture', walks: 'poisson', hits: 'normal', total_bases: 'normal', rbis: 'normal', runs_scored: 'poisson', strikeouts_batter: 'poisson', earned_runs: 'poisson', outs_recorded: 'normal', walks_allowed: 'poisson', hits_allowed: 'normal', pitches_thrown: 'normal', }; /** * Get the distribution shape for a stat type. * @param {string} statType * @returns {string} Distribution shape name */ function getDistributionShape(statType) { return DISTRIBUTION_SHAPES[statType] || 'normal'; } /** * Normal CDF using rational approximation. */ function normalCDF(x, mean, stddev) { if (stddev <= 0) return x >= mean ? 1 : 0; const z = (x - mean) / stddev; const t = 1 / (1 + 0.2316419 * Math.abs(z)); const d = 0.3989422804014327; const p = d * Math.exp(-z * z / 2) * (t * (0.3193815 + t * (-0.3565638 + t * (1.781478 + t * (-1.8212560 + t * 1.3302744))))); return z > 0 ? 1 - p : p; } /** * Poisson CDF: P(X <= x) for Poisson(lambda). * @param {number} x - Value (floored to integer) * @param {number} lambda - Rate parameter * @returns {number} Cumulative probability */ function poissonCDF(x, lambda) { if (lambda <= 0) return 1; const k = Math.floor(x); if (k < 0) return 0; let cdf = 0; let term = Math.exp(-lambda); cdf += term; for (let i = 1; i <= k; i++) { term *= lambda / i; cdf += term; } return Math.min(1, cdf); } /** * Negative Binomial CDF: P(X <= x) for NB(r, p). * Uses direct summation of PMF. * @param {number} x - Value (floored to integer) * @param {number} r - Number of successes * @param {number} p - Probability of success per trial * @returns {number} Cumulative probability */ function negativeBinomialCDF(x, r, p) { if (r <= 0 || p <= 0 || p > 1) return 0; const k = Math.floor(x); if (k < 0) return 0; let cdf = 0; // log of binomial coefficient using lgamma approximation function logGamma(z) { // Stirling approximation for lgamma if (z < 0.5) return Math.log(Math.PI / Math.sin(Math.PI * z)) - logGamma(1 - z); z -= 1; const coeffs = [ 76.18009172947146, -86.50532032941677, 24.01409824083091, -1.231739572450155, 0.001208650973866179, -0.000005395239384953, ]; let x = 0.99999999999980993; for (let i = 0; i < coeffs.length; i++) { x += coeffs[i] / (z + i + 1); } const t = z + coeffs.length - 0.5; return 0.5 * Math.log(2 * Math.PI) + (z + 0.5) * Math.log(t) - t + Math.log(x); } for (let i = 0; i <= k; i++) { const logCoeff = logGamma(i + r) - logGamma(i + 1) - logGamma(r); const logProb = logCoeff + r * Math.log(p) + i * Math.log(1 - p); cdf += Math.exp(logProb); } return Math.min(1, Math.max(0, cdf)); } /** * Calculate probability based on distribution shape. * @param {string} shape - Distribution type * @param {object} params - Distribution parameters * @param {number} line - Prop line * @param {string} direction - 'over' or 'under' * @returns {number} Probability 0-1 */ function calculateProbability(shape, params, line, direction) { let cdf; switch (shape) { case 'normal': cdf = normalCDF(line, params.mean, params.stddev); break; case 'poisson': cdf = poissonCDF(line, params.lambda); break; case 'negative_binomial': cdf = negativeBinomialCDF(line, params.r, params.p); break; case 'bimodal_mixture': // Weighted mixture of two normals const w1 = params.weight1 || 0.5; const cdf1 = normalCDF(line, params.mean1, params.stddev1); const cdf2 = normalCDF(line, params.mean2, params.stddev2); cdf = w1 * cdf1 + (1 - w1) * cdf2; break; default: cdf = normalCDF(line, params.mean, params.stddev); } return direction === 'over' ? 1 - cdf : cdf; } module.exports = { DISTRIBUTION_SHAPES, getDistributionShape, calculateProbability, normalCDF, poissonCDF, negativeBinomialCDF, };