Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 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 };
|
||||
Reference in New Issue
Block a user