Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Per-grade-tier accuracy tracking.
|
||||
*
|
||||
* Every resolution increments counters for (sport, grade, period).
|
||||
* The "all_time" period is the canonical record; "last_30d" and
|
||||
* "last_7d" are derived views recomputed by a periodic refresh job
|
||||
* (n8n). We update all three counters on every resolve so callers can
|
||||
* read instant values without rolling a window themselves.
|
||||
*
|
||||
* BASELINE LOCK:
|
||||
* After a (sport, grade, 'all_time') accumulates 100 decisive
|
||||
* resolutions (hits + misses, not push/void), the hit rate at that
|
||||
* moment is locked as `baseline_hit_rate` and `baseline_locked`
|
||||
* flips to true. Future accuracy compares to the baseline to detect
|
||||
* drift.
|
||||
*
|
||||
* EXPECTED HIT RATES (from spec):
|
||||
* A+ ≥ 65%, A ≥ 60%, A- ≥ 58%, B+ ≥ 55%, B ≥ 53%, B- ≥ 51%
|
||||
* C+ ≈ 50%, C ≈ 48%, C- ≈ 45%, D ≈ 40%, F ≈ 35%
|
||||
*/
|
||||
|
||||
const { getSupabaseServiceClient } = require('../../utils/supabase');
|
||||
|
||||
const BASELINE_LOCK_AT = 100;
|
||||
const PERIODS = ['all_time', 'last_30d', 'last_7d'];
|
||||
|
||||
const EXPECTED_HIT_RATES = Object.freeze({
|
||||
'A+': 0.65, 'A': 0.60, 'A-': 0.58,
|
||||
'B+': 0.55, 'B': 0.53, 'B-': 0.51,
|
||||
'C+': 0.50, 'C': 0.48, 'C-': 0.45,
|
||||
'D': 0.40, 'F': 0.35,
|
||||
});
|
||||
|
||||
function computeHitRate(hit, miss) {
|
||||
const denom = hit + miss;
|
||||
return denom > 0 ? hit / denom : null;
|
||||
}
|
||||
|
||||
async function fetchRow(supabase, sport, grade, period) {
|
||||
const { data, error } = await supabase
|
||||
.from('accuracy_tracking')
|
||||
.select('*')
|
||||
.eq('sport', sport)
|
||||
.eq('grade', grade)
|
||||
.eq('period', period)
|
||||
.maybeSingle();
|
||||
if (error) {
|
||||
console.warn('[accuracy] fetch failed:', error.message);
|
||||
return null;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function upsertRow(supabase, row) {
|
||||
const { error } = await supabase
|
||||
.from('accuracy_tracking')
|
||||
.upsert(row, { onConflict: 'sport,grade,period' });
|
||||
if (error) console.warn('[accuracy] upsert failed:', error.message);
|
||||
}
|
||||
|
||||
async function recordResolution(sport, grade, result) {
|
||||
if (!sport || !grade || !result) return;
|
||||
const supabase = getSupabaseServiceClient();
|
||||
for (const period of PERIODS) {
|
||||
const existing = await fetchRow(supabase, sport, grade, period) || {
|
||||
sport, grade, period,
|
||||
total_graded: 0, total_hit: 0, total_miss: 0, total_push: 0, total_void: 0,
|
||||
hit_rate: null, baseline_hit_rate: null, baseline_locked: false,
|
||||
};
|
||||
existing.total_graded += 1;
|
||||
if (result === 'hit') existing.total_hit += 1;
|
||||
else if (result === 'miss') existing.total_miss += 1;
|
||||
else if (result === 'push') existing.total_push += 1;
|
||||
else if (result === 'void') existing.total_void += 1;
|
||||
existing.hit_rate = computeHitRate(existing.total_hit, existing.total_miss);
|
||||
|
||||
if (
|
||||
period === 'all_time'
|
||||
&& !existing.baseline_locked
|
||||
&& (existing.total_hit + existing.total_miss) >= BASELINE_LOCK_AT
|
||||
) {
|
||||
existing.baseline_hit_rate = existing.hit_rate;
|
||||
existing.baseline_locked = true;
|
||||
}
|
||||
existing.last_updated = new Date().toISOString();
|
||||
await upsertRow(supabase, existing);
|
||||
}
|
||||
}
|
||||
|
||||
async function getAccuracy(sport, grade, period = 'all_time') {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const row = await fetchRow(supabase, sport, grade, period);
|
||||
if (!row) {
|
||||
return {
|
||||
sport, grade, period,
|
||||
hit_rate: null,
|
||||
baseline: null,
|
||||
expected: EXPECTED_HIT_RATES[grade] ?? null,
|
||||
total: 0,
|
||||
delta: null,
|
||||
locked: false,
|
||||
};
|
||||
}
|
||||
const total = row.total_hit + row.total_miss;
|
||||
const delta = row.baseline_hit_rate != null && row.hit_rate != null
|
||||
? row.hit_rate - row.baseline_hit_rate
|
||||
: null;
|
||||
return {
|
||||
sport, grade, period,
|
||||
hit_rate: row.hit_rate,
|
||||
baseline: row.baseline_hit_rate,
|
||||
expected: EXPECTED_HIT_RATES[grade] ?? null,
|
||||
total,
|
||||
delta,
|
||||
locked: !!row.baseline_locked,
|
||||
};
|
||||
}
|
||||
|
||||
async function getAllAccuracy(sport, period = 'all_time') {
|
||||
const grades = Object.keys(EXPECTED_HIT_RATES);
|
||||
const out = [];
|
||||
for (const grade of grades) out.push(await getAccuracy(sport, grade, period));
|
||||
return out;
|
||||
}
|
||||
|
||||
async function isBaselineLocked(sport, grade) {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const row = await fetchRow(supabase, sport, grade, 'all_time');
|
||||
return !!row?.baseline_locked;
|
||||
}
|
||||
|
||||
async function getAccuracyDashboard() {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const { data, error } = await supabase
|
||||
.from('accuracy_tracking')
|
||||
.select('*')
|
||||
.eq('period', 'all_time');
|
||||
if (error) {
|
||||
console.warn('[accuracy] dashboard query failed:', error.message);
|
||||
return [];
|
||||
}
|
||||
return data || [];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
recordResolution,
|
||||
getAccuracy,
|
||||
getAllAccuracy,
|
||||
isBaselineLocked,
|
||||
getAccuracyDashboard,
|
||||
EXPECTED_HIT_RATES,
|
||||
BASELINE_LOCK_AT,
|
||||
__internals: { computeHitRate, PERIODS },
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Coach system + pace signal.
|
||||
*
|
||||
* Two signals exposed:
|
||||
* coach_pace_delta: coach's career pace MINUS current team's pace,
|
||||
* scaled by tenure (longer tenure = stronger
|
||||
* adjustment).
|
||||
* coach_player_interaction: magnitude of system shift when the primary
|
||||
* player is OUT vs IN. Drives suppression for
|
||||
* role players when the star sits.
|
||||
*
|
||||
* Profiles live in `coach_profiles` (migration 017). On first read for a
|
||||
* team we check the table; if empty, fall back to the seed file at
|
||||
* src/config/coaches.json so launch isn't blocked on a fully populated
|
||||
* table.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { getSupabaseServiceClient } = require('../../utils/supabase');
|
||||
|
||||
let seedCache = null;
|
||||
function loadSeed() {
|
||||
if (seedCache !== null) return seedCache;
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'config', 'coaches.json'), 'utf8'));
|
||||
seedCache = { coaches: raw.coaches || [] };
|
||||
} catch {
|
||||
seedCache = { coaches: [] };
|
||||
}
|
||||
return seedCache;
|
||||
}
|
||||
|
||||
function tenureAdjustment(games) {
|
||||
// Linear ramp to 1.0 over ~40 games — a coach inheriting a roster needs
|
||||
// time before the system actually drifts toward their preference.
|
||||
const g = Number(games) || 0;
|
||||
return Math.min(1.0, Math.max(0, g / 40));
|
||||
}
|
||||
|
||||
async function getCoachProfile(sport, teamAbbr) {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const { data, error } = await supabase
|
||||
.from('coach_profiles')
|
||||
.select('coach_name, team, sport, career_avg_pace, current_team_pace, tenure_games, primary_player, system_style, without_primary_style, without_primary_pace_delta')
|
||||
.eq('team', teamAbbr)
|
||||
.eq('sport', sport)
|
||||
.maybeSingle();
|
||||
if (error) {
|
||||
console.warn('[coachSignals] profile lookup failed:', error.message);
|
||||
}
|
||||
if (data) return data;
|
||||
// Fall back to the seed file — same shape, different home.
|
||||
const seed = loadSeed();
|
||||
return seed.coaches.find((c) => c.team === teamAbbr && c.sport === sport) || null;
|
||||
}
|
||||
|
||||
async function getCoachImpact(sport, teamAbbr, gameContext = {}) {
|
||||
const profile = await getCoachProfile(sport, teamAbbr);
|
||||
if (!profile) return null;
|
||||
|
||||
const career = Number(profile.career_avg_pace);
|
||||
const team = Number(profile.current_team_pace);
|
||||
const paceDelta = Number.isFinite(career) && Number.isFinite(team) ? career - team : null;
|
||||
const tenureAdj = tenureAdjustment(profile.tenure_games);
|
||||
const adjustedPaceDelta = paceDelta != null ? paceDelta * tenureAdj : null;
|
||||
|
||||
// Primary-player status comes from the caller — usually injuryParser told
|
||||
// them whether the star is OUT/DOUBTFUL.
|
||||
const primaryStatus = gameContext.primary_player_status ?? 'unknown';
|
||||
const systemOverride = primaryStatus === 'out' || primaryStatus === 'doubtful'
|
||||
? profile.without_primary_style
|
||||
: null;
|
||||
const withoutPrimaryShift = primaryStatus === 'out'
|
||||
? Number(profile.without_primary_pace_delta) || 0
|
||||
: 0;
|
||||
|
||||
return {
|
||||
coach_name: profile.coach_name,
|
||||
system_style: profile.system_style ?? null,
|
||||
primary_player: profile.primary_player ?? null,
|
||||
pace_delta: paceDelta,
|
||||
tenure_adjustment: tenureAdj,
|
||||
adjusted_pace_delta: adjustedPaceDelta,
|
||||
primary_player_status: primaryStatus,
|
||||
system_override: systemOverride,
|
||||
without_primary_pace_shift: withoutPrimaryShift,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { getCoachImpact, getCoachProfile, tenureAdjustment, loadSeed };
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Consistency score — how predictable is this player for this stat?
|
||||
*
|
||||
* cv = stddev / mean
|
||||
*
|
||||
* Coefficient of variation collapses sample-size differences and lets us
|
||||
* compare a 25-point scorer with low variance to a 12-point scorer with
|
||||
* the same absolute variance. Lower cv = more reliable.
|
||||
*
|
||||
* The consistency score modifies Engine 2's confidence. An "elite"
|
||||
* consistency player gets a tighter projection range; a "boom_bust"
|
||||
* player gets a wider one.
|
||||
*/
|
||||
|
||||
const gameLogService = require('./gameLogService');
|
||||
|
||||
function statFromGameLog(row, statType) {
|
||||
if (!row) return null;
|
||||
switch (statType) {
|
||||
case 'pts_reb_ast':
|
||||
return (Number(row.points) || 0) + (Number(row.rebounds) || 0) + (Number(row.assists) || 0);
|
||||
case 'pts_reb':
|
||||
return (Number(row.points) || 0) + (Number(row.rebounds) || 0);
|
||||
case 'pts_ast':
|
||||
return (Number(row.points) || 0) + (Number(row.assists) || 0);
|
||||
case 'reb_ast':
|
||||
return (Number(row.rebounds) || 0) + (Number(row.assists) || 0);
|
||||
case 'stl_blk':
|
||||
return (Number(row.steals) || 0) + (Number(row.blocks) || 0);
|
||||
default: {
|
||||
const v = Number(row[statType]);
|
||||
return Number.isFinite(v) ? v : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function classify(cv) {
|
||||
if (cv < 0.15) return { consistency: 'elite', score: 1.0 };
|
||||
if (cv < 0.30) return { consistency: 'reliable', score: 0.7 };
|
||||
if (cv < 0.50) return { consistency: 'volatile', score: 0.4 };
|
||||
return { consistency: 'boom_bust', score: 0.1 };
|
||||
}
|
||||
|
||||
function statsFor(values) {
|
||||
const clean = values.filter((v) => Number.isFinite(v));
|
||||
if (clean.length < 2) return null;
|
||||
const mean = clean.reduce((a, b) => a + b, 0) / clean.length;
|
||||
if (mean === 0) return null;
|
||||
const variance = clean.reduce((s, v) => s + (v - mean) ** 2, 0) / (clean.length - 1);
|
||||
const stddev = Math.sqrt(variance);
|
||||
return { mean, stddev, cv: stddev / Math.abs(mean), games: clean.length };
|
||||
}
|
||||
|
||||
async function getConsistency(input = {}) {
|
||||
const { playerName, sport, statType, gameLogs: providedLogs } = input;
|
||||
const logs = providedLogs || await gameLogService.getGameLogs(playerName, sport, 20);
|
||||
if (!logs || logs.length < 2) {
|
||||
return { consistency: 'unknown', score: null, games: logs?.length ?? 0 };
|
||||
}
|
||||
const values = logs.map((row) => statFromGameLog(row, statType)).filter((v) => v != null);
|
||||
const s = statsFor(values);
|
||||
if (!s) return { consistency: 'unknown', score: null, games: values.length };
|
||||
return { ...s, ...classify(s.cv) };
|
||||
}
|
||||
|
||||
module.exports = { getConsistency, classify, statsFor, statFromGameLog };
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Engine 1 — rule-based grading on the v6b feature vector.
|
||||
*
|
||||
* Engine 1 is deterministic. Same inputs always produce the same grade.
|
||||
* That predictability is intentional: when Engine 2 (LLM, non-deterministic)
|
||||
* disagrees with Engine 1, the disagreement itself is a signal we surface
|
||||
* to users — and a stable reference point makes that signal meaningful.
|
||||
*
|
||||
* Grade scale (11 steps): F, D, C-, C, C+, B-, B, B+, A-, A, A+
|
||||
* Start at C (neutral); positive signals push UP, negative push DOWN.
|
||||
*
|
||||
* Factors carry the top 3 contributors out so Engine 2 sees them in its
|
||||
* prompt and the UI can render a "why this grade" tooltip.
|
||||
*/
|
||||
|
||||
const GRADE_SCALE = ['F', 'D', 'C-', 'C', 'C+', 'B-', 'B', 'B+', 'A-', 'A', 'A+'];
|
||||
const NEUTRAL_INDEX = 3; // 'C'
|
||||
|
||||
const GRADE_TO_CONFIDENCE = {
|
||||
'A+': 1.00,
|
||||
'A': 0.90,
|
||||
'A-': 0.80,
|
||||
'B+': 0.65,
|
||||
'B': 0.55,
|
||||
'B-': 0.45,
|
||||
'C+': 0.35,
|
||||
'C': 0.25,
|
||||
'C-': 0.20,
|
||||
'D': 0.15,
|
||||
'F': 0.10,
|
||||
};
|
||||
|
||||
function clampIndex(idx) {
|
||||
return Math.max(0, Math.min(GRADE_SCALE.length - 1, idx));
|
||||
}
|
||||
|
||||
function indexToGrade(idx) {
|
||||
return GRADE_SCALE[clampIndex(Math.round(idx))];
|
||||
}
|
||||
|
||||
// Each factor produces a delta (positive or negative) plus a label that
|
||||
// lands in the top-N list. We track magnitude for sorting so the UI can
|
||||
// surface "this matters most" honestly.
|
||||
function computeFactors(input) {
|
||||
const { features = {}, trap = {}, consistency = {}, prop } = input;
|
||||
const factors = [];
|
||||
const line = Number(prop?.line);
|
||||
const direction = prop?.direction;
|
||||
const overWeighted = direction === 'over';
|
||||
|
||||
// Recent form vs the line.
|
||||
if (Number.isFinite(features.l5_avg) && Number.isFinite(line) && line > 0) {
|
||||
const delta = (features.l5_avg - line) / line; // fractional gap
|
||||
if (overWeighted) {
|
||||
if (delta >= 0.15) factors.push({ label: 'l5_hot_vs_line', delta: 1.0, magnitude: Math.abs(delta) });
|
||||
else if (delta <= -0.15) factors.push({ label: 'l5_cold_vs_line', delta: -1.0, magnitude: Math.abs(delta) });
|
||||
} else {
|
||||
// For UNDER props the signs flip.
|
||||
if (delta <= -0.15) factors.push({ label: 'l5_under_friendly', delta: 1.0, magnitude: Math.abs(delta) });
|
||||
else if (delta >= 0.15) factors.push({ label: 'l5_hot_vs_under', delta: -1.0, magnitude: Math.abs(delta) });
|
||||
}
|
||||
}
|
||||
|
||||
// Trend confirmation from L20.
|
||||
if (Number.isFinite(features.l20_avg) && Number.isFinite(line) && line > 0) {
|
||||
const delta20 = (features.l20_avg - line) / line;
|
||||
if (overWeighted && delta20 > 0) factors.push({ label: 'l20_over_line', delta: 1.0, magnitude: Math.abs(delta20) });
|
||||
else if (!overWeighted && delta20 < 0) factors.push({ label: 'l20_under_line', delta: 1.0, magnitude: Math.abs(delta20) });
|
||||
}
|
||||
|
||||
// Consistency.
|
||||
const cLabel = consistency.consistency;
|
||||
if (cLabel === 'elite' || cLabel === 'reliable') {
|
||||
factors.push({ label: `consistency_${cLabel}`, delta: 1.0, magnitude: consistency.score ?? 0.7 });
|
||||
} else if (cLabel === 'boom_bust') {
|
||||
factors.push({ label: 'consistency_boom_bust', delta: -1.0, magnitude: 0.9 });
|
||||
}
|
||||
|
||||
// Opponent rank (0..1 scale where 1.0 = worst defense, easiest matchup).
|
||||
if (Number.isFinite(features.opp_rank_stat)) {
|
||||
if (features.opp_rank_stat >= 0.70) {
|
||||
const adj = overWeighted ? 1.0 : -1.0;
|
||||
factors.push({ label: 'weak_opponent_defense', delta: adj, magnitude: features.opp_rank_stat });
|
||||
} else if (features.opp_rank_stat <= 0.30) {
|
||||
const adj = overWeighted ? -1.0 : 1.0;
|
||||
factors.push({ label: 'top_opponent_defense', delta: adj, magnitude: 1 - features.opp_rank_stat });
|
||||
}
|
||||
}
|
||||
|
||||
// Home / away.
|
||||
if (features.home_away === 1.0) {
|
||||
factors.push({ label: 'home_game', delta: 0.5, magnitude: 0.5 });
|
||||
} else if (features.home_away === 0.0 && features.opp_rank_stat != null && features.opp_rank_stat <= 0.15) {
|
||||
factors.push({ label: 'away_vs_top5_defense', delta: -0.5, magnitude: 0.7 });
|
||||
}
|
||||
|
||||
// Rest / fatigue.
|
||||
if (features.rest_days >= 2) factors.push({ label: 'rested_2plus', delta: 0.5, magnitude: 0.5 });
|
||||
if (features.rest_days === 0) factors.push({ label: 'back_to_back', delta: -0.5, magnitude: 0.7 });
|
||||
if ((features.game_count_in_7d ?? 0) >= 4) factors.push({ label: 'heavy_workload_7d', delta: -0.5, magnitude: 0.6 });
|
||||
|
||||
// Coach pace.
|
||||
if (Number.isFinite(features.coach_pace_delta) && Math.abs(features.coach_pace_delta) > 0.5) {
|
||||
const sign = overWeighted ? Math.sign(features.coach_pace_delta) : -Math.sign(features.coach_pace_delta);
|
||||
factors.push({ label: 'coach_pace_delta', delta: 0.5 * sign, magnitude: Math.abs(features.coach_pace_delta) / 5 });
|
||||
}
|
||||
|
||||
// Ref pace.
|
||||
if (Number.isFinite(features.ref_pace_adjustment) && Math.abs(features.ref_pace_adjustment) > 0.1) {
|
||||
const sign = overWeighted ? Math.sign(features.ref_pace_adjustment) : -Math.sign(features.ref_pace_adjustment);
|
||||
factors.push({ label: 'ref_pace_adjustment', delta: 0.5 * sign, magnitude: Math.abs(features.ref_pace_adjustment) });
|
||||
}
|
||||
|
||||
// Ref foul tendency — a high-foul crew puts FT-heavy scorers at the line
|
||||
// more often. We treat the magnitude as a binary boost for scoring props.
|
||||
if (Number.isFinite(features.ref_foul_adjustment)) {
|
||||
if (features.ref_foul_adjustment > 0.5) {
|
||||
factors.push({ label: 'ref_foul_high', delta: overWeighted ? 0.5 : -0.5, magnitude: features.ref_foul_adjustment });
|
||||
} else if (features.ref_foul_adjustment < -0.5) {
|
||||
factors.push({ label: 'ref_foul_low', delta: overWeighted ? -0.5 : 0.5, magnitude: Math.abs(features.ref_foul_adjustment) });
|
||||
}
|
||||
}
|
||||
|
||||
// Opponent injury severity — 2-3+ starters out means a thinner rotation
|
||||
// and easier matchup. Always lifts an OVER, never matters for UNDER.
|
||||
if (Number.isFinite(features.injury_severity_score) && overWeighted) {
|
||||
if (features.injury_severity_score >= 3) {
|
||||
factors.push({ label: 'opp_3plus_starters_out', delta: 1.0, magnitude: 1.0 });
|
||||
} else if (features.injury_severity_score >= 2) {
|
||||
factors.push({ label: 'opp_2_starters_out', delta: 0.5, magnitude: 0.7 });
|
||||
}
|
||||
}
|
||||
|
||||
// Playoff experience — rookies in playoffs are volatile (downgrade);
|
||||
// veterans handle the spotlight better (upgrade). Only meaningful in
|
||||
// playoff games (season_type >= 2 in our config).
|
||||
if (Number.isFinite(features.career_playoff_games) && features.season_type >= 2) {
|
||||
if (features.career_playoff_games === 0) {
|
||||
factors.push({ label: 'rookie_in_playoffs', delta: -0.5, magnitude: 0.8 });
|
||||
} else if (features.career_playoff_games > 30) {
|
||||
factors.push({ label: 'veteran_in_playoffs', delta: 0.5, magnitude: 0.6 });
|
||||
}
|
||||
}
|
||||
|
||||
// Trap composite — the big lever.
|
||||
if (Number.isFinite(trap.composite) && trap.composite > 0.5) {
|
||||
factors.push({ label: 'trap_composite_high', delta: -1.0, magnitude: trap.composite });
|
||||
}
|
||||
|
||||
return factors;
|
||||
}
|
||||
|
||||
function gradeFromFactors(factors) {
|
||||
let idx = NEUTRAL_INDEX;
|
||||
for (const f of factors) idx += f.delta;
|
||||
idx = clampIndex(Math.round(idx));
|
||||
return { grade: GRADE_SCALE[idx], confidence: GRADE_TO_CONFIDENCE[GRADE_SCALE[idx]] ?? 0.25 };
|
||||
}
|
||||
|
||||
function topFactorLabels(factors, n = 3) {
|
||||
return [...factors]
|
||||
.sort((a, b) => Math.abs(b.delta * b.magnitude) - Math.abs(a.delta * a.magnitude))
|
||||
.slice(0, n)
|
||||
.map((f) => f.label);
|
||||
}
|
||||
|
||||
function gradeProp(input) {
|
||||
const factors = computeFactors(input);
|
||||
const { grade, confidence } = gradeFromFactors(factors);
|
||||
return {
|
||||
grade,
|
||||
confidence,
|
||||
top_factors: topFactorLabels(factors, 3),
|
||||
all_factors: factors.map((f) => f.label),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
gradeProp,
|
||||
GRADE_SCALE,
|
||||
GRADE_TO_CONFIDENCE,
|
||||
__internals: { computeFactors, gradeFromFactors, topFactorLabels, indexToGrade, NEUTRAL_INDEX },
|
||||
};
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Engine 2 — LLM analysis layer on top of Engine 1 grades.
|
||||
*
|
||||
* Engine 2 doesn't REPLACE Engine 1. It runs after Engine 1 produces a
|
||||
* grade for an A/B-tier prop, applies natural-language reasoning over the
|
||||
* full feature vector + trap signals, and either agrees or disagrees.
|
||||
* Disagreement is itself a signal — surface it in the UI so users can
|
||||
* see when our two systems diverge.
|
||||
*
|
||||
* Architecture choices:
|
||||
* - Async + non-blocking. Engine 1 returns immediately; Engine 2 fills
|
||||
* in 5-30 seconds later via the queue.
|
||||
* - Queue is in-memory (Map keyed by gradeId). On process restart we
|
||||
* lose the queue, which is acceptable — n8n can re-queue from
|
||||
* grade_history WHERE engine2_analyzed_at IS NULL.
|
||||
* - Only A/B-tier props qualify. C/D/F grades skip Engine 2 entirely;
|
||||
* they're already flagged as low-confidence and don't need narrative.
|
||||
* - Prompt is GENERIC — no 'VYNDR' brand string. The model has no idea
|
||||
* who we are. That keeps our system prompt out of any provider's
|
||||
* training/QA pipeline.
|
||||
*/
|
||||
|
||||
const openRouter = require('../adapters/openRouterAdapter');
|
||||
const { getSupabaseServiceClient } = require('../../utils/supabase');
|
||||
|
||||
const BATCH_SIZE = Number(process.env.ENGINE2_BATCH_SIZE) || 10;
|
||||
const ENABLED = String(process.env.ENGINE2_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
|
||||
// Grades that qualify for Engine 2 analysis. C/D/F skip.
|
||||
const ELIGIBLE_GRADES = new Set(['A+', 'A', 'A-', 'B+', 'B', 'B-']);
|
||||
const VALID_GRADES = new Set([
|
||||
'A+', 'A', 'A-', 'B+', 'B', 'B-', 'C+', 'C', 'C-', 'D', 'F', null,
|
||||
]);
|
||||
|
||||
// In-process FIFO queue. Map preserves insertion order — values carry the
|
||||
// context needed to build the prompt without re-querying upstream.
|
||||
const queue = new Map();
|
||||
|
||||
const SYSTEM_MESSAGE = (
|
||||
"You are a sports analytics engine analyzing player prop bets. "
|
||||
+ "Respond ONLY with valid JSON. No preamble, no markdown, no explanation "
|
||||
+ "outside the JSON structure. If you cannot analyze this prop, respond "
|
||||
+ 'with { "grade": null, "reason": "insufficient data" }.'
|
||||
);
|
||||
|
||||
function buildPrompt(ctx) {
|
||||
const features = ctx.features || {};
|
||||
const trapSignals = ctx.trap?.signals || {};
|
||||
const recent = ctx.recentGames || [];
|
||||
|
||||
const featureLines = Object.entries(features)
|
||||
.map(([k, v]) => {
|
||||
if (typeof v === 'number') {
|
||||
return `${k}: ${Number.isInteger(v) ? v : v.toFixed(2)}`;
|
||||
}
|
||||
return `${k}: ${v}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const activeTraps = Object.entries(trapSignals)
|
||||
.filter(([, s]) => s?.active && s?.score > 0)
|
||||
.map(([name, s]) => `- ${name}: ${s.score.toFixed(2)} (${s.explanation})`)
|
||||
.join('\n') || 'none';
|
||||
|
||||
const recentLines = recent
|
||||
.map((g) => ` ${g.date}: ${g.value} vs ${g.opponent}${g.home ? ' (home)' : ''}`)
|
||||
.join('\n') || ' (no recent games)';
|
||||
|
||||
return [
|
||||
`PLAYER: ${ctx.player_name} (${ctx.team || 'unknown'})`,
|
||||
`SPORT: ${ctx.sport}`,
|
||||
`PROP: ${ctx.direction} ${ctx.line} ${ctx.stat_type}`,
|
||||
`GAME: ${ctx.away_team || '?'} @ ${ctx.home_team || '?'}, ${ctx.game_date || '?'}`,
|
||||
'',
|
||||
'FEATURES:',
|
||||
featureLines || ' (no features computed)',
|
||||
'',
|
||||
`ENGINE 1 GRADE: ${ctx.engine1_grade} (${(ctx.engine1_factors || []).slice(0, 3).join(', ') || 'no factors'})`,
|
||||
'',
|
||||
'TRAP SIGNALS:',
|
||||
activeTraps,
|
||||
`Trap composite: ${(ctx.trap?.composite ?? 0).toFixed(2)} (${ctx.trap?.recommendation || 'unknown'})`,
|
||||
'',
|
||||
`CONSISTENCY: ${ctx.consistency?.consistency || 'unknown'} (cv=${(ctx.consistency?.cv ?? 0).toFixed(2)}, score=${(ctx.consistency?.score ?? 0).toFixed(2)})`,
|
||||
'',
|
||||
...(ctx.probability && Number.isFinite(ctx.probability.p_over) ? [
|
||||
`PROBABILITY: P(Over) = ${ctx.probability.p_over.toFixed(2)} | P(Under) = ${(1 - ctx.probability.p_over).toFixed(2)}`,
|
||||
`Components: ${
|
||||
Object.entries(ctx.probability.components || {})
|
||||
.filter(([, v]) => Number.isFinite(Number(v)))
|
||||
.map(([k, v]) => `${k}=${Number(v).toFixed(2)}`)
|
||||
.join(', ') || 'none'
|
||||
}`,
|
||||
'',
|
||||
] : []),
|
||||
'RECENT PERFORMANCE:',
|
||||
recentLines,
|
||||
'',
|
||||
'Analyze this prop and respond with:',
|
||||
'{',
|
||||
' "grade": "A+/A/A-/B+/B/B-/C+/C/C-/D/F",',
|
||||
' "confidence": 0.0-1.0,',
|
||||
' "agrees_with_engine1": true/false,',
|
||||
' "narrative": "2-3 sentence analysis",',
|
||||
' "trap_concern": "specific trap risk if any, or null",',
|
||||
' "key_factor": "single most important factor"',
|
||||
'}',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// Four-strategy parser. The model is supposed to return raw JSON, but
|
||||
// "supposed to" is doing a lot of work — we layer fallbacks so a chatty
|
||||
// model doesn't make us drop the whole analysis. Strategy 4 (regex field
|
||||
// extraction) is the last-ditch — at least we capture the grade.
|
||||
function parseResponse(raw) {
|
||||
if (!raw || typeof raw !== 'string') return null;
|
||||
|
||||
// 1. Direct parse.
|
||||
try {
|
||||
const j = JSON.parse(raw.trim());
|
||||
if (j && typeof j === 'object') return j;
|
||||
} catch { /* fall through */ }
|
||||
|
||||
// 2. Markdown fenced block.
|
||||
const fence = raw.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||||
if (fence?.[1]) {
|
||||
try {
|
||||
const j = JSON.parse(fence[1].trim());
|
||||
if (j && typeof j === 'object') return j;
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
|
||||
// 3. First {...} block.
|
||||
const obj = raw.match(/\{[\s\S]*\}/);
|
||||
if (obj) {
|
||||
try {
|
||||
const j = JSON.parse(obj[0]);
|
||||
if (j && typeof j === 'object') return j;
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
|
||||
// 4. Field-level regex extraction — last resort. We at least want the
|
||||
// grade letter; the narrative becomes a flag string so the row is
|
||||
// distinguishable from a model that returned valid JSON.
|
||||
const gradeMatch = raw.match(/["']?grade["']?\s*[:=]\s*["']?([A-F][+-]?)/i);
|
||||
if (gradeMatch) {
|
||||
const confMatch = raw.match(/["']?confidence["']?\s*[:=]\s*([\d.]+)/i);
|
||||
const conf = confMatch ? parseFloat(confMatch[1]) : NaN;
|
||||
return {
|
||||
grade: gradeMatch[1].toUpperCase(),
|
||||
confidence: Number.isFinite(conf) && conf >= 0 && conf <= 1 ? conf : 0.5,
|
||||
narrative: 'Extracted from malformed response',
|
||||
agrees_with_engine1: null,
|
||||
key_factor: null,
|
||||
trap_concern: null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateAnalysis(parsed) {
|
||||
if (!parsed) return null;
|
||||
// Allow the explicit "I can't" response.
|
||||
if (parsed.grade === null) return { grade: null, reason: parsed.reason || 'insufficient data' };
|
||||
|
||||
if (!VALID_GRADES.has(parsed.grade)) return null;
|
||||
const confidence = Number(parsed.confidence);
|
||||
if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) return null;
|
||||
const narrative = typeof parsed.narrative === 'string' ? parsed.narrative.slice(0, 500) : null;
|
||||
if (!narrative || narrative.length === 0) return null;
|
||||
return {
|
||||
grade: parsed.grade,
|
||||
confidence,
|
||||
narrative,
|
||||
agrees_with_engine1: !!parsed.agrees_with_engine1,
|
||||
trap_concern: typeof parsed.trap_concern === 'string' ? parsed.trap_concern.slice(0, 300) : null,
|
||||
key_factor: typeof parsed.key_factor === 'string' ? parsed.key_factor.slice(0, 200) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function queueAnalysis(gradeId, propContext) {
|
||||
if (!ENABLED) return;
|
||||
if (!gradeId || !propContext) return;
|
||||
if (!ELIGIBLE_GRADES.has(propContext.engine1_grade)) return;
|
||||
// De-dupe by gradeId — re-queuing on retry is fine; we just overwrite.
|
||||
queue.set(gradeId, propContext);
|
||||
}
|
||||
|
||||
function getQueueSize() {
|
||||
return queue.size;
|
||||
}
|
||||
|
||||
function clearQueue() {
|
||||
queue.clear();
|
||||
}
|
||||
|
||||
async function persistResult(gradeId, analysis, modelUsed, latencyMs) {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const patch = {
|
||||
engine2_grade: analysis.grade,
|
||||
engine2_confidence: analysis.confidence,
|
||||
engine2_narrative: analysis.narrative,
|
||||
engine2_agrees: analysis.agrees_with_engine1,
|
||||
engine2_key_factor: analysis.key_factor,
|
||||
engine2_trap_concern: analysis.trap_concern,
|
||||
engine2_model: modelUsed,
|
||||
engine2_latency_ms: latencyMs,
|
||||
engine2_analyzed_at: new Date().toISOString(),
|
||||
};
|
||||
const { error } = await supabase.from('grade_history').update(patch).eq('id', gradeId);
|
||||
if (error) {
|
||||
console.warn('[engine2] persist failed for', gradeId, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function analyzeOne(gradeId, propContext) {
|
||||
const userPrompt = buildPrompt(propContext);
|
||||
const result = await openRouter.analyze(SYSTEM_MESSAGE, userPrompt);
|
||||
if (!result) return { ok: false, reason: 'openrouter unavailable' };
|
||||
|
||||
const parsed = parseResponse(result.response);
|
||||
const analysis = validateAnalysis(parsed);
|
||||
if (!analysis) return { ok: false, reason: 'parse/validate failed' };
|
||||
if (analysis.grade === null) return { ok: false, reason: analysis.reason };
|
||||
|
||||
await persistResult(gradeId, analysis, result.modelUsed, result.latencyMs);
|
||||
return { ok: true, analysis, modelUsed: result.modelUsed, latencyMs: result.latencyMs };
|
||||
}
|
||||
|
||||
async function processQueue() {
|
||||
if (!ENABLED) return { processed: 0, succeeded: 0, failed: 0 };
|
||||
let processed = 0;
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
for (const [gradeId, ctx] of queue.entries()) {
|
||||
if (processed >= BATCH_SIZE) break;
|
||||
queue.delete(gradeId);
|
||||
processed += 1;
|
||||
try {
|
||||
const res = await analyzeOne(gradeId, ctx);
|
||||
if (res.ok) succeeded += 1; else failed += 1;
|
||||
} catch (err) {
|
||||
console.warn('[engine2] analyze threw for', gradeId, err.message);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
return { processed, succeeded, failed, remaining: queue.size };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
queueAnalysis,
|
||||
processQueue,
|
||||
getQueueSize,
|
||||
clearQueue,
|
||||
__internals: {
|
||||
buildPrompt,
|
||||
parseResponse,
|
||||
validateAnalysis,
|
||||
analyzeOne,
|
||||
persistResult,
|
||||
queue,
|
||||
SYSTEM_MESSAGE,
|
||||
ELIGIBLE_GRADES,
|
||||
VALID_GRADES,
|
||||
BATCH_SIZE,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Feature cache — the central feature-vector builder for every prop.
|
||||
*
|
||||
* Philosophy: features are OMITTED when the underlying data source is
|
||||
* unavailable, never zeroed. Engine 2 handles variable-length feature
|
||||
* sets; a zero would lie to the model about what we actually know.
|
||||
*
|
||||
* Per-feature TTL categories (Redis):
|
||||
* game_log: 4h — game logs refresh once per night
|
||||
* team: 24h — opponent stats are daily
|
||||
* coach: 30d — coach profiles are rare to change
|
||||
* ref: 12h — assignments published morning of game day
|
||||
* injury: 2h — injuries change at shootaround
|
||||
* line: 2m — line state changes constantly during the day
|
||||
* context: none — computed on demand (home/away, rest days)
|
||||
*
|
||||
* Cache key: features:{sport}:{playerId}:{statType}:{gameId}
|
||||
* The full vector is cached for 2 minutes so repeated calls during the
|
||||
* same grading cycle don't recompute. After 2 minutes, individual
|
||||
* features get refreshed from their own caches.
|
||||
*/
|
||||
|
||||
const { cacheGet, cacheSet } = require('../../utils/redis');
|
||||
const { getTeamStats, getOpponentRank } = require('./teamStatsCache');
|
||||
const { getRefImpact } = require('./refSignals');
|
||||
const { getCoachImpact } = require('./coachSignals');
|
||||
const { roleValue } = require('./lineupSignals');
|
||||
const { getTeamInjuries } = require('./injuryParser');
|
||||
const { getLineMovement } = require('./lineMovement');
|
||||
const gameLogs = require('./gameLogService');
|
||||
|
||||
const VECTOR_TTL_SECONDS = 120;
|
||||
|
||||
function avg(values) {
|
||||
const clean = values.filter((v) => Number.isFinite(v));
|
||||
if (clean.length === 0) return null;
|
||||
return clean.reduce((a, b) => a + b, 0) / clean.length;
|
||||
}
|
||||
|
||||
function stddev(values) {
|
||||
const clean = values.filter((v) => Number.isFinite(v));
|
||||
if (clean.length < 2) return null;
|
||||
const mean = avg(clean);
|
||||
const sq = clean.reduce((sum, v) => sum + (v - mean) ** 2, 0);
|
||||
return Math.sqrt(sq / (clean.length - 1));
|
||||
}
|
||||
|
||||
// Extract a stat value from a single game-log entry by stat type. Game-log
|
||||
// rows out of the Python service are keyed by stat name (points,
|
||||
// rebounds, etc.) and combo stats need to be summed at read time.
|
||||
function statFromGameLog(row, statType) {
|
||||
if (!row) return null;
|
||||
switch (statType) {
|
||||
case 'pts_reb_ast': {
|
||||
const s = (Number(row.points) || 0) + (Number(row.rebounds) || 0) + (Number(row.assists) || 0);
|
||||
return s;
|
||||
}
|
||||
case 'pts_reb':
|
||||
return (Number(row.points) || 0) + (Number(row.rebounds) || 0);
|
||||
case 'pts_ast':
|
||||
return (Number(row.points) || 0) + (Number(row.assists) || 0);
|
||||
case 'reb_ast':
|
||||
return (Number(row.rebounds) || 0) + (Number(row.assists) || 0);
|
||||
case 'stl_blk':
|
||||
return (Number(row.steals) || 0) + (Number(row.blocks) || 0);
|
||||
default: {
|
||||
const v = Number(row[statType]);
|
||||
return Number.isFinite(v) ? v : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function daysBetween(aIso, bIso) {
|
||||
const ms = new Date(aIso).getTime() - new Date(bIso).getTime();
|
||||
if (!Number.isFinite(ms)) return null;
|
||||
return Math.floor(ms / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
async function gameLogFeatures(playerName, sport, statType) {
|
||||
const logs = await gameLogs.getGameLogs(playerName, sport, 20);
|
||||
if (!logs || logs.length === 0) return {};
|
||||
|
||||
const valuesAll = logs.map((row) => statFromGameLog(row, statType)).filter((v) => v != null);
|
||||
const l5 = valuesAll.slice(0, 5);
|
||||
const l20 = valuesAll;
|
||||
const l10 = valuesAll.slice(0, 10);
|
||||
|
||||
const out = {};
|
||||
const m5 = avg(l5);
|
||||
const m20 = avg(l20);
|
||||
const s10 = stddev(l10);
|
||||
if (m5 != null) out.l5_avg = m5;
|
||||
if (m20 != null) out.l20_avg = m20;
|
||||
if (s10 != null) out.l10_stddev = s10;
|
||||
|
||||
// Career playoff games is a separate endpoint.
|
||||
const cp = await gameLogs.getCareerPlayoffGames(playerName, sport);
|
||||
if (Number.isFinite(cp)) out.career_playoff_games = cp;
|
||||
return out;
|
||||
}
|
||||
|
||||
async function teamFeatures(sport, opponentAbbr, statType) {
|
||||
const out = {};
|
||||
if (!opponentAbbr) return out;
|
||||
const oppStats = await getTeamStats(sport, opponentAbbr);
|
||||
if (oppStats) {
|
||||
if (Number.isFinite(oppStats.pace)) out.pace_factor = oppStats.pace;
|
||||
if (Number.isFinite(oppStats.pace)) out.team_pace = oppStats.pace;
|
||||
}
|
||||
const rank = await getOpponentRank(sport, opponentAbbr, statType);
|
||||
if (rank != null) out.opp_rank_stat = rank;
|
||||
return out;
|
||||
}
|
||||
|
||||
function contextFeatures(gameContext = {}) {
|
||||
const out = {};
|
||||
if (gameContext.home_away === 'home') out.home_away = 1.0;
|
||||
else if (gameContext.home_away === 'away') out.home_away = 0.0;
|
||||
if (Number.isFinite(gameContext.rest_days)) out.rest_days = gameContext.rest_days;
|
||||
if (Number.isFinite(gameContext.game_count_in_7d)) out.game_count_in_7d = gameContext.game_count_in_7d;
|
||||
if (gameContext.season_type != null) out.season_type = gameContext.season_type;
|
||||
if (Number.isFinite(gameContext.game_in_series)) out.game_in_series = gameContext.game_in_series;
|
||||
if (Number.isFinite(gameContext.season_phase)) out.season_phase = gameContext.season_phase;
|
||||
return out;
|
||||
}
|
||||
|
||||
async function injuryFeatures(sport, teamId, knownStarterIds = []) {
|
||||
const out = {};
|
||||
if (!teamId) return out;
|
||||
const list = await getTeamInjuries(sport, teamId);
|
||||
if (!list || list.length === 0) {
|
||||
out.injury_severity_score = 0;
|
||||
return out;
|
||||
}
|
||||
const starterSet = new Set(knownStarterIds.map(String));
|
||||
const missingStarters = list.filter(
|
||||
(i) => starterSet.has(i.playerId) && (i.status === 'OUT' || i.status === 'DOUBTFUL')
|
||||
);
|
||||
out.injury_severity_score = Math.min(5, missingStarters.length);
|
||||
|
||||
// Teammate-absence bump: a league-average constant when we don't have
|
||||
// with/without splits for this player. Engine 2 can replace this with
|
||||
// a learned value over time.
|
||||
if (missingStarters.length > 0) out.teammate_absence_bump = 0.05 * missingStarters.length;
|
||||
return out;
|
||||
}
|
||||
|
||||
async function lineFeatures(gameId, playerName, statType) {
|
||||
const lm = await getLineMovement(gameId, playerName, statType);
|
||||
if (!lm) return {};
|
||||
return { line_delta: lm.movement };
|
||||
}
|
||||
|
||||
async function refFeatures(gameId) {
|
||||
const impact = await getRefImpact(gameId);
|
||||
if (!impact) return {};
|
||||
const out = {};
|
||||
if (Number.isFinite(impact.pace_impact)) out.ref_pace_adjustment = impact.pace_impact;
|
||||
if (Number.isFinite(impact.foul_adjustment)) out.ref_foul_adjustment = impact.foul_adjustment;
|
||||
return out;
|
||||
}
|
||||
|
||||
async function coachFeatures(sport, teamAbbr, gameContext = {}) {
|
||||
const impact = await getCoachImpact(sport, teamAbbr, gameContext);
|
||||
if (!impact) return {};
|
||||
const out = {};
|
||||
if (Number.isFinite(impact.adjusted_pace_delta)) out.coach_pace_delta = impact.adjusted_pace_delta;
|
||||
if (Number.isFinite(impact.without_primary_pace_shift)) {
|
||||
out.coach_player_interaction = impact.without_primary_pace_shift;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function lineupFeatures(role) {
|
||||
if (!role) return {};
|
||||
return { lineup_ball_handler_role: roleValue(role) };
|
||||
}
|
||||
|
||||
// Top-level: build the full vector. Each sub-call is independent so a
|
||||
// failure in one (e.g. ref assignments not yet published) just omits its
|
||||
// feature and the rest of the vector is still useful.
|
||||
async function getFeatures(input = {}) {
|
||||
const {
|
||||
playerId,
|
||||
playerName,
|
||||
statType,
|
||||
sport,
|
||||
teamAbbr,
|
||||
opponentAbbr,
|
||||
teamId,
|
||||
opponentTeamId,
|
||||
gameId,
|
||||
gameContext,
|
||||
role,
|
||||
knownStarterIds = [],
|
||||
} = input;
|
||||
|
||||
const cacheKey = `features:${sport}:${playerId}:${statType}:${gameId}`;
|
||||
const cached = await cacheGet(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const [gl, team, ctx, injury, line, ref, coach, lineup] = await Promise.all([
|
||||
gameLogFeatures(playerName, sport, statType),
|
||||
teamFeatures(sport, opponentAbbr, statType),
|
||||
Promise.resolve(contextFeatures(gameContext)),
|
||||
injuryFeatures(sport, teamId, knownStarterIds),
|
||||
lineFeatures(gameId, playerName, statType),
|
||||
refFeatures(gameId),
|
||||
coachFeatures(sport, teamAbbr, gameContext),
|
||||
Promise.resolve(lineupFeatures(role)),
|
||||
]);
|
||||
|
||||
const features = { ...gl, ...team, ...ctx, ...injury, ...line, ...ref, ...coach, ...lineup };
|
||||
const FEATURE_NAMES = [
|
||||
'l5_avg', 'l20_avg', 'l10_stddev', 'career_playoff_games',
|
||||
'opp_rank_stat', 'pace_factor', 'team_pace',
|
||||
'home_away', 'rest_days', 'game_count_in_7d', 'season_type', 'game_in_series', 'season_phase',
|
||||
'teammate_absence_bump', 'primary_stat_suppression', 'injury_severity_score',
|
||||
'line_delta',
|
||||
'ref_pace_adjustment', 'ref_foul_adjustment',
|
||||
'coach_pace_delta', 'coach_player_interaction',
|
||||
'lineup_ball_handler_role',
|
||||
];
|
||||
const available = FEATURE_NAMES.filter((n) => features[n] != null);
|
||||
const missing = FEATURE_NAMES.filter((n) => features[n] == null);
|
||||
|
||||
const payload = {
|
||||
features,
|
||||
meta: {
|
||||
computed_at: new Date().toISOString(),
|
||||
features_available: available,
|
||||
features_missing: missing,
|
||||
},
|
||||
};
|
||||
await cacheSet(cacheKey, payload, VECTOR_TTL_SECONDS);
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function clearCache(cacheKey) {
|
||||
// Hook for tests + manual invalidation.
|
||||
const { cacheDel } = require('../../utils/redis');
|
||||
return cacheDel(cacheKey);
|
||||
}
|
||||
|
||||
function getCacheStats() {
|
||||
return { ttlSeconds: VECTOR_TTL_SECONDS };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getFeatures,
|
||||
clearCache,
|
||||
getCacheStats,
|
||||
// Internal helpers exported for unit tests + Engine 2 reuse.
|
||||
__internals: {
|
||||
gameLogFeatures,
|
||||
teamFeatures,
|
||||
contextFeatures,
|
||||
injuryFeatures,
|
||||
lineFeatures,
|
||||
refFeatures,
|
||||
coachFeatures,
|
||||
lineupFeatures,
|
||||
statFromGameLog,
|
||||
avg,
|
||||
stddev,
|
||||
daysBetween,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Game-log service — fetches recent player game logs.
|
||||
*
|
||||
* Primary path: the Python FastAPI service at PYTHON_SERVICE_URL (default
|
||||
* http://localhost:8000). Its /stats/last-n and /wnba/stats/last-n
|
||||
* endpoints return per-game stat rows.
|
||||
*
|
||||
* Secondary path: not implemented in this session. If the Python service
|
||||
* is unreachable, we return null and let the feature cache omit the
|
||||
* features that depend on game logs. A flaky stats backend should NOT
|
||||
* generate fake feature values.
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const { cacheGet, cacheSet } = require('../../utils/redis');
|
||||
|
||||
const PYTHON_BASE = process.env.PYTHON_SERVICE_URL || 'http://localhost:8000';
|
||||
const CACHE_TTL_SECONDS = 4 * 60 * 60; // 4h — game logs change once per night
|
||||
const HTTP_TIMEOUT_MS = 15_000;
|
||||
|
||||
function pythonPath(sport) {
|
||||
switch (sport) {
|
||||
case 'nba': return '/stats/last-n';
|
||||
case 'wnba': return '/wnba/stats/last-n';
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getGameLogs(playerName, sport, count = 20) {
|
||||
const path = pythonPath(sport);
|
||||
if (!path) return null;
|
||||
const cacheKey = `gamelogs:${sport}:${playerName}:${count}`;
|
||||
const cached = await cacheGet(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const res = await axios.get(`${PYTHON_BASE}${path}`, {
|
||||
params: { player: playerName, n: count },
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
});
|
||||
const games = res.data?.games || res.data?.results || [];
|
||||
if (!Array.isArray(games) || games.length === 0) return null;
|
||||
await cacheSet(cacheKey, games, CACHE_TTL_SECONDS);
|
||||
return games;
|
||||
} catch (err) {
|
||||
// Python service down or returning 404 — return null, caller omits.
|
||||
if (err?.response?.status !== 404) {
|
||||
console.warn(`[gameLog] fetch failed for ${playerName}:`, err?.message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Career playoff games — approximated from the season-avg endpoint's career
|
||||
// summary, if present. If the Python service doesn't surface this, return
|
||||
// null and let the caller skip the feature.
|
||||
async function getCareerPlayoffGames(playerName, sport) {
|
||||
if (sport !== 'nba' && sport !== 'wnba') return null;
|
||||
try {
|
||||
const res = await axios.get(`${PYTHON_BASE}/stats/season-avg`, {
|
||||
params: { player: playerName, season: 'career' },
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
});
|
||||
const games = res.data?.career_playoff_games;
|
||||
return Number.isFinite(Number(games)) ? Number(games) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// with/without analysis — compare a player's stats when a specific teammate
|
||||
// is in vs out. Requires the Python service to expose this; if not, the
|
||||
// feature falls back to a league-average bump (caller's choice).
|
||||
async function getWithWithoutStats(playerName, sport, statType, teammateName) {
|
||||
if (sport !== 'nba' && sport !== 'wnba') return null;
|
||||
try {
|
||||
const res = await axios.get(`${PYTHON_BASE}/stats/with-without`, {
|
||||
params: { player: playerName, stat_type: statType, teammate: teammateName },
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
});
|
||||
return res.data || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getGameLogs, getCareerPlayoffGames, getWithWithoutStats };
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Grading pipeline orchestrator.
|
||||
*
|
||||
* Called by n8n at 10:30 AM, 1 PM, 4 PM, 6 PM ET (and on demand from the
|
||||
* /api/grading/pipeline endpoint). For one sport per call, it:
|
||||
*
|
||||
* 1. Pulls today's scoreboard from the sport config's ESPN endpoint.
|
||||
* We do NOT call SharpAPI for the slate — only for player props per
|
||||
* game. Scoreboard is the source of truth for which games exist.
|
||||
* 2. For each game, fetches player props via SharpAPI.
|
||||
* 3. For each prop, builds a feature vector + trap composite +
|
||||
* consistency score, then asks Engine 1 to grade.
|
||||
* 4. Persists the grade to grade_history.
|
||||
* 5. Queues A/B-tier grades for Engine 2.
|
||||
* 6. Drains the Engine 2 queue (best-effort, one batch).
|
||||
*
|
||||
* Failure semantics:
|
||||
* - SharpAPI down → 0 props graded, summary still returns.
|
||||
* - Per-prop error → log + skip, other props continue.
|
||||
* - Engine 2 queue failure → does not affect Engine 1 grades that
|
||||
* are already in the database.
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const { getSportConfig } = require('../../config/sports');
|
||||
const { getSupabaseServiceClient } = require('../../utils/supabase');
|
||||
const featureCache = require('./featureCache');
|
||||
const trapDetection = require('./trapDetection');
|
||||
const consistencyScore = require('./consistencyScore');
|
||||
const engine1 = require('./engine1');
|
||||
const engine2 = require('./engine2');
|
||||
const gameLogService = require('./gameLogService');
|
||||
const probabilityEstimator = require('./probabilityEstimator');
|
||||
const sharpApi = require('../adapters/sharpApiAdapter');
|
||||
|
||||
const HTTP_TIMEOUT_MS = 15_000;
|
||||
|
||||
async function fetchTodaysGames(sportCfg) {
|
||||
try {
|
||||
const res = await axios.get(sportCfg.espnScoreboard, { timeout: HTTP_TIMEOUT_MS });
|
||||
const events = res.data?.events || [];
|
||||
return events.map((ev) => {
|
||||
const comp = ev?.competitions?.[0];
|
||||
const teams = (comp?.competitors || []).reduce((acc, t) => {
|
||||
const role = t?.homeAway === 'home' ? 'home' : 'away';
|
||||
acc[role] = { id: t?.id, abbr: t?.team?.abbreviation, name: t?.team?.displayName };
|
||||
return acc;
|
||||
}, {});
|
||||
return {
|
||||
gameId: String(ev.id),
|
||||
gameDate: ev?.date,
|
||||
home: teams.home,
|
||||
away: teams.away,
|
||||
state: ev?.status?.type?.state,
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[orchestrator] scoreboard fetch failed:', err.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function buildPropContext(prop, game, sport) {
|
||||
// Determine whether this prop's player is on home or away team. We
|
||||
// don't have a roster lookup at this point of the pipeline; the orchestrator
|
||||
// treats prop.team (if SharpAPI provides) as the canonical, falling back
|
||||
// to "unknown" for home_away.
|
||||
const team = prop.team || prop.teamAbbr;
|
||||
const isHome = team && game.home?.abbr === team;
|
||||
const opponentAbbr = isHome ? game.away?.abbr : game.home?.abbr;
|
||||
return {
|
||||
playerId: prop.playerId || prop.player_id || null,
|
||||
playerName: prop.player,
|
||||
statType: prop.statType || prop.stat_type,
|
||||
sport,
|
||||
line: Number(prop.line),
|
||||
direction: prop.direction || 'over',
|
||||
teamAbbr: team,
|
||||
opponentAbbr,
|
||||
gameId: game.gameId,
|
||||
gameContext: {
|
||||
home_away: team ? (isHome ? 'home' : 'away') : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function gradeProp(prop, game, sport) {
|
||||
const ctx = await buildPropContext(prop, game, sport);
|
||||
|
||||
// Feature vector — every signal computed in 6b.
|
||||
const featurePayload = await featureCache.getFeatures({
|
||||
playerId: ctx.playerId,
|
||||
playerName: ctx.playerName,
|
||||
statType: ctx.statType,
|
||||
sport: ctx.sport,
|
||||
teamAbbr: ctx.teamAbbr,
|
||||
opponentAbbr: ctx.opponentAbbr,
|
||||
gameId: ctx.gameId,
|
||||
gameContext: ctx.gameContext,
|
||||
});
|
||||
const features = featurePayload?.features || {};
|
||||
|
||||
// Trap detector — uses features + lineMovement snapshots already in DB.
|
||||
const trap = await trapDetection.getTrapScore({
|
||||
playerName: ctx.playerName,
|
||||
statType: ctx.statType,
|
||||
sport: ctx.sport,
|
||||
gameId: ctx.gameId,
|
||||
gameContext: ctx.gameContext,
|
||||
features,
|
||||
odds: { playerLine: ctx.line, consensus: prop.consensus },
|
||||
});
|
||||
|
||||
// Consistency — Engine 2 uses this verbatim in its prompt.
|
||||
let consistency = { consistency: 'unknown', score: null, games: 0 };
|
||||
let gameLogs = null;
|
||||
try {
|
||||
gameLogs = await gameLogService.getGameLogs(ctx.playerName, ctx.sport, 20);
|
||||
if (gameLogs && gameLogs.length) {
|
||||
consistency = await consistencyScore.getConsistency({
|
||||
playerName: ctx.playerName,
|
||||
sport: ctx.sport,
|
||||
statType: ctx.statType,
|
||||
gameLogs,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[orchestrator] consistency failed for', ctx.playerName, err.message);
|
||||
}
|
||||
|
||||
// P(Over) — quantile-based probability from game logs. We pass the same
|
||||
// game logs to the estimator that consistency uses, so both views agree
|
||||
// on the same data window. Null if no logs (Python service down).
|
||||
let probability = { p_over: null, p_under: null, components: {}, reason: 'no_logs' };
|
||||
if (gameLogs && gameLogs.length) {
|
||||
probability = probabilityEstimator.estimateProbability({
|
||||
gameLogs,
|
||||
line: ctx.line,
|
||||
statType: ctx.statType,
|
||||
features,
|
||||
});
|
||||
}
|
||||
|
||||
// Engine 1 — rule-based, deterministic.
|
||||
const result = engine1.gradeProp({
|
||||
features,
|
||||
trap,
|
||||
consistency,
|
||||
prop: { line: ctx.line, direction: ctx.direction },
|
||||
});
|
||||
|
||||
return { ctx, features, trap, consistency, probability, engine1Result: result };
|
||||
}
|
||||
|
||||
async function persistGrade(graded, prop, sport) {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const { ctx, engine1Result, trap, consistency, features, probability } = graded;
|
||||
const row = {
|
||||
player_id: ctx.playerId,
|
||||
player_name: ctx.playerName,
|
||||
sport,
|
||||
stat_type: ctx.statType,
|
||||
line: ctx.line,
|
||||
direction: ctx.direction,
|
||||
grade: engine1Result.grade,
|
||||
projection: Number.isFinite(features.l5_avg) ? features.l5_avg : null,
|
||||
// modeled_prob is the implied probability from Engine 1's grade tier;
|
||||
// p_over is the quantile-based probability from game logs. Both useful
|
||||
// — the former for grade-vs-line edge math, the latter for UI display.
|
||||
modeled_prob: Number.isFinite(engine1Result?.confidence) ? engine1Result.confidence : null,
|
||||
implied_prob: null,
|
||||
p_over: Number.isFinite(probability?.p_over) ? probability.p_over : null,
|
||||
// factors drive the weight adjuster: each resolved prop's factors get
|
||||
// nudged based on hit/miss outcome. Stored as JSONB so we can also
|
||||
// surface them in the UI "why this grade" tooltip.
|
||||
factors: Array.isArray(engine1Result?.all_factors)
|
||||
? engine1Result.all_factors
|
||||
: (Array.isArray(engine1Result?.top_factors) ? engine1Result.top_factors : null),
|
||||
game_date: new Date().toISOString().slice(0, 10),
|
||||
game_id: ctx.gameId,
|
||||
};
|
||||
const { data, error } = await supabase.from('grade_history').insert(row).select('id').single();
|
||||
if (error) {
|
||||
console.warn('[orchestrator] grade_history insert failed:', error.message);
|
||||
return null;
|
||||
}
|
||||
// Hand the gradeId + full context to engine2 so it can build a prompt.
|
||||
engine2.queueAnalysis(data.id, {
|
||||
player_name: ctx.playerName,
|
||||
team: ctx.teamAbbr,
|
||||
sport,
|
||||
direction: ctx.direction,
|
||||
line: ctx.line,
|
||||
stat_type: ctx.statType,
|
||||
home_team: prop._home,
|
||||
away_team: prop._away,
|
||||
game_date: row.game_date,
|
||||
engine1_grade: engine1Result.grade,
|
||||
engine1_factors: engine1Result.top_factors,
|
||||
features,
|
||||
trap,
|
||||
consistency,
|
||||
probability,
|
||||
recentGames: [],
|
||||
});
|
||||
return data.id;
|
||||
}
|
||||
|
||||
async function gradeProps(props, game, sport) {
|
||||
const out = [];
|
||||
for (const prop of props) {
|
||||
try {
|
||||
const graded = await gradeProp(prop, game, sport);
|
||||
const gradeId = await persistGrade(graded, { ...prop, _home: game.home?.name, _away: game.away?.name }, sport);
|
||||
out.push({ gradeId, grade: graded.engine1Result.grade, prop });
|
||||
} catch (err) {
|
||||
console.warn('[orchestrator] gradeProp failed for', prop?.player, err.message);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function runPipeline(sport, options = {}) {
|
||||
const start = Date.now();
|
||||
let sportCfg;
|
||||
try { sportCfg = getSportConfig(sport); }
|
||||
catch (err) { return { error: err.message, sport, games_processed: 0, props_graded: 0, duration_ms: Date.now() - start }; }
|
||||
|
||||
const games = await fetchTodaysGames(sportCfg);
|
||||
if (games.length === 0) {
|
||||
return { sport, games_processed: 0, props_graded: 0, engine2_queued: 0, errors: 0, duration_ms: Date.now() - start };
|
||||
}
|
||||
|
||||
let propsGraded = 0;
|
||||
let errors = 0;
|
||||
let engine2Queued = 0;
|
||||
for (const game of games) {
|
||||
let props;
|
||||
try {
|
||||
props = await sharpApi.getPlayerProps(sport, game.gameId);
|
||||
} catch (err) {
|
||||
console.warn('[orchestrator] sharpApi failed for', game.gameId, err.message);
|
||||
errors += 1;
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(props) || props.length === 0) continue;
|
||||
const before = engine2.getQueueSize();
|
||||
const graded = await gradeProps(props, game, sport);
|
||||
propsGraded += graded.length;
|
||||
engine2Queued += engine2.getQueueSize() - before;
|
||||
}
|
||||
|
||||
// Drain the Engine 2 queue with a bounded loop. Each processQueue()
|
||||
// call handles ENGINE2_BATCH_SIZE items, so for slates of ~50+ A/B
|
||||
// grades one call would leave most of the queue parked. Cap at 5
|
||||
// iterations (≈50 props per pipeline run with default batch size)
|
||||
// — beyond that, the next pipeline cycle picks up the remainder.
|
||||
let engine2Summary = { processed: 0, succeeded: 0, failed: 0, remaining: engine2.getQueueSize() };
|
||||
if (!options.skipEngine2) {
|
||||
const MAX_DRAIN_ITERS = 5;
|
||||
let drainIters = 0;
|
||||
const totals = { processed: 0, succeeded: 0, failed: 0 };
|
||||
while (engine2.getQueueSize() > 0 && drainIters < MAX_DRAIN_ITERS) {
|
||||
const round = await engine2.processQueue();
|
||||
totals.processed += round.processed || 0;
|
||||
totals.succeeded += round.succeeded || 0;
|
||||
totals.failed += round.failed || 0;
|
||||
drainIters += 1;
|
||||
// If a round processes 0 items, the queue is stuck (likely
|
||||
// disabled or all calls failing) — break early instead of looping.
|
||||
if ((round.processed || 0) === 0) break;
|
||||
}
|
||||
engine2Summary = { ...totals, remaining: engine2.getQueueSize(), iterations: drainIters };
|
||||
}
|
||||
|
||||
return {
|
||||
sport,
|
||||
games_processed: games.length,
|
||||
props_graded: propsGraded,
|
||||
engine2_queued: engine2Queued,
|
||||
engine2_summary: engine2Summary,
|
||||
errors,
|
||||
duration_ms: Date.now() - start,
|
||||
};
|
||||
}
|
||||
|
||||
function getEngineStatus() {
|
||||
return {
|
||||
engine2_queue_size: engine2.getQueueSize(),
|
||||
adapters_configured: {
|
||||
sharp_api: sharpApi.configured(),
|
||||
open_router: require('../adapters/openRouterAdapter').configured(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
runPipeline,
|
||||
gradeProps,
|
||||
gradeProp,
|
||||
getEngineStatus,
|
||||
__internals: { fetchTodaysGames, buildPropContext, persistGrade },
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* ESPN injury parser.
|
||||
*
|
||||
* Two data paths:
|
||||
* 1. ESPN team-injuries endpoint:
|
||||
* https://site.api.espn.com/apis/site/v2/sports/{sport}/{league}/teams/{teamId}/injuries
|
||||
* 2. Injury info embedded in scoreboard / summary responses under
|
||||
* events[i].competitions[0].competitors[t].injuries
|
||||
*
|
||||
* We expose three callers:
|
||||
* getTeamInjuries(sport, teamId) — primary fetch + cache
|
||||
* getGameInjuries(sport, gameId, espnSummary?) — convenience reading
|
||||
* the summary JSON the resolution path already loads, so we don't
|
||||
* refetch
|
||||
* isPlayerOut / getMissingStarters — derived helpers
|
||||
*
|
||||
* Cache: Redis, 2-hour TTL — injuries can change at shootaround on
|
||||
* game day so we deliberately don't go longer.
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const { cacheGet, cacheSet } = require('../../utils/redis');
|
||||
const { createLimiter, createCircuitBreaker } = require('../../utils/rateLimiter');
|
||||
|
||||
const HTTP_TIMEOUT_MS = 10_000;
|
||||
const CACHE_TTL_SECONDS = 2 * 60 * 60;
|
||||
|
||||
// ESPN's team-injuries endpoint takes a sport/league path. We resolve the
|
||||
// league portion off the same SPORT_CONFIG used by the resolution poller
|
||||
// rather than maintaining a parallel map.
|
||||
const ESPN_BASE = 'https://site.api.espn.com/apis/site/v2/sports';
|
||||
const SPORT_PATH = Object.freeze({
|
||||
nba: 'basketball/nba',
|
||||
wnba: 'basketball/wnba',
|
||||
mlb: 'baseball/mlb',
|
||||
nfl: 'football/nfl',
|
||||
nhl: 'hockey/nhl',
|
||||
ncaab: 'basketball/mens-college-basketball',
|
||||
ncaafb: 'football/college-football',
|
||||
});
|
||||
|
||||
const limiter = createLimiter({ tokensPerInterval: 6, interval: 60_000 });
|
||||
const breaker = createCircuitBreaker({ failureThreshold: 3, resetTimeout: 60_000 });
|
||||
|
||||
const STATUS_CANON = (status) => {
|
||||
if (!status) return 'UNKNOWN';
|
||||
const upper = String(status).toUpperCase();
|
||||
if (upper.includes('OUT')) return 'OUT';
|
||||
if (upper.includes('DOUBTFUL')) return 'DOUBTFUL';
|
||||
if (upper.includes('QUESTIONABLE')) return 'QUESTIONABLE';
|
||||
if (upper.includes('PROBABLE')) return 'PROBABLE';
|
||||
if (upper.includes('DAY-TO-DAY') || upper.includes('DAY_TO_DAY') || upper.includes('DTD')) return 'DAY_TO_DAY';
|
||||
return upper;
|
||||
};
|
||||
|
||||
function normalizeInjuryEntry(entry) {
|
||||
// ESPN payloads vary — entries may carry the player at `.athlete` or be
|
||||
// flat with `.name` / `.id`. Try both shapes.
|
||||
const player = entry?.athlete ?? entry;
|
||||
return {
|
||||
playerId: String(player?.id ?? entry?.id ?? ''),
|
||||
playerName: player?.displayName ?? player?.fullName ?? entry?.name ?? null,
|
||||
status: STATUS_CANON(entry?.status ?? entry?.type?.description ?? entry?.details?.type),
|
||||
detail: entry?.details?.detail ?? entry?.shortComment ?? entry?.longComment ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function getTeamInjuries(sport, teamId) {
|
||||
const path = SPORT_PATH[sport];
|
||||
if (!path) return [];
|
||||
const cacheKey = `injuries:${sport}:${teamId}`;
|
||||
const cached = await cacheGet(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
await limiter.waitForToken();
|
||||
try {
|
||||
const data = await breaker.call(async () => {
|
||||
const res = await axios.get(`${ESPN_BASE}/${path}/teams/${teamId}/injuries`, {
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
validateStatus: (s) => (s >= 200 && s < 300) || s === 404,
|
||||
});
|
||||
// ESPN returns 404 for teams with no current injuries on some sports
|
||||
// — that's a clean "no injuries", not an error.
|
||||
if (res.status === 404) return { injuries: [] };
|
||||
return res.data;
|
||||
});
|
||||
const raw = data?.injuries || data?.athletes || [];
|
||||
const normalized = (Array.isArray(raw) ? raw : []).map(normalizeInjuryEntry).filter((e) => e.playerName);
|
||||
await cacheSet(cacheKey, normalized, CACHE_TTL_SECONDS);
|
||||
return normalized;
|
||||
} catch (err) {
|
||||
if (err?.code !== 'CIRCUIT_OPEN') {
|
||||
console.warn(`[injuries] fetch failed for ${sport}/${teamId}:`, err?.message);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function extractGameInjuries(espnSummary) {
|
||||
// espnSummary is the JSON from /summary?event={id}. Some sports nest
|
||||
// injuries under competitions[0].competitors[t].injuries; others under
|
||||
// a top-level injuries[] array. We try both.
|
||||
const out = { home: [], away: [] };
|
||||
const comp = espnSummary?.header?.competitions?.[0] ?? espnSummary?.competitions?.[0];
|
||||
if (comp?.competitors) {
|
||||
for (const team of comp.competitors) {
|
||||
const bucket = team?.homeAway === 'home' ? 'home' : 'away';
|
||||
const list = team?.injuries || [];
|
||||
for (const e of list) {
|
||||
const normalized = normalizeInjuryEntry(e);
|
||||
if (normalized.playerName) out[bucket].push(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Array.isArray(espnSummary?.injuries)) {
|
||||
for (const e of espnSummary.injuries) {
|
||||
const normalized = normalizeInjuryEntry(e);
|
||||
if (!normalized.playerName) continue;
|
||||
const bucket = e?.team === 'home' ? 'home' : 'away';
|
||||
out[bucket].push(normalized);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function getGameInjuries(sport, gameId, espnSummary) {
|
||||
if (espnSummary) return extractGameInjuries(espnSummary);
|
||||
// Without a summary in hand, we'd need both team IDs from the scoreboard
|
||||
// — defer to the caller to pass espnSummary so we don't multiply ESPN
|
||||
// requests.
|
||||
return { home: [], away: [] };
|
||||
}
|
||||
|
||||
async function isPlayerOut(sport, teamId, playerId) {
|
||||
const list = await getTeamInjuries(sport, teamId);
|
||||
const match = list.find((i) => i.playerId === String(playerId));
|
||||
if (!match) return false;
|
||||
return match.status === 'OUT' || match.status === 'DOUBTFUL';
|
||||
}
|
||||
|
||||
// starterIds is an iterable of ESPN player IDs known to start for this team
|
||||
// (resolved upstream from player_id_map or yesterday's box score).
|
||||
async function getMissingStarters(sport, teamId, starterIds) {
|
||||
const injuries = await getTeamInjuries(sport, teamId);
|
||||
const starterSet = new Set([...starterIds].map(String));
|
||||
return injuries.filter(
|
||||
(i) => starterSet.has(i.playerId) && (i.status === 'OUT' || i.status === 'DOUBTFUL')
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getTeamInjuries,
|
||||
getGameInjuries,
|
||||
isPlayerOut,
|
||||
getMissingStarters,
|
||||
__internals: { limiter, breaker, normalizeInjuryEntry, STATUS_CANON },
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Line movement signals built on top of line_snapshots.
|
||||
*
|
||||
* Two derived signals power the trap detector:
|
||||
*
|
||||
* reverseLineMovement
|
||||
* The line moved AGAINST where the public is betting. If the public is
|
||||
* hammering OVER but the line drops (toward UNDER), sharp money is on
|
||||
* the under and the over is a trap.
|
||||
*
|
||||
* juiceDegradation
|
||||
* The line didn't move but the vig on one side got worse (e.g. -110 →
|
||||
* -130). Books are charging more for the same number — that side is
|
||||
* the trap.
|
||||
*
|
||||
* Both signals require at least two snapshots. If snapshots are missing we
|
||||
* return null so trap detection can mark the signal "inactive" instead of
|
||||
* scoring zero (which would dilute the composite).
|
||||
*/
|
||||
|
||||
const { getSupabaseServiceClient } = require('../../utils/supabase');
|
||||
const { oddsToImplied } = require('../../utils/odds');
|
||||
|
||||
async function fetchSnapshots(gameId, playerName, statType) {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const { data, error } = await supabase
|
||||
.from('line_snapshots')
|
||||
.select('line, over_odds, under_odds, consensus_median, snapshot_at')
|
||||
.eq('game_id', gameId)
|
||||
.eq('stat_type', statType)
|
||||
.eq('player_name', playerName)
|
||||
.order('snapshot_at', { ascending: true });
|
||||
if (error) {
|
||||
console.warn('[lineMovement] snapshot lookup failed:', error.message);
|
||||
return [];
|
||||
}
|
||||
return data || [];
|
||||
}
|
||||
|
||||
async function getLineMovement(gameId, playerName, statType) {
|
||||
const snaps = await fetchSnapshots(gameId, playerName, statType);
|
||||
if (snaps.length < 2) return null;
|
||||
const open = snaps[0];
|
||||
const close = snaps[snaps.length - 1];
|
||||
const movement = Number(close.line) - Number(open.line);
|
||||
const overJuiceOpen = Number(open.over_odds);
|
||||
const overJuiceClose = Number(close.over_odds);
|
||||
const underJuiceOpen = Number(open.under_odds);
|
||||
const underJuiceClose = Number(close.under_odds);
|
||||
return {
|
||||
opening_line: Number(open.line),
|
||||
current_line: Number(close.line),
|
||||
movement,
|
||||
direction: movement > 0 ? 'up' : movement < 0 ? 'down' : 'flat',
|
||||
opening_over_odds: Number.isFinite(overJuiceOpen) ? overJuiceOpen : null,
|
||||
current_over_odds: Number.isFinite(overJuiceClose) ? overJuiceClose : null,
|
||||
opening_under_odds: Number.isFinite(underJuiceOpen) ? underJuiceOpen : null,
|
||||
current_under_odds: Number.isFinite(underJuiceClose) ? underJuiceClose : null,
|
||||
juice_change_over: Number.isFinite(overJuiceClose - overJuiceOpen) ? overJuiceClose - overJuiceOpen : null,
|
||||
juice_change_under: Number.isFinite(underJuiceClose - underJuiceOpen) ? underJuiceClose - underJuiceOpen : null,
|
||||
snapshots_count: snaps.length,
|
||||
first_seen: open.snapshot_at,
|
||||
last_seen: close.snapshot_at,
|
||||
};
|
||||
}
|
||||
|
||||
// publicBetPct is the public-money percentage on the OVER (0-100). If we
|
||||
// don't have it, we estimate from odds movement direction: when the over
|
||||
// got more expensive (smaller positive / bigger negative), the public was
|
||||
// on the over.
|
||||
async function reverseLineMovement(gameId, playerName, statType, publicBetPct) {
|
||||
const lm = await getLineMovement(gameId, playerName, statType);
|
||||
if (!lm) return null;
|
||||
|
||||
// Estimate public side if not provided.
|
||||
let publicSide;
|
||||
if (Number.isFinite(publicBetPct)) {
|
||||
publicSide = publicBetPct >= 50 ? 'over' : 'under';
|
||||
} else if (Number.isFinite(lm.juice_change_over)) {
|
||||
// If over juice got worse (became more negative), book is shading away
|
||||
// from over — public was on over.
|
||||
publicSide = lm.juice_change_over < 0 ? 'over' : 'under';
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Line movement direction tells us where sharp money went.
|
||||
const lineDirection = lm.movement > 0 ? 'over' : lm.movement < 0 ? 'under' : 'flat';
|
||||
if (lineDirection === 'flat') return null;
|
||||
|
||||
const isReverse = publicSide !== lineDirection;
|
||||
if (!isReverse) return { score: 0, isReverse: false, publicSide, lineDirection };
|
||||
|
||||
// Magnitude normalized to typical movement (1 point is meaningful for
|
||||
// basketball points; everything bigger gets capped at 1.0).
|
||||
const magnitude = Math.min(Math.abs(lm.movement), 1.0);
|
||||
const publicWeight = Number.isFinite(publicBetPct)
|
||||
? Math.max(0.5, Math.abs(publicBetPct - 50) / 50)
|
||||
: 0.6;
|
||||
return {
|
||||
score: Math.min(1.0, magnitude * publicWeight),
|
||||
isReverse: true,
|
||||
publicSide,
|
||||
lineDirection,
|
||||
movement: lm.movement,
|
||||
};
|
||||
}
|
||||
|
||||
async function juiceDegradation(gameId, playerName, statType) {
|
||||
const lm = await getLineMovement(gameId, playerName, statType);
|
||||
if (!lm) return null;
|
||||
// Only meaningful when the line itself barely moved — if both line and
|
||||
// juice shifted, that's regular line movement, captured by RLM instead.
|
||||
if (Math.abs(lm.movement) > 0.5) return { score: 0, applicable: false };
|
||||
|
||||
const overShift = Number.isFinite(lm.juice_change_over) ? lm.juice_change_over : 0;
|
||||
const underShift = Number.isFinite(lm.juice_change_under) ? lm.juice_change_under : 0;
|
||||
// Worst-side degradation: the side whose implied-prob increase is bigger
|
||||
// is the one the books are pulling money to.
|
||||
const overImpliedShift = (oddsToImplied(lm.current_over_odds) ?? 0) - (oddsToImplied(lm.opening_over_odds) ?? 0);
|
||||
const underImpliedShift = (oddsToImplied(lm.current_under_odds) ?? 0) - (oddsToImplied(lm.opening_under_odds) ?? 0);
|
||||
const worstSide = overImpliedShift >= underImpliedShift ? 'over' : 'under';
|
||||
// Normalize to a 20-cent (e.g. -110 → -130) max move.
|
||||
const magnitude = Math.max(Math.abs(overShift), Math.abs(underShift));
|
||||
return {
|
||||
score: Math.min(1.0, magnitude / 20),
|
||||
applicable: true,
|
||||
worstSide,
|
||||
overShift,
|
||||
underShift,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { getLineMovement, reverseLineMovement, juiceDegradation, fetchSnapshots };
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Lineup / role signals.
|
||||
*
|
||||
* Two derived inputs:
|
||||
* getProjectedStarters: from ESPN summary (post-game or pregame) or
|
||||
* yesterday's box score as a fallback. The poller already caches the
|
||||
* summary; we just walk it.
|
||||
* getLineupRole: maps a player to 'primary_handler' | 'secondary' |
|
||||
* 'role_player' based on usage signals. For now this is a coarse
|
||||
* heuristic driven by usage_rate; the feature cache pulls a finer
|
||||
* value once Engine 2 surfaces per-player usage.
|
||||
*/
|
||||
|
||||
function rolesFromBoxScore(boxScore) {
|
||||
const home = [];
|
||||
const away = [];
|
||||
const teams = boxScore?.boxscore?.players || [];
|
||||
for (let i = 0; i < teams.length; i += 1) {
|
||||
const team = teams[i];
|
||||
const bucket = i === 0 ? home : away;
|
||||
const athletes = team?.statistics?.[0]?.athletes || [];
|
||||
for (const a of athletes) {
|
||||
if (!a?.starter) continue;
|
||||
const id = a?.athlete?.id || a?.id;
|
||||
const name = a?.athlete?.displayName || a?.athlete?.fullName;
|
||||
if (!id || !name) continue;
|
||||
bucket.push({
|
||||
playerId: String(id),
|
||||
name,
|
||||
position: a?.athlete?.position?.abbreviation ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { home, away };
|
||||
}
|
||||
|
||||
async function getProjectedStarters(sport, gameId, espnSummary) {
|
||||
if (!espnSummary) return { home: [], away: [] };
|
||||
const lineup = rolesFromBoxScore(espnSummary);
|
||||
// Add 'role' annotation — first starter on each side defaults to primary
|
||||
// handler. Once usage data is available we refine; for now this is the
|
||||
// ESPN-listed starting order.
|
||||
for (const side of ['home', 'away']) {
|
||||
lineup[side] = lineup[side].map((p, idx) => ({
|
||||
...p,
|
||||
role: idx === 0 ? 'primary_handler' : idx <= 2 ? 'secondary' : 'role_player',
|
||||
}));
|
||||
}
|
||||
return lineup;
|
||||
}
|
||||
|
||||
// Coarse classification from a precomputed usage rate (0-1). Caller has
|
||||
// the rate via teamStatsCache or the Python game-log service.
|
||||
function classifyByUsage(usageRate) {
|
||||
const u = Number(usageRate);
|
||||
if (!Number.isFinite(u)) return 'role_player';
|
||||
if (u >= 0.28) return 'primary_handler';
|
||||
if (u >= 0.18) return 'secondary';
|
||||
return 'role_player';
|
||||
}
|
||||
|
||||
function roleValue(role) {
|
||||
if (role === 'primary_handler') return 1.0;
|
||||
if (role === 'secondary') return 0.5;
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
async function getLineupRole(_sport, _teamAbbr, _playerId, usageRate) {
|
||||
// Until usage rates feed in, the caller passes one explicitly. If they
|
||||
// don't, classifyByUsage returns 'role_player' (the safe default).
|
||||
return classifyByUsage(usageRate);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getProjectedStarters,
|
||||
getLineupRole,
|
||||
classifyByUsage,
|
||||
roleValue,
|
||||
rolesFromBoxScore,
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* P(Over) — estimated probability that the player goes over the line.
|
||||
*
|
||||
* This is the *quantile-based* probability we surface to users ("73%
|
||||
* chance over") and feed into Engine 2's prompt. It is NOT the implied
|
||||
* probability from the book — that's odds-derived and includes vig. This
|
||||
* one is from the player's actual distribution.
|
||||
*
|
||||
* Formula layers:
|
||||
* 1. Base — empirical frequency of stat > line across the sample
|
||||
* 2. Recency — last 5 games weighted 2× to capture trend
|
||||
* 3. Opponent — bump for weak D, fade for top D (uses 0..1 opp_rank_stat)
|
||||
* 4. Home / away — +1.5% / -1.5%
|
||||
* 5. Consistency — volatile players get pulled toward 0.50
|
||||
*
|
||||
* Clamp at [0.10, 0.95] — we never claim certainty in either direction.
|
||||
*/
|
||||
|
||||
const CV_VOLATILE_THRESHOLD = 0.40;
|
||||
const PROB_FLOOR = 0.10;
|
||||
const PROB_CEIL = 0.95;
|
||||
|
||||
function statFromRow(row, statType) {
|
||||
if (!row) return null;
|
||||
switch (statType) {
|
||||
case 'pts_reb_ast':
|
||||
return (Number(row.points) || 0) + (Number(row.rebounds) || 0) + (Number(row.assists) || 0);
|
||||
case 'pts_reb':
|
||||
return (Number(row.points) || 0) + (Number(row.rebounds) || 0);
|
||||
case 'pts_ast':
|
||||
return (Number(row.points) || 0) + (Number(row.assists) || 0);
|
||||
case 'reb_ast':
|
||||
return (Number(row.rebounds) || 0) + (Number(row.assists) || 0);
|
||||
case 'stl_blk':
|
||||
return (Number(row.steals) || 0) + (Number(row.blocks) || 0);
|
||||
default: {
|
||||
const v = Number(row[statType]);
|
||||
return Number.isFinite(v) ? v : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function frequencyOver(values, line) {
|
||||
const decisive = values.filter((v) => v !== line); // push games don't count
|
||||
if (decisive.length === 0) return null;
|
||||
const over = decisive.filter((v) => v > line).length;
|
||||
return over / decisive.length;
|
||||
}
|
||||
|
||||
function clamp(p) {
|
||||
return Math.max(PROB_FLOOR, Math.min(PROB_CEIL, p));
|
||||
}
|
||||
|
||||
function estimateProbability({ gameLogs = [], line, statType, features = {} } = {}) {
|
||||
if (!Array.isArray(gameLogs) || gameLogs.length === 0 || !Number.isFinite(Number(line))) {
|
||||
return { p_over: null, p_under: null, components: {}, reason: 'insufficient_data' };
|
||||
}
|
||||
const numericLine = Number(line);
|
||||
const values = gameLogs.map((r) => statFromRow(r, statType)).filter((v) => v != null);
|
||||
if (values.length === 0) {
|
||||
return { p_over: null, p_under: null, components: {}, reason: 'no_stat_values' };
|
||||
}
|
||||
|
||||
const base = frequencyOver(values, numericLine);
|
||||
if (base == null) return { p_over: null, p_under: null, components: {}, reason: 'all_pushes' };
|
||||
|
||||
// Recency: last 5 games count 2× in a weighted blend.
|
||||
const recent = values.slice(0, Math.min(5, values.length));
|
||||
const recencyRate = frequencyOver(recent, numericLine);
|
||||
const weighted = recencyRate != null
|
||||
? 0.6 * base + 0.4 * recencyRate
|
||||
: base;
|
||||
|
||||
let p = weighted;
|
||||
|
||||
// Opponent adjustment using 0..1 normalized rank.
|
||||
// opp_rank_stat ≥ 0.70 → weak defense, bump toward over
|
||||
// opp_rank_stat ≤ 0.30 → strong defense, fade
|
||||
const oppAdj = (() => {
|
||||
const r = Number(features.opp_rank_stat);
|
||||
if (!Number.isFinite(r)) return 0;
|
||||
if (r >= 0.70) return +0.03;
|
||||
if (r <= 0.30) return -0.03;
|
||||
return 0;
|
||||
})();
|
||||
p += oppAdj;
|
||||
|
||||
const homeAdj = features.home_away === 1.0 ? +0.015 : features.home_away === 0.0 ? -0.015 : 0;
|
||||
p += homeAdj;
|
||||
|
||||
// Consistency pull: volatile players are uncertain — drag p toward 0.50.
|
||||
const cv = Number(features.l10_stddev) > 0 && Number(features.l20_avg) > 0
|
||||
? Number(features.l10_stddev) / Number(features.l20_avg)
|
||||
: null;
|
||||
const consistencyAdj = (() => {
|
||||
if (!Number.isFinite(cv)) return null;
|
||||
if (cv > CV_VOLATILE_THRESHOLD) {
|
||||
// p' = p * 0.9 + 0.5 * 0.1
|
||||
const before = p;
|
||||
p = p * 0.9 + 0.05;
|
||||
return p - before;
|
||||
}
|
||||
return 0;
|
||||
})();
|
||||
|
||||
const pOver = clamp(p);
|
||||
return {
|
||||
p_over: pOver,
|
||||
p_under: 1 - pOver,
|
||||
components: {
|
||||
base,
|
||||
recency: recencyRate,
|
||||
weighted,
|
||||
opp_adjustment: oppAdj,
|
||||
home_adjustment: homeAdj,
|
||||
consistency_adjustment: consistencyAdj,
|
||||
cv,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
estimateProbability,
|
||||
__internals: { statFromRow, frequencyOver, clamp, CV_VOLATILE_THRESHOLD, PROB_FLOOR, PROB_CEIL },
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Referee impact signal.
|
||||
*
|
||||
* Game-day ref assignments live in `game_ref_assignments` (migration 017).
|
||||
* Per-referee tendencies live in `ref_profiles`, populated by the
|
||||
* Sports-Reference scraper (scripts/scrape-sports-reference.js).
|
||||
*
|
||||
* Crew impact is computed by averaging the three refs' profiles. If any
|
||||
* profile is missing we still return a partial impact (averaging only the
|
||||
* available refs) — the feature cache decides whether to surface the
|
||||
* feature or omit it based on coverage.
|
||||
*/
|
||||
|
||||
const { getSupabaseServiceClient } = require('../../utils/supabase');
|
||||
|
||||
const LOOPBACK_IPS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
||||
|
||||
async function getRefAssignment(gameId) {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const { data, error } = await supabase
|
||||
.from('game_ref_assignments')
|
||||
.select('ref1_name, ref2_name, ref3_name, ref_crew_avg_fouls, ref_crew_pace_impact')
|
||||
.eq('game_id', gameId)
|
||||
.maybeSingle();
|
||||
if (error) {
|
||||
console.warn('[refSignals] assignment lookup failed:', error.message);
|
||||
return null;
|
||||
}
|
||||
return data || null;
|
||||
}
|
||||
|
||||
async function getRefProfiles(refNames) {
|
||||
const named = refNames.filter(Boolean);
|
||||
if (named.length === 0) return [];
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const { data, error } = await supabase
|
||||
.from('ref_profiles')
|
||||
.select('ref_name, avg_fouls_per_game, avg_free_throws_per_game, pace_impact, home_whistle_bias')
|
||||
.in('ref_name', named);
|
||||
if (error) {
|
||||
console.warn('[refSignals] profile lookup failed:', error.message);
|
||||
return [];
|
||||
}
|
||||
return data || [];
|
||||
}
|
||||
|
||||
function average(values) {
|
||||
const clean = values.filter((v) => Number.isFinite(v));
|
||||
if (clean.length === 0) return null;
|
||||
return clean.reduce((a, b) => a + b, 0) / clean.length;
|
||||
}
|
||||
|
||||
async function getRefImpact(gameId) {
|
||||
const assignment = await getRefAssignment(gameId);
|
||||
if (!assignment) return null;
|
||||
const crew = [assignment.ref1_name, assignment.ref2_name, assignment.ref3_name].filter(Boolean);
|
||||
if (crew.length === 0) return null;
|
||||
|
||||
// If precomputed crew values exist on the assignment row (scraper wrote
|
||||
// them), prefer those — they were derived from the same profiles but
|
||||
// baked at assignment time. Note: Number(null) === 0 is finite, so guard
|
||||
// explicitly against null/undefined before going through Number().
|
||||
const hasFouls = assignment.ref_crew_avg_fouls != null && Number.isFinite(Number(assignment.ref_crew_avg_fouls));
|
||||
const hasPace = assignment.ref_crew_pace_impact != null && Number.isFinite(Number(assignment.ref_crew_pace_impact));
|
||||
if (hasFouls || hasPace) {
|
||||
return {
|
||||
crew,
|
||||
avg_fouls: assignment.ref_crew_avg_fouls,
|
||||
pace_impact: assignment.ref_crew_pace_impact,
|
||||
foul_adjustment: assignment.ref_crew_avg_fouls,
|
||||
home_bias: null,
|
||||
profilesUsed: crew.length,
|
||||
};
|
||||
}
|
||||
|
||||
const profiles = await getRefProfiles(crew);
|
||||
return {
|
||||
crew,
|
||||
avg_fouls: average(profiles.map((p) => p.avg_fouls_per_game)),
|
||||
pace_impact: average(profiles.map((p) => p.pace_impact)),
|
||||
foul_adjustment: average(profiles.map((p) => p.avg_free_throws_per_game)),
|
||||
home_bias: average(profiles.map((p) => p.home_whistle_bias)),
|
||||
profilesUsed: profiles.length,
|
||||
};
|
||||
}
|
||||
|
||||
// Manual entry endpoint helper — the route module (not built here) calls
|
||||
// this when ops POSTs an assignment.
|
||||
async function setRefAssignment(gameId, sport, gameDate, refs) {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
// Pull profiles synchronously to precompute crew impact at insert time so
|
||||
// downstream reads don't need a join.
|
||||
const profiles = await getRefProfiles(refs);
|
||||
const avgFouls = average(profiles.map((p) => p.avg_fouls_per_game));
|
||||
const paceImpact = average(profiles.map((p) => p.pace_impact));
|
||||
const { error } = await supabase
|
||||
.from('game_ref_assignments')
|
||||
.upsert({
|
||||
game_id: gameId,
|
||||
sport,
|
||||
game_date: gameDate,
|
||||
ref1_name: refs[0] || null,
|
||||
ref2_name: refs[1] || null,
|
||||
ref3_name: refs[2] || null,
|
||||
ref_crew_avg_fouls: avgFouls,
|
||||
ref_crew_pace_impact: paceImpact,
|
||||
}, { onConflict: 'game_id' });
|
||||
if (error) {
|
||||
console.warn('[refSignals] assignment upsert failed:', error.message);
|
||||
return { ok: false, error: error.message };
|
||||
}
|
||||
return { ok: true, avg_fouls: avgFouls, pace_impact: paceImpact };
|
||||
}
|
||||
|
||||
module.exports = { getRefImpact, getRefAssignment, getRefProfiles, setRefAssignment, LOOPBACK_IPS };
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* Team stats cache — daily refresh, Redis-backed.
|
||||
*
|
||||
* Source priority for each sport:
|
||||
* nba / wnba / ncaab : ESPN team statistics endpoint
|
||||
* mlb : ESPN + MLB Stats API team totals
|
||||
* nfl / ncaafb : ESPN + CFBD talent composite (college)
|
||||
* nhl : ESPN team statistics endpoint
|
||||
*
|
||||
* The cache key is `team_stats:{sport}:{teamAbbr}` with a 24h TTL. The
|
||||
* refresh function (called from n8n or app startup) walks every team in
|
||||
* the sport and writes one cache entry per team. Rate-limited at 1
|
||||
* request per 2 seconds to be respectful to ESPN.
|
||||
*
|
||||
* Per-team payload normalizes into a uniform shape; values not available
|
||||
* for a sport are simply omitted (mirrors the feature-cache philosophy).
|
||||
*
|
||||
* {
|
||||
* offensive_rating, defensive_rating, pace, opponent_ppg,
|
||||
* team_fg_pct, team_3pt_pct, team_ft_rate,
|
||||
* opponent_fg_pct, opponent_3pt_pct,
|
||||
* team_k_rate, // MLB only
|
||||
* defensive_rank, // 1-N (1 = best D)
|
||||
* by_stat: { points: { allowed: N, rank: 1-30 }, ... }
|
||||
* }
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const { cacheGet, cacheSet } = require('../../utils/redis');
|
||||
const { createLimiter, createCircuitBreaker } = require('../../utils/rateLimiter');
|
||||
|
||||
const ESPN_BASE = 'https://site.api.espn.com/apis/site/v2/sports';
|
||||
const CACHE_TTL_SECONDS = 24 * 60 * 60;
|
||||
const HTTP_TIMEOUT_MS = 10_000;
|
||||
|
||||
const SPORT_PATH = Object.freeze({
|
||||
nba: 'basketball/nba',
|
||||
wnba: 'basketball/wnba',
|
||||
mlb: 'baseball/mlb',
|
||||
nfl: 'football/nfl',
|
||||
nhl: 'hockey/nhl',
|
||||
ncaab: 'basketball/mens-college-basketball',
|
||||
ncaafb: 'football/college-football',
|
||||
});
|
||||
|
||||
const limiter = createLimiter({ tokensPerInterval: 30, interval: 60_000 }); // 1/2s
|
||||
const breaker = createCircuitBreaker({ failureThreshold: 3, resetTimeout: 60_000 });
|
||||
|
||||
function teamCacheKey(sport, teamAbbr) {
|
||||
return `team_stats:${sport}:${String(teamAbbr).toUpperCase()}`;
|
||||
}
|
||||
|
||||
// Pull a numeric value out of ESPN's labeled statistics arrays. ESPN
|
||||
// returns categories with .stats[] of { name, value, displayValue, abbreviation }.
|
||||
function pickStat(categoryStats, name) {
|
||||
if (!Array.isArray(categoryStats)) return null;
|
||||
const match = categoryStats.find(
|
||||
(s) =>
|
||||
(s?.name || '').toLowerCase() === name.toLowerCase()
|
||||
|| (s?.abbreviation || '').toLowerCase() === name.toLowerCase()
|
||||
);
|
||||
if (!match) return null;
|
||||
const v = Number(match.value);
|
||||
return Number.isFinite(v) ? v : null;
|
||||
}
|
||||
|
||||
function flattenTeamStats(payload) {
|
||||
// ESPN returns: { team, season, splits: [...], stats: [...] } depending on
|
||||
// endpoint. Most commonly: payload.results.stats[]/categories[] for
|
||||
// /teams/{id}/statistics
|
||||
const buckets = payload?.results?.stats || payload?.stats || [];
|
||||
const all = [];
|
||||
for (const b of buckets) {
|
||||
if (Array.isArray(b?.stats)) all.push(...b.stats);
|
||||
if (Array.isArray(b?.splits)) {
|
||||
for (const split of b.splits) {
|
||||
if (Array.isArray(split?.stats)) all.push(...split.stats);
|
||||
}
|
||||
}
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
function normalizeBasketball(payload) {
|
||||
const all = flattenTeamStats(payload);
|
||||
return {
|
||||
offensive_rating: pickStat(all, 'offensiveRating') ?? pickStat(all, 'oRtg'),
|
||||
defensive_rating: pickStat(all, 'defensiveRating') ?? pickStat(all, 'dRtg'),
|
||||
pace: pickStat(all, 'pace'),
|
||||
opponent_ppg: pickStat(all, 'avgPointsAgainst') ?? pickStat(all, 'oppPPG'),
|
||||
team_fg_pct: pickStat(all, 'fieldGoalPct'),
|
||||
team_3pt_pct: pickStat(all, 'threePointFieldGoalPct') ?? pickStat(all, 'threePtPct'),
|
||||
team_ft_rate: pickStat(all, 'freeThrowAttemptRate'),
|
||||
opponent_fg_pct: pickStat(all, 'opponentFieldGoalPct'),
|
||||
opponent_3pt_pct: pickStat(all, 'opponentThreePointFieldGoalPct'),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMlb(payload) {
|
||||
const all = flattenTeamStats(payload);
|
||||
return {
|
||||
team_k_rate: pickStat(all, 'strikeOutRate') ?? pickStat(all, 'strikeoutsPerNine'),
|
||||
opponent_ppg: pickStat(all, 'runsAgainst'),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFootball(payload) {
|
||||
const all = flattenTeamStats(payload);
|
||||
return {
|
||||
offensive_rating: pickStat(all, 'totalPoints'),
|
||||
defensive_rating: pickStat(all, 'pointsAgainst'),
|
||||
opponent_ppg: pickStat(all, 'avgPointsAgainst'),
|
||||
};
|
||||
}
|
||||
|
||||
function normalize(sport, payload) {
|
||||
switch (sport) {
|
||||
case 'nba':
|
||||
case 'wnba':
|
||||
case 'ncaab':
|
||||
return normalizeBasketball(payload);
|
||||
case 'mlb':
|
||||
return normalizeMlb(payload);
|
||||
case 'nfl':
|
||||
case 'ncaafb':
|
||||
return normalizeFootball(payload);
|
||||
case 'nhl':
|
||||
default:
|
||||
return flattenTeamStats(payload).reduce((acc, s) => {
|
||||
if (s?.name && Number.isFinite(Number(s.value))) acc[s.name] = Number(s.value);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTeamStatsRaw(sport, teamId) {
|
||||
const path = SPORT_PATH[sport];
|
||||
if (!path) return null;
|
||||
await limiter.waitForToken();
|
||||
return breaker.call(async () => {
|
||||
const res = await axios.get(`${ESPN_BASE}/${path}/teams/${teamId}/statistics`, {
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
});
|
||||
return res.data;
|
||||
});
|
||||
}
|
||||
|
||||
async function listTeams(sport) {
|
||||
const path = SPORT_PATH[sport];
|
||||
if (!path) return [];
|
||||
await limiter.waitForToken();
|
||||
const res = await axios.get(`${ESPN_BASE}/${path}/teams`, { timeout: HTTP_TIMEOUT_MS });
|
||||
const groups = res.data?.sports?.[0]?.leagues?.[0]?.teams || [];
|
||||
return groups
|
||||
.map((t) => t?.team)
|
||||
.filter(Boolean)
|
||||
.map((t) => ({ id: String(t.id), abbr: t.abbreviation, name: t.displayName }));
|
||||
}
|
||||
|
||||
async function refreshTeamStats(sport) {
|
||||
const teams = await listTeams(sport);
|
||||
// Two-pass: fetch every team's stats first, then rank across the league
|
||||
// so we can normalize opponent rank to 0..1. A raw defensive_rating
|
||||
// means different things across sports (NBA ~100-120, NHL ~2.5-3.5
|
||||
// goals/game), so the cache stores both: raw + normalized.
|
||||
const fetched = [];
|
||||
let captured = 0;
|
||||
let errored = 0;
|
||||
for (const team of teams) {
|
||||
try {
|
||||
const raw = await fetchTeamStatsRaw(sport, team.id);
|
||||
if (!raw) { errored += 1; continue; }
|
||||
const stats = normalize(sport, raw);
|
||||
fetched.push({ team, stats });
|
||||
captured += 1;
|
||||
} catch (err) {
|
||||
if (err?.code !== 'CIRCUIT_OPEN') {
|
||||
console.warn(`[teamStats] ${sport}/${team.abbr} failed: ${err?.message}`);
|
||||
}
|
||||
errored += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Rank teams by defensive_rating ascending (lower allowed = better D).
|
||||
// Then map each team's rank to [0, 1] — 0 = best D (hardest matchup),
|
||||
// 1 = worst D (easiest matchup). The feature cache uses this directly.
|
||||
const withDef = fetched.filter((f) => Number.isFinite(Number(f.stats.defensive_rating)));
|
||||
withDef.sort((a, b) => Number(a.stats.defensive_rating) - Number(b.stats.defensive_rating));
|
||||
const total = withDef.length;
|
||||
for (let i = 0; i < withDef.length; i += 1) {
|
||||
withDef[i].stats.defensive_rank_normalized = total > 1 ? i / (total - 1) : 0.5;
|
||||
}
|
||||
|
||||
for (const { team, stats } of fetched) {
|
||||
await cacheSet(
|
||||
teamCacheKey(sport, team.abbr),
|
||||
{ ...stats, team_id: team.id, team_name: team.name },
|
||||
CACHE_TTL_SECONDS,
|
||||
);
|
||||
}
|
||||
return { captured, errored, total: teams.length };
|
||||
}
|
||||
|
||||
async function getTeamStats(sport, teamAbbr) {
|
||||
return cacheGet(teamCacheKey(sport, teamAbbr));
|
||||
}
|
||||
|
||||
// Returns the opponent's normalized defensive rank on a 0..1 scale.
|
||||
// 0.0 = best defense in the league (hardest matchup)
|
||||
// 1.0 = worst defense (easiest matchup)
|
||||
// Comparable across sports — NBA, NHL, NFL all collapse to the same
|
||||
// scale even though their raw defensive_rating values differ by orders
|
||||
// of magnitude. Returns null when we have no cache entry yet.
|
||||
async function getOpponentRank(sport, teamAbbr, _statType) {
|
||||
const stats = await getTeamStats(sport, teamAbbr);
|
||||
if (!stats) return null;
|
||||
if (Number.isFinite(Number(stats.defensive_rank_normalized))) {
|
||||
return Number(stats.defensive_rank_normalized);
|
||||
}
|
||||
// Backward-compat: if the cache predates the normalization upgrade, we
|
||||
// can't normalize a single-team read in isolation — return null and
|
||||
// let the feature cache omit the feature rather than emit a raw value.
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
refreshTeamStats,
|
||||
getTeamStats,
|
||||
getOpponentRank,
|
||||
__internals: { listTeams, normalize, teamCacheKey, limiter, breaker },
|
||||
};
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Trap detection — 7 independent signals → one composite trap score.
|
||||
*
|
||||
* reverse_line_movement — sharp money moved AGAINST public side
|
||||
* historical_hit_rate_paradox — high hit-rate AND line moving against them
|
||||
* new_context_trap — first game in a new context (playoffs G1)
|
||||
* recency_inflation — L5 dramatically above L20 (chasing hot)
|
||||
* juice_degradation — vig got worse while line stayed flat
|
||||
* teammate_return_trap — key teammate returning from injury
|
||||
* line_consensus_divergence — one book's line ≠ the consensus
|
||||
*
|
||||
* Composite formula:
|
||||
* composite = average(active_signal_scores)
|
||||
*
|
||||
* Only ACTIVE signals (the ones with enough data to compute) average in.
|
||||
* A null/inactive signal does NOT dilute the score — this prevents thin
|
||||
* data from producing an artificially-low trap score on new deployments.
|
||||
*
|
||||
* < 0.25 → proceed
|
||||
* < 0.50 → caution
|
||||
* ≥ 0.50 → avoid
|
||||
*/
|
||||
|
||||
const { reverseLineMovement, juiceDegradation, getLineMovement } = require('./lineMovement');
|
||||
const { getSupabaseServiceClient } = require('../../utils/supabase');
|
||||
|
||||
function inactive(reason) {
|
||||
return { score: 0, active: false, explanation: reason };
|
||||
}
|
||||
|
||||
// Normalize player names for matching across data sources. ParlayAPI may
|
||||
// emit "Brunson, Jalen" while ESPN emits "Jalen Brunson" — strip case,
|
||||
// punctuation, suffixes, and collapse whitespace so equivalence works.
|
||||
function normalizeName(name) {
|
||||
if (!name) return '';
|
||||
return String(name)
|
||||
.normalize('NFD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/\b(jr|sr|ii|iii|iv|v)\.?\b/g, '')
|
||||
.replace(/[^a-z\s]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Detect whether a key teammate transitioned from OUT in the recent past
|
||||
// to AVAILABLE now. Called by the orchestrator (Section 2) before invoking
|
||||
// the trap detector — the orchestrator owns the injury-history context.
|
||||
// priorInjuriesByGame is an array of injury snapshots (most recent first).
|
||||
// Each entry: array of { playerId, status }. Returns the highest-usage
|
||||
// teammate that has flipped from OUT/DOUBTFUL to PROBABLE/active, or null.
|
||||
function detectReturningTeammate(currentInjuries, priorInjuriesByGame, usageMap = {}) {
|
||||
if (!Array.isArray(priorInjuriesByGame) || priorInjuriesByGame.length === 0) return null;
|
||||
const currentOutIds = new Set(
|
||||
(currentInjuries || [])
|
||||
.filter((i) => i.status === 'OUT' || i.status === 'DOUBTFUL')
|
||||
.map((i) => String(i.playerId)),
|
||||
);
|
||||
// A player was "previously out" if they appeared as OUT/DOUBTFUL in any
|
||||
// of the last 1-3 games' snapshots.
|
||||
const priorOutIds = new Set();
|
||||
for (const snap of priorInjuriesByGame.slice(0, 3)) {
|
||||
for (const inj of snap || []) {
|
||||
if (inj.status === 'OUT' || inj.status === 'DOUBTFUL') {
|
||||
priorOutIds.add(String(inj.playerId));
|
||||
}
|
||||
}
|
||||
}
|
||||
let best = null;
|
||||
for (const id of priorOutIds) {
|
||||
if (currentOutIds.has(id)) continue;
|
||||
const usage = Number(usageMap[id]) || 0;
|
||||
if (!best || usage > best.usage) best = { playerId: id, usage };
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// 1. Reverse line movement.
|
||||
async function signalReverseLineMovement(input) {
|
||||
const { gameId, playerName, statType, publicBetPct } = input;
|
||||
if (!gameId || !playerName || !statType) return inactive('missing inputs');
|
||||
const r = await reverseLineMovement(gameId, playerName, statType, publicBetPct);
|
||||
if (!r) return inactive('not enough snapshots');
|
||||
if (!r.isReverse) return { score: 0, active: true, explanation: 'line moved with public' };
|
||||
return {
|
||||
score: r.score,
|
||||
active: true,
|
||||
explanation: `line moved toward ${r.lineDirection} while public was on ${r.publicSide}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Historical hit-rate paradox — high hit rate AND line moving against the
|
||||
// player. Uses resolution_results history. Confidence-scaled: thin history
|
||||
// gets a proportional penalty.
|
||||
async function signalHistoricalHitRateParadox(input) {
|
||||
const { playerName, statType, sport, gameId } = input;
|
||||
if (!playerName || !statType || !sport) return inactive('missing inputs');
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const { data, error } = await supabase
|
||||
.from('resolution_results')
|
||||
.select('result, direction, line')
|
||||
.eq('sport', sport)
|
||||
.eq('stat_type', statType)
|
||||
.eq('player_name', playerName);
|
||||
if (error || !data || data.length < 10) {
|
||||
return inactive(`only ${data?.length ?? 0} historical resolves`);
|
||||
}
|
||||
const hits = data.filter((r) => r.result === 'hit').length;
|
||||
const hitRate = hits / data.length;
|
||||
|
||||
const lm = gameId ? await getLineMovement(gameId, playerName, statType) : null;
|
||||
if (!lm) return inactive('no line movement context');
|
||||
|
||||
// "Against direction" — if the player generally bets OVER and the line
|
||||
// moves DOWN, that's a trap; flip for UNDER.
|
||||
const directionGuess = data.filter((r) => r.direction === 'over').length >= data.length / 2 ? 'over' : 'under';
|
||||
const againstDirection = (directionGuess === 'over' && lm.movement < 0)
|
||||
|| (directionGuess === 'under' && lm.movement > 0);
|
||||
if (!againstDirection) return { score: 0, active: true, explanation: 'line moving with the player\'s usual side' };
|
||||
|
||||
const confidence = Math.min(data.length / 20, 1.0);
|
||||
const score = Math.min(1.0, hitRate * Math.abs(lm.movement)) * confidence;
|
||||
return {
|
||||
score,
|
||||
active: true,
|
||||
explanation: `hit rate ${(hitRate * 100).toFixed(0)}% (${data.length} resolves) but line moved ${lm.movement} against ${directionGuess}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 3. New context trap — first game in a context where stats may not transfer.
|
||||
function signalNewContextTrap(input) {
|
||||
const { gameContext = {} } = input;
|
||||
let flags = 0;
|
||||
const reasons = [];
|
||||
if (gameContext.game_in_series === 1) { flags += 1; reasons.push('series_g1'); }
|
||||
if (gameContext.first_playoff_game) { flags += 1; reasons.push('first_playoff_game'); }
|
||||
if (gameContext.new_opponent_in_series) { flags += 1; reasons.push('new_opponent_in_series'); }
|
||||
if (gameContext.new_venue) { flags += 1; reasons.push('new_venue'); }
|
||||
if (flags === 0) return inactive('no context flags');
|
||||
return {
|
||||
score: flags / 4,
|
||||
active: true,
|
||||
explanation: `new context: ${reasons.join(', ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 4. Recency inflation — L5 dramatically above L20.
|
||||
function signalRecencyInflation(input) {
|
||||
const f = input.features || {};
|
||||
const l5 = Number(f.l5_avg);
|
||||
const l20 = Number(f.l20_avg);
|
||||
if (!Number.isFinite(l5) || !Number.isFinite(l20) || l20 <= 0) {
|
||||
return inactive('l5_avg or l20_avg missing');
|
||||
}
|
||||
const ratio = (l5 - l20) / l20;
|
||||
if (ratio <= 0) return { score: 0, active: true, explanation: 'L5 not hotter than L20' };
|
||||
return {
|
||||
score: Math.min(1.0, ratio),
|
||||
active: true,
|
||||
explanation: `L5 (${l5.toFixed(1)}) ${(ratio * 100).toFixed(0)}% above L20 (${l20.toFixed(1)})`,
|
||||
};
|
||||
}
|
||||
|
||||
// 5. Juice degradation — vig got worse while the line stayed flat.
|
||||
async function signalJuiceDegradation(input) {
|
||||
const { gameId, playerName, statType } = input;
|
||||
if (!gameId || !playerName || !statType) return inactive('missing inputs');
|
||||
const r = await juiceDegradation(gameId, playerName, statType);
|
||||
if (!r) return inactive('not enough snapshots');
|
||||
if (!r.applicable) return inactive('line moved too much for juice signal');
|
||||
return { score: r.score, active: true, explanation: `juice worsening on ${r.worstSide}` };
|
||||
}
|
||||
|
||||
// 6. Teammate return trap — key teammate returning → suppression.
|
||||
function signalTeammateReturnTrap(input) {
|
||||
const { gameContext = {} } = input;
|
||||
const returning = gameContext.returning_teammate_usage_rate;
|
||||
if (!Number.isFinite(returning) || returning <= 0) return inactive('no returning teammate');
|
||||
return {
|
||||
score: Math.min(1.0, returning * 0.5),
|
||||
active: true,
|
||||
explanation: `teammate returning with ${(returning * 100).toFixed(0)}% usage`,
|
||||
};
|
||||
}
|
||||
|
||||
// 7. Line consensus divergence — one book's line differs from the consensus.
|
||||
function signalLineConsensusDivergence(input) {
|
||||
const { odds = {} } = input;
|
||||
const consensus = odds.consensus;
|
||||
const playerLine = Number(odds.playerLine);
|
||||
if (!consensus || !Number.isFinite(playerLine)) return inactive('no consensus or player line');
|
||||
const median = Number(consensus.median);
|
||||
if (!Number.isFinite(median)) return inactive('consensus median missing');
|
||||
// Standard deviation across books; fall back to a 0.5 floor so even a
|
||||
// tight consensus produces a meaningful divisor.
|
||||
const stddev = Math.max(Number(consensus.stddev) || 0.5, 0.5);
|
||||
const score = Math.min(1.0, Math.abs(playerLine - median) / stddev);
|
||||
return {
|
||||
score,
|
||||
active: true,
|
||||
explanation: `player line ${playerLine} vs consensus median ${median} (σ=${stddev})`,
|
||||
};
|
||||
}
|
||||
|
||||
const SIGNALS = [
|
||||
['reverse_line_movement', signalReverseLineMovement],
|
||||
['historical_hit_rate_paradox', signalHistoricalHitRateParadox],
|
||||
['new_context_trap', signalNewContextTrap],
|
||||
['recency_inflation', signalRecencyInflation],
|
||||
['juice_degradation', signalJuiceDegradation],
|
||||
['teammate_return_trap', signalTeammateReturnTrap],
|
||||
['line_consensus_divergence', signalLineConsensusDivergence],
|
||||
];
|
||||
|
||||
function recommend(composite) {
|
||||
if (composite >= 0.5) return 'avoid';
|
||||
if (composite >= 0.25) return 'caution';
|
||||
return 'proceed';
|
||||
}
|
||||
|
||||
async function getTrapScore(input = {}) {
|
||||
const signals = {};
|
||||
for (const [name, fn] of SIGNALS) {
|
||||
try {
|
||||
const result = await fn(input);
|
||||
signals[name] = result;
|
||||
} catch (err) {
|
||||
signals[name] = { score: 0, active: false, explanation: `error: ${err?.message || 'unknown'}` };
|
||||
}
|
||||
}
|
||||
const activeScores = Object.values(signals)
|
||||
.filter((s) => s.active)
|
||||
.map((s) => s.score);
|
||||
const composite = activeScores.length === 0
|
||||
? 0
|
||||
: activeScores.reduce((a, b) => a + b, 0) / activeScores.length;
|
||||
return {
|
||||
composite,
|
||||
signals,
|
||||
active_count: activeScores.length,
|
||||
recommendation: recommend(composite),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getTrapScore,
|
||||
normalizeName,
|
||||
detectReturningTeammate,
|
||||
__internals: {
|
||||
signalReverseLineMovement,
|
||||
signalHistoricalHitRateParadox,
|
||||
signalNewContextTrap,
|
||||
signalRecencyInflation,
|
||||
signalJuiceDegradation,
|
||||
signalTeammateReturnTrap,
|
||||
signalLineConsensusDivergence,
|
||||
recommend,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Engine 1 weight adjustment — the learning loop.
|
||||
*
|
||||
* Every resolved prop nudges Engine 1's factor weights in the direction
|
||||
* the outcome implies. Factors that contributed to a winning grade get a
|
||||
* small boost; factors behind a losing grade get pulled down. Each nudge
|
||||
* is tiny on purpose:
|
||||
* - max ±0.5% per resolution
|
||||
* - weights clamped to [0.1, 5.0]
|
||||
* - versioned per (sport, stat_type, factor_name) for rollback
|
||||
* - skipped entirely until 20+ resolutions exist for the sport
|
||||
* (don't overfit a small sample)
|
||||
*
|
||||
* Every adjustment writes a new row in engine1_weights — the table is
|
||||
* append-only. To recall a factor's current weight, we read the latest
|
||||
* version. Rolling back means inserting a new row whose weight equals
|
||||
* an older version's weight.
|
||||
*/
|
||||
|
||||
const { getSupabaseServiceClient } = require('../../utils/supabase');
|
||||
|
||||
const LEARNING_RATE = 0.005;
|
||||
const MIN_WEIGHT = 0.1;
|
||||
const MAX_WEIGHT = 5.0;
|
||||
const MIN_RESOLUTIONS_TO_LEARN = 20;
|
||||
const DEFAULT_WEIGHT = 1.0;
|
||||
|
||||
const GRADE_CONFIDENCE = {
|
||||
'A+': 1.00, 'A': 0.90, 'A-': 0.80,
|
||||
'B+': 0.65, 'B': 0.55, 'B-': 0.45,
|
||||
'C+': 0.35, 'C': 0.25, 'C-': 0.20,
|
||||
'D': 0.15, 'F': 0.10,
|
||||
};
|
||||
|
||||
function clamp(w) {
|
||||
return Math.max(MIN_WEIGHT, Math.min(MAX_WEIGHT, w));
|
||||
}
|
||||
|
||||
async function countResolutions(sport) {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const { count, error } = await supabase
|
||||
.from('resolution_results')
|
||||
.select('id', { head: true, count: 'exact' })
|
||||
.eq('sport', sport);
|
||||
if (error) {
|
||||
console.warn('[weightAdjuster] count failed:', error.message);
|
||||
return 0;
|
||||
}
|
||||
return Number(count) || 0;
|
||||
}
|
||||
|
||||
async function getCurrentWeights(sport, statType) {
|
||||
// Latest version of each factor for (sport, stat_type).
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const { data, error } = await supabase
|
||||
.from('engine1_weights')
|
||||
.select('factor_name, weight, version')
|
||||
.eq('sport', sport)
|
||||
.eq('stat_type', statType)
|
||||
.order('version', { ascending: false });
|
||||
if (error) {
|
||||
console.warn('[weightAdjuster] read failed:', error.message);
|
||||
return {};
|
||||
}
|
||||
const latest = {};
|
||||
for (const row of data || []) {
|
||||
if (!(row.factor_name in latest)) {
|
||||
latest[row.factor_name] = { weight: Number(row.weight), version: row.version };
|
||||
}
|
||||
}
|
||||
// Flatten to { factor: weight }
|
||||
const out = {};
|
||||
for (const k of Object.keys(latest)) out[k] = latest[k].weight;
|
||||
return out;
|
||||
}
|
||||
|
||||
async function getNextVersion(sport, statType, factorName) {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const { data, error } = await supabase
|
||||
.from('engine1_weights')
|
||||
.select('version')
|
||||
.eq('sport', sport)
|
||||
.eq('stat_type', statType)
|
||||
.eq('factor_name', factorName)
|
||||
.order('version', { ascending: false })
|
||||
.limit(1);
|
||||
if (error) {
|
||||
console.warn('[weightAdjuster] version lookup failed:', error.message);
|
||||
return 1;
|
||||
}
|
||||
const top = data?.[0]?.version;
|
||||
return Number.isFinite(Number(top)) ? Number(top) + 1 : 1;
|
||||
}
|
||||
|
||||
async function persistAdjustment(sport, statType, factorName, newWeight, prevWeight, reason, resolvedGradeId) {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const version = await getNextVersion(sport, statType, factorName);
|
||||
const { error } = await supabase.from('engine1_weights').insert({
|
||||
sport,
|
||||
stat_type: statType,
|
||||
factor_name: factorName,
|
||||
weight: newWeight,
|
||||
previous_weight: prevWeight,
|
||||
adjustment_reason: reason,
|
||||
resolved_grade_id: resolvedGradeId || null,
|
||||
version,
|
||||
});
|
||||
if (error) {
|
||||
console.warn('[weightAdjuster] insert failed:', error.message);
|
||||
return null;
|
||||
}
|
||||
return version;
|
||||
}
|
||||
|
||||
// Public entry point. resolvedGrade carries the Engine 1 grade, the prop's
|
||||
// stat_type/sport, the resolved result, and the factors that drove the
|
||||
// grade (top_factors or all_factors).
|
||||
async function adjustWeights(resolvedGrade) {
|
||||
const { sport, stat_type: statType, grade, result, factors, grade_id: resolvedGradeId } = resolvedGrade || {};
|
||||
if (!sport || !statType || !grade || !result || !Array.isArray(factors) || factors.length === 0) {
|
||||
return { skipped: true, reason: 'incomplete_input' };
|
||||
}
|
||||
if (result !== 'hit' && result !== 'miss') {
|
||||
return { skipped: true, reason: 'non_decisive_result' };
|
||||
}
|
||||
|
||||
const sampleCount = await countResolutions(sport);
|
||||
if (sampleCount < MIN_RESOLUTIONS_TO_LEARN) {
|
||||
return { skipped: true, reason: 'thin_sample', sampleCount };
|
||||
}
|
||||
|
||||
const current = await getCurrentWeights(sport, statType);
|
||||
const confidence = GRADE_CONFIDENCE[grade] ?? 0.5;
|
||||
const sign = result === 'hit' ? 1 : -1;
|
||||
const multiplier = 1 + sign * LEARNING_RATE * confidence;
|
||||
|
||||
const adjustments = [];
|
||||
for (const factor of factors) {
|
||||
const prev = current[factor] ?? DEFAULT_WEIGHT;
|
||||
const next = clamp(prev * multiplier);
|
||||
const version = await persistAdjustment(
|
||||
sport, statType, factor, next, prev,
|
||||
`${result} on grade ${grade}`,
|
||||
resolvedGradeId,
|
||||
);
|
||||
adjustments.push({ factor, previous: prev, next, version });
|
||||
}
|
||||
return { skipped: false, adjustments, multiplier, confidence };
|
||||
}
|
||||
|
||||
// Restore to a prior version by inserting a NEW row whose weight equals the
|
||||
// target version's weight. Append-only is the safe primitive — we never
|
||||
// mutate or delete history.
|
||||
async function rollbackToVersion(sport, statType, factorName, targetVersion) {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const { data, error } = await supabase
|
||||
.from('engine1_weights')
|
||||
.select('weight')
|
||||
.eq('sport', sport)
|
||||
.eq('stat_type', statType)
|
||||
.eq('factor_name', factorName)
|
||||
.eq('version', targetVersion)
|
||||
.maybeSingle();
|
||||
if (error || !data) {
|
||||
console.warn('[weightAdjuster] rollback target not found');
|
||||
return false;
|
||||
}
|
||||
const current = (await getCurrentWeights(sport, statType))[factorName] ?? DEFAULT_WEIGHT;
|
||||
const version = await persistAdjustment(
|
||||
sport, statType, factorName, Number(data.weight), current,
|
||||
`rollback to v${targetVersion}`,
|
||||
null,
|
||||
);
|
||||
return Number.isFinite(version);
|
||||
}
|
||||
|
||||
async function getWeightHistory(sport, statType, factorName, limit = 50) {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const { data, error } = await supabase
|
||||
.from('engine1_weights')
|
||||
.select('weight, previous_weight, adjustment_reason, version, created_at')
|
||||
.eq('sport', sport)
|
||||
.eq('stat_type', statType)
|
||||
.eq('factor_name', factorName)
|
||||
.order('version', { ascending: false })
|
||||
.limit(limit);
|
||||
if (error) return [];
|
||||
return data || [];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
adjustWeights,
|
||||
getCurrentWeights,
|
||||
rollbackToVersion,
|
||||
getWeightHistory,
|
||||
LEARNING_RATE,
|
||||
MIN_WEIGHT,
|
||||
MAX_WEIGHT,
|
||||
MIN_RESOLUTIONS_TO_LEARN,
|
||||
__internals: { clamp, countResolutions, getNextVersion, persistAdjustment, GRADE_CONFIDENCE },
|
||||
};
|
||||
Reference in New Issue
Block a user