128 lines
4.2 KiB
JavaScript
128 lines
4.2 KiB
JavaScript
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,
|
|
};
|