152 lines
5.1 KiB
JavaScript
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 };
|