feat: Feature 1.3 — Prop Analysis Engine with 6-step grading pipeline
Core intelligence for BetonBLK prop analysis: - POST /api/analyze/prop — single prop analysis - POST /api/analyze/batch — multi-prop analysis for parlay scanner - 6-step pipeline: season avg → recent form → situational splits → cross-book lines → kill conditions → grade (A/B/C/D) - 6 kill conditions: low_minutes, small_sample, b2b_high_usage, blowout_risk, split_conflict, no_opponent_data - Composite scoring with confidence (30-95), bonuses, penalties - Added spreads market to Odds API fetch (zero extra credits) - Full reasoning output with step-by-step breakdown 36 new tests (unit + integration), 128 total across all features Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
const { computeGrade } = require('../../src/services/grader');
|
||||
|
||||
function makeStepResults(overrides = {}) {
|
||||
return {
|
||||
seasonDelta: 0,
|
||||
recentDelta: 0,
|
||||
situationalDelta: 0,
|
||||
lineEdge: 0,
|
||||
killConditions: [],
|
||||
gamesPlayed: 65,
|
||||
seasonAndRecentAgree: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('grader', () => {
|
||||
describe('grade assignment', () => {
|
||||
test('composite >= 3.0 returns grade A with confidence 80-95', () => {
|
||||
// composite = (4*1 + 4*1.5 + 4*1.2 + 4*0.8) / 4.5 = 18/4.5 = 4.0
|
||||
const result = computeGrade(makeStepResults({
|
||||
seasonDelta: 4, recentDelta: 4, situationalDelta: 4, lineEdge: 4,
|
||||
}));
|
||||
expect(result.grade).toBe('A');
|
||||
expect(result.confidence).toBeGreaterThanOrEqual(80);
|
||||
expect(result.confidence).toBeLessThanOrEqual(95);
|
||||
});
|
||||
|
||||
test('composite 1.5-2.99 returns grade B with confidence 65-79', () => {
|
||||
// composite = (2*1 + 2*1.5 + 2*1.2 + 2*0.8) / 4.5 = 9/4.5 = 2.0
|
||||
const result = computeGrade(makeStepResults({
|
||||
seasonDelta: 2, recentDelta: 2, situationalDelta: 2, lineEdge: 2,
|
||||
}));
|
||||
expect(result.grade).toBe('B');
|
||||
expect(result.confidence).toBeGreaterThanOrEqual(65);
|
||||
expect(result.confidence).toBeLessThanOrEqual(79);
|
||||
});
|
||||
|
||||
test('composite 0.5-1.49 returns grade C with confidence 50-64', () => {
|
||||
// composite = (1*1 + 1*1.5 + 1*1.2 + 0*0.8) / 4.5 = 3.7/4.5 ≈ 0.82
|
||||
const result = computeGrade(makeStepResults({
|
||||
seasonDelta: 1, recentDelta: 1, situationalDelta: 1, lineEdge: 0,
|
||||
}));
|
||||
expect(result.grade).toBe('C');
|
||||
expect(result.confidence).toBeGreaterThanOrEqual(50);
|
||||
expect(result.confidence).toBeLessThanOrEqual(64);
|
||||
});
|
||||
|
||||
test('composite < 0.5 returns grade D with confidence 30-49', () => {
|
||||
const result = computeGrade(makeStepResults({
|
||||
seasonDelta: -1, recentDelta: -1, situationalDelta: -1, lineEdge: 0,
|
||||
}));
|
||||
expect(result.grade).toBe('D');
|
||||
expect(result.confidence).toBeGreaterThanOrEqual(30);
|
||||
expect(result.confidence).toBeLessThanOrEqual(49);
|
||||
});
|
||||
});
|
||||
|
||||
describe('kill condition penalty', () => {
|
||||
test('caps grade at C and reduces confidence by 15', () => {
|
||||
const result = computeGrade(makeStepResults({
|
||||
seasonDelta: 4, recentDelta: 4, situationalDelta: 4, lineEdge: 4,
|
||||
killConditions: [{ code: 'blowout_risk' }],
|
||||
}));
|
||||
expect(result.grade).toBe('C');
|
||||
// Original would be A (80+), minus 15 = 65+
|
||||
expect(result.confidence).toBeLessThan(85);
|
||||
});
|
||||
|
||||
test('grade B with kill condition becomes C', () => {
|
||||
const result = computeGrade(makeStepResults({
|
||||
seasonDelta: 2, recentDelta: 2, situationalDelta: 2, lineEdge: 2,
|
||||
killConditions: [{ code: 'low_minutes' }],
|
||||
}));
|
||||
expect(result.grade).toBe('C');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bonuses', () => {
|
||||
test('sample bonus +5 for > 50 games', () => {
|
||||
const with50 = computeGrade(makeStepResults({ seasonDelta: 1, recentDelta: 1, gamesPlayed: 55 }));
|
||||
const without = computeGrade(makeStepResults({ seasonDelta: 1, recentDelta: 1, gamesPlayed: 20 }));
|
||||
expect(with50.confidence).toBe(without.confidence + 5);
|
||||
});
|
||||
|
||||
test('sample bonus +3 for > 30 games', () => {
|
||||
const with30 = computeGrade(makeStepResults({ seasonDelta: 1, recentDelta: 1, gamesPlayed: 35 }));
|
||||
const without = computeGrade(makeStepResults({ seasonDelta: 1, recentDelta: 1, gamesPlayed: 20 }));
|
||||
expect(with30.confidence).toBe(without.confidence + 3);
|
||||
});
|
||||
|
||||
test('consistency bonus +5 when season and recent agree', () => {
|
||||
const agree = computeGrade(makeStepResults({
|
||||
seasonDelta: 2, recentDelta: 2, seasonAndRecentAgree: true,
|
||||
}));
|
||||
const noInfo = computeGrade(makeStepResults({
|
||||
seasonDelta: 2, recentDelta: 2, seasonAndRecentAgree: null,
|
||||
}));
|
||||
expect(agree.confidence).toBe(noInfo.confidence + 5);
|
||||
});
|
||||
|
||||
test('consistency penalty -5 when season and recent conflict', () => {
|
||||
const conflict = computeGrade(makeStepResults({
|
||||
seasonDelta: 2, recentDelta: 2, seasonAndRecentAgree: false,
|
||||
}));
|
||||
const noInfo = computeGrade(makeStepResults({
|
||||
seasonDelta: 2, recentDelta: 2, seasonAndRecentAgree: null,
|
||||
}));
|
||||
expect(conflict.confidence).toBe(noInfo.confidence - 5);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user