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,39 @@
|
||||
const { getSupabaseServiceClient } = require('../utils/supabase');
|
||||
|
||||
async function getAlertsForUser(userId) {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
|
||||
const { data: alerts, error } = await supabase
|
||||
.from('cascade_alerts')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.eq('is_read', false)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const { count } = await supabase
|
||||
.from('cascade_alerts')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('user_id', userId)
|
||||
.eq('is_read', false);
|
||||
|
||||
return { alerts: alerts || [], unread_count: count || (alerts || []).length };
|
||||
}
|
||||
|
||||
async function markAlertRead(alertId, userId) {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('cascade_alerts')
|
||||
.update({ is_read: true })
|
||||
.eq('id', alertId)
|
||||
.eq('user_id', userId)
|
||||
.select('id, is_read')
|
||||
.single();
|
||||
|
||||
if (error || !data) return null;
|
||||
return data;
|
||||
}
|
||||
|
||||
module.exports = { getAlertsForUser, markAlertRead };
|
||||
@@ -0,0 +1,134 @@
|
||||
const { getSupabaseServiceClient } = require('../utils/supabase');
|
||||
const { getRedisClient } = require('../utils/redis');
|
||||
const { gradeParlayFromLegs } = require('./parlayGrader');
|
||||
|
||||
function getToday() {
|
||||
return new Date().toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
async function detectScratches(sport, currentProps) {
|
||||
const redis = getRedisClient();
|
||||
const today = getToday();
|
||||
const playerSetKey = `odds:players:${sport}:${today}`;
|
||||
|
||||
// Build current player set (players with props from 2+ books)
|
||||
const playerBooks = {};
|
||||
for (const prop of currentProps) {
|
||||
if (!playerBooks[prop.player]) playerBooks[prop.player] = new Set();
|
||||
playerBooks[prop.player].add(prop.book);
|
||||
}
|
||||
|
||||
const currentPlayers = new Set();
|
||||
for (const [player, books] of Object.entries(playerBooks)) {
|
||||
if (books.size >= 2) currentPlayers.add(player);
|
||||
}
|
||||
|
||||
// Get previous player set
|
||||
const prevPlayersRaw = await redis.get(playerSetKey);
|
||||
const prevPlayers = prevPlayersRaw ? new Set(JSON.parse(prevPlayersRaw)) : null;
|
||||
|
||||
// Store current set for next comparison
|
||||
await redis.set(playerSetKey, JSON.stringify([...currentPlayers]), 'EX', 86400);
|
||||
|
||||
if (!prevPlayers) {
|
||||
// First fetch — no comparison possible
|
||||
return { scratchedPlayers: [] };
|
||||
}
|
||||
|
||||
// Find players who disappeared (were in prev but not in current)
|
||||
const scratched = [];
|
||||
for (const player of prevPlayers) {
|
||||
if (!currentPlayers.has(player)) {
|
||||
scratched.push(player);
|
||||
}
|
||||
}
|
||||
|
||||
if (scratched.length > 0) {
|
||||
await processScratches(scratched);
|
||||
}
|
||||
|
||||
return { scratchedPlayers: scratched };
|
||||
}
|
||||
|
||||
async function processScratches(scratchedPlayers) {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const today = getToday();
|
||||
|
||||
for (const player of scratchedPlayers) {
|
||||
// Find scan sessions from today that include this player
|
||||
const { data: picks } = await supabase
|
||||
.from('picks')
|
||||
.select('id, user_id, player, stat_type, grade, confidence')
|
||||
.eq('player', player)
|
||||
.gte('created_at', `${today}T00:00:00Z`);
|
||||
|
||||
if (!picks || picks.length === 0) continue;
|
||||
|
||||
// Find unique sessions containing these picks
|
||||
const affectedUserIds = [...new Set(picks.map((p) => p.user_id))];
|
||||
|
||||
for (const userId of affectedUserIds) {
|
||||
// Find the user's scan sessions from today
|
||||
const { data: sessions } = await supabase
|
||||
.from('scan_sessions')
|
||||
.select('id, legs, final_grade')
|
||||
.eq('user_id', userId)
|
||||
.gte('created_at', `${today}T00:00:00Z`);
|
||||
|
||||
if (!sessions) continue;
|
||||
|
||||
for (const session of sessions) {
|
||||
// Check if any of the scratched player's picks are in this session
|
||||
const scratchedPickIds = picks
|
||||
.filter((p) => p.user_id === userId)
|
||||
.map((p) => p.id);
|
||||
|
||||
const sessionLegs = session.legs || [];
|
||||
const affectedLegIds = sessionLegs.filter((legId) =>
|
||||
scratchedPickIds.includes(legId)
|
||||
);
|
||||
|
||||
if (affectedLegIds.length === 0) continue;
|
||||
|
||||
// Re-grade without the scratched legs
|
||||
const remainingLegIds = sessionLegs.filter((legId) =>
|
||||
!affectedLegIds.includes(legId)
|
||||
);
|
||||
|
||||
let regradeDetail;
|
||||
if (remainingLegIds.length < 2) {
|
||||
regradeDetail = `${player} scratched. Your parlay has only ${remainingLegIds.length} leg(s) remaining — cannot be graded as a parlay.`;
|
||||
} else {
|
||||
// Fetch remaining picks for re-grading
|
||||
const { data: remainingPicks } = await supabase
|
||||
.from('picks')
|
||||
.select('grade, confidence')
|
||||
.in('id', remainingLegIds);
|
||||
|
||||
if (remainingPicks && remainingPicks.length >= 2) {
|
||||
const mockLegs = remainingPicks.map((p) => ({
|
||||
grade: p.grade,
|
||||
confidence: p.confidence,
|
||||
_composite: p.grade === 'A' ? 3.5 : p.grade === 'B' ? 2.0 : p.grade === 'C' ? 0.8 : 0.1,
|
||||
}));
|
||||
const { grade: newGrade } = gradeParlayFromLegs(mockLegs, []);
|
||||
regradeDetail = `${player} scratched. Your ${sessionLegs.length}-leg parlay regraded from ${session.final_grade} to ${newGrade} without this leg.`;
|
||||
} else {
|
||||
regradeDetail = `${player} scratched. Your parlay may be affected — check with your sportsbook.`;
|
||||
}
|
||||
}
|
||||
|
||||
// Create alert
|
||||
await supabase.from('cascade_alerts').insert({
|
||||
user_id: userId,
|
||||
alert_type: 'player_scratched',
|
||||
scan_session_id: session.id,
|
||||
player,
|
||||
detail: regradeDetail,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { detectScratches, processScratches };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -145,6 +145,21 @@ async function getOdds(sport) {
|
||||
const cacheData = { updated_at: now, props, spreads };
|
||||
await redis.set(cacheKey, JSON.stringify(cacheData), 'EX', CACHE_TTL);
|
||||
|
||||
// Line movement + cascade detection (best-effort, don't block response)
|
||||
let movements = [];
|
||||
let scratchedPlayers = [];
|
||||
try {
|
||||
const lineMovement = require('./lineMovementService');
|
||||
const cascade = require('./cascadeService');
|
||||
const moveResult = await lineMovement.processNewOdds(sport, props);
|
||||
movements = moveResult.movements || [];
|
||||
const cascadeResult = await cascade.detectScratches(sport, props);
|
||||
scratchedPlayers = cascadeResult.scratchedPlayers || [];
|
||||
} catch (e) {
|
||||
// Non-fatal — log and continue
|
||||
console.warn('[BetonBLK] Movement/cascade detection error:', e.message);
|
||||
}
|
||||
|
||||
return {
|
||||
sport,
|
||||
updated_at: now,
|
||||
@@ -152,6 +167,8 @@ async function getOdds(sport) {
|
||||
quota_remaining: quotaRemaining,
|
||||
props,
|
||||
spreads,
|
||||
movements,
|
||||
scratchedPlayers,
|
||||
};
|
||||
} catch (err) {
|
||||
// If API fails, try stale cache (no TTL check — any cached data)
|
||||
|
||||
Reference in New Issue
Block a user