Files
vyndr/src/services/intradayRefreshService.js
T
builtbykev 77a58e4113 Arm harness on our scheduler + start closing-line capture (capture only)
PART A — HARNESS ARMED ON OUR OWN INFRA. snapshotScheduler now runs the
nightly backtest at HARNESS_HOUR_UTC (default 14), appends to
harness_results, and pages via opsWatch.harnessStaleAlarm — a validator
that stops running looks exactly like one that keeps passing. No external
dependency: the join is plain SQL through the service client and the
harness is a pure function. POST /api/internal/harness/run induces the
same code path on demand, because a scheduled mechanism is verified by
inducing it, never by waiting for a slot.

PART B PHASE 0 — GATE PASSED for what is capturable:
- C4 diagnosed: closing_line is ONE overwritable field with no timestamp
  and no provenance. captureClosing writes the current line and, when a
  prop fails to match, silently leaves the earlier value (= the lock) in
  place — so "captured a real close" is indistinguishable from "never
  updated". It is 92% equal, not 100%: 56 rows DID record movement, so
  the defect is provenance, not the value.
- Feeds: normalized props already carry BOTH raw side prices per book,
  with game_time, and the intraday refresh polls every ~20 min during
  slate hours — so the last observable pre-lock line is available.
- SHARP close: pinnacle is in ALLOWED_BOOKS -> a no-vig reference is
  capturable ("beat the market").
- ODAWA: NOT capturable. 'odawa' exists only as a UI preference option in
  onboarding/settings; it is in no adapter, no ALLOWED_BOOKS, no feed. An
  un-capturable source is a finding, not a gap to paper over.
- JOIN: must drop `line` from the natural key, because a close that MOVED
  off the graded line is the entire point of CLV. Verified safe — all 164
  current identity groups have exactly ONE line per
  (sport, player_key, stat, side, game_date). Zero ambiguity.

PART B PHASE 1 — CAPTURE ONLY, built test-first. The refusal was proven
before the capture logic existed: unbound game_time, doubleheader
ambiguity, a missed pre-lock window, or a one-sided price all record
missed_reason with NO price. A stale or mid-day line substituted for a
close would manufacture a CLV proof from a number that was never the
close.

migration 029 closing_captures: append-only, never overwritten (that is
the provenance C4 lacked), BOTH raw side prices so the existing de-vig
engine can compute a fair closing probability later, sharp vs book line
types kept distinct. Wired into the intraday refresh with a capture-rate
alarm — a missed close is unrecoverable.

NO CLV metric built, as ordered. This starts the clock.

Suite 284/3417 green, build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
2026-07-20 12:05:16 -04:00

276 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 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 */ }
// 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 },
};