Files
vyndr/src/services/intradayRefreshService.js
T
builtbykev f0543b57a4 Product identity + widen books for DISPLAY, model input byte-identical
IDENTITY (CLAUDE.md top + MASTER-PLAN header). VYNDR is a PREDICTIVE MODEL:
it projects what a player will DO and picks accurately. Market edge is a
BYPRODUCT of a good prediction, never the success criterion. Success =
the forecast is honest about its own confidence AND still ranks --
calibration and resolution, both. No edge/CLV term belongs in a pass/fail
gate; they are diagnostics we report, not thresholds a model must clear.
A model tuned to beat a closing line has been fitted to the market instead
of to the game.

Per-sport doctrine (Phillips 2022, classify by what players DO not by
position): each sport is its own model -- own variables, archetypes,
conditions, calibration, honest ceiling. Shared across sports: ONLY the
Bayesian inference math.

Truth Law: no fabricated data; honest-absent over invented; label
limitations in-band; provisional stays provisional until re-run;
documented is not verified.

PHASE 2 -- AGGREGATOR WIDENING (live). normalizeProps now emits every
DISPLAY book instead of 5 of 18. Before this we discarded 13 books of our
own accord and 64.8% of the MLB slate was invisible to users. Every prop
carries book_role (both/takeable/reference/dfs/offshore) so the display
layer can say WHAT a price is -- a fixed-payout DFS number and a two-way
sportsbook price are not interchangeable objects. Unknown books are still
dropped.

PHASE 3 -- MODEL GATE (the model does not move). bookRoles splits
MODEL_BOOKS (the legacy allow-list, character for character) from
DISPLAY_BOOKS. Both model paths re-filter before they pick a line:
gradeSlateService.dedupeProps (before first-row-wins AND before the limit)
and intradayRefreshService.indexOddsProps (which RE-GRADES at the current
line -- without the gate, widening would have silently moved locked lines
onto books the model has never been calibrated against). A test asserts
the graded set is byte-identical through the widening.

CURRENT_RULER_VERSION stays v1_first_book. The gate lifts only when the
MLB calibration is re-run on the consensus ruler and v2 is promoted.

HONEST FRAMING, recorded in the plan: this is an AGGREGATOR win and it
does NOT fix the model. WNBA still abstains -- a model problem, not a
coverage problem; it is better covered than MLB. MLB isotonic still
provisional. The consensus is MARKET, not SHARP: pinnacle, matchbook and
polymarket are 0% on both sports, so no sharp anchor exists in our feed.

Two superseded tests updated to stronger properties rather than deleted:
roleOf now names the KIND of book, and the normalizer test asserts the
display set widens WHILE the model set does not.

Gates: 4,027 tests / 322 suites green; next build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-08-01 00:50:54 -04:00

284 lines
12 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 { isModelBook } = require('../config/bookRoles');
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';
// ORDER ZERO GATE — same reasoning as gradeSlateService.dedupeProps. This index
// feeds line-movement detection and RE-GRADES props at the current line, so it
// is a MODEL path: it must see only MODEL_BOOKS. Without this gate the widened
// display feed would silently move locked lines onto DFS/exchange numbers the
// model has never been calibrated against.
function indexOddsProps(props) {
const map = {};
for (const p of props || []) {
if (!p || !p.player || !p.stat_type) continue;
if (!isModelBook(p.book)) 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 */ }
// Session 64 — CLOSING CAPTURE. This refresh already runs every ~20 min
// during slate hours, so it is the natural place to observe the last line
// before each game locks. Append-only and best-effort: a capture failure must
// never break the refresh, but a MISSED close is unrecoverable, so the rate
// is alarmed rather than silently tolerated.
try {
const closing = deps.closingCapture || require('./closingCapture');
const rows = closing.buildCaptureRows(sp, props, {});
if (rows.length) {
const res = await closing.persist(rows);
const captured = rows.filter((r) => !r.missed_reason).length;
const al = closing.captureRateAlarm({ eligible: rows.length, captured });
console.log(`[intraday] closing capture ${sp}: ${captured}/${rows.length} priced, ${res.written} stored${al.alarm ? ' — RATE ALARM' : ''}`);
if (al.alarm && deps.notify) {
await deps.notify(`Closing capture degraded for ${sp.toUpperCase()}${al.reason}`,
{ title: 'VYNDR closing capture', priority: 'high', tags: ['rotating_light'] });
}
}
} catch (e) {
console.warn(`[intraday] closing capture failed for ${sp}:`, e.message);
}
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 },
};