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:
Kev
2026-03-21 11:41:18 -04:00
parent 3da1b4242c
commit c8c0962e56
16 changed files with 1560 additions and 40 deletions
+111
View File
@@ -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);
});
});
});
+86
View File
@@ -0,0 +1,86 @@
const { evaluateKillConditions } = require('../../src/services/killConditions');
function makeContext(overrides = {}) {
return {
seasonStats: { minutes: 34, games_played: 65, points: 26 },
recentStats: { value: 28 },
homeAwaySplit: { avg: 27 },
restSplit: { isB2B: false },
vsOpponentSplit: { games: 3 },
spread: -3,
...overrides,
};
}
describe('killConditions', () => {
test('returns empty array when no conditions trigger', () => {
const result = evaluateKillConditions(makeContext());
expect(result).toEqual([]);
});
test('low_minutes: triggers when avg minutes < 24', () => {
const result = evaluateKillConditions(makeContext({
seasonStats: { minutes: 22, games_played: 65 },
}));
expect(result).toHaveLength(1);
expect(result[0].code).toBe('low_minutes');
});
test('small_sample: triggers when games_played < 15', () => {
const result = evaluateKillConditions(makeContext({
seasonStats: { minutes: 34, games_played: 10 },
}));
expect(result).toHaveLength(1);
expect(result[0].code).toBe('small_sample');
});
test('b2b_high_usage: triggers when B2B and minutes > 32', () => {
const result = evaluateKillConditions(makeContext({
restSplit: { isB2B: true },
seasonStats: { minutes: 35, games_played: 65 },
}));
expect(result).toHaveLength(1);
expect(result[0].code).toBe('b2b_high_usage');
});
test('blowout_risk: triggers when spread > 10', () => {
const result = evaluateKillConditions(makeContext({ spread: -12 }));
expect(result).toHaveLength(1);
expect(result[0].code).toBe('blowout_risk');
});
test('blowout_risk: triggers when spread > +10 too', () => {
const result = evaluateKillConditions(makeContext({ spread: 11 }));
expect(result).toHaveLength(1);
expect(result[0].code).toBe('blowout_risk');
});
test('split_conflict: triggers when home/away vs recent differs > 5', () => {
const result = evaluateKillConditions(makeContext({
homeAwaySplit: { avg: 20 },
recentStats: { value: 28 },
}));
expect(result).toHaveLength(1);
expect(result[0].code).toBe('split_conflict');
});
test('no_opponent_data: triggers when vs_team games < 2', () => {
const result = evaluateKillConditions(makeContext({
vsOpponentSplit: { games: 1 },
}));
expect(result).toHaveLength(1);
expect(result[0].code).toBe('no_opponent_data');
});
test('multiple conditions can trigger simultaneously', () => {
const result = evaluateKillConditions(makeContext({
seasonStats: { minutes: 20, games_played: 10 },
spread: -15,
}));
const codes = result.map((r) => r.code);
expect(codes).toContain('low_minutes');
expect(codes).toContain('small_sample');
expect(codes).toContain('blowout_risk');
expect(result.length).toBe(3);
});
});
+49
View File
@@ -0,0 +1,49 @@
const { deltaToSignal, directedDelta } = require('../../src/utils/signals');
describe('signals', () => {
describe('deltaToSignal', () => {
test('0.0-0.49 maps to neutral', () => {
expect(deltaToSignal(0)).toBe('neutral');
expect(deltaToSignal(0.3)).toBe('neutral');
expect(deltaToSignal(0.49)).toBe('neutral');
});
test('0.5-1.99 maps to lean', () => {
expect(deltaToSignal(0.5)).toBe('lean');
expect(deltaToSignal(1.5)).toBe('lean');
expect(deltaToSignal(1.99)).toBe('lean');
});
test('2.0-3.99 maps to bullish', () => {
expect(deltaToSignal(2.0)).toBe('bullish');
expect(deltaToSignal(3.5)).toBe('bullish');
});
test('>= 4.0 maps to strong_bullish', () => {
expect(deltaToSignal(4.0)).toBe('strong_bullish');
expect(deltaToSignal(7.0)).toBe('strong_bullish');
});
test('negative deltas map to bearish equivalents', () => {
expect(deltaToSignal(-0.3)).toBe('neutral');
expect(deltaToSignal(-1.0)).toBe('lean_bearish');
expect(deltaToSignal(-2.5)).toBe('bearish');
expect(deltaToSignal(-5.0)).toBe('strong_bearish');
});
});
describe('directedDelta', () => {
test('over: positive when avg > line', () => {
expect(directedDelta(28, 26, 'over')).toBe(2);
});
test('over: negative when avg < line', () => {
expect(directedDelta(24, 26, 'over')).toBe(-2);
});
test('under: inverts delta', () => {
expect(directedDelta(28, 26, 'under')).toBe(-2);
expect(directedDelta(24, 26, 'under')).toBe(2);
});
});
});