const SHARP_BOOKS = ['pinnacle', 'circa', 'bookmaker']; const SQUARE_BOOKS = ['draftkings', 'fanduel', 'betmgm', 'caesars', 'fanatics', 'bet365']; /** * Detect discrepancy between sharp and square book consensus lines. * @param {Array<{book: string, line: number}>} propLines * @returns {object} { discrepancy, gap, sharp_consensus, square_consensus } */ function detectDiscrepancy(propLines) { if (!propLines || propLines.length === 0) { return { discrepancy: false, gap: 0, sharp_consensus: null, square_consensus: null }; } const sharpLines = propLines.filter(p => SHARP_BOOKS.includes(p.book.toLowerCase())); const squareLines = propLines.filter(p => SQUARE_BOOKS.includes(p.book.toLowerCase())); if (sharpLines.length === 0 || squareLines.length === 0) { return { discrepancy: false, gap: 0, sharp_consensus: null, square_consensus: null }; } const sharpConsensus = sharpLines.reduce((s, p) => s + p.line, 0) / sharpLines.length; const squareConsensus = squareLines.reduce((s, p) => s + p.line, 0) / squareLines.length; const gap = Math.abs(sharpConsensus - squareConsensus); return { discrepancy: gap > 0.5, gap: Math.round(gap * 100) / 100, sharp_consensus: Math.round(sharpConsensus * 100) / 100, square_consensus: Math.round(squareConsensus * 100) / 100, sharp_books_used: sharpLines.length, square_books_used: squareLines.length, }; } /** * Detect steam move: 0.5+ movement at 3+ books within 10 minutes. * @param {Array<{book: string, line: number, timestamp: string}>} movements * @returns {object} { steam_move, books_moved, magnitude, window_minutes } */ function detectSteamMove(movements) { if (!movements || movements.length < 3) { return { steam_move: false, books_moved: 0, magnitude: 0, window_minutes: 0 }; } const sorted = [...movements].sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp)); const windowMs = 10 * 60 * 1000; // 10 minutes for (let i = 0; i < sorted.length; i++) { const windowStart = new Date(sorted[i].timestamp).getTime(); const windowEnd = windowStart + windowMs; const inWindow = sorted.filter(m => { const t = new Date(m.timestamp).getTime(); return t >= windowStart && t <= windowEnd; }); // Group by book, find those with 0.5+ movement const bookMovements = {}; for (const m of inWindow) { if (!bookMovements[m.book]) bookMovements[m.book] = []; bookMovements[m.book].push(m.line); } const significantMoves = Object.entries(bookMovements).filter(([_, lines]) => { if (lines.length < 2) return false; const range = Math.max(...lines) - Math.min(...lines); return range >= 0.5; }); // Also count books that appear with already-moved lines (single entry with magnitude info) const booksWithMovement = inWindow.filter(m => Math.abs(m.line) >= 0.5); const uniqueBooks = new Set(booksWithMovement.map(m => m.book)); if (uniqueBooks.size >= 3 || significantMoves.length >= 3) { const allMagnitudes = booksWithMovement.map(m => Math.abs(m.line)); return { steam_move: true, books_moved: Math.max(uniqueBooks.size, significantMoves.length), magnitude: Math.round((allMagnitudes.reduce((s, v) => s + v, 0) / allMagnitudes.length) * 100) / 100, window_minutes: 10, }; } } return { steam_move: false, books_moved: 0, magnitude: 0, window_minutes: 0 }; } /** * Get reliability score for a prop type in a sport from historical accuracy. * @param {string} propType * @param {string} sport * @returns {number} Reliability score 0-1 */ function getReliabilityScore(propType, sport) { const reliabilityMap = { nba: { points: 0.72, rebounds: 0.65, assists: 0.68, threes: 0.60, steals: 0.45, blocks: 0.42, pts_rebs_asts: 0.70, }, mlb: { hits: 0.55, home_runs: 0.40, rbis: 0.48, stolen_bases: 0.52, strikeouts_pitcher: 0.65, earned_runs: 0.58, total_bases: 0.53, }, }; const sportMap = reliabilityMap[sport.toLowerCase()]; if (!sportMap) return 0.50; // default return sportMap[propType.toLowerCase()] || 0.50; } module.exports = { SHARP_BOOKS, SQUARE_BOOKS, detectDiscrepancy, detectSteamMove, getReliabilityScore, };