Files
vyndr/src/services/intelligence/clvTracker.js
T

152 lines
5.1 KiB
JavaScript

/**
* 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 };