/** * VYNDR Ship Grading Engine — Unit Tests * * Pure logic tests for grading engine components. * All constants and formulas are inlined — no external imports. * Tests the DATA CONTRACTS that the grading engine must honor. */ // --------------------------------------------------------------------------- // 1. Grade Thresholds (4 tests) // --------------------------------------------------------------------------- describe('Grade Thresholds', () => { const GRADE_MAP = [ { min: 0.85, max: 1.00, grade: 'A+' }, { min: 0.78, max: 0.84, grade: 'A' }, { min: 0.72, max: 0.77, grade: 'B+' }, { min: 0.66, max: 0.71, grade: 'B' }, { min: 0.55, max: 0.65, grade: 'C+' }, { min: 0.40, max: 0.54, grade: 'C' }, { min: 0.29, max: 0.39, grade: 'D' }, { min: 0.00, max: 0.28, grade: 'F' }, ]; function toGrade(confidence) { for (const tier of GRADE_MAP) { if (confidence >= tier.min && confidence <= tier.max) return tier.grade; } return 'F'; } test('A+ range covers 0.85 to 1.00', () => { expect(toGrade(0.85)).toBe('A+'); expect(toGrade(0.92)).toBe('A+'); expect(toGrade(1.00)).toBe('A+'); }); test('B+ range covers 0.72 to 0.77 (not 0.66-0.71 which is B)', () => { expect(toGrade(0.66)).toBe('B'); expect(toGrade(0.71)).toBe('B'); expect(toGrade(0.72)).toBe('B+'); }); test('C+ caps at 0.54 on the low end when data is limited', () => { const DATA_LIMITED_CAP = 0.54; const rawConfidence = 0.78; const capped = Math.min(rawConfidence, DATA_LIMITED_CAP); expect(capped).toBe(0.54); expect(toGrade(capped)).toBe('C'); }); test('F grade for confidence below 0.29', () => { expect(toGrade(0.10)).toBe('F'); expect(toGrade(0.28)).toBe('F'); expect(toGrade(0.00)).toBe('F'); }); }); // --------------------------------------------------------------------------- // 2. Bayesian Weights Per-Stat-Type (5 tests) // --------------------------------------------------------------------------- describe('Bayesian Weights Per-Stat-Type', () => { const BAYESIAN_WEIGHTS = { strikeouts: { prior: 0.40, recent: 0.35, context: 0.25 }, points: { prior: 0.30, recent: 0.45, context: 0.25 }, assists: { prior: 0.25, recent: 0.50, context: 0.25 }, rebounds: { prior: 0.30, recent: 0.40, context: 0.30 }, threes: { prior: 0.35, recent: 0.40, context: 0.25 }, }; const DEFAULT_WEIGHTS = { prior: 0.33, recent: 0.34, context: 0.33 }; test('strikeouts has prior weight of 0.40', () => { expect(BAYESIAN_WEIGHTS.strikeouts.prior).toBe(0.40); }); test('points has recent weight of 0.45', () => { expect(BAYESIAN_WEIGHTS.points.recent).toBe(0.45); }); test('assists has recent weight of 0.50', () => { expect(BAYESIAN_WEIGHTS.assists.recent).toBe(0.50); }); test('default weights sum to approximately 1.0', () => { const sum = DEFAULT_WEIGHTS.prior + DEFAULT_WEIGHTS.recent + DEFAULT_WEIGHTS.context; expect(sum).toBeCloseTo(1.0, 5); }); test('every stat type has prior + recent + context summing to 1.0', () => { for (const [stat, weights] of Object.entries(BAYESIAN_WEIGHTS)) { const sum = weights.prior + weights.recent + weights.context; expect(sum).toBeCloseTo(1.0, 5); } }); }); // --------------------------------------------------------------------------- // 3. Abstention Logic (3 tests) // --------------------------------------------------------------------------- describe('Abstention Logic', () => { function shouldAbstain({ confidence, similar_games, data_quality }) { if (confidence >= 0.40 && confidence <= 0.55 && similar_games < 3) return true; if (data_quality === 'limited' && confidence < 0.55) return true; return false; } test('abstain when confidence 0.40-0.55 AND similar_games < 3', () => { expect(shouldAbstain({ confidence: 0.45, similar_games: 2, data_quality: 'normal' })).toBe(true); expect(shouldAbstain({ confidence: 0.50, similar_games: 0, data_quality: 'normal' })).toBe(true); }); test('abstain when data_quality is limited AND confidence < 0.55', () => { expect(shouldAbstain({ confidence: 0.50, similar_games: 10, data_quality: 'limited' })).toBe(true); expect(shouldAbstain({ confidence: 0.40, similar_games: 5, data_quality: 'limited' })).toBe(true); }); test('do NOT abstain when confidence > 0.55', () => { expect(shouldAbstain({ confidence: 0.60, similar_games: 1, data_quality: 'normal' })).toBe(false); expect(shouldAbstain({ confidence: 0.80, similar_games: 0, data_quality: 'normal' })).toBe(false); }); }); // --------------------------------------------------------------------------- // 4. Global Offset Clamped +/-0.15 (3 tests) // --------------------------------------------------------------------------- describe('Global Offset Clamped +/-0.15', () => { const OFFSET_CLAMP = 0.15; const MIN_SAMPLE_SIZE = 20; function clampOffset(rawOffset, sampleSize) { if (sampleSize < MIN_SAMPLE_SIZE) return 0.0; return Math.max(-OFFSET_CLAMP, Math.min(OFFSET_CLAMP, rawOffset)); } test('clamps positive offset to +0.15', () => { expect(clampOffset(0.30, 50)).toBe(0.15); expect(clampOffset(0.15, 50)).toBe(0.15); }); test('clamps negative offset to -0.15', () => { expect(clampOffset(-0.25, 50)).toBe(-0.15); expect(clampOffset(-0.15, 50)).toBe(-0.15); }); test('returns zero when sample size is insufficient', () => { expect(clampOffset(0.10, 5)).toBe(0.0); expect(clampOffset(-0.20, 19)).toBe(0.0); }); }); // --------------------------------------------------------------------------- // 5. Data Sufficiency Smooth Curve (4 tests) // --------------------------------------------------------------------------- describe('Data Sufficiency Smooth Curve', () => { const C_PLUS_CAP = 0.54; const MIN_GAMES = 5; const FULL_GAMES = MIN_GAMES * 2; // 10 function dataSufficiencyMultiplier(gamesPlayed) { if (gamesPlayed < MIN_GAMES) return C_PLUS_CAP; if (gamesPlayed >= FULL_GAMES) return 1.0; // smooth ramp from 0.70 to 1.0 between MIN_GAMES and FULL_GAMES const t = (gamesPlayed - MIN_GAMES) / (FULL_GAMES - MIN_GAMES); return 0.70 + 0.30 * t; } test('below minimum returns C+ cap (0.54)', () => { expect(dataSufficiencyMultiplier(0)).toBe(C_PLUS_CAP); expect(dataSufficiencyMultiplier(4)).toBe(C_PLUS_CAP); }); test('at minimum returns 70% confidence multiplier', () => { expect(dataSufficiencyMultiplier(MIN_GAMES)).toBeCloseTo(0.70, 5); }); test('at 2x minimum returns 100% confidence multiplier', () => { expect(dataSufficiencyMultiplier(FULL_GAMES)).toBe(1.0); expect(dataSufficiencyMultiplier(15)).toBe(1.0); }); test('ramp is smooth between min and 2x min', () => { const at6 = dataSufficiencyMultiplier(6); const at7 = dataSufficiencyMultiplier(7); const at8 = dataSufficiencyMultiplier(8); // monotonically increasing expect(at7).toBeGreaterThan(at6); expect(at8).toBeGreaterThan(at7); // all within the 0.70–1.0 band expect(at6).toBeGreaterThanOrEqual(0.70); expect(at8).toBeLessThanOrEqual(1.0); }); }); // --------------------------------------------------------------------------- // 6. Real Edge with Vig (4 tests) // --------------------------------------------------------------------------- describe('Real Edge with Vig', () => { function americanToImpliedProb(odds) { if (odds < 0) return Math.abs(odds) / (Math.abs(odds) + 100); return 100 / (odds + 100); } function edgeVerdict(modelProb, impliedProb) { const edge = modelProb - impliedProb; return edge > 0 ? 'BET' : 'NO BET'; } test('-110 implied probability is approximately 0.524', () => { const prob = americanToImpliedProb(-110); expect(prob).toBeCloseTo(0.524, 2); }); test('+150 implied probability is approximately 0.40', () => { const prob = americanToImpliedProb(150); expect(prob).toBeCloseTo(0.40, 2); }); test('positive EV when model probability > implied probability', () => { const implied = americanToImpliedProb(-110); // ~0.524 const modelProb = 0.60; expect(edgeVerdict(modelProb, implied)).toBe('BET'); }); test('negative EV returns NO BET', () => { const implied = americanToImpliedProb(-110); // ~0.524 const modelProb = 0.48; expect(edgeVerdict(modelProb, implied)).toBe('NO BET'); }); }); // --------------------------------------------------------------------------- // 7. Kelly Criterion (3 tests) // --------------------------------------------------------------------------- describe('Kelly Criterion', () => { function americanToDecimalOdds(odds) { if (odds < 0) return 1 + (100 / Math.abs(odds)); return 1 + (odds / 100); } function quarterKelly(modelProb, americanOdds) { const decimal = americanToDecimalOdds(americanOdds); const b = decimal - 1; const q = 1 - modelProb; const fullKelly = (modelProb * b - q) / b; if (fullKelly <= 0) return 0; return fullKelly / 4; } test('quarter Kelly divides full Kelly by 4', () => { const modelProb = 0.60; const odds = -110; const decimal = americanToDecimalOdds(odds); // ~1.909 const b = decimal - 1; const fullKelly = (modelProb * b - (1 - modelProb)) / b; const qk = quarterKelly(modelProb, odds); expect(qk).toBeCloseTo(fullKelly / 4, 5); }); test('returns 0 for negative EV bets', () => { expect(quarterKelly(0.40, -150)).toBe(0); expect(quarterKelly(0.30, -110)).toBe(0); }); test('reasonable sizing for +EV bet', () => { const size = quarterKelly(0.60, -110); // quarter Kelly should be modest — well under 10% of bankroll expect(size).toBeGreaterThan(0); expect(size).toBeLessThan(0.10); }); }); // --------------------------------------------------------------------------- // 8. Brier Score (3 tests) // --------------------------------------------------------------------------- describe('Brier Score', () => { function brierScore(predictions) { if (!predictions || predictions.length === 0) return null; const sum = predictions.reduce((acc, { prob, outcome }) => { return acc + Math.pow(prob - outcome, 2); }, 0); return sum / predictions.length; } test('perfect prediction yields Brier score of 0', () => { const preds = [ { prob: 1.0, outcome: 1 }, { prob: 0.0, outcome: 0 }, ]; expect(brierScore(preds)).toBe(0); }); test('coin flip predictions yield Brier score of 0.25', () => { const preds = [ { prob: 0.5, outcome: 1 }, { prob: 0.5, outcome: 0 }, ]; expect(brierScore(preds)).toBeCloseTo(0.25, 5); }); test('returns null for empty data', () => { expect(brierScore([])).toBeNull(); expect(brierScore(null)).toBeNull(); }); }); // --------------------------------------------------------------------------- // 9. Similar Game Confidence Modifier (3 tests) // --------------------------------------------------------------------------- describe('Similar Game Confidence Modifier', () => { function similarGameModifier(similarGames) { if (similarGames >= 10) return 0.05; if (similarGames <= 1) return -0.03; if (similarGames >= 2 && similarGames <= 4) return 0.0; // 5-9 range — partial boost return 0.02; } test('+0.05 for 10+ similar games', () => { expect(similarGameModifier(10)).toBe(0.05); expect(similarGameModifier(25)).toBe(0.05); }); test('-0.03 for 0-1 similar games', () => { expect(similarGameModifier(0)).toBe(-0.03); expect(similarGameModifier(1)).toBe(-0.03); }); test('0.0 for 2-4 similar games', () => { expect(similarGameModifier(2)).toBe(0.0); expect(similarGameModifier(3)).toBe(0.0); expect(similarGameModifier(4)).toBe(0.0); }); }); // --------------------------------------------------------------------------- // 10. Skewness (2 tests) // --------------------------------------------------------------------------- describe('Skewness', () => { function skewness(values) { if (!values || values.length < 10) return 0.0; const n = values.length; const mean = values.reduce((a, b) => a + b, 0) / n; const variance = values.reduce((a, v) => a + Math.pow(v - mean, 2), 0) / n; const std = Math.sqrt(variance); if (std === 0) return 0.0; const skew = values.reduce((a, v) => a + Math.pow((v - mean) / std, 3), 0) / n; return skew; } test('returns 0.0 for fewer than 10 values', () => { expect(skewness([1, 2, 3])).toBe(0.0); expect(skewness([1, 2, 3, 4, 5, 6, 7, 8, 9])).toBe(0.0); expect(skewness(null)).toBe(0.0); }); test('positive skew for right-skewed data', () => { // Mostly low values with a few high outliers const data = [1, 2, 2, 3, 3, 3, 4, 4, 5, 20]; expect(skewness(data)).toBeGreaterThan(0); }); }); // --------------------------------------------------------------------------- // 11. Matchup Pace (3 tests) // --------------------------------------------------------------------------- describe('Matchup Pace', () => { const LEAGUE_AVG_PACE = 100.0; const HOME_WEIGHT = 0.60; const AWAY_WEIGHT = 0.40; function matchupPace(homePace, awayPace) { const blended = homePace * HOME_WEIGHT + awayPace * AWAY_WEIGHT; return blended / LEAGUE_AVG_PACE; } test('two fast teams produce pace factor > 1.0', () => { const factor = matchupPace(108, 106); expect(factor).toBeGreaterThan(1.0); }); test('home team weighted 60/40 over away', () => { const factor = matchupPace(110, 90); // 110*0.6 + 90*0.4 = 66 + 36 = 102 => 102/100 = 1.02 expect(factor).toBeCloseTo(1.02, 5); }); test('league average teams return pace of 1.0', () => { const factor = matchupPace(LEAGUE_AVG_PACE, LEAGUE_AVG_PACE); expect(factor).toBe(1.0); }); }); // --------------------------------------------------------------------------- // 12. Foul Trouble Risk (3 tests) // --------------------------------------------------------------------------- describe('Foul Trouble Risk', () => { function foulTroubleBoost(avgFouls) { if (avgFouls >= 3.5) return 3.0; if (avgFouls >= 2.8) return 1.5; return 0.0; } test('>= 3.5 average fouls returns 3.0 std deviation boost', () => { expect(foulTroubleBoost(3.5)).toBe(3.0); expect(foulTroubleBoost(4.2)).toBe(3.0); }); test('>= 2.8 average fouls returns 1.5 std deviation boost', () => { expect(foulTroubleBoost(2.8)).toBe(1.5); expect(foulTroubleBoost(3.4)).toBe(1.5); }); test('< 2.8 average fouls returns 0.0', () => { expect(foulTroubleBoost(2.0)).toBe(0.0); expect(foulTroubleBoost(2.7)).toBe(0.0); }); }); // --------------------------------------------------------------------------- // 13. B2B Stat-Specific Adjustments (4 tests) // --------------------------------------------------------------------------- describe('B2B Stat-Specific Adjustments', () => { const B2B_ADJ = { points: -0.04, rebounds: 0.02, threes: -0.03, assists: 0.00, }; function applyB2B(projection, statType, isB2B) { if (!isB2B) return projection; const adj = B2B_ADJ[statType] || 0; return projection * (1 + adj); } test('B2B points adjustment is -4%', () => { const adjusted = applyB2B(25.0, 'points', true); expect(adjusted).toBe(24.0); }); test('B2B rebounds adjustment is +2%', () => { const adjusted = applyB2B(10.0, 'rebounds', true); expect(adjusted).toBeCloseTo(10.2, 5); }); test('B2B threes adjustment is -3%', () => { const adjusted = applyB2B(3.0, 'threes', true); expect(adjusted).toBeCloseTo(2.91, 5); }); test('no adjustment when not B2B', () => { expect(applyB2B(25.0, 'points', false)).toBe(25.0); expect(applyB2B(10.0, 'rebounds', false)).toBe(10.0); expect(applyB2B(3.0, 'threes', false)).toBe(3.0); }); }); // --------------------------------------------------------------------------- // 14. Usage-Efficiency Tradeoff (2 tests) // --------------------------------------------------------------------------- describe('Usage-Efficiency Tradeoff', () => { const TS_DROP_PER_5_USG = -0.015; // -1.5% TS per +5% usage function usageEfficiencyAdjustment(usageDelta) { // usageDelta in percentage points (e.g., +5 means usage went up 5%) return (usageDelta / 5) * TS_DROP_PER_5_USG; } function netEffect(usageDelta, baseTS) { const tsAdj = usageEfficiencyAdjustment(usageDelta); return baseTS + tsAdj; } test('-1.5% TS per +5% usage increase', () => { const adj = usageEfficiencyAdjustment(5); expect(adj).toBeCloseTo(-0.015, 5); const adj10 = usageEfficiencyAdjustment(10); expect(adj10).toBeCloseTo(-0.030, 5); }); test('net effect is sum of base TS and adjustment', () => { const baseTS = 0.580; const usageDelta = 5; const net = netEffect(usageDelta, baseTS); expect(net).toBeCloseTo(0.580 + (-0.015), 5); expect(net).toBeCloseTo(0.565, 5); }); }); // --------------------------------------------------------------------------- // 15. Dynamic Usage Boost Headroom (2 tests) // --------------------------------------------------------------------------- describe('Dynamic Usage Boost Headroom', () => { const MAX_BOOST = 0.08; // 8% max boost const HIGH_USAGE_THRESHOLD = 0.30; // 30% usage rate function usageBoost(currentUsage, projectedIncrease) { const headroom = Math.max(0, HIGH_USAGE_THRESHOLD - currentUsage); const scaleFactor = Math.min(1.0, headroom / HIGH_USAGE_THRESHOLD); return projectedIncrease * scaleFactor * MAX_BOOST; } test('low usage player gets full boost', () => { // 15% usage — lots of headroom const boost = usageBoost(0.15, 1.0); // headroom = 0.30 - 0.15 = 0.15, scaleFactor = 0.15/0.30 = 0.5 expect(boost).toBeCloseTo(0.04, 5); // But a very low usage player gets even more const boostLow = usageBoost(0.10, 1.0); expect(boostLow).toBeGreaterThan(boost); }); test('high usage player gets scaled down boost', () => { // 28% usage — close to threshold, little headroom const boost = usageBoost(0.28, 1.0); // headroom = 0.30 - 0.28 = 0.02, scaleFactor = 0.02/0.30 ≈ 0.0667 expect(boost).toBeCloseTo(0.02 / 0.30 * MAX_BOOST, 4); expect(boost).toBeLessThan(0.01); // At or above threshold — zero boost const boostAtCap = usageBoost(0.30, 1.0); expect(boostAtCap).toBe(0); }); });