Files
vyndr/src/services/lineMovementService.js
T
builtbykev 2366660f5e 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>
2026-03-21 14:21:34 -04:00

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