2366660f5e
Line movement system: - Baseline capture on first odds fetch of the day - Movement detection >= 0.5 points with direction (up/down) - Sharp money heuristic (sharp_action/public_action/unknown) - GET /api/movements with player, stat_type, min_movement filters - Movements included in GET /api/odds/nba live responses Cascade detection system: - Scratch detection: player props disappear from 2+ books - Affected user lookup via scan_sessions + picks - Parlay re-grade without scratched legs - cascade_alerts created for affected users - GET /api/alerts (Analyst/Desk only), PATCH /api/alerts/:id/read Zero extra Odds API credits — all detection piggybacks on existing fetches. Migration 002: line_baselines, line_movements, cascade_alerts tables. 30 new tests, 188 total (161 Node.js + 27 Python), all passing. Phase 2 Core Product COMPLETE. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
161 lines
4.5 KiB
JavaScript
161 lines
4.5 KiB
JavaScript
const { getSupabaseServiceClient } = require('../utils/supabase');
|
|
const { getRedisClient } = require('../utils/redis');
|
|
|
|
function getToday() {
|
|
return new Date().toISOString().split('T')[0];
|
|
}
|
|
|
|
async function processNewOdds(sport, props) {
|
|
const supabase = getSupabaseServiceClient();
|
|
const redis = getRedisClient();
|
|
const today = getToday();
|
|
const baselineKey = `odds:baseline_set:${sport}:${today}`;
|
|
|
|
// Check if baseline exists for today
|
|
const hasBaseline = await redis.get(baselineKey);
|
|
|
|
if (!hasBaseline) {
|
|
// First fetch of the day — capture baseline
|
|
await captureBaseline(supabase, sport, today, props);
|
|
await redis.set(baselineKey, '1', 'EX', 86400);
|
|
return { movements: [], isBaselineCapture: true };
|
|
}
|
|
|
|
// Compare current lines to baseline
|
|
const movements = await detectMovements(supabase, sport, today, props);
|
|
return { movements, isBaselineCapture: false };
|
|
}
|
|
|
|
async function captureBaseline(supabase, sport, today, props) {
|
|
const rows = props.map((p) => ({
|
|
sport,
|
|
game_date: today,
|
|
player: p.player,
|
|
stat_type: p.stat_type,
|
|
book: p.book,
|
|
baseline_line: p.line,
|
|
}));
|
|
|
|
if (rows.length === 0) return;
|
|
|
|
// Upsert to handle re-captures (unique index prevents duplicates)
|
|
await supabase.from('line_baselines').upsert(rows, {
|
|
onConflict: 'game_date,player,stat_type,book',
|
|
});
|
|
|
|
// Cleanup old baselines
|
|
await supabase.from('line_baselines').delete().lt('game_date', getOldDate());
|
|
await supabase.from('line_movements').delete().lt('game_date', getOldDate());
|
|
}
|
|
|
|
async function detectMovements(supabase, sport, today, props) {
|
|
// Fetch today's baselines
|
|
const { data: baselines } = await supabase
|
|
.from('line_baselines')
|
|
.select('*')
|
|
.eq('game_date', today);
|
|
|
|
if (!baselines || baselines.length === 0) return [];
|
|
|
|
// Build lookup
|
|
const baselineMap = {};
|
|
for (const b of baselines) {
|
|
baselineMap[`${b.player}::${b.stat_type}::${b.book}`] = b;
|
|
}
|
|
|
|
const movements = [];
|
|
for (const prop of props) {
|
|
const key = `${prop.player}::${prop.stat_type}::${prop.book}`;
|
|
const baseline = baselineMap[key];
|
|
if (!baseline) continue;
|
|
|
|
const diff = prop.line - baseline.baseline_line;
|
|
if (Math.abs(diff) < 0.5) continue;
|
|
|
|
const direction = diff > 0 ? 'up' : 'down';
|
|
const sharpIndicator = determineSharpIndicator(
|
|
baseline.baseline_line, prop.line, prop.over_odds, prop.under_odds, direction
|
|
);
|
|
|
|
const movement = {
|
|
sport,
|
|
game_date: today,
|
|
player: prop.player,
|
|
stat_type: prop.stat_type,
|
|
book: prop.book,
|
|
baseline_line: baseline.baseline_line,
|
|
current_line: prop.line,
|
|
movement: Math.round(Math.abs(diff) * 10) / 10,
|
|
direction,
|
|
sharp_indicator: sharpIndicator,
|
|
};
|
|
|
|
movements.push(movement);
|
|
}
|
|
|
|
// Store movements in DB
|
|
if (movements.length > 0) {
|
|
await supabase.from('line_movements').insert(movements);
|
|
}
|
|
|
|
return movements;
|
|
}
|
|
|
|
function determineSharpIndicator(baselineLine, currentLine, overOdds, underOdds, direction) {
|
|
// Heuristic: if line moves up (higher) and over odds get worse (more negative),
|
|
// sharps are on the under. If line moves down and under odds get worse, sharps on over.
|
|
if (overOdds == null || underOdds == null) return 'unknown';
|
|
|
|
if (direction === 'up') {
|
|
// Line went up — if over is expensive (< -115), sharps may be on under
|
|
if (overOdds < -115) return 'sharp_action';
|
|
if (underOdds < -115) return 'public_action';
|
|
} else {
|
|
// Line went down — if under is expensive, sharps may be on over
|
|
if (underOdds < -115) return 'sharp_action';
|
|
if (overOdds < -115) return 'public_action';
|
|
}
|
|
|
|
return 'unknown';
|
|
}
|
|
|
|
function getOldDate() {
|
|
const d = new Date();
|
|
d.setUTCDate(d.getUTCDate() - 2);
|
|
return d.toISOString().split('T')[0];
|
|
}
|
|
|
|
async function getMovementsForToday(sport, filters = {}) {
|
|
const supabase = getSupabaseServiceClient();
|
|
const today = getToday();
|
|
|
|
let query = supabase
|
|
.from('line_movements')
|
|
.select('*')
|
|
.eq('game_date', today);
|
|
|
|
if (filters.player) {
|
|
query = query.ilike('player', `%${filters.player}%`);
|
|
}
|
|
if (filters.stat_type) {
|
|
query = query.eq('stat_type', filters.stat_type);
|
|
}
|
|
|
|
const { data: movements } = await query;
|
|
|
|
let filtered = movements || [];
|
|
if (filters.min_movement) {
|
|
filtered = filtered.filter((m) => m.movement >= filters.min_movement);
|
|
}
|
|
|
|
return filtered;
|
|
}
|
|
|
|
module.exports = {
|
|
processNewOdds,
|
|
captureBaseline,
|
|
detectMovements,
|
|
determineSharpIndicator,
|
|
getMovementsForToday,
|
|
};
|