'use strict'; /** * intradayRefreshService — Phase 2.5 (Session 60, night2/D). * * A lightweight ODDS-ONLY refresh during slate hours (~noon–midnight ET). * No full re-grade run: it re-reads the market, computes the signed delta * per graded prop RELATIVE TO THE GRADED SIDE, and acts on DIRECTION: * * moved WITH the grade (market chasing our number) → STEAM ▲ +N badge. * Good for the record; entry edge compressed. NO re-grade. * moved AGAINST ≥ 1.0 → re-grade THAT * PROP ONLY at the current line: * grade holds → VALUE ▲ (better number, same read) * grade drops → PUBLIC revision: grade updates with * revised_from_grade set (original preserved, struck through in * the UI + ledger). Never a silent regrade — Ledger ethos. * * Every displayed line stays a REAL book value from this refresh — the * refresh CAPTURES market numbers, never computes them. Each run also * re-captures closing_line/odds (ledgerService.captureClosing): the last * pre-game write IS the close, now at refresh fidelity. * * QUOTA MATH (zero out-of-pocket): one getOdds call per sport per run. * 20-min cadence × 12 slate hours = 36 runs/day/sport × 4 sports = * ≤144 PropLine requests/day — against 9,000/day free capacity (3 keys × * 3,000). Re-grades are internal feature computation (free) and bounded * to props that moved against ≥ 1.0. * * Everything injectable → unit-tested with zero network. */ const { nameKey } = require('../utils/playerName'); const STEAM_NOISE = 0.5; // ignore movement below this (both directions) const REGRADE_TRIGGER = 1.0; // moved-against threshold that triggers a re-grade const SNAP_TTL = 24 * 3600; // keep in sync with snapshotService const TICKER_MOVE_CAP = 6; const HISTORY_CAP = 24; // {t, line} points per grade (S6 sparklines) const GRADE_RANK = { 'A+': 0, A: 1, 'A-': 2, 'B+': 3, B: 4, 'B-': 5, 'C+': 6, C: 7, 'C-': 8, D: 9, F: 10 }; const rank = (g) => (g && GRADE_RANK[g] !== undefined ? GRADE_RANK[g] : 99); const sideOver = (dir) => String(dir || 'over').toLowerCase() !== 'under'; function indexOddsProps(props) { const map = {}; for (const p of props || []) { if (!p || !p.player || !p.stat_type) continue; const k = `${nameKey(p.player)}|${String(p.stat_type).toLowerCase()}`; if (!map[k]) map[k] = p; } return map; } /** * S6 (A1 board) — line-history capture for the row sparklines. Appends the * CURRENT real feed line as a {t, line} point on the grade's movement * tracking, persisted in the snapshot this refresh already writes back * (zero new keys). Rules: * - real points only: an unparseable current line appends nothing; * - seed: an empty history first records the LOCKED line at its own * graded timestamp (a real captured value); * - dedupe: consecutive identical lines don't append — a point means the * line MOVED, so ≥3 points = a real movement story, not a flat pulse; * - cap: last HISTORY_CAP (24) points. */ function trackHistory(g, currentLine, ts) { const prev = Array.isArray(g.history) ? g.history : []; // Strict: Number(null) is 0 — a fabricated line (Data Semantics Rule). const current = currentLine == null ? NaN : Number(currentLine); if (!Number.isFinite(current)) return prev.length > 0 ? prev : undefined; let hist = prev; if (hist.length === 0) { const locked = g.gradedAt && g.gradedAt.line != null ? g.gradedAt.line : g.line; const lockedNum = Number(locked); if (locked != null && Number.isFinite(lockedNum)) { hist = [{ t: (g.gradedAt && g.gradedAt.timestamp) || ts, line: lockedNum }]; } } const last = hist[hist.length - 1]; if (!last || last.line !== current) hist = [...hist, { t: ts, line: current }]; return hist.slice(-HISTORY_CAP); } /** Signed movement RELATIVE TO THE GRADED SIDE: positive = toward (with). * Strict null-safe parse — Number(null) is 0, a fabricated line. */ function signedDelta(side, lockedLine, currentLine) { if (lockedLine == null || currentLine == null) return null; const locked = Number(lockedLine); const current = Number(currentLine); if (!Number.isFinite(locked) || !Number.isFinite(current)) return null; const raw = current - locked; return Math.round((sideOver(side) ? raw : -raw) * 100) / 100; } /** * Run one intraday refresh for a sport. Returns * { sport, status, checked, steam, value, revised } — never throws. */ async function runIntradayRefresh(sport, opts = {}) { const sp = String(sport || '').toLowerCase(); const deps = { getOdds: opts.getOdds || require('./oddsService').getOdds, analyze: opts.analyze || require('./intelligence/analyzeViaEngine1').analyzeViaEngine1, cacheGet: opts.cacheGet || require('../utils/redis').cacheGet, cacheSet: opts.cacheSet || require('../utils/redis').cacheSet, ledger: opts.ledger || require('./ledgerService'), pushTickerItems: opts.pushTickerItems || require('./snapshotService').pushTickerItems, now: opts.now || (() => new Date().toISOString()), }; const ts = deps.now(); const snap = await deps.cacheGet(`snapshot:${sp}:latest`); if (!snap || !Array.isArray(snap.grades) || snap.grades.length === 0) { return { sport: sp, status: 'skipped', reason: 'no snapshot', checked: 0, steam: 0, value: 0, revised: 0 }; } let odds; try { odds = await deps.getOdds(sp); } catch (e) { return { sport: sp, status: 'error', reason: e.message, checked: 0, steam: 0, value: 0, revised: 0 }; } const props = odds && Array.isArray(odds.props) ? odds.props : []; if (props.length === 0) { return { sport: sp, status: 'skipped', reason: 'no odds', checked: 0, steam: 0, value: 0, revised: 0 }; } const byKey = indexOddsProps(props); let steam = 0; let value = 0; let revised = 0; let checked = 0; const moveEvents = []; const grades = []; for (const g of snap.grades) { const player = g.player || g.player_name; const stat = String(g.stat_type || g.stat || '').toLowerCase(); const locked = g.gradedAt && g.gradedAt.line != null ? g.gradedAt.line : g.line; const prop = byKey[`${nameKey(player)}|${stat}`]; // Prop gone from the feed (game started / market pulled) → frozen as-is. if (!prop || prop.line == null || locked == null) { grades.push(g); continue; } checked += 1; // S6 — capture the real current line into the grade's {t, line} history // (sparkline fuel). Rides inside the snapshot write below — no new keys. const history = trackHistory(g, prop.line, ts); const withHist = (obj) => (history ? { ...obj, history } : obj); const delta = signedDelta(g.direction, locked, prop.line); if (delta == null || Math.abs(delta) < STEAM_NOISE) { grades.push(withHist({ ...g, movement: null })); continue; } const current = Number(prop.line); if (delta > 0) { // Moved WITH the grade — the market is chasing our number. steam += 1; grades.push(withHist({ ...g, movement: { kind: 'steam', delta, currentLine: current, at: ts } })); if (Math.abs(delta) >= REGRADE_TRIGGER) { moveEvents.push(moveEvent(sp, g, locked, current, ts)); } continue; } // Moved AGAINST the grade. if (Math.abs(delta) < REGRADE_TRIGGER) { grades.push(withHist({ ...g, movement: { kind: 'against', delta, currentLine: current, at: ts } })); continue; } // ≥ 1.0 against → re-grade THIS PROP ONLY at the current (real) line. let res = null; try { res = await deps.analyze({ player, stat_type: stat, line: current, direction: g.direction || 'over', sport: sp, book: prop.book, }); } catch { /* re-grade unavailable → treated as hold below */ } moveEvents.push(moveEvent(sp, g, locked, current, ts)); if (!res || !res.grade || res.insufficient_data || rank(res.grade) <= rank(g.grade)) { // Grade holds (or the model refuses to re-read) → better entry, same read. value += 1; grades.push(withHist({ ...g, movement: { kind: 'value', delta, currentLine: current, at: ts } })); continue; } // Grade DROPS → public revision. Original grade preserved once, forever. revised += 1; const fromGrade = g.revised_from_grade || g.grade; grades.push(withHist({ ...g, grade: res.grade, revised_from_grade: fromGrade, movement: { kind: 'revised', delta, currentLine: current, at: ts }, })); try { await deps.ledger.applyRevision(sp, { playerKey: nameKey(player), stat, line: Number(locked), side: sideOver(g.direction) ? 'over' : 'under', newGrade: res.grade, fromGrade, }); } catch (e) { console.warn(`[intraday] ledger revision failed for ${player}:`, e.message); } } // Write back: refreshed movement state + the higher-fidelity close. const updated = { ...snap, grades, refreshed_at: ts }; await deps.cacheSet(`snapshot:${sp}:latest`, updated, SNAP_TTL); await deps.cacheSet(`grades:${sp}`, { grades, updated_at: snap.updated_at, refreshed_at: ts, source: snap.source || 'refresh' }, SNAP_TTL); try { await deps.ledger.captureClosing(sp, props); } catch { /* best-effort */ } if (moveEvents.length > 0) { await deps.pushTickerItems(moveEvents.slice(0, TICKER_MOVE_CAP), deps); } return { sport: sp, status: 'ok', checked, steam, value, revised }; } function moveEvent(sport, g, locked, current, ts) { const s = sideOver(g.direction) ? 'o' : 'u'; const arrow = current > locked ? '▲' : '▼'; const diff = Math.round((current - locked) * 100) / 100; const last = String(g.player || g.player_name || '').trim().split(/\s+/).pop() || ''; return { tag: 'MOVE', color: 'var(--amber)', ts, sport, text: `${last} ${s}${locked} → ${s}${current} ${arrow}${diff > 0 ? '+' : ''}${diff}`, }; } /** Slate hours: noon–midnight ET. */ function inSlateHours(date = new Date()) { const h = Number(new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York', hour: 'numeric', hour12: false }).format(date)); return h >= 12 && h <= 23; } async function runAllIntradayRefreshes(opts = {}) { const sports = opts.sports || require('./snapshotService').ACTIVE_SPORTS; const results = []; for (const sp of sports) { try { results.push(await runIntradayRefresh(sp, opts)); } catch (e) { results.push({ sport: sp, status: 'error', reason: e.message }); } } return results; } module.exports = { runIntradayRefresh, runAllIntradayRefreshes, inSlateHours, __internals: { signedDelta, indexOddsProps, moveEvent, rank, trackHistory, STEAM_NOISE, REGRADE_TRIGGER, HISTORY_CAP }, };