Files
vyndr/src/services/intradayRefreshService.js
T
builtbykev d3637e7abd S6 (a1): display — the full picture under the grammar
- specs/ROW-GRAMMAR.md: the row grammar law (slot order, color law, mark
  law, mobile stacking, no-truncation), locked by tests/unit/rowGrammar.
  StatStrip violations fixed: MovementChip before the grade (market
  context before model output); ViabilityChips after the archetype
  (identity is one contiguous run).
- Line-movement sparklines: intradayRefreshService.trackHistory captures
  real {t,line} points per grade (seeded with the lock, deduped when
  flat, capped 24) inside the snapshot write-back; StatStrip.LineSparkline
  renders at >=3 points (green toward / amber against / dim flat).
- Last-10 dot strips: services/last10Dots (streaksService accessors) ->
  /api/snapshot/:sport attaches last10_dots from rosterlogs:{sport};
  StatStrip.DotStrip renders vs the LOCKED line, newest first.
- CLV distribution: getModelAggregate emits clv_distribution (7 signed
  buckets, outliers clamped) only past the centralized n>=20 gate;
  ledger MODEL header renders the bar strip.
- Global search: SearchModal (cmd-K via GlobalHosts + window.__search),
  players via /api/players/search per sport + static lib/teams.js
  (soccer deliberately absent); Nav search icon + Search first in the
  mobile More sheet. Explore tab untouched.
- Landing LCP: fade-up floored at opacity .6 (hero h1 contentful on
  first frame, S33 visible-floor rule) + IBM Plex Mono preload:false
  (4 decorative font files off the slow-4G critical path).

2654 -> 2698 tests (226 suites) green; web build exit 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:08:24 -04:00

254 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/**
* intradayRefreshService — Phase 2.5 (Session 60, night2/D).
*
* A lightweight ODDS-ONLY refresh during slate hours (~noonmidnight 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: noonmidnight 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 },
};