feat: Feature 2.2 — Line Movement + Cascade Detection
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>
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
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,
|
||||
};
|
||||
Reference in New Issue
Block a user