/** * Normal CDF using rational approximation (Abramowitz & Stegun). */ function normalCDF(x, mean = 0, stddev = 1) { 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; // 1/sqrt(2*pi) 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; } /** * Calculate model probability for a prop line using normal distribution. * @param {number} mean - Projected mean * @param {number} stddev - Standard deviation * @param {number} line - The prop line * @param {string} direction - 'over' or 'under' * @returns {number} Probability 0-1 */ function calculateModelProbability(mean, stddev, line, direction) { if (stddev <= 0) { if (direction === 'over') return mean > line ? 1 : 0; return mean < line ? 1 : 0; } const cdf = normalCDF(line, mean, stddev); return direction === 'over' ? 1 - cdf : cdf; } /** * Convert American odds to implied probability. * @param {number} odds - American odds (e.g. -110, +150) * @returns {number} Implied probability 0-1 */ function americanToImplied(odds) { if (odds < 0) return Math.abs(odds) / (Math.abs(odds) + 100); return 100 / (odds + 100); } /** * Compare model probability to book implied probability. * @param {number} modelProb - Model-calculated probability * @param {number} bookOdds - American odds from the book * @returns {object} { model_prob, book_implied, edge, value_detected } */ function compareToBookImplied(modelProb, bookOdds) { const bookImplied = americanToImplied(bookOdds); const edge = modelProb - bookImplied; return { model_prob: Math.round(modelProb * 1000) / 1000, book_implied: Math.round(bookImplied * 1000) / 1000, edge: Math.round(edge * 1000) / 1000, value_detected: edge > 0, }; } /** * Scan alternate lines for A-grade props to find optimal value. * @param {object} prop - { player, stat, projected_mean, projected_stddev, grade } * @param {Array} oddsData - Array of { line, odds, book } from alt markets * @returns {object|null} Best alt line with edge, or null */ function scanAltLines(prop, oddsData) { if (!prop || !oddsData || oddsData.length === 0) return null; const { projected_mean, projected_stddev } = prop; const direction = prop.direction || 'over'; const evaluated = oddsData.map(alt => { const modelProb = calculateModelProbability(projected_mean, projected_stddev, alt.line, direction); const comparison = compareToBookImplied(modelProb, alt.odds); return { line: alt.line, odds: alt.odds, book: alt.book, model_probability: comparison.model_prob, book_implied: comparison.book_implied, edge: comparison.edge, value_detected: comparison.value_detected, }; }); const withValue = evaluated.filter(e => e.value_detected); if (withValue.length === 0) return null; withValue.sort((a, b) => b.edge - a.edge); const optimal = withValue[0]; return { optimal_line: optimal.line, odds: optimal.odds, book: optimal.book, model_probability: optimal.model_probability, book_implied: optimal.book_implied, edge: optimal.edge, all_value_lines: withValue, }; } module.exports = { scanAltLines, calculateModelProbability, compareToBookImplied, normalCDF, americanToImplied, };