Files
vyndr/tests/unit/shipGradingEngine.test.js
T

525 lines
18 KiB
JavaScript
Raw 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.
/**
* 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.701.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);
});
});