c5580f333e
Step 0 found we have been flying without one. p_win lives only in model_snapshots, which has 1,000 rows and ZERO settled outcomes; the closing line lives only in closing_captures, which carries no link to a result; and ledger_entries, the row that actually settles, carries no probability at all. So "is the projection calibrated" and "does it beat the market" have never been answerable — the entire measurable universe was 35 rows recovered by a lossy in-memory join. PHASE 0 — closing coverage verified BEFORE reuse, because an instrument built on a partial close measures a biased subset. closing_captures holds 70,254 rows of which 13,364 are usable, and the 56,890 refusals are candidates we never graded plus one-sided prices — not refusals of our props. Coverage on graded props since capture started is 83/83, 100%. Safe to reuse, with the honest caveat that capture only began 2026-07-20. THE FOUR-TUPLE NOW LANDS ON ONE ROW. ledger_entries gains p_win, fair_prob_lock, archetype_vector and projection_locked_at at LOCK time, and closing_prob plus closing_captured_at from the append-only capture store. The join is the whole point: calibration is p_win against outcome, market-comparison is p_win against the close, and both become plain SQL on one record instead of a join that silently drops 90% of the rows. p_win and the archetype vector are IMMUTABLE — written once at lock via the existing ignoreDuplicates upsert, never re-derived at settle. A re-derivation would measure a projection we never made. The archetype is stored as the VECTOR, not the label. "Did archetype-awareness help?" can only be answered against the axes that were live at grade time, and a single text column cannot express a blend. A grade with no archetype stores null rather than an empty object. HONEST-ABSENT BOTH WAYS. A past game with no usable capture is marked market_unavailable_reason and never given an imputed line; calibration still scores on those rows, only market-comparison is absent. And a game that has not started yet is NOT declared closeless — a close can still arrive, and premature absence is as dishonest as imputation in the other direction. One bug caught before it shipped: the scheduler hook iterated a SPORTS identifier that does not exist in that scope. Inside its try/catch it would have thrown ReferenceError every tick and silently never run — the instrument would have looked wired and captured nothing. Now iterates cadence.ALL_SPORTS. The baseline accrues FORWARD. Historical p_win and closes are gone, discarded before this existed. Calibration and market-comparison stay honest-absent until volume accrues. Tests 3614 passed / 294 suites, web build exit 0. Migration 033 applied. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
394 lines
22 KiB
JavaScript
394 lines
22 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* snapshotScheduler — in-process snapshot cron (Session 45).
|
||
*
|
||
* No new dependency: a 1-minute unref'd interval that fires `runAllSnapshots`
|
||
* at the configured UTC hours (default 14,19,22,1,3 = 10AM/3PM/6PM/9PM/11PM ET,
|
||
* matching the sports cycle — morning research, afternoon news, pre-game lock,
|
||
* in-game). Gated on SNAPSHOT_CRON=1 so it never runs in dev/test or on a
|
||
* container that shouldn't own the schedule. Prefer an EXTERNAL cron (n8n) hitting
|
||
* POST /api/internal/snapshot/all when running multiple API replicas — this
|
||
* in-process variant assumes a single scheduler instance.
|
||
*/
|
||
|
||
const HOURS_UTC = (process.env.SNAPSHOT_HOURS_UTC || '14,19,22,1,3')
|
||
.split(',')
|
||
.map((n) => parseInt(n, 10))
|
||
.filter((n) => Number.isInteger(n) && n >= 0 && n <= 23);
|
||
|
||
// Job 1 — per-sport cadence. The scheduler still FIRES at HOURS_UTC (and the
|
||
// missed-cron watchdog still references MLB, which runs at every grid hour), but
|
||
// each slot grades only the sports whose lines actually post/move then, and only
|
||
// PropLine-backed sports get the 20-min intraday refresh (soccer is odds-api
|
||
// quota-gated). All of that lives in the config table, not here.
|
||
const cadence = require('./config/sportCadence');
|
||
|
||
// Session 56 — missed-cron detection (pure, testable).
|
||
// The latest scheduled hour:00 (UTC) at or before `date`. null if none in 48h.
|
||
function mostRecentExpectedSlot(date, hours = HOURS_UTC) {
|
||
const d = new Date(date.getTime());
|
||
d.setUTCMinutes(0, 0, 0);
|
||
for (let i = 0; i < 48; i += 1) {
|
||
if (hours.includes(d.getUTCHours())) return new Date(d.getTime());
|
||
d.setUTCHours(d.getUTCHours() - 1);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// True when we're >graceMin past the most recent expected slot AND the last
|
||
// recorded snapshot predates that slot (i.e. the run was missed). Never fires on
|
||
// cold boot (no lastSnapshotIso) — we don't cry wolf before the first snapshot.
|
||
function isSnapshotOverdue(lastSnapshotIso, now = new Date(), hours = HOURS_UTC, graceMin = 30) {
|
||
const slot = mostRecentExpectedSlot(now, hours);
|
||
if (!slot) return false;
|
||
if (now.getTime() - slot.getTime() < graceMin * 60_000) return false;
|
||
if (!lastSnapshotIso) return false;
|
||
const last = new Date(lastSnapshotIso).getTime();
|
||
return Number.isFinite(last) && last < slot.getTime();
|
||
}
|
||
|
||
function startSnapshotScheduler(opts = {}) {
|
||
if (process.env.SNAPSHOT_CRON !== '1') {
|
||
// Session 52 — log the disarmed state so container logs make it unambiguous
|
||
// that the in-process cron is intentionally off (vs. crashed/missing).
|
||
if (process.env.NODE_ENV !== 'test') {
|
||
console.log(`[snapshotScheduler] disarmed — SNAPSHOT_CRON=${process.env.SNAPSHOT_CRON ?? 'unset'} (in-process cron off; use external cron → POST /api/internal/snapshot/all)`);
|
||
}
|
||
return null;
|
||
}
|
||
const runAll = opts.runAllSnapshots || require('./services/snapshotService').runAllSnapshots;
|
||
// Session 55 — self-learning loop. Settle the PRIOR snapshot's grades against
|
||
// real (now-completed) results BEFORE grading the fresh slate, so the accuracy
|
||
// record reflects yesterday's games each cycle.
|
||
const settleAll = opts.settleAllOutcomes || require('./services/outcomeService').settleAllOutcomes;
|
||
// Session 58 — Phase 1 truth infrastructure: settle the persistent ledger
|
||
// (outcome + actual + CLV) in the same pre-grade settle pass.
|
||
const settleLedgers = opts.settleAllLedgers || require('./services/ledgerService').settleAllLedgers;
|
||
const notify = opts.notify || require('./utils/opsNotify').notify;
|
||
const cacheGet = opts.cacheGet || require('./utils/redis').cacheGet;
|
||
const cacheSet = opts.cacheSet || require('./utils/redis').cacheSet;
|
||
const now = opts.now || (() => new Date());
|
||
// Session 8 (A1 board) — ops: the product watches itself. Pure logic lives
|
||
// in services/opsWatch + services/systemHealth; this file only wires it.
|
||
const opsWatch = require('./services/opsWatch');
|
||
const getSystemHealth = opts.getSystemHealth || require('./services/systemHealth').getSystemHealth;
|
||
const healthIssues = opts.healthIssues || require('./services/systemHealth').healthIssues;
|
||
const getQuotaStatus = opts.getQuotaStatus || require('./services/quotaTracker').getQuotaStatus;
|
||
const countLedgerRows = opts.countLedgerRows || require('./services/ledgerService').countRowsForDate;
|
||
// Wave 1 — real-finals probe for the zero-settle alarm (NBA/WNBA only page
|
||
// when yesterday actually had games). Reuses the free, cached ESPN scoreboard.
|
||
const getSchedule = opts.getSchedule || require('./services/scheduleService').getSchedule;
|
||
const failureTracker = opts.failureTracker || opsWatch.createFailureTracker();
|
||
let lastFiredSlot = null;
|
||
let lastOverdueSlot = null;
|
||
let lastPulseDate = null;
|
||
|
||
// Session 56 — missed-cron watchdog. Runs every minute (independent of the
|
||
// fire schedule): if a scheduled slot came and went without a snapshot, alert
|
||
// ONCE per missed slot.
|
||
const checkOverdue = async () => {
|
||
try {
|
||
const d = now();
|
||
const slot = mostRecentExpectedSlot(d);
|
||
if (!slot) return;
|
||
const slotKey = slot.toISOString();
|
||
if (slotKey === lastOverdueSlot) return; // already alerted for this slot
|
||
const latest = await cacheGet('snapshot:mlb:latest');
|
||
const lastTs = latest && latest.updated_at;
|
||
if (isSnapshotOverdue(lastTs, d)) {
|
||
lastOverdueSlot = slotKey;
|
||
await notify(`⚠️ Snapshot OVERDUE — expected ${slot.getUTCHours()}:00 UTC, last was ${lastTs || 'never'}`, { title: 'VYNDR pipeline', priority: 'high', tags: ['warning'] });
|
||
}
|
||
} catch { /* watchdog must never throw */ }
|
||
};
|
||
|
||
// Session 8 — daily 9 AM ET pulse (13:00 UTC), ONE notification. Dedupe is
|
||
// belt-and-braces: in-process date + a Redis key that survives a restart
|
||
// inside the same day. Runs on the per-minute cadence, independent of slots.
|
||
const PULSE_HOUR_UTC = Number.parseInt(process.env.PULSE_HOUR_UTC || '13', 10);
|
||
const pulseTick = async () => {
|
||
try {
|
||
const d = now();
|
||
if (d.getUTCHours() !== PULSE_HOUR_UTC || d.getUTCMinutes() !== 0) return;
|
||
const dayKey = d.toISOString().slice(0, 10);
|
||
if (dayKey === lastPulseDate) return;
|
||
lastPulseDate = dayKey;
|
||
if (await cacheGet(`ops:pulse:${dayKey}`)) return; // already sent today (pre-restart)
|
||
await cacheSet(`ops:pulse:${dayKey}`, '1', 2 * 24 * 3600);
|
||
|
||
const yesterdayEt = opsWatch.dateET(new Date(d.getTime() - 24 * 3600 * 1000));
|
||
let ledgerRows = null;
|
||
try { ledgerRows = await countLedgerRows(yesterdayEt); } catch { /* n/a */ }
|
||
let settles24h = null;
|
||
try {
|
||
const logs = [];
|
||
for (const sp of ['mlb', 'nba', 'wnba', 'soccer']) {
|
||
const raw = await cacheGet(`outcomes:${sp}:log`);
|
||
logs.push(Array.isArray(raw) ? raw : (raw && raw.log) || []);
|
||
}
|
||
settles24h = opsWatch.countRecentSettles(logs, d.getTime());
|
||
} catch { /* n/a */ }
|
||
let quota = null;
|
||
try { quota = await getQuotaStatus('odds-api'); } catch { /* n/a */ }
|
||
let health = null;
|
||
try { health = await getSystemHealth(); } catch { /* n/a */ }
|
||
|
||
await notify(
|
||
opsWatch.buildPulseMessage({ dateEt: opsWatch.dateET(d), ledgerRows, settles24h, quota, health }),
|
||
{ title: 'VYNDR daily pulse', tags: ['newspaper'] },
|
||
);
|
||
// Box health pages separately at high priority — the pulse is a read,
|
||
// the threshold breach is an action item.
|
||
const issues = healthIssues(health);
|
||
if (issues.length > 0) {
|
||
await notify(`Box health: ${issues.join('; ')}. The record does not fit on a full disk.`, {
|
||
title: 'VYNDR box', priority: 'high', tags: ['warning'],
|
||
});
|
||
}
|
||
} catch { /* the pulse must never break the scheduler */ }
|
||
};
|
||
|
||
const tick = async () => {
|
||
await checkOverdue();
|
||
await pulseTick();
|
||
const d = now();
|
||
if (d.getUTCMinutes() !== 0) return;
|
||
const h = d.getUTCHours();
|
||
if (!HOURS_UTC.includes(h)) return;
|
||
const slot = `${d.getUTCFullYear()}-${d.getUTCMonth()}-${d.getUTCDate()}-${h}`;
|
||
if (slot === lastFiredSlot) return; // fire once per slot
|
||
lastFiredSlot = slot;
|
||
// Session 8 — a settle pass that THROWS is a paged event, not a log line.
|
||
// Settlement failing quietly is how the record dies.
|
||
try {
|
||
const settled = await settleAll();
|
||
const totalSettled = settled.reduce((n, r) => n + (r.settled || 0), 0);
|
||
console.log(`[outcomes] cron fired ${h}:00 UTC — ${totalSettled} props settled vs real results`);
|
||
} catch (e) {
|
||
console.warn('[outcomes] settle run failed:', e.message);
|
||
await notify(`Outcome settlement THREW at ${h}:00 UTC — ${e.message}. Yesterday's grades are unsettled until the next slot.`, {
|
||
title: 'VYNDR settlement', priority: 'high', tags: ['rotating_light'],
|
||
});
|
||
}
|
||
let ledgerResults = null;
|
||
try {
|
||
ledgerResults = await settleLedgers();
|
||
const n = ledgerResults.reduce((t, r) => t + (r.settled || 0), 0);
|
||
const nv = ledgerResults.reduce((t, r) => t + (r.voided || 0), 0);
|
||
const nu = ledgerResults.reduce((t, r) => t + (r.unrecoverable || 0), 0);
|
||
console.log(`[ledger] settle pass — ${n} settled, ${nv} voided, ${nu} unrecoverable (outcome + CLV)`);
|
||
// Session 64 — SETTLEMENT-RATE ALARM. zeroSettleAlarm only catches a
|
||
// TOTAL zero; the diagnostic found ~30% of a slate quietly failing while
|
||
// the pass "succeeded". A persistently low resolution rate is a broken
|
||
// pipe, not a quiet night.
|
||
try {
|
||
const sr = opsWatch.settlementRateAlarm(ledgerResults);
|
||
if (sr.alarm) {
|
||
await notify(`Settlement rate BELOW FLOOR at ${h}:00 UTC — ${sr.reason}. Rows that cannot resolve are pending, not scored.`, {
|
||
title: 'VYNDR settlement', priority: 'high', tags: ['rotating_light'],
|
||
});
|
||
}
|
||
} catch { /* alarm evaluation must never break the tick */ }
|
||
} catch (e) {
|
||
console.warn('[ledger] settle run failed:', e.message);
|
||
await notify(`Ledger settlement THREW at ${h}:00 UTC — ${e.message}. The public record did not advance.`, {
|
||
title: 'VYNDR settlement', priority: 'high', tags: ['rotating_light'],
|
||
});
|
||
}
|
||
// Session 8 — zero-settle alarm, MORNING slot only (the book-closing pass).
|
||
// Signal = the ledger settle's own Postgres-backed return values (see
|
||
// opsWatch.zeroSettleAlarm for why not snapshot:{sport}:previous). Deduped
|
||
// once per ET date; a genuinely empty yesterday (0 rows fetched) never fires.
|
||
try {
|
||
if (h === opsWatch.morningHourUtc(HOURS_UTC) && ledgerResults) {
|
||
// Wave 1 — probe yesterday's ESPN scoreboard so an NBA/WNBA off-day
|
||
// (0 finals) never false-pages "settled 0" on stale pending rows.
|
||
const yEt = opsWatch.dateET(new Date(d.getTime() - 24 * 3600 * 1000));
|
||
const finalsBySport = {};
|
||
for (const fsp of opsWatch.FINALS_GATED_SPORTS) {
|
||
try {
|
||
const games = await getSchedule(fsp, yEt);
|
||
finalsBySport[fsp] = Array.isArray(games) && games.some((g) => g && g.status === 'post');
|
||
} catch { finalsBySport[fsp] = false; }
|
||
}
|
||
const z = opsWatch.zeroSettleAlarm(ledgerResults, { finalsBySport });
|
||
if (z.alarm) {
|
||
const dk = `ops:settle_zero:${opsWatch.dateET(d)}`;
|
||
if (!(await cacheGet(dk))) {
|
||
await cacheSet(dk, '1', 2 * 24 * 3600);
|
||
await notify(`Morning settle pass closed with 0 settles — ${z.pending} ledger rows from before today are still pending. Yesterday had graded rows; the record did not advance.`, {
|
||
title: 'VYNDR settlement', priority: 'high', tags: ['rotating_light'],
|
||
});
|
||
}
|
||
}
|
||
}
|
||
} catch { /* alarm evaluation must never break the tick */ }
|
||
try {
|
||
// Job 1 — only the sports whose cadence includes this hour. MLB runs every
|
||
// grid hour; WNBA skips 14/3 UTC (props not posted); soccer runs 14/19 only.
|
||
const scheduled = cadence.sportsForHour(h);
|
||
const results = await runAll({ sports: scheduled });
|
||
const ok = results.filter((r) => r.status === 'ok');
|
||
console.log(`[snapshot] cron fired ${h}:00 UTC — ${ok.length}/${results.length} sports graded (scheduled: ${scheduled.join(',') || 'none'})`);
|
||
// Session 63 (A1-S4) — after the day's FIRST slot, tell Kev the media
|
||
// pack is ready. One ping, only when something actually graded.
|
||
if (h === HOURS_UTC[0] && ok.length > 0) {
|
||
const total = results.reduce((n, r) => n + (r.gradeCount || 0), 0);
|
||
await notify(`Desk pack ready — ${total} props graded across ${ok.length} sports. vyndr.app/desk`, { title: 'VYNDR desk', tags: ['newspaper'] });
|
||
}
|
||
// Session 64 — RETENTION ZERO-WRITE ALARM. Features are irreplaceable: a
|
||
// night not captured cannot be reconstructed. Retention is best-effort so
|
||
// it never breaks a snapshot, which means a broken write is silent —
|
||
// this is the counterweight, at missed-snapshot severity.
|
||
try {
|
||
const written = {};
|
||
for (const r of results) written[r.sport] = r.retentionRows;
|
||
const rz = opsWatch.retentionZeroWriteAlarm(results, written);
|
||
if (rz.alarm) {
|
||
await notify(`RETENTION NOT CAPTURING at ${h}:00 UTC — ${rz.reason}. Feature vectors for this slate are irreplaceable and are being lost.`, {
|
||
title: 'VYNDR retention', priority: 'high', tags: ['rotating_light'],
|
||
});
|
||
}
|
||
} catch { /* alarm evaluation must never break the tick */ }
|
||
|
||
// Session 8 — persistent-failure pager: 3+ CONSECUTIVE erroring slots for
|
||
// a sport pages once (single-slot errors are normal before lines post).
|
||
for (const r of results) {
|
||
const t = failureTracker.record(r.sport, r);
|
||
if (t.shouldPage) {
|
||
await notify(`${String(r.sport).toUpperCase()} snapshot has produced nothing for ${t.count} consecutive slots (latest: ${r.status}${r.reason ? ` — ${r.reason}` : ''}). One empty slot is a quiet book; ${t.count} is a broken pipe.`, {
|
||
title: 'VYNDR pipeline', priority: 'high', tags: ['rotating_light'],
|
||
});
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn('[snapshot] cron run failed:', e.message);
|
||
}
|
||
// Session 64 — NIGHTLY BACKTEST HARNESS, on our own scheduler. Runs once
|
||
// per day at HARNESS_HOUR_UTC and appends to harness_results, so the
|
||
// calibration trend is visible as retention compounds. An
|
||
// INSUFFICIENT_HISTORY verdict is expected and correct.
|
||
try {
|
||
const harnessHour = Number(process.env.HARNESS_HOUR_UTC || 14);
|
||
if (h === harnessHour) {
|
||
const runner = opts.harnessRunner || require('./services/harnessRunner');
|
||
const res = await runner.runAndRecord();
|
||
console.log(`[harness] nightly run — verdict=${res.verdict || 'n/a'} scored=${res.scored ?? 'n/a'}${res.error ? ` ERROR: ${res.error}` : ''}`);
|
||
// A validator that stops running looks exactly like one that keeps
|
||
// passing — so staleness pages.
|
||
const stale = opsWatch.harnessStaleAlarm(await runner.lastRunAt(), now());
|
||
if (stale.alarm) {
|
||
await notify(`Backtest harness stale — ${stale.reason}. The metric gate is not running.`, {
|
||
title: 'VYNDR harness', priority: 'high', tags: ['rotating_light'],
|
||
});
|
||
}
|
||
}
|
||
} catch (e) { console.warn('[harness] nightly run failed:', e.message); }
|
||
|
||
// Session 70 — THE MEASUREMENT INSTRUMENT. Attach the de-vigged CLOSING
|
||
// probability to locked rows from the append-only closing_captures, so
|
||
// p_win, the close, the archetype vector and the outcome all land on ONE
|
||
// joinable record. Runs beside the settle pass; write-once, and a past game
|
||
// with no usable capture is marked market-unavailable rather than imputed.
|
||
try {
|
||
const led = opts.ledgerService || require('./services/ledgerService');
|
||
for (const sp of cadence.ALL_SPORTS) {
|
||
const r = await led.attachClosingProb(sp, {});
|
||
if (r && (r.updated || r.absent)) {
|
||
console.log(`[instrument] ${sp} — closes attached ${r.updated}, market-unavailable ${r.absent}`);
|
||
}
|
||
}
|
||
} catch (e) { console.warn('[instrument] closing attach failed:', e.message); }
|
||
|
||
// Session 68 — LAYER 1 MECHANISM DATA. Nightly full re-pull of the
|
||
// ~1,350-row Statcast aggregate set at STATCAST_HOUR_UTC. Backfill and
|
||
// refresh are the SAME call, upserted on the natural key, so the job is
|
||
// idempotent and self-healing: a missed night self-corrects on the next
|
||
// run with no incremental bookkeeping to drift. Kill switch: STATCAST=0.
|
||
try {
|
||
const statcastHour = Number(process.env.STATCAST_HOUR_UTC || 11); // ~7 AM ET, after every game is final
|
||
if (h === statcastHour && process.env.STATCAST !== '0') {
|
||
const agg = opts.statcastService || require('./services/statcastAggregateService');
|
||
const res = await agg.refreshSeason({});
|
||
console.log(`[statcast] nightly refresh — ok=${res.ok} rows=${res.rows ?? 'n/a'} written=${res.written ?? 0} joined=${res.joined ?? 'n/a'} thin=${res.thin ?? 'n/a'}${res.reason ? ` reason: ${res.reason}` : ''}`);
|
||
if (!res.ok) {
|
||
await notify(`Statcast refresh failed — ${res.reason || 'unknown'}. Mechanism data is not updating.`, {
|
||
title: 'VYNDR statcast', priority: 'high', tags: ['rotating_light'],
|
||
});
|
||
}
|
||
// Serving a stale aggregate as current is a quiet fabrication of
|
||
// currency, so staleness pages on its own — separate from a failed run,
|
||
// because a silently-not-scheduled job never produces a failure.
|
||
const fresh = await agg.getFreshness({});
|
||
if (agg.isStale(fresh)) {
|
||
await notify(`Statcast aggregates stale — last updated ${fresh.updated_at} (${fresh.age_hours}h). Mechanism data is being served as current.`, {
|
||
title: 'VYNDR statcast', priority: 'high', tags: ['rotating_light'],
|
||
});
|
||
}
|
||
}
|
||
} catch (e) { console.warn('[statcast] nightly refresh failed:', e.message); }
|
||
|
||
// Session 8 — quota check after each snapshot run: odds-api >= 80% alerts
|
||
// once per day (Redis-deduped). Never throws (guarded inside checkQuotaDaily).
|
||
await opsWatch.checkQuotaDaily({ getStatus: getQuotaStatus, cacheGet, cacheSet, notify, now: () => now() });
|
||
};
|
||
|
||
// Session 60 (night2/D) — Phase 2.5 intraday refresh. Every
|
||
// REFRESH_MINUTES during slate hours (noon–midnight ET), odds-only:
|
||
// STEAM/VALUE badges, ≥1.0-against re-grades with public revisions,
|
||
// refresh-fidelity closing capture, higher-frequency ticker MOVEs.
|
||
// Skips the top of scheduled snapshot hours (the full run owns those).
|
||
// Kill switch: INTRADAY_REFRESH=0.
|
||
const REFRESH_MINUTES = Math.max(5, parseInt(process.env.INTRADAY_REFRESH_MINUTES || '20', 10) || 20);
|
||
const refreshAll = opts.runAllIntradayRefreshes || require('./services/intradayRefreshService').runAllIntradayRefreshes;
|
||
const inSlateHours = opts.inSlateHours || require('./services/intradayRefreshService').inSlateHours;
|
||
let lastRefreshSlot = null;
|
||
const refreshTick = async () => {
|
||
if (process.env.INTRADAY_REFRESH === '0') return;
|
||
const d = now();
|
||
if (!inSlateHours(d)) return;
|
||
if (d.getUTCMinutes() % REFRESH_MINUTES !== 0) return;
|
||
if (d.getUTCMinutes() === 0 && HOURS_UTC.includes(d.getUTCHours())) return; // full snapshot owns this slot
|
||
const slot = `${d.toISOString().slice(0, 13)}-${d.getUTCMinutes()}`;
|
||
if (slot === lastRefreshSlot) return;
|
||
lastRefreshSlot = slot;
|
||
try {
|
||
// Job 1 — only PropLine-backed sports refresh intraday (9k/day headroom).
|
||
// Soccer is excluded: a 20-min odds-api refresh would drain the 500/mo key.
|
||
const results = await refreshAll({ sports: cadence.intradaySports() });
|
||
const touched = results.filter((r) => r.status === 'ok');
|
||
const revised = results.reduce((n, r) => n + (r.revised || 0), 0);
|
||
if (touched.length > 0) console.log(`[intraday] refresh — ${touched.length} sports, ${revised} public revisions`);
|
||
} catch (e) {
|
||
console.warn('[intraday] refresh failed:', e.message);
|
||
}
|
||
};
|
||
|
||
const interval = setInterval(() => { void tick(); void refreshTick(); }, 60_000);
|
||
if (interval.unref) interval.unref();
|
||
console.log(`[snapshotScheduler] armed — SNAPSHOT_CRON=${process.env.SNAPSHOT_CRON}, hours=${HOURS_UTC.join(',')} UTC, intraday=${process.env.INTRADAY_REFRESH === '0' ? 'off' : `${REFRESH_MINUTES}m (slate hours)`}, statcast=${process.env.STATCAST === '0' ? 'off' : `${process.env.STATCAST_HOUR_UTC || 11}h UTC`}`);
|
||
// Job 1 — state each sport's cadence at boot so "when does WNBA grade?" is
|
||
// answerable from logs, and warn if a sport is scheduled at an hour the
|
||
// scheduler never fires (it would silently never run).
|
||
const cadenceSummary = cadence.ALL_SPORTS.map((s) => `${s}=${cadence.hoursFor(s).join('/') || '—'}${cadence.SPORT_CADENCE[s].intraday ? '' : ' (no intraday)'}`).join(' ');
|
||
console.log(`[snapshotScheduler] per-sport cadence — ${cadenceSummary}`);
|
||
const orphanHours = cadence.ALL_HOURS.filter((h) => !HOURS_UTC.includes(h));
|
||
if (orphanHours.length > 0) {
|
||
console.warn(`[snapshotScheduler] WARNING — sports scheduled at ${orphanHours.join(',')} UTC but the scheduler never fires then (not in hours=${HOURS_UTC.join(',')}); those slots will silently never run. Add them to SNAPSHOT_HOURS_UTC.`);
|
||
}
|
||
// Session 61 — settlement is scheduled IN THIS tick (settleAllOutcomes +
|
||
// settleAllLedgers run FIRST at every snapshot slot, before grading). It
|
||
// was invisible at boot, which made "is settlement scheduled?" unanswerable
|
||
// from logs. This line makes it verifiable forever.
|
||
// Wave 1 — announce settlement PER settleable sport so "does NBA settle?" is
|
||
// answerable from boot logs (mlb=statsapi, nba/wnba=ESPN game logs).
|
||
const settleTags = require('./services/opsWatch').SETTLEABLE_SPORTS.map((s) => `[settle:${s}]`).join(' ');
|
||
console.log(`[settlement] armed — ${settleTags} outcomes + ledger settle pass runs FIRST at each snapshot slot (${HOURS_UTC.join(',')} UTC), idempotent re-runs`);
|
||
// Session 8 — same verifiability rule: every watchdog states itself at boot.
|
||
console.log(`[opsWatch] armed — settle alarms (throw + morning zero-settle), failure pager (${failureTracker.threshold} consecutive), quota daily check, pulse ${PULSE_HOUR_UTC}:00 UTC`);
|
||
return { interval, tick, refreshTick, pulseTick };
|
||
}
|
||
|
||
module.exports = { startSnapshotScheduler, HOURS_UTC, mostRecentExpectedSlot, isSnapshotOverdue };
|