Files
vyndr/src/services/lineDiscrepancyDetector.js
T

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