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
+79
View File
@@ -0,0 +1,79 @@
const express = require('express');
const { analyzeProp } = require('../services/propAnalyzer');
const router = express.Router();
const VALID_STAT_TYPES = new Set([
'points', 'rebounds', 'assists', 'threes', 'blocks',
'steals', 'pra', 'turnovers',
]);
const VALID_DIRECTIONS = new Set(['over', 'under']);
function validateProp(prop) {
const errors = [];
if (!prop.player) errors.push('player is required');
if (!prop.stat_type) errors.push('stat_type is required');
if (prop.stat_type && !VALID_STAT_TYPES.has(prop.stat_type)) {
errors.push(`Invalid stat_type: ${prop.stat_type}`);
}
if (prop.line == null) errors.push('line is required');
if (!prop.direction) errors.push('direction is required');
if (prop.direction && !VALID_DIRECTIONS.has(prop.direction)) {
errors.push(`Invalid direction: ${prop.direction}`);
}
return errors;
}
router.post('/prop', async (req, res) => {
const errors = validateProp(req.body);
if (errors.length > 0) {
return res.status(400).json({ error: errors.join('; ') });
}
try {
const result = await analyzeProp(req.body);
return res.json(result);
} catch (err) {
if (err.response && err.response.status === 404) {
return res.status(404).json({ error: `Player not found: ${req.body.player}` });
}
if (err.statusCode === 429 || err.statusCode === 503) {
return res.status(err.statusCode).json({ error: err.message });
}
console.error('[BetonBLK] Analysis error:', err.message);
return res.status(503).json({ error: 'Analysis service temporarily unavailable' });
}
});
router.post('/batch', async (req, res) => {
const { props } = req.body;
if (!Array.isArray(props) || props.length === 0) {
return res.status(400).json({ error: 'props array is required and must not be empty' });
}
const results = [];
for (const prop of props) {
const errors = validateProp(prop);
if (errors.length > 0) {
results.push({ error: errors.join('; '), input: prop });
continue;
}
try {
const result = await analyzeProp(prop);
results.push(result);
} catch (err) {
results.push({
error: err.response?.status === 404
? `Player not found: ${prop.player}`
: 'Analysis failed for this prop',
input: prop,
});
}
}
return res.json({ results });
});
module.exports = router;