Files
vyndr/src/services/gradeSlateService.js
T
builtbykev f0c8b4f29b Session 32: Grades pipeline + NFL/NHL wiring + rate limiting + audit cleanup (1718 tests)
- gradeSlateService writes grades:{sport} cache (closes content pipeline →
  dataLevel full); fire-and-forget from oddsService.recordDownstream, gated
  by shouldGradeSlate (off in test, GRADE_SLATE_ON_FETCH override)
- NFL/NHL wired: oddsService SPORT_KEYS/SPORT_MARKETS (correct the-odds-api
  keys americanfootball_nfl/icehockey_nhl), proplineAdapter MARKETS, NHL
  MARKET_MAP keys to avoid silent-zero
- rate limiting mounted on 8 public cached routers (odds/parlay 30/min,
  rest 60/min)
- jsonlLogger writes to temp under test (no more dirtied tracked artifact);
  5MB pipeline test given 20s timeout

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:21:32 -04:00

138 lines
5.3 KiB
JavaScript

'use strict';
/**
* Grade-slate writer (Session 32).
*
* Closes the content pipeline. The grading engine (engine1 via
* analyzeViaEngine1) grades props on demand, but nothing ever persisted a
* sport's graded slate. `contentTemplateService.collectSlateData` reads a
* `grades:{sport}` cache "when present" — without a writer it was always
* empty, so slate/POTD content degraded to lines/schedule and never reached
* `dataLevel: 'full'`.
*
* This service grades a sport's freshly-fetched props and writes the
* `grades:{sport}` cache in the exact shape contentTemplateService expects.
* It is wired fire-and-forget into `oddsService.recordDownstream` so it runs
* on a cache MISS (≈hourly) WITHOUT holding the odds HTTP response — content
* endpoints read the grades cache independently and asynchronously.
*
* The legacy grade shape (player_name?/player, stat_type, line, direction,
* grade, confidence, edge_pct, reasoning.summary) is already what
* `contentTemplateService.normalizeGrade` reads — so no field remapping is
* needed at the write boundary.
*/
// Cost bounds: the odds slate carries one row per player+stat+line+book.
// We dedupe to unique player+stat+line and cap how many we grade, because
// each grade fans out to feature computation. Grading runs at most once per
// cache-miss per sport, but we still bound the herd.
const DEFAULT_LIMIT = 25;
const DEFAULT_CONCURRENCY = 5;
const DEFAULT_TTL = 7200; // 2 hours — matches the spec's grades-cache TTL.
// Collapse the multi-book prop rows to one entry per gradeable prop.
function dedupeProps(props, limit) {
const seen = new Set();
const out = [];
for (const p of props || []) {
if (!p || !p.player || !p.stat_type || p.line == null) continue;
const key = `${p.player}::${p.stat_type}::${p.line}`;
if (seen.has(key)) continue;
seen.add(key);
out.push(p);
if (out.length >= limit) break;
}
return out;
}
// engine1 is direction-aware, so a prop grades differently over vs under.
// Grade both sides and keep the higher-confidence verdict — that's the
// side the engine actually favors.
async function gradeBestSide(grade, prop, sport) {
const base = {
player: prop.player,
stat_type: prop.stat_type,
line: prop.line,
sport,
book: prop.book,
};
const sides = await Promise.all([
Promise.resolve()
.then(() => grade({ ...base, direction: 'over' }))
.catch(() => null),
Promise.resolve()
.then(() => grade({ ...base, direction: 'under' }))
.catch(() => null),
]);
const cands = sides.filter(Boolean);
if (cands.length === 0) return null;
return cands.reduce((a, b) => ((Number(b.confidence) || 0) > (Number(a.confidence) || 0) ? b : a));
}
// Run an async mapper over items with a bounded concurrency.
async function mapLimit(items, concurrency, fn) {
const results = new Array(items.length);
let cursor = 0;
async function worker() {
while (cursor < items.length) {
const i = cursor++;
results[i] = await fn(items[i], i);
}
}
const pool = Array.from({ length: Math.min(concurrency, items.length) }, () => worker());
await Promise.all(pool);
return results;
}
/**
* Grade a sport's props and write the `grades:{sport}` cache.
*
* @param {string} sport
* @param {Array} props normalized odds props (oddsNormalizer shape)
* @param {Object} [opts]
* @param {Function} [opts.grade] grader (default analyzeViaEngine1)
* @param {Function} [opts.cacheSet] cache writer (default redis.cacheSet)
* @param {string} [opts.source] provider tag ('propline' | 'odds-api')
* @param {number} [opts.limit] max unique props graded
* @param {number} [opts.ttl] cache TTL seconds
* @param {Function} [opts.now] timestamp source (testable)
* @returns {Promise<{written:boolean,count:number,error?:string}>}
*/
async function gradeAndCacheSlate(sport, props, opts = {}) {
const grade = opts.grade || require('./intelligence/analyzeViaEngine1').analyzeViaEngine1;
const cacheSet = opts.cacheSet || require('../utils/redis').cacheSet;
const source = opts.source || 'odds-api';
const limit = opts.limit || DEFAULT_LIMIT;
const ttl = opts.ttl || DEFAULT_TTL;
const concurrency = opts.concurrency || DEFAULT_CONCURRENCY;
const now = opts.now || (() => new Date().toISOString());
if (!Array.isArray(props) || props.length === 0) {
return { written: false, count: 0 };
}
try {
const unique = dedupeProps(props, limit);
if (unique.length === 0) return { written: false, count: 0 };
const graded = (await mapLimit(unique, concurrency, (p) => gradeBestSide(grade, p, sport)))
.filter(Boolean)
.sort((a, b) => (Number(b.confidence) || 0) - (Number(a.confidence) || 0));
if (graded.length === 0) return { written: false, count: 0 };
const envelope = { grades: graded, updated_at: now(), source };
await cacheSet(`grades:${sport}`, envelope, ttl);
return { written: true, count: graded.length };
} catch (e) {
// Best-effort — slate grading must never break odds delivery.
console.warn('[gradeSlateService] grade slate failed:', e.message);
return { written: false, count: 0, error: e.message };
}
}
module.exports = {
gradeAndCacheSlate,
__internals: { dedupeProps, gradeBestSide, mapLimit, DEFAULT_LIMIT, DEFAULT_TTL, DEFAULT_CONCURRENCY },
};