Files
vyndr/tests/unit/shipResolution.test.js

549 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* shipResolution.test.js
* Tests the resolution pipeline, calibration, archetype/weights, parlay,
* capper content, and migration SQL for the VYNDR ship build.
*/
const fs = require('fs');
const path = require('path');
// ---------- Migration SQL ----------
const sql005 = fs.readFileSync(path.join(__dirname, '../../supabase/migrations/005_lineup_scheme_data.sql'), 'utf8');
const sql006 = fs.readFileSync(path.join(__dirname, '../../supabase/migrations/006_data_warehouse_calibration.sql'), 'utf8');
const sql007 = fs.readFileSync(path.join(__dirname, '../../supabase/migrations/007_lineup_odds_trust_health.sql'), 'utf8');
// ==========================================================
// Helpers — inline logic that mirrors production functions
// ==========================================================
function resolveHit(direction, propLine, actualValue) {
if (direction === 'over') return actualValue > propLine;
if (direction === 'under') return actualValue < propLine;
return null;
}
function calculateCLV(openingLine, closingLine, direction) {
if (openingLine == null || closingLine == null) return null;
const movement = closingLine - openingLine;
const favorable = direction === 'over' ? movement > 0 : movement < 0;
return {
clv: favorable ? Math.abs(movement) : -Math.abs(movement),
clv_magnitude: Math.abs(movement),
};
}
function modelMarketAlignment(openingLine, closingLine, direction) {
if (openingLine == null || closingLine == null) return null;
const movement = closingLine - openingLine;
const marketMovedWithUs =
(direction === 'over' && movement > 0) ||
(direction === 'under' && movement < 0);
return marketMovedWithUs ? 'confirming' : 'contrarian';
}
function createJointOutcomes(grades) {
const resolved = grades.filter((g) => g.hit != null);
const pairs = [];
for (let i = 0; i < resolved.length; i++) {
for (let j = i + 1; j < resolved.length; j++) {
pairs.push({
player_a_id: resolved[i].player_id,
player_b_id: resolved[j].player_id,
stat_a: resolved[i].stat_type,
stat_b: resolved[j].stat_type,
hit_a: resolved[i].hit,
hit_b: resolved[j].hit,
});
}
}
return pairs;
}
function shouldRecalibrate(sampleSize) {
return [25, 50, 75, 100].includes(sampleSize);
}
function clampOffset(raw) {
return Math.max(-0.15, Math.min(0.15, raw));
}
function brierScore(confidence, hit) {
return (confidence - (hit ? 1 : 0)) ** 2;
}
function detectBlindSpots(grades) {
if (grades.length < 200) return [];
const grouped = {};
for (const g of grades) {
const key = `${g.stat_type}|${g.context}`;
if (!grouped[key]) grouped[key] = { total: 0, hits: 0 };
grouped[key].total += 1;
if (g.hit) grouped[key].hits += 1;
}
const overall = grades.filter((g) => g.hit).length / grades.length;
const spots = [];
for (const [key, val] of Object.entries(grouped)) {
const rate = val.hits / val.total;
if ((overall - rate) / overall >= 0.25) {
spots.push({ key, rate, degradation: (overall - rate) / overall });
}
}
return spots;
}
function catastrophicMisses(grades) {
const resolved = grades.filter((g) => g.hit === false && g.actual_value != null);
resolved.sort((a, b) => Math.abs(b.actual_value - b.prop_line) - Math.abs(a.actual_value - a.prop_line));
const cutoff = Math.max(5, Math.ceil(resolved.length * 0.05));
return resolved.slice(0, cutoff);
}
// NBA archetype detection & weight blending
const ARCHETYPE_WEIGHTS = {
primary_scorer: { matchup_defense: 0.30, usage_context: 0.25, recent_form: 0.20, pace_impact: 0.15, rest_travel: 0.10 },
secondary_creator: { usage_context: 0.35, matchup_defense: 0.20, recent_form: 0.20, pace_impact: 0.15, rest_travel: 0.10 },
stretch_big: { matchup_defense: 0.25, usage_context: 0.20, recent_form: 0.20, pace_impact: 0.20, rest_travel: 0.15 },
default: { matchup_defense: 0.20, usage_context: 0.20, recent_form: 0.20, pace_impact: 0.20, rest_travel: 0.20 },
};
function detectArchetypes(profile) {
const scores = {};
if (profile.pts_per_game >= 22 && profile.usage_rate >= 0.28) {
scores.primary_scorer = (profile.pts_per_game / 30) * 0.5 + (profile.usage_rate / 0.35) * 0.5;
}
if (profile.ast_per_game >= 5 && profile.usage_rate >= 0.20) {
scores.secondary_creator = (profile.ast_per_game / 10) * 0.5 + (profile.usage_rate / 0.30) * 0.5;
}
if (profile.reb_per_game >= 7 && profile.three_pa_rate >= 0.25) {
scores.stretch_big = (profile.reb_per_game / 12) * 0.5 + (profile.three_pa_rate / 0.40) * 0.5;
}
return scores;
}
function blendWeights(archetypeScores) {
const entries = Object.entries(archetypeScores).filter(([, s]) => s >= 0.1);
if (entries.length === 0) return { ...ARCHETYPE_WEIGHTS.default };
const totalScore = entries.reduce((s, [, v]) => s + v, 0);
const blended = {};
for (const [arch, score] of entries) {
const proportion = score / totalScore;
const aw = ARCHETYPE_WEIGHTS[arch];
for (const [dim, w] of Object.entries(aw)) {
blended[dim] = (blended[dim] || 0) + w * proportion;
}
}
return blended;
}
// Parlay helpers
function isSameGame(legs) {
const gameIds = legs.map((l) => l.game_id);
return new Set(gameIds).size === 1;
}
function structuralPenalty(legCount) {
return legCount > 2 ? (legCount - 2) * 0.03 : 0;
}
function canComputePhi(jointCount) {
return jointCount >= 30;
}
// Capper content formatters
function formatCapperPick(pick) {
return `VYNDR Scan #${pick.number}: ${pick.player} ${pick.direction} ${pick.line} ${pick.stat}`;
}
function formatBreakingAlert(alert) {
return `BREAKING: ${alert.player}${alert.message}`;
}
function formatDailyResults(results) {
return results.map((r) => `${r.hit ? '✅' : '❌'} ${r.player} ${r.stat} ${r.line}`).join('\n');
}
function formatMissAutopsy(miss) {
return `${miss.player} ${miss.stat} ${miss.line}\nWhy: ${miss.reason}`;
}
// ==========================================================
// TESTS
// ==========================================================
// ---- 1. Resolution hit/miss ----
describe('Resolution hit/miss', () => {
test('over hit when actual > line', () => {
expect(resolveHit('over', 22.5, 28)).toBe(true);
});
test('over miss when actual < line', () => {
expect(resolveHit('over', 22.5, 18)).toBe(false);
});
test('under hit when actual < line', () => {
expect(resolveHit('under', 22.5, 18)).toBe(true);
});
test('under miss when actual > line', () => {
expect(resolveHit('under', 22.5, 28)).toBe(false);
});
});
// ---- 2. CLV calculation ----
describe('CLV calculation', () => {
test('positive CLV when closing moves toward our direction', () => {
const result = calculateCLV(22.5, 24.0, 'over');
expect(result.clv).toBeGreaterThan(0);
});
test('negative CLV when closing moves against our direction', () => {
const result = calculateCLV(22.5, 20.0, 'over');
expect(result.clv).toBeLessThan(0);
});
test('null when no odds data', () => {
expect(calculateCLV(null, null, 'over')).toBeNull();
});
test('clv_magnitude is abs(movement)', () => {
const result = calculateCLV(22.5, 20.0, 'over');
expect(result.clv_magnitude).toBe(2.5);
});
});
// ---- 3. Model-market alignment ----
describe('Model-market alignment', () => {
test('confirming when market moves with us (over)', () => {
expect(modelMarketAlignment(22.5, 24.0, 'over')).toBe('confirming');
});
test('contrarian when market moves against us', () => {
expect(modelMarketAlignment(22.5, 20.0, 'over')).toBe('contrarian');
});
test('null when no data', () => {
expect(modelMarketAlignment(null, null, 'over')).toBeNull();
});
});
// ---- 4. Joint outcome logging ----
describe('Joint outcome logging', () => {
test('creates pair for same-game grades', () => {
const grades = [
{ player_id: 'A', stat_type: 'pts', hit: true },
{ player_id: 'B', stat_type: 'reb', hit: false },
];
const pairs = createJointOutcomes(grades);
expect(pairs).toHaveLength(1);
expect(pairs[0].player_a_id).toBe('A');
expect(pairs[0].player_b_id).toBe('B');
});
test('skips self-pair', () => {
const grades = [{ player_id: 'A', stat_type: 'pts', hit: true }];
const pairs = createJointOutcomes(grades);
expect(pairs).toHaveLength(0);
});
test('skips unresolved pairs', () => {
const grades = [
{ player_id: 'A', stat_type: 'pts', hit: true },
{ player_id: 'B', stat_type: 'reb', hit: null },
];
const pairs = createJointOutcomes(grades);
expect(pairs).toHaveLength(0);
});
});
// ---- 5. Calibration thresholds ----
describe('Calibration thresholds', () => {
test('triggers recalibration at 25, 50, 75, 100', () => {
expect(shouldRecalibrate(25)).toBe(true);
expect(shouldRecalibrate(50)).toBe(true);
expect(shouldRecalibrate(75)).toBe(true);
expect(shouldRecalibrate(100)).toBe(true);
expect(shouldRecalibrate(30)).toBe(false);
});
test('point-biserial correlation bounds 0.050.50', () => {
const lower = 0.05;
const upper = 0.50;
const validCorrelation = 0.22;
expect(validCorrelation).toBeGreaterThanOrEqual(lower);
expect(validCorrelation).toBeLessThanOrEqual(upper);
expect(0.01).toBeLessThan(lower);
expect(0.55).toBeGreaterThan(upper);
});
test('global offset thresholds at 100/250/500/1000', () => {
const thresholds = [100, 250, 500, 1000];
expect(thresholds).toContain(100);
expect(thresholds).toContain(250);
expect(thresholds).toContain(500);
expect(thresholds).toContain(1000);
});
});
// ---- 6. Global offset clamp ----
describe('Global offset clamp', () => {
test('clamped to 0.15 max', () => {
expect(clampOffset(0.30)).toBe(0.15);
});
test('clamped to -0.15 min', () => {
expect(clampOffset(-0.25)).toBe(-0.15);
});
});
// ---- 7. Brier score update ----
describe('Brier score', () => {
test('Brier = 0.0 for perfect prediction', () => {
expect(brierScore(1.0, true)).toBeCloseTo(0.0);
});
test('Brier = 0.25 for coin flip', () => {
expect(brierScore(0.5, true)).toBeCloseTo(0.25);
});
});
// ---- 8. Blind spot detection ----
describe('Blind spot detection', () => {
test('requires 200+ grades minimum', () => {
const grades = Array.from({ length: 150 }, (_, i) => ({
stat_type: 'pts', context: 'home', hit: i % 2 === 0,
}));
expect(detectBlindSpots(grades)).toEqual([]);
});
test('flags 25%+ degradation', () => {
// 200 grades: 140 hit overall (70%), but stat_type=reb|away: 10 total, 3 hit (30%) → degradation = (0.7-0.3)/0.7 = 0.57
const grades = [];
for (let i = 0; i < 190; i++) {
grades.push({ stat_type: 'pts', context: 'home', hit: i < 137 });
}
for (let i = 0; i < 10; i++) {
grades.push({ stat_type: 'reb', context: 'away', hit: i < 3 });
}
const spots = detectBlindSpots(grades);
expect(spots.length).toBeGreaterThanOrEqual(1);
expect(spots[0].degradation).toBeGreaterThanOrEqual(0.25);
});
test('returns empty below threshold', () => {
const grades = Array.from({ length: 199 }, () => ({
stat_type: 'pts', context: 'home', hit: true,
}));
expect(detectBlindSpots(grades)).toEqual([]);
});
});
// ---- 9. Catastrophic miss tracking ----
describe('Catastrophic miss tracking', () => {
test('finds worst 5%', () => {
const grades = [];
for (let i = 0; i < 200; i++) {
grades.push({
hit: false,
actual_value: 10 + i,
prop_line: 5,
});
}
const misses = catastrophicMisses(grades);
expect(misses.length).toBe(10); // 5% of 200
});
test('minimum 5 misses returned', () => {
const grades = [];
for (let i = 0; i < 20; i++) {
grades.push({ hit: false, actual_value: 30 + i, prop_line: 10 });
}
const misses = catastrophicMisses(grades);
expect(misses.length).toBeGreaterThanOrEqual(5);
});
test('sorted by abs_error descending', () => {
const grades = [
{ hit: false, actual_value: 50, prop_line: 20 },
{ hit: false, actual_value: 25, prop_line: 20 },
{ hit: false, actual_value: 40, prop_line: 20 },
];
const misses = catastrophicMisses(grades);
const errors = misses.map((m) => Math.abs(m.actual_value - m.prop_line));
for (let i = 1; i < errors.length; i++) {
expect(errors[i - 1]).toBeGreaterThanOrEqual(errors[i]);
}
});
});
// ---- 10. NBA archetype weight blending ----
describe('NBA archetype weight blending', () => {
test('primary_scorer has matchup_defense at 0.30', () => {
expect(ARCHETYPE_WEIGHTS.primary_scorer.matchup_defense).toBe(0.30);
});
test('secondary_creator has usage_context at 0.35', () => {
expect(ARCHETYPE_WEIGHTS.secondary_creator.usage_context).toBe(0.35);
});
test('default weights when all archetype scores < 0.1', () => {
const weights = blendWeights({ primary_scorer: 0.05, secondary_creator: 0.02 });
expect(weights).toEqual(ARCHETYPE_WEIGHTS.default);
});
test('blending with multiple archetypes produces proportional mix', () => {
const scores = { primary_scorer: 0.6, secondary_creator: 0.4 };
const weights = blendWeights(scores);
// primary proportion = 0.6, secondary = 0.4, total = 1.0
const expectedMatchupDefense = 0.30 * 0.6 + 0.20 * 0.4;
expect(weights.matchup_defense).toBeCloseTo(expectedMatchupDefense);
});
test('weights sum to ~1.0', () => {
const scores = { primary_scorer: 0.8, secondary_creator: 0.5 };
const weights = blendWeights(scores);
const sum = Object.values(weights).reduce((a, b) => a + b, 0);
expect(sum).toBeCloseTo(1.0, 2);
});
test('stretch_big detected from reb_per_game + three_pa_rate', () => {
const profile = {
pts_per_game: 14, usage_rate: 0.18, ast_per_game: 2,
reb_per_game: 9.5, three_pa_rate: 0.32,
};
const archetypes = detectArchetypes(profile);
expect(archetypes).toHaveProperty('stretch_big');
expect(archetypes.stretch_big).toBeGreaterThan(0);
});
});
// ---- 11. Parlay correlation ----
describe('Parlay correlation', () => {
test('same-game detected', () => {
const legs = [
{ game_id: 'G1', player: 'A' },
{ game_id: 'G1', player: 'B' },
];
expect(isSameGame(legs)).toBe(true);
});
test('structural penalty 0.03 per extra leg beyond 2', () => {
expect(structuralPenalty(2)).toBe(0);
expect(structuralPenalty(3)).toBeCloseTo(0.03);
expect(structuralPenalty(5)).toBeCloseTo(0.09);
});
test('phi requires 30+ joints', () => {
expect(canComputePhi(29)).toBe(false);
expect(canComputePhi(30)).toBe(true);
});
test('warning on 4+ legs', () => {
const legCount = 4;
const warning = legCount >= 4 ? 'High-leg parlay — correlation risk elevated' : null;
expect(warning).not.toBeNull();
expect(warning).toContain('correlation');
});
});
// ---- 12. Capper content ----
describe('Capper content', () => {
test('pick number increments', () => {
const pick1 = formatCapperPick({ number: 1, player: 'Tatum', direction: 'over', line: 27.5, stat: 'pts' });
const pick2 = formatCapperPick({ number: 2, player: 'Brown', direction: 'under', line: 5.5, stat: 'ast' });
expect(pick1).toContain('#1');
expect(pick2).toContain('#2');
});
test('breaking alert format includes BREAKING', () => {
const alert = formatBreakingAlert({ player: 'Embiid', message: 'ruled out' });
expect(alert).toContain('BREAKING');
});
test('standard format includes VYNDR Scan', () => {
const pick = formatCapperPick({ number: 5, player: 'Jokic', direction: 'over', line: 11.5, stat: 'reb' });
expect(pick).toContain('VYNDR Scan');
});
test('daily results format includes hit/miss icons', () => {
const results = formatDailyResults([
{ hit: true, player: 'LeBron', stat: 'pts', line: 25.5 },
{ hit: false, player: 'AD', stat: 'reb', line: 10.5 },
]);
expect(results).toContain('✅');
expect(results).toContain('❌');
});
test('miss autopsy includes Why:', () => {
const autopsy = formatMissAutopsy({
player: 'Harden', stat: 'ast', line: 9.5,
reason: 'Early foul trouble limited minutes',
});
expect(autopsy).toContain('Why:');
});
});
// ---- 13. Migration 005 SQL ----
describe('Migration 005 SQL', () => {
test('creates lineup_scheme_data', () => {
expect(sql005).toContain('CREATE TABLE IF NOT EXISTS lineup_scheme_data');
});
test('has RLS', () => {
expect(sql005).toContain('ENABLE ROW LEVEL SECURITY');
});
test('has indexes', () => {
expect(sql005).toContain('CREATE INDEX IF NOT EXISTS idx_ls_team');
expect(sql005).toContain('CREATE INDEX IF NOT EXISTS idx_ls_hash');
expect(sql005).toContain('CREATE INDEX IF NOT EXISTS idx_ls_date');
});
});
// ---- 14. Migration 006 SQL ----
describe('Migration 006 SQL', () => {
test('creates grade_outcomes with discipline_score column', () => {
expect(sql006).toContain('CREATE TABLE IF NOT EXISTS grade_outcomes');
expect(sql006).toContain('discipline_score DECIMAL');
});
test('creates nba_data_cache', () => {
expect(sql006).toContain('CREATE TABLE IF NOT EXISTS nba_data_cache');
});
test('creates player_calibrated_weights', () => {
expect(sql006).toContain('CREATE TABLE IF NOT EXISTS player_calibrated_weights');
});
test('grade_outcomes has clv columns', () => {
expect(sql006).toContain('clv_opening_line DECIMAL');
expect(sql006).toContain('clv_closing_line DECIMAL');
expect(sql006).toContain('clv_movement DECIMAL');
expect(sql006).toContain('clv_win BOOLEAN');
});
});
// ---- 15. Migration 007 SQL ----
describe('Migration 007 SQL', () => {
test('creates reporter_trust with source_type', () => {
expect(sql007).toContain('CREATE TABLE IF NOT EXISTS reporter_trust');
expect(sql007).toContain('source_type TEXT NOT NULL');
});
test('creates odds_warehouse', () => {
expect(sql007).toContain('CREATE TABLE IF NOT EXISTS odds_warehouse');
});
test('creates reporter_line_correlation', () => {
expect(sql007).toContain('CREATE TABLE IF NOT EXISTS reporter_line_correlation');
});
test('creates ship_joint_outcomes', () => {
expect(sql007).toContain('CREATE TABLE IF NOT EXISTS ship_joint_outcomes');
});
test('creates global_calibration', () => {
expect(sql007).toContain('CREATE TABLE IF NOT EXISTS global_calibration');
});
});