f0543b57a4
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
284 lines
12 KiB
JavaScript
284 lines
12 KiB
JavaScript
'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 { 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: 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 },
|
||
};
|