f0c8b4f29b
- 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>
138 lines
5.3 KiB
JavaScript
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 },
|
|
};
|