/** * Closing Line Value (CLV) tracking. * * CLV measures how much edge we found vs the market close. Beating the * close consistently is the canonical signal of real edge, regardless * of any individual prop's outcome. This is how we prove (to ourselves * and to users) that VYNDR's grades are doing something real. * * Computation: * - For OVER: CLV = closing_line - graded_line * We graded a line at 25.5, close was 27.5 → we saw the over was * too cheap before the market did → +2.0 CLV. * - For UNDER: CLV = graded_line - closing_line * We graded under 25.5, close was 23.5 → +2.0 CLV. * * Closing lines come from oddspapi via the resolution poller, stored in * closing_lines (migration 016). The match key is * (game_id, player_espn_id OR player_name, stat_type) * so a graded prop without a captured close returns null — not zero. */ const { getSupabaseServiceClient } = require('../../utils/supabase'); function rawCLV(direction, gradedLine, closingLine) { // Guard against null/undefined first — Number(null) === 0 is finite, // which would silently produce a 0-based CLV instead of "unknown." if (gradedLine == null || closingLine == null) return null; const g = Number(gradedLine); const c = Number(closingLine); if (!Number.isFinite(g) || !Number.isFinite(c)) return null; return direction === 'over' ? c - g : g - c; } async function fetchGrade(gradeId) { const supabase = getSupabaseServiceClient(); const { data, error } = await supabase .from('grade_history') .select('id, game_id, sport, player_id, player_name, stat_type, line, direction, clv') .eq('id', gradeId) .maybeSingle(); if (error) { console.warn('[clv] grade lookup failed:', error.message); return null; } return data; } async function fetchClosingLine(grade) { const supabase = getSupabaseServiceClient(); let query = supabase .from('closing_lines') .select('id, pinnacle_line') .eq('game_id', grade.game_id) .eq('stat_type', grade.stat_type); // Prefer ID match (canonical), fall back to name match. query = grade.player_id ? query.eq('player_espn_id', grade.player_id) : query.eq('player_name', grade.player_name); const { data, error } = await query.maybeSingle(); if (error) { console.warn('[clv] closing line lookup failed:', error.message); return null; } return data; } async function persistCLV(gradeId, clv, closingLineId) { const supabase = getSupabaseServiceClient(); const { error } = await supabase .from('grade_history') .update({ clv, closing_line_id: closingLineId || null }) .eq('id', gradeId); if (error) console.warn('[clv] persist failed:', error.message); } async function computeCLV(gradeId) { const grade = await fetchGrade(gradeId); if (!grade) return null; const closing = await fetchClosingLine(grade); if (!closing) { return { gradeId, clv: null, graded_line: Number(grade.line), closing_line: null, direction: grade.direction, sport: grade.sport, reason: 'no_closing_line', }; } const clv = rawCLV(grade.direction, grade.line, closing.pinnacle_line); if (clv != null) await persistCLV(gradeId, clv, closing.id); return { gradeId, clv, graded_line: Number(grade.line), closing_line: Number(closing.pinnacle_line), direction: grade.direction, sport: grade.sport, }; } async function batchComputeCLV(gradeIds) { const out = []; for (const id of gradeIds) { try { out.push(await computeCLV(id)); } catch (err) { console.warn('[clv] batch entry failed:', id, err.message); out.push({ gradeId: id, clv: null, error: err.message }); } } return out; } async function getCLVSummary(sport, period = 'all_time') { const supabase = getSupabaseServiceClient(); let query = supabase .from('grade_history') .select('clv') .eq('sport', sport) .not('clv', 'is', null); if (period === 'last_30d') { const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); query = query.gte('graded_at', since); } else if (period === 'last_7d') { const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); query = query.gte('graded_at', since); } const { data, error } = await query; if (error) { console.warn('[clv] summary query failed:', error.message); return { avg_clv: null, median_clv: null, positive_rate: null, total: 0 }; } if (!data || data.length === 0) { return { avg_clv: null, median_clv: null, positive_rate: null, total: 0 }; } const values = data.map((r) => Number(r.clv)).filter((v) => Number.isFinite(v)); const avg = values.reduce((a, b) => a + b, 0) / values.length; const sorted = [...values].sort((a, b) => a - b); const mid = Math.floor(sorted.length / 2); const median = sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; const positive = values.filter((v) => v > 0).length; return { avg_clv: avg, median_clv: median, positive_rate: positive / values.length, total: values.length, }; } module.exports = { computeCLV, batchComputeCLV, getCLVSummary, rawCLV };