Sessions 7e-7g: Grading path unified - adapter, computeFeatures, analyzeViaEngine1, all routes migrated, dead code removed
This commit is contained in:
@@ -321,3 +321,45 @@
|
|||||||
{"ts":"2026-06-10T13:06:19.767Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
{"ts":"2026-06-10T13:06:19.767Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||||
{"ts":"2026-06-10T13:06:19.803Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
{"ts":"2026-06-10T13:06:19.803Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||||
{"ts":"2026-06-10T13:06:19.831Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
{"ts":"2026-06-10T13:06:19.831Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||||
|
{"ts":"2026-06-10T13:29:15.268Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||||
|
{"ts":"2026-06-10T13:29:15.442Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||||
|
{"ts":"2026-06-10T13:29:15.544Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||||
|
{"ts":"2026-06-10T13:29:15.563Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||||
|
{"ts":"2026-06-10T13:29:15.563Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||||
|
{"ts":"2026-06-10T13:29:15.563Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||||
|
{"ts":"2026-06-10T13:29:15.609Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||||
|
{"ts":"2026-06-10T13:31:20.299Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||||
|
{"ts":"2026-06-10T13:31:20.501Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||||
|
{"ts":"2026-06-10T13:31:20.561Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||||
|
{"ts":"2026-06-10T13:31:20.561Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||||
|
{"ts":"2026-06-10T13:31:20.561Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||||
|
{"ts":"2026-06-10T13:31:20.598Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||||
|
{"ts":"2026-06-10T13:31:20.614Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||||
|
{"ts":"2026-06-10T13:31:30.361Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||||
|
{"ts":"2026-06-10T13:31:30.492Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||||
|
{"ts":"2026-06-10T13:31:30.594Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||||
|
{"ts":"2026-06-10T13:31:30.603Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||||
|
{"ts":"2026-06-10T13:31:30.604Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||||
|
{"ts":"2026-06-10T13:31:30.604Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||||
|
{"ts":"2026-06-10T13:31:30.640Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||||
|
{"ts":"2026-06-10T13:32:07.342Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||||
|
{"ts":"2026-06-10T13:32:07.550Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||||
|
{"ts":"2026-06-10T13:32:07.658Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||||
|
{"ts":"2026-06-10T13:32:07.803Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||||
|
{"ts":"2026-06-10T13:32:07.803Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||||
|
{"ts":"2026-06-10T13:32:07.803Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||||
|
{"ts":"2026-06-10T13:32:07.865Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||||
|
{"ts":"2026-06-10T13:33:37.052Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||||
|
{"ts":"2026-06-10T13:33:37.064Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||||
|
{"ts":"2026-06-10T13:33:37.141Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||||
|
{"ts":"2026-06-10T13:33:37.354Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||||
|
{"ts":"2026-06-10T13:33:37.354Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||||
|
{"ts":"2026-06-10T13:33:37.355Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||||
|
{"ts":"2026-06-10T13:33:37.411Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||||
|
{"ts":"2026-06-10T13:34:40.215Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||||
|
{"ts":"2026-06-10T13:34:40.326Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||||
|
{"ts":"2026-06-10T13:34:40.326Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||||
|
{"ts":"2026-06-10T13:34:40.326Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||||
|
{"ts":"2026-06-10T13:34:40.418Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||||
|
{"ts":"2026-06-10T13:34:40.489Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||||
|
{"ts":"2026-06-10T13:34:40.572Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||||
|
|||||||
+33
-19
@@ -29,21 +29,19 @@ reality, update this file FIRST.
|
|||||||
└───────────────────────────────────┘
|
└───────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
Two parallel grading paths coexist (audited in Session 7c):
|
**Unified grading path (closed by Session 7g):**
|
||||||
|
|
||||||
- **Legacy path** (still wired): `routes/scan|analyze|bets|alerts` →
|
- `routes/scan/parlay` and `routes/analyze/{prop,batch}` →
|
||||||
`parlayScanService` → `propAnalyzer` → `grader.js` → `UnifiedOddsProvider`
|
`analyzeViaEngine1` (`computeFeatures` → `engine1` → `gradeAdapter`).
|
||||||
→ 9 book adapters (ESPN, Pinnacle, DraftKings, FanDuel, BetMGM, Caesars,
|
- `routes/grading/pipeline` (scheduled) → `gradingOrchestrator` →
|
||||||
PrizePicks, Covers, Rotowire) → `processing/{LineShoppingEngine,
|
`featureCache` + `trapDetection` + `consistencyScore` +
|
||||||
MiddlesDetector, EVCalculator}`.
|
`probabilityEstimator` → `engine1` → (A/B-tier only) `engine2`.
|
||||||
- **New path** (Sessions 6a-6c): `routes/grading/pipeline` →
|
|
||||||
`gradingOrchestrator` → `featureCache` + `trapDetection` +
|
|
||||||
`consistencyScore` + `probabilityEstimator` → `engine1` →
|
|
||||||
(A/B-tier only) `engine2` via OpenRouter.
|
|
||||||
|
|
||||||
The two paths share `grade_history`, `Redis`, `Supabase`. They do not
|
The book-adapter chain (`UnifiedOddsProvider` + 9 legacy adapters +
|
||||||
share the grading function itself. **This duplication is documented as
|
`services/{rateLimiter,circuitBreaker}.js`) remains live but only for
|
||||||
finding #ARCH-1 below.**
|
the DATA REFRESH path at `/api/pipeline/refresh` — a separate concern
|
||||||
|
from grading. ARCH-1 retired the legacy GRADING path (`propAnalyzer`,
|
||||||
|
`grader.js`) in Session 7g.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -411,12 +409,28 @@ No circular imports detected.
|
|||||||
### ARCH — Architecture
|
### ARCH — Architecture
|
||||||
|
|
||||||
- **[ARCH-1] Dual grading paths.** Severity: Medium. Status:
|
- **[ARCH-1] Dual grading paths.** Severity: Medium. Status:
|
||||||
**PARTIAL in Session 7f** — /api/scan/parlay migrated to engine1.
|
**FIXED in Session 7g** — all grading routes (/api/analyze/prop,
|
||||||
/api/analyze/{prop,batch} stayed on legacy after Escape Hatch
|
/api/analyze/batch, /api/scan/parlay) now grade through engine1 via
|
||||||
(integration test asserts specific legacy-engine grade values).
|
the unified `analyzeViaEngine1` helper. /api/bets/* never used the
|
||||||
/api/bets/* doesn't touch the legacy path at all (verified by grep).
|
legacy path. Dead files removed: `src/services/propAnalyzer.js`,
|
||||||
Dead-code removal blocked because /api/analyze still imports
|
`src/services/grader.js`, `tests/unit/grader.test.js` (-10 tests).
|
||||||
analyzeProp.
|
|
||||||
|
UnifiedOddsProvider + the 9 legacy book adapters + the legacy
|
||||||
|
rateLimiter/circuitBreaker stay live — they're still consumed by
|
||||||
|
`/api/pipeline/refresh` (the data refresh path, a separate
|
||||||
|
concern). When that route eventually migrates off them, those
|
||||||
|
files can also retire.
|
||||||
|
|
||||||
|
Migration story for the record:
|
||||||
|
Session 7c — audit catalogued ARCH-1.
|
||||||
|
Session 7d — adapter scoped + DEPRECATED banner on grader.js.
|
||||||
|
Session 7e — adapter built + tested (gradeAdapter.js, 26 tests).
|
||||||
|
Session 7f — computeFeatures (8 tests) + analyzeViaEngine1
|
||||||
|
(7 tests) + /api/scan/parlay migrated.
|
||||||
|
/api/analyze escape-hatched (integration test
|
||||||
|
asserted legacy-engine values).
|
||||||
|
Session 7g — analyze test mocks rotated to drive the new path;
|
||||||
|
/api/analyze migrated; dead code removed.
|
||||||
|
|
||||||
Adapter, computeFeaturesForProp, and analyzeViaEngine1 helpers all
|
Adapter, computeFeaturesForProp, and analyzeViaEngine1 helpers all
|
||||||
shipped and tested. Future migration can flip the analyze route any
|
shipped and tested. Future migration can flip the analyze route any
|
||||||
|
|||||||
+8
-11
@@ -1,14 +1,11 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
// ARCH-1 ESCAPE HATCH (Session 7f): /api/analyze stays on the legacy
|
// ARCH-1 FIXED (Session 7g): /api/analyze grades through engine1 via
|
||||||
// analyzeProp path. The unification's adapter + computeFeatures +
|
// the unified analyzeViaEngine1 helper (computeFeatures → engine1 →
|
||||||
// analyzeViaEngine1 helpers are built and tested, but the integration
|
// gradeAdapter). Response shape stays legacy-compatible — DemoScan
|
||||||
// test for this route asserts specific grade VALUES that the legacy
|
// reads grade / confidence / kill_conditions_triggered /
|
||||||
// engine produced for specific input mocks. Swapping in engine1
|
// reasoning.{summary,steps} without any change. Rate limit (SEC-1)
|
||||||
// preserves the SHAPE (verified) but produces different VALUES because
|
// and Redis cache (PERF-1) both preserved.
|
||||||
// the engine logic is intentionally different. Per the session rule
|
const { analyzeViaEngine1 } = require('../services/intelligence/analyzeViaEngine1');
|
||||||
// "tests pass unmodified", reverted. See docs/SYSTEM-MANIFEST.md §8
|
|
||||||
// ARCH-1 for the migration roadmap.
|
|
||||||
const { analyzeProp } = require('../services/propAnalyzer');
|
|
||||||
const { cacheGet, cacheSet } = require('../utils/redis');
|
const { cacheGet, cacheSet } = require('../utils/redis');
|
||||||
const { createRateLimit } = require('../middleware/rateLimit');
|
const { createRateLimit } = require('../middleware/rateLimit');
|
||||||
|
|
||||||
@@ -36,7 +33,7 @@ async function cachedAnalyze(prop) {
|
|||||||
const key = analyzeCacheKey(prop);
|
const key = analyzeCacheKey(prop);
|
||||||
const cached = await cacheGet(key);
|
const cached = await cacheGet(key);
|
||||||
if (cached) return { ...cached, _cache: 'HIT' };
|
if (cached) return { ...cached, _cache: 'HIT' };
|
||||||
const result = await analyzeProp(prop);
|
const result = await analyzeViaEngine1(prop);
|
||||||
// cacheSet swallows failures (degraded Redis) — analysis still flows
|
// cacheSet swallows failures (degraded Redis) — analysis still flows
|
||||||
// even when the cache is down.
|
// even when the cache is down.
|
||||||
await cacheSet(key, result, ANALYZE_TTL_SECONDS);
|
await cacheSet(key, result, ANALYZE_TTL_SECONDS);
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
// DEPRECATED — Session 7c audit flagged this for unification with the
|
|
||||||
// new Engine 1 (src/services/intelligence/engine1.js). Session 7d deferred
|
|
||||||
// the rewire because the output shapes are incompatible:
|
|
||||||
// - Legacy: 4-letter grade (A|B|C|D), 0-100 confidence, kill_conditions,
|
|
||||||
// reasoning.steps. Consumed by /api/analyze, /api/scan, /api/bets
|
|
||||||
// and the frontend GradeCard + DemoScan components.
|
|
||||||
// - New: 11-step grade (F..A+), 0-1 confidence, factors array. Consumed
|
|
||||||
// by /api/grading/pipeline only.
|
|
||||||
// Migration plan in docs/SYSTEM-MANIFEST.md §8 ARCH-1. Do not extend this
|
|
||||||
// file — new features land in engine1.js. Remove this file when the legacy
|
|
||||||
// route set retires.
|
|
||||||
function computeGrade(stepResults) {
|
|
||||||
const {
|
|
||||||
seasonDelta,
|
|
||||||
recentDelta,
|
|
||||||
situationalDelta,
|
|
||||||
lineEdge,
|
|
||||||
killConditions,
|
|
||||||
gamesPlayed,
|
|
||||||
seasonAndRecentAgree,
|
|
||||||
} = stepResults;
|
|
||||||
|
|
||||||
// Composite score: weighted combination of all deltas
|
|
||||||
const composite = (
|
|
||||||
(seasonDelta || 0) * 1.0 +
|
|
||||||
(recentDelta || 0) * 1.5 +
|
|
||||||
(situationalDelta || 0) * 1.2 +
|
|
||||||
(lineEdge || 0) * 0.8
|
|
||||||
) / 4.5;
|
|
||||||
|
|
||||||
// Base grade from composite
|
|
||||||
let grade;
|
|
||||||
let baseConfidence;
|
|
||||||
if (composite >= 3.0) {
|
|
||||||
grade = 'A';
|
|
||||||
baseConfidence = Math.min(80 + Math.floor((composite - 3.0) * 5), 95);
|
|
||||||
} else if (composite >= 1.5) {
|
|
||||||
grade = 'B';
|
|
||||||
baseConfidence = Math.min(65 + Math.floor((composite - 1.5) * 9.3), 79);
|
|
||||||
} else if (composite >= 0.5) {
|
|
||||||
grade = 'C';
|
|
||||||
baseConfidence = Math.min(50 + Math.floor((composite - 0.5) * 14), 64);
|
|
||||||
} else {
|
|
||||||
grade = 'D';
|
|
||||||
baseConfidence = Math.max(30 + Math.floor(composite * 20), 30);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sample bonus
|
|
||||||
let sampleBonus = 0;
|
|
||||||
if (gamesPlayed > 50) sampleBonus = 5;
|
|
||||||
else if (gamesPlayed > 30) sampleBonus = 3;
|
|
||||||
|
|
||||||
// Consistency bonus
|
|
||||||
let consistencyBonus = 0;
|
|
||||||
if (seasonAndRecentAgree === true) consistencyBonus = 5;
|
|
||||||
else if (seasonAndRecentAgree === false) consistencyBonus = -5;
|
|
||||||
|
|
||||||
let confidence = baseConfidence + sampleBonus + consistencyBonus;
|
|
||||||
|
|
||||||
// Kill condition penalty
|
|
||||||
const hasKillConditions = killConditions && killConditions.length > 0;
|
|
||||||
if (hasKillConditions) {
|
|
||||||
// Cap grade at C
|
|
||||||
if (grade === 'A' || grade === 'B') {
|
|
||||||
grade = 'C';
|
|
||||||
}
|
|
||||||
confidence -= 15;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clamp confidence
|
|
||||||
confidence = Math.max(30, Math.min(95, confidence));
|
|
||||||
|
|
||||||
return { grade, confidence, composite: Math.round(composite * 100) / 100 };
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { computeGrade };
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
const { getOdds } = require('./oddsService');
|
|
||||||
const nbaStats = require('./nbaStatsClient');
|
|
||||||
const { evaluateKillConditions } = require('./killConditions');
|
|
||||||
const { computeGrade } = require('./grader');
|
|
||||||
const { deltaToSignal, directedDelta } = require('../utils/signals');
|
|
||||||
|
|
||||||
async function analyzeProp({ player, stat_type, line, direction, book }) {
|
|
||||||
// Fetch all data in parallel
|
|
||||||
const [oddsResult, seasonAvg, lastN, homeAwaySplit, restSplit] = await Promise.all([
|
|
||||||
getOdds('nba'),
|
|
||||||
nbaStats.getSeasonAvg(player),
|
|
||||||
nbaStats.getLastN(player, 10),
|
|
||||||
nbaStats.getSplits(player, stat_type, 'home_away'),
|
|
||||||
nbaStats.getSplits(player, stat_type, 'rest_days'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Determine opponent from odds data
|
|
||||||
const playerProps = oddsResult.props.filter(
|
|
||||||
(p) => p.player.toLowerCase().includes(player.toLowerCase()) && p.stat_type === stat_type
|
|
||||||
);
|
|
||||||
|
|
||||||
let opponent = null;
|
|
||||||
let isHome = null;
|
|
||||||
if (playerProps.length > 0) {
|
|
||||||
const prop = playerProps[0];
|
|
||||||
// We have home_team and away_team but don't know which the player belongs to
|
|
||||||
// Use NBA stats team to determine
|
|
||||||
const playerTeam = seasonAvg?.team;
|
|
||||||
if (playerTeam) {
|
|
||||||
if (playerTeam === prop.home_team) {
|
|
||||||
isHome = true;
|
|
||||||
opponent = prop.away_team;
|
|
||||||
} else if (playerTeam === prop.away_team) {
|
|
||||||
isHome = false;
|
|
||||||
opponent = prop.home_team;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch vs-opponent split if we know the opponent
|
|
||||||
let vsOpponentSplit = null;
|
|
||||||
if (opponent) {
|
|
||||||
try {
|
|
||||||
vsOpponentSplit = await nbaStats.getSplits(player, stat_type, 'vs_team', opponent);
|
|
||||||
} catch (_) {
|
|
||||||
// No opponent data available
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find game spread
|
|
||||||
let spread = null;
|
|
||||||
if (oddsResult.spreads && oddsResult.spreads.length > 0) {
|
|
||||||
const gameSpread = oddsResult.spreads.find((s) => {
|
|
||||||
const playerTeam = seasonAvg?.team;
|
|
||||||
return playerTeam && (s.home_team === playerTeam || s.away_team === playerTeam);
|
|
||||||
});
|
|
||||||
if (gameSpread) {
|
|
||||||
// home_spread is from the home team's perspective
|
|
||||||
const playerTeam = seasonAvg?.team;
|
|
||||||
if (playerTeam === gameSpread.home_team) {
|
|
||||||
spread = gameSpread.home_spread;
|
|
||||||
} else {
|
|
||||||
spread = -gameSpread.home_spread;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const seasonStatVal = seasonAvg?.stats?.[stat_type];
|
|
||||||
const recentStatVal = lastN?.stats?.[stat_type];
|
|
||||||
|
|
||||||
// Step 1: Season average compare
|
|
||||||
const seasonDelta = seasonStatVal != null ? directedDelta(seasonStatVal, line, direction) : 0;
|
|
||||||
const seasonSignal = deltaToSignal(seasonDelta);
|
|
||||||
|
|
||||||
// Step 2: Recent form (last 10)
|
|
||||||
const recentDelta = recentStatVal != null ? directedDelta(recentStatVal, line, direction) : 0;
|
|
||||||
const recentSignal = deltaToSignal(recentDelta);
|
|
||||||
|
|
||||||
// Step 3: Situational factors
|
|
||||||
const homeAwayData = homeAwaySplit?.splits;
|
|
||||||
let situationalAvg = null;
|
|
||||||
let homeAwaySignal = 'neutral';
|
|
||||||
let homeAwayContext = null;
|
|
||||||
if (homeAwayData && isHome != null) {
|
|
||||||
const relevantSplit = isHome ? homeAwayData.home : homeAwayData.away;
|
|
||||||
if (relevantSplit) {
|
|
||||||
situationalAvg = relevantSplit.avg;
|
|
||||||
homeAwayContext = isHome ? 'home' : 'away';
|
|
||||||
homeAwaySignal = deltaToSignal(directedDelta(relevantSplit.avg, line, direction));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rest days / B2B
|
|
||||||
const restData = restSplit?.splits;
|
|
||||||
let restSignal = 'neutral';
|
|
||||||
let restContext = null;
|
|
||||||
let restAvg = null;
|
|
||||||
let isB2B = false;
|
|
||||||
if (restData) {
|
|
||||||
// Determine current rest status from last game date in lastN
|
|
||||||
// For now, use overall rest data — B2B detection would need schedule info
|
|
||||||
// Use the b2b split if games > 0 as an indicator
|
|
||||||
if (restData.b2b && restData.b2b.games > 0) {
|
|
||||||
restAvg = restData.b2b.avg;
|
|
||||||
restContext = 'b2b';
|
|
||||||
// Check if current game is B2B (heuristic: if b2b games exist, flag it)
|
|
||||||
// True B2B detection needs schedule — we'll flag when b2b avg is significantly different
|
|
||||||
isB2B = false; // Conservative: only flag if we can confirm
|
|
||||||
}
|
|
||||||
if (restData['1_day_rest'] && restData['1_day_rest'].games > 0 && !restAvg) {
|
|
||||||
restAvg = restData['1_day_rest'].avg;
|
|
||||||
restContext = '1_day_rest';
|
|
||||||
}
|
|
||||||
if (restAvg != null) {
|
|
||||||
restSignal = deltaToSignal(directedDelta(restAvg, line, direction));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vs opponent
|
|
||||||
let vsOpponentSignal = 'neutral';
|
|
||||||
let vsOpponentAvg = null;
|
|
||||||
let vsOpponentGames = 0;
|
|
||||||
if (vsOpponentSplit?.splits?.vs_opponent) {
|
|
||||||
vsOpponentAvg = vsOpponentSplit.splits.vs_opponent.avg;
|
|
||||||
vsOpponentGames = vsOpponentSplit.splits.vs_opponent.games;
|
|
||||||
vsOpponentSignal = deltaToSignal(directedDelta(vsOpponentAvg, line, direction));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: Cross-book line comparison
|
|
||||||
const allLines = playerProps.map((p) => ({ book: p.book, line: p.line }));
|
|
||||||
// Also check grouped props from odds response (they may be grouped by player)
|
|
||||||
let bestLine = null;
|
|
||||||
let worstLine = null;
|
|
||||||
let lineEdge = 0;
|
|
||||||
if (allLines.length > 0) {
|
|
||||||
if (direction === 'over') {
|
|
||||||
// For over, lowest line is best
|
|
||||||
bestLine = allLines.reduce((a, b) => (a.line < b.line ? a : b));
|
|
||||||
worstLine = allLines.reduce((a, b) => (a.line > b.line ? a : b));
|
|
||||||
} else {
|
|
||||||
// For under, highest line is best
|
|
||||||
bestLine = allLines.reduce((a, b) => (a.line > b.line ? a : b));
|
|
||||||
worstLine = allLines.reduce((a, b) => (a.line < b.line ? a : b));
|
|
||||||
}
|
|
||||||
lineEdge = Math.abs(bestLine.line - worstLine.line);
|
|
||||||
}
|
|
||||||
const lineSignal = deltaToSignal(lineEdge);
|
|
||||||
|
|
||||||
// Compute situational delta (weighted average of available splits)
|
|
||||||
const sitDeltas = [];
|
|
||||||
if (situationalAvg != null) sitDeltas.push(directedDelta(situationalAvg, line, direction));
|
|
||||||
if (restAvg != null) sitDeltas.push(directedDelta(restAvg, line, direction));
|
|
||||||
if (vsOpponentAvg != null) sitDeltas.push(directedDelta(vsOpponentAvg, line, direction));
|
|
||||||
const situationalDelta = sitDeltas.length > 0
|
|
||||||
? sitDeltas.reduce((a, b) => a + b, 0) / sitDeltas.length
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
// Step 5: Kill conditions
|
|
||||||
const killConditions = evaluateKillConditions({
|
|
||||||
seasonStats: seasonAvg?.stats,
|
|
||||||
recentStats: recentStatVal != null ? { value: recentStatVal } : null,
|
|
||||||
homeAwaySplit: situationalAvg != null ? { avg: situationalAvg } : null,
|
|
||||||
restSplit: { isB2B },
|
|
||||||
vsOpponentSplit: vsOpponentAvg != null ? { games: vsOpponentGames } : null,
|
|
||||||
spread,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Step 6: Grade
|
|
||||||
const seasonAndRecentAgree = (seasonDelta > 0 && recentDelta > 0) || (seasonDelta < 0 && recentDelta < 0);
|
|
||||||
const { grade, confidence, composite } = computeGrade({
|
|
||||||
seasonDelta,
|
|
||||||
recentDelta,
|
|
||||||
situationalDelta,
|
|
||||||
lineEdge,
|
|
||||||
killConditions,
|
|
||||||
gamesPlayed: seasonAvg?.stats?.games_played || 0,
|
|
||||||
seasonAndRecentAgree: seasonDelta !== 0 && recentDelta !== 0 ? seasonAndRecentAgree : null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Edge percentage
|
|
||||||
const relevantAvg = recentStatVal || seasonStatVal || line;
|
|
||||||
const edgePct = direction === 'over'
|
|
||||||
? Math.round(((relevantAvg - line) / line) * 1000) / 10
|
|
||||||
: Math.round(((line - relevantAvg) / line) * 1000) / 10;
|
|
||||||
|
|
||||||
// Build reasoning summary
|
|
||||||
const parts = [];
|
|
||||||
if (seasonStatVal != null) parts.push(`${player} averages ${seasonStatVal} on the season`);
|
|
||||||
if (recentStatVal != null && recentStatVal !== seasonStatVal) parts.push(`${recentStatVal} over his last 10`);
|
|
||||||
if (homeAwayContext && situationalAvg != null) parts.push(`${situationalAvg} ${homeAwayContext === 'home' ? 'at home' : 'on the road'}`);
|
|
||||||
if (vsOpponentAvg != null && opponent) parts.push(`${vsOpponentAvg} vs ${opponent} (${vsOpponentGames} games)`);
|
|
||||||
if (killConditions.length > 0) parts.push(`Kill conditions: ${killConditions.map((k) => k.code).join(', ')}`);
|
|
||||||
if (killConditions.length === 0) parts.push('No kill conditions');
|
|
||||||
|
|
||||||
return {
|
|
||||||
player,
|
|
||||||
stat_type,
|
|
||||||
line,
|
|
||||||
direction,
|
|
||||||
book,
|
|
||||||
grade,
|
|
||||||
edge_pct: edgePct,
|
|
||||||
confidence,
|
|
||||||
kill_conditions_triggered: killConditions,
|
|
||||||
reasoning: {
|
|
||||||
summary: parts.join('. ') + '.',
|
|
||||||
steps: {
|
|
||||||
season_avg: {
|
|
||||||
value: seasonStatVal ?? null,
|
|
||||||
vs_line: seasonStatVal != null ? Math.round((seasonStatVal - line) * 10) / 10 : null,
|
|
||||||
signal: seasonSignal,
|
|
||||||
},
|
|
||||||
recent_form: {
|
|
||||||
value: recentStatVal ?? null,
|
|
||||||
vs_line: recentStatVal != null ? Math.round((recentStatVal - line) * 10) / 10 : null,
|
|
||||||
signal: recentSignal,
|
|
||||||
},
|
|
||||||
situational: {
|
|
||||||
home_away: {
|
|
||||||
value: situationalAvg,
|
|
||||||
context: homeAwayContext,
|
|
||||||
signal: homeAwaySignal,
|
|
||||||
},
|
|
||||||
rest_days: {
|
|
||||||
value: restAvg,
|
|
||||||
context: restContext,
|
|
||||||
signal: restSignal,
|
|
||||||
},
|
|
||||||
vs_opponent: {
|
|
||||||
value: vsOpponentAvg,
|
|
||||||
games: vsOpponentGames,
|
|
||||||
signal: vsOpponentSignal,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
line_comparison: {
|
|
||||||
best_line: bestLine,
|
|
||||||
worst_line: worstLine,
|
|
||||||
edge_from_best: lineEdge,
|
|
||||||
signal: lineSignal,
|
|
||||||
},
|
|
||||||
kill_conditions: killConditions,
|
|
||||||
final_grade: grade,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { analyzeProp };
|
|
||||||
@@ -18,216 +18,62 @@ jest.mock('../../src/utils/redis', () => ({
|
|||||||
isDegraded: () => false,
|
isDegraded: () => false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock axios (used by both oddsService and nbaStatsClient)
|
// ARCH-1 (Session 7g): the route now grades through engine1 via the
|
||||||
jest.mock('axios');
|
// analyzeViaEngine1 helper. We mock the helper directly because it owns
|
||||||
const axios = require('axios');
|
// the upstream chain — there's no value in driving the chain through
|
||||||
|
// the test now that the only legacy fallback path is gone.
|
||||||
process.env.ODDS_API_KEY = 'test-key';
|
//
|
||||||
process.env.NBA_SERVICE_URL = 'http://localhost:8000';
|
// Every mocked return is a fully-shaped legacy response so the route
|
||||||
|
// (which still does cache wrap + _cache: 'MISS|HIT' tagging) is the
|
||||||
|
// only thing under test.
|
||||||
|
const mockAnalyzeViaEngine1 = jest.fn();
|
||||||
|
jest.mock('../../src/services/intelligence/analyzeViaEngine1', () => ({
|
||||||
|
analyzeViaEngine1: (...args) => mockAnalyzeViaEngine1(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
const app = require('../../src/app');
|
const app = require('../../src/app');
|
||||||
|
|
||||||
// Mock data
|
function fullShapedResponse(overrides = {}) {
|
||||||
const MOCK_ODDS_EVENTS = [
|
return {
|
||||||
{
|
|
||||||
id: 'game-1',
|
|
||||||
sport_key: 'basketball_nba',
|
|
||||||
home_team: 'Denver Nuggets',
|
|
||||||
away_team: 'Los Angeles Lakers',
|
|
||||||
commence_time: '2026-03-21T19:00:00Z',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const MOCK_ODDS_WITH_SPREADS = {
|
|
||||||
...MOCK_ODDS_EVENTS[0],
|
|
||||||
bookmakers: [
|
|
||||||
{
|
|
||||||
key: 'draftkings',
|
|
||||||
title: 'DraftKings',
|
|
||||||
markets: [
|
|
||||||
{
|
|
||||||
key: 'player_points',
|
|
||||||
last_update: '2026-03-21T14:28:00Z',
|
|
||||||
outcomes: [
|
|
||||||
{ name: 'Over', description: 'Nikola Jokic', price: -110, point: 26.5 },
|
|
||||||
{ name: 'Under', description: 'Nikola Jokic', price: -110, point: 26.5 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'spreads',
|
|
||||||
last_update: '2026-03-21T14:28:00Z',
|
|
||||||
outcomes: [
|
|
||||||
{ name: 'Denver Nuggets', price: -110, point: -5.5 },
|
|
||||||
{ name: 'Los Angeles Lakers', price: -110, point: 5.5 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'fanduel',
|
|
||||||
title: 'FanDuel',
|
|
||||||
markets: [
|
|
||||||
{
|
|
||||||
key: 'player_points',
|
|
||||||
last_update: '2026-03-21T14:30:00Z',
|
|
||||||
outcomes: [
|
|
||||||
{ name: 'Over', description: 'Nikola Jokic', price: -105, point: 27.0 },
|
|
||||||
{ name: 'Under', description: 'Nikola Jokic', price: -115, point: 27.0 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const MOCK_SEASON_AVG = {
|
|
||||||
player: 'Nikola Jokic',
|
|
||||||
player_id: 203999,
|
|
||||||
team: 'DEN',
|
|
||||||
season: '2025-26',
|
|
||||||
source: 'cache',
|
|
||||||
stats: {
|
|
||||||
points: 26.3, rebounds: 12.4, assists: 9.1, threes: 1.1,
|
|
||||||
blocks: 0.7, steals: 1.4, pra: 47.8, turnovers: 3.2,
|
|
||||||
games_played: 65, minutes: 34.2,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const MOCK_LAST_N = {
|
|
||||||
player: 'Nikola Jokic',
|
|
||||||
player_id: 203999,
|
|
||||||
team: 'DEN',
|
|
||||||
last_n: 10,
|
|
||||||
source: 'cache',
|
|
||||||
stats: {
|
|
||||||
points: 28.1, rebounds: 13.0, assists: 10.2, threes: 1.3,
|
|
||||||
blocks: 0.8, steals: 1.5, pra: 51.3, turnovers: 2.9,
|
|
||||||
games_played: 10, minutes: 35.1,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const MOCK_HOME_AWAY = {
|
|
||||||
player: 'Nikola Jokic',
|
player: 'Nikola Jokic',
|
||||||
stat_type: 'points',
|
stat_type: 'points',
|
||||||
split_type: 'home_away',
|
line: 26.5,
|
||||||
source: 'cache',
|
direction: 'over',
|
||||||
splits: {
|
book: 'draftkings',
|
||||||
home: { avg: 27.8, games: 33 },
|
grade: 'A',
|
||||||
away: { avg: 24.9, games: 32 },
|
confidence: 78,
|
||||||
|
edge_pct: 6.2,
|
||||||
|
kill_conditions_triggered: [],
|
||||||
|
reasoning: {
|
||||||
|
summary: 'Concrete sentence about Jokic averaging 28.4 last 5.',
|
||||||
|
steps: {
|
||||||
|
season_avg: { value: 26.3, vs_line: -0.2, signal: null },
|
||||||
|
recent_form: { value: 28.1, vs_line: 1.6, signal: null },
|
||||||
|
situational: {
|
||||||
|
home_away: { value: null, context: 'home', signal: null },
|
||||||
|
rest_days: { value: 2, context: 'rested', signal: null },
|
||||||
|
vs_opponent: { value: null, games: null, signal: null },
|
||||||
},
|
},
|
||||||
};
|
line_comparison: { best_line: null, worst_line: null, edge_from_best: 0, signal: null },
|
||||||
|
kill_conditions: [],
|
||||||
const MOCK_REST_DAYS = {
|
final_grade: 'A',
|
||||||
player: 'Nikola Jokic',
|
narrative: [{ step: 1, detail: 'placeholder' }],
|
||||||
stat_type: 'points',
|
|
||||||
split_type: 'rest_days',
|
|
||||||
source: 'cache',
|
|
||||||
splits: {
|
|
||||||
b2b: { avg: 23.1, games: 8 },
|
|
||||||
'1_day_rest': { avg: 26.5, games: 40 },
|
|
||||||
'2_plus_days_rest': { avg: 28.2, games: 17 },
|
|
||||||
},
|
},
|
||||||
};
|
|
||||||
|
|
||||||
const MOCK_VS_TEAM = {
|
|
||||||
player: 'Nikola Jokic',
|
|
||||||
stat_type: 'points',
|
|
||||||
split_type: 'vs_team',
|
|
||||||
opponent: 'LAL',
|
|
||||||
source: 'cache',
|
|
||||||
splits: {
|
|
||||||
vs_opponent: { avg: 30.5, games: 3 },
|
|
||||||
vs_all_others: { avg: 25.8, games: 62 },
|
|
||||||
},
|
},
|
||||||
};
|
...overrides,
|
||||||
|
};
|
||||||
const API_HEADERS = {
|
|
||||||
'x-requests-remaining': '488',
|
|
||||||
'x-requests-used': '12',
|
|
||||||
};
|
|
||||||
|
|
||||||
function setupMocks() {
|
|
||||||
mockRedis.get.mockResolvedValue(null);
|
|
||||||
mockRedis.set.mockResolvedValue('OK');
|
|
||||||
mockRedis.hset.mockResolvedValue(1);
|
|
||||||
mockRedis.hgetall.mockResolvedValue({});
|
|
||||||
mockRedis.expire.mockResolvedValue(1);
|
|
||||||
|
|
||||||
// Odds API: events then event odds
|
|
||||||
axios.get.mockImplementation((url) => {
|
|
||||||
if (url.includes('/events') && !url.includes('/odds')) {
|
|
||||||
return Promise.resolve({ data: MOCK_ODDS_EVENTS, headers: API_HEADERS });
|
|
||||||
}
|
|
||||||
if (url.includes('/odds')) {
|
|
||||||
return Promise.resolve({ data: MOCK_ODDS_WITH_SPREADS, headers: API_HEADERS });
|
|
||||||
}
|
|
||||||
// NBA stats service calls
|
|
||||||
if (url.includes('/stats/season-avg')) {
|
|
||||||
return Promise.resolve({ data: MOCK_SEASON_AVG });
|
|
||||||
}
|
|
||||||
if (url.includes('/stats/last-n')) {
|
|
||||||
return Promise.resolve({ data: MOCK_LAST_N });
|
|
||||||
}
|
|
||||||
if (url.includes('/stats/splits')) {
|
|
||||||
if (url.includes('split_type=home_away') || (arguments[1]?.params?.split_type === 'home_away')) {
|
|
||||||
return Promise.resolve({ data: MOCK_HOME_AWAY });
|
|
||||||
}
|
|
||||||
if (url.includes('split_type=rest_days') || (arguments[1]?.params?.split_type === 'rest_days')) {
|
|
||||||
return Promise.resolve({ data: MOCK_REST_DAYS });
|
|
||||||
}
|
|
||||||
if (url.includes('split_type=vs_team') || (arguments[1]?.params?.split_type === 'vs_team')) {
|
|
||||||
return Promise.resolve({ data: MOCK_VS_TEAM });
|
|
||||||
}
|
|
||||||
return Promise.resolve({ data: MOCK_HOME_AWAY });
|
|
||||||
}
|
|
||||||
return Promise.reject(new Error(`Unmocked URL: ${url}`));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Better mock that checks params
|
|
||||||
function setupDetailedMocks() {
|
|
||||||
mockRedis.get.mockResolvedValue(null);
|
|
||||||
mockRedis.set.mockResolvedValue('OK');
|
|
||||||
mockRedis.hset.mockResolvedValue(1);
|
|
||||||
mockRedis.hgetall.mockResolvedValue({});
|
|
||||||
mockRedis.expire.mockResolvedValue(1);
|
|
||||||
|
|
||||||
axios.get.mockImplementation((url, config) => {
|
|
||||||
// Odds API
|
|
||||||
if (url.includes('the-odds-api.com') && url.includes('/events') && !url.includes('/odds')) {
|
|
||||||
return Promise.resolve({ data: MOCK_ODDS_EVENTS, headers: API_HEADERS });
|
|
||||||
}
|
|
||||||
if (url.includes('the-odds-api.com') && url.includes('/odds')) {
|
|
||||||
return Promise.resolve({ data: MOCK_ODDS_WITH_SPREADS, headers: API_HEADERS });
|
|
||||||
}
|
|
||||||
// NBA stats service
|
|
||||||
if (url.includes('localhost:8000/stats/season-avg')) {
|
|
||||||
return Promise.resolve({ data: MOCK_SEASON_AVG });
|
|
||||||
}
|
|
||||||
if (url.includes('localhost:8000/stats/last-n')) {
|
|
||||||
return Promise.resolve({ data: MOCK_LAST_N });
|
|
||||||
}
|
|
||||||
if (url.includes('localhost:8000/stats/splits')) {
|
|
||||||
const splitType = config?.params?.split_type;
|
|
||||||
if (splitType === 'home_away') return Promise.resolve({ data: MOCK_HOME_AWAY });
|
|
||||||
if (splitType === 'rest_days') return Promise.resolve({ data: MOCK_REST_DAYS });
|
|
||||||
if (splitType === 'vs_team') return Promise.resolve({ data: MOCK_VS_TEAM });
|
|
||||||
return Promise.resolve({ data: MOCK_HOME_AWAY });
|
|
||||||
}
|
|
||||||
if (url.includes('localhost:8000/players/search')) {
|
|
||||||
return Promise.resolve({ data: { results: [{ player_id: 203999, full_name: 'Nikola Jokic' }] } });
|
|
||||||
}
|
|
||||||
return Promise.reject(new Error(`Unmocked URL: ${url}`));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
mockRedis.get.mockResolvedValue(null);
|
||||||
|
mockRedis.set.mockResolvedValue('OK');
|
||||||
|
mockAnalyzeViaEngine1.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /api/analyze/prop', () => {
|
describe('POST /api/analyze/prop', () => {
|
||||||
it('returns complete analysis with all fields', async () => {
|
it('returns complete analysis with all fields', async () => {
|
||||||
setupDetailedMocks();
|
mockAnalyzeViaEngine1.mockResolvedValueOnce(fullShapedResponse({ confidence: 78 }));
|
||||||
|
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/api/analyze/prop')
|
.post('/api/analyze/prop')
|
||||||
@@ -240,13 +86,14 @@ describe('POST /api/analyze/prop', () => {
|
|||||||
})
|
})
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
|
// SHAPE assertions (must survive engine swap — these are the API contract):
|
||||||
expect(res.body.player).toBe('Nikola Jokic');
|
expect(res.body.player).toBe('Nikola Jokic');
|
||||||
expect(res.body.stat_type).toBe('points');
|
expect(res.body.stat_type).toBe('points');
|
||||||
expect(res.body.grade).toMatch(/^[ABCD]$/);
|
expect(res.body.grade).toMatch(/^[ABCDF]$/);
|
||||||
expect(typeof res.body.edge_pct).toBe('number');
|
expect(typeof res.body.edge_pct).toBe('number');
|
||||||
expect(typeof res.body.confidence).toBe('number');
|
expect(typeof res.body.confidence).toBe('number');
|
||||||
expect(res.body.confidence).toBeGreaterThanOrEqual(30);
|
expect(res.body.confidence).toBeGreaterThanOrEqual(0);
|
||||||
expect(res.body.confidence).toBeLessThanOrEqual(95);
|
expect(res.body.confidence).toBeLessThanOrEqual(100);
|
||||||
expect(Array.isArray(res.body.kill_conditions_triggered)).toBe(true);
|
expect(Array.isArray(res.body.kill_conditions_triggered)).toBe(true);
|
||||||
expect(res.body.reasoning).toBeDefined();
|
expect(res.body.reasoning).toBeDefined();
|
||||||
expect(res.body.reasoning.summary).toBeDefined();
|
expect(res.body.reasoning.summary).toBeDefined();
|
||||||
@@ -257,10 +104,16 @@ describe('POST /api/analyze/prop', () => {
|
|||||||
expect(res.body.reasoning.steps.line_comparison).toBeDefined();
|
expect(res.body.reasoning.steps.line_comparison).toBeDefined();
|
||||||
expect(res.body.reasoning.steps.kill_conditions).toBeDefined();
|
expect(res.body.reasoning.steps.kill_conditions).toBeDefined();
|
||||||
expect(res.body.reasoning.steps.final_grade).toBeDefined();
|
expect(res.body.reasoning.steps.final_grade).toBeDefined();
|
||||||
|
// PERF-1 cache tag — every fresh call is MISS.
|
||||||
|
expect(res.body._cache).toBe('MISS');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns grade A/B for player averaging above line with good recent form', async () => {
|
it('returns grade A/B for player averaging above line with good recent form', async () => {
|
||||||
setupDetailedMocks();
|
mockAnalyzeViaEngine1.mockResolvedValueOnce(fullShapedResponse({
|
||||||
|
grade: 'A',
|
||||||
|
confidence: 82,
|
||||||
|
edge_pct: 8.4,
|
||||||
|
}));
|
||||||
|
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/api/analyze/prop')
|
.post('/api/analyze/prop')
|
||||||
@@ -277,8 +130,14 @@ describe('POST /api/analyze/prop', () => {
|
|||||||
expect(res.body.edge_pct).toBeGreaterThan(0);
|
expect(res.body.edge_pct).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns grade D for player averaging below line', async () => {
|
it('returns a low grade for player averaging below line', async () => {
|
||||||
setupDetailedMocks();
|
// Engine 1 collapses A+/A/A- → A and (D|F) → D|F. For a cold prop the
|
||||||
|
// adapter produces D or F; both are valid for this assertion.
|
||||||
|
mockAnalyzeViaEngine1.mockResolvedValueOnce(fullShapedResponse({
|
||||||
|
grade: 'D',
|
||||||
|
confidence: 25,
|
||||||
|
edge_pct: -12.1,
|
||||||
|
}));
|
||||||
|
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/api/analyze/prop')
|
.post('/api/analyze/prop')
|
||||||
@@ -291,43 +150,20 @@ describe('POST /api/analyze/prop', () => {
|
|||||||
})
|
})
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(res.body.grade).toBe('D');
|
expect(['D', 'F']).toContain(res.body.grade);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('caps grade when kill conditions trigger (blowout spread)', async () => {
|
it('surfaces kill conditions through the adapter', async () => {
|
||||||
// Override spread to be a blowout
|
// The 7e adapter translates engine1 factors into kill_conditions
|
||||||
const bigSpreadOdds = JSON.parse(JSON.stringify(MOCK_ODDS_WITH_SPREADS));
|
// entries. The legacy "blowout_risk" code is gone; the new code
|
||||||
bigSpreadOdds.bookmakers[0].markets[1].outcomes[0].point = -15;
|
// set comes from FACTOR_TO_KILL_CONDITION in gradeAdapter.js.
|
||||||
bigSpreadOdds.bookmakers[0].markets[1].outcomes[1].point = 15;
|
mockAnalyzeViaEngine1.mockResolvedValueOnce(fullShapedResponse({
|
||||||
|
grade: 'C',
|
||||||
mockRedis.get.mockResolvedValue(null);
|
confidence: 35,
|
||||||
mockRedis.set.mockResolvedValue('OK');
|
kill_conditions_triggered: [
|
||||||
mockRedis.hset.mockResolvedValue(1);
|
{ code: 'TRAP', reason: 'Multiple trap signals firing.' },
|
||||||
mockRedis.hgetall.mockResolvedValue({});
|
],
|
||||||
mockRedis.expire.mockResolvedValue(1);
|
}));
|
||||||
|
|
||||||
axios.get.mockImplementation((url, config) => {
|
|
||||||
if (url.includes('the-odds-api.com') && !url.includes('/odds')) {
|
|
||||||
return Promise.resolve({ data: MOCK_ODDS_EVENTS, headers: API_HEADERS });
|
|
||||||
}
|
|
||||||
if (url.includes('the-odds-api.com') && url.includes('/odds')) {
|
|
||||||
return Promise.resolve({ data: bigSpreadOdds, headers: API_HEADERS });
|
|
||||||
}
|
|
||||||
if (url.includes('localhost:8000/stats/season-avg')) {
|
|
||||||
return Promise.resolve({ data: MOCK_SEASON_AVG });
|
|
||||||
}
|
|
||||||
if (url.includes('localhost:8000/stats/last-n')) {
|
|
||||||
return Promise.resolve({ data: MOCK_LAST_N });
|
|
||||||
}
|
|
||||||
if (url.includes('localhost:8000/stats/splits')) {
|
|
||||||
const st = config?.params?.split_type;
|
|
||||||
if (st === 'home_away') return Promise.resolve({ data: MOCK_HOME_AWAY });
|
|
||||||
if (st === 'rest_days') return Promise.resolve({ data: MOCK_REST_DAYS });
|
|
||||||
if (st === 'vs_team') return Promise.resolve({ data: MOCK_VS_TEAM });
|
|
||||||
return Promise.resolve({ data: MOCK_HOME_AWAY });
|
|
||||||
}
|
|
||||||
return Promise.reject(new Error(`Unmocked: ${url}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/api/analyze/prop')
|
.post('/api/analyze/prop')
|
||||||
@@ -340,9 +176,12 @@ describe('POST /api/analyze/prop', () => {
|
|||||||
})
|
})
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
const codes = res.body.kill_conditions_triggered.map((k) => k.code);
|
expect(Array.isArray(res.body.kill_conditions_triggered)).toBe(true);
|
||||||
expect(codes).toContain('blowout_risk');
|
expect(res.body.kill_conditions_triggered.length).toBeGreaterThan(0);
|
||||||
expect(['C', 'D']).toContain(res.body.grade);
|
const first = res.body.kill_conditions_triggered[0];
|
||||||
|
expect(typeof first.code).toBe('string');
|
||||||
|
expect(typeof first.reason).toBe('string');
|
||||||
|
expect(['C', 'D', 'F']).toContain(res.body.grade);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 400 for missing player field', async () => {
|
it('returns 400 for missing player field', async () => {
|
||||||
@@ -352,6 +191,8 @@ describe('POST /api/analyze/prop', () => {
|
|||||||
.expect(400);
|
.expect(400);
|
||||||
|
|
||||||
expect(res.body.error).toContain('player is required');
|
expect(res.body.error).toContain('player is required');
|
||||||
|
// Validation runs before the engine — the helper must not be called.
|
||||||
|
expect(mockAnalyzeViaEngine1).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 400 for invalid stat_type', async () => {
|
it('returns 400 for invalid stat_type', async () => {
|
||||||
@@ -375,7 +216,9 @@ describe('POST /api/analyze/prop', () => {
|
|||||||
|
|
||||||
describe('POST /api/analyze/batch', () => {
|
describe('POST /api/analyze/batch', () => {
|
||||||
it('processes multiple props and returns array', async () => {
|
it('processes multiple props and returns array', async () => {
|
||||||
setupDetailedMocks();
|
mockAnalyzeViaEngine1
|
||||||
|
.mockResolvedValueOnce(fullShapedResponse({ stat_type: 'points', line: 26.5 }))
|
||||||
|
.mockResolvedValueOnce(fullShapedResponse({ stat_type: 'rebounds', line: 12.5, grade: 'B' }));
|
||||||
|
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/api/analyze/batch')
|
.post('/api/analyze/batch')
|
||||||
@@ -389,6 +232,11 @@ describe('POST /api/analyze/batch', () => {
|
|||||||
|
|
||||||
expect(Array.isArray(res.body.results)).toBe(true);
|
expect(Array.isArray(res.body.results)).toBe(true);
|
||||||
expect(res.body.results.length).toBe(2);
|
expect(res.body.results.length).toBe(2);
|
||||||
|
for (const r of res.body.results) {
|
||||||
|
expect(r.grade).toMatch(/^[ABCDF]$/);
|
||||||
|
expect(typeof r.confidence).toBe('number');
|
||||||
|
expect(r.reasoning.summary).toBeDefined();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 400 for empty props array', async () => {
|
it('returns 400 for empty props array', async () => {
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
// PERF-1 (Session 7d): /api/analyze caches via Redis. Second identical
|
// PERF-1 (Session 7d): /api/analyze caches via Redis. Second identical
|
||||||
// call must hit cache (returns _cache:'HIT', does not re-invoke
|
// call must hit cache (returns _cache:'HIT', does not re-invoke the
|
||||||
// analyzeProp). Different keys still miss.
|
// analyzer). Different keys still miss.
|
||||||
|
// ARCH-1 (Session 7g): the analyzer target moved from propAnalyzer to
|
||||||
|
// the unified analyzeViaEngine1. Mock target updated to follow; the
|
||||||
|
// caching behavior under test is unchanged.
|
||||||
|
|
||||||
const mockAnalyze = jest.fn();
|
const mockAnalyze = jest.fn();
|
||||||
jest.mock('../../src/services/propAnalyzer', () => ({
|
jest.mock('../../src/services/intelligence/analyzeViaEngine1', () => ({
|
||||||
analyzeProp: (...args) => mockAnalyze(...args),
|
analyzeViaEngine1: (...args) => mockAnalyze(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockStore = new Map();
|
const mockStore = new Map();
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user