Job 1: per-sport snapshot cadence (config, not baseball's rhythm for all)
Every ACTIVE sport was graded at all five MLB slots (14/19/22/1/3 UTC). Sports post lines on different clocks, so that inheritance was wasteful both ways: WNBA props aren't posted at 14:00 UTC (10am ET) → that slot always graded 0 (the audit's "wnba:0"); soccer odds come from the 500/MONTH odds-api key, so five slots/day is a third of the budget for 1-2 matches. New src/config/sportCadence.js is the single source of truth (config-over- constants). Mapped from reality + quota headroom (PropLine 9k/day abundant, odds-api 500/mo scarce): mlb 14/19/22/1/3 intraday (full grid — games+props all day) nba 14/19/22/1/3 intraday (in-season fits; off-season self-skips empty) wnba 19/22/1 intraday (afternoon→evening ET; drops the 14/3 waste) soccer 14/19 NO intraday (WC live; 2 lean odds-api reads, key-protected) The scheduler still fires at HOURS_UTC and the missed-cron watchdog still references MLB (which runs every grid hour) — each slot now grades only sportsForHour(h), and only intradaySports() get the 20-min refresh. Every sport's hours are kept a subset of the firing grid (a boot-time guard + a test warn if that's ever violated). Retune a sport by editing one table row. Adaptive, not constant: near-zero when a sport is quiet, protecting the scarce odds-api quota from being drained by noon. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,99 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-sport snapshot cadence (Job 1) — the config that replaces baseball's
|
||||||
|
* one-size-fits-all rhythm.
|
||||||
|
*
|
||||||
|
* WHY THIS EXISTS
|
||||||
|
* The snapshot cron fired every ACTIVE sport at all five MLB slots
|
||||||
|
* (14/19/22/1/3 UTC). But sports post their lines on entirely different
|
||||||
|
* clocks, so that inheritance was wasteful in two directions:
|
||||||
|
* - WNBA props aren't posted at 14:00 UTC (10am ET) → that slot always
|
||||||
|
* graded 0 for WNBA (the "wnba:0" the audit flagged). Wasted work.
|
||||||
|
* - Soccer odds come from the odds-api key (500/MONTH, scarce). Running it
|
||||||
|
* at five slots/day is a third of the monthly budget for 1–2 matches.
|
||||||
|
*
|
||||||
|
* THE RULE: high frequency only when a sport's lines actually post/move,
|
||||||
|
* near-zero otherwise. This is a SCHEDULING fix, not a volume one — "never a
|
||||||
|
* gap" must never become "pull constantly", because burning the odds-api
|
||||||
|
* quota by noon means nothing all afternoon (strictly worse than a gap).
|
||||||
|
*
|
||||||
|
* QUOTA HEADROOM (mapped from reality, 2026-07):
|
||||||
|
* - PropLine (mlb/nba/wnba props): 3 keys × 3,000/day = 9,000/day. Abundant.
|
||||||
|
* Five slots × a few sports × intraday is a rounding error against that.
|
||||||
|
* - The Odds API (soccer props + futures): 500/MONTH. Every soccer slot is
|
||||||
|
* ~1 credit. So soccer gets the fewest, best-timed slots and NO intraday
|
||||||
|
* refresh (a 20-min soccer refresh would burn ~36 credits/day = the whole
|
||||||
|
* month in ~2 weeks).
|
||||||
|
*
|
||||||
|
* DESIGN CONSTRAINT (low blast radius): every sport's hours are a SUBSET of the
|
||||||
|
* scheduler's firing grid (snapshotScheduler.HOURS_UTC, default 14/19/22/1/3).
|
||||||
|
* The scheduler still fires at those hours; this config just decides WHICH
|
||||||
|
* sports run at each one. Keeping the union inside the grid means the
|
||||||
|
* missed-cron watchdog (which references MLB) needs no change. If a sport ever
|
||||||
|
* genuinely needs an hour outside the grid, add it to HOURS_UTC too AND scope
|
||||||
|
* the overdue watchdog to MLB's hours (see snapshotScheduler).
|
||||||
|
*
|
||||||
|
* POSTING RHYTHMS (why each sport gets the hours it does):
|
||||||
|
* - mlb — games + props all day; the full grid is correct. Keep 14/19/22/1/3.
|
||||||
|
* - nba — off-season now (self-skips empty on PropLine, ~0 cost); in-season
|
||||||
|
* posts morning→afternoon ET. The full grid fits both. Keep.
|
||||||
|
* - wnba — games tip evening ET; props post ~afternoon ET. 19 UTC (3pm ET)
|
||||||
|
* catches early games, 22 UTC (6pm ET) the bulk, 1 UTC (9pm ET) the
|
||||||
|
* west-coast lock. 14 UTC (10am ET) and 3 UTC (11pm ET, games done)
|
||||||
|
* are dropped — that's the wasted "wnba:0" slot removed.
|
||||||
|
* - soccer— World Cup live now (final Jul 19), club leagues resume Aug. Lines
|
||||||
|
* post well ahead and barely move; two well-placed odds-api reads
|
||||||
|
* (14 UTC morning research, 19 UTC pre-afternoon-match lock) cover a
|
||||||
|
* match day for ~2 credits. NO intraday (protects the 500/mo key).
|
||||||
|
*
|
||||||
|
* config-over-constants: to retune a sport, edit ONLY this table.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SPORT_CADENCE = Object.freeze({
|
||||||
|
mlb: { hours: [14, 19, 22, 1, 3], intraday: true, source: 'propline' },
|
||||||
|
nba: { hours: [14, 19, 22, 1, 3], intraday: true, source: 'propline' },
|
||||||
|
wnba: { hours: [19, 22, 1], intraday: true, source: 'propline' },
|
||||||
|
soccer: { hours: [14, 19], intraday: false, source: 'odds-api' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const ALL_SPORTS = Object.freeze(Object.keys(SPORT_CADENCE));
|
||||||
|
|
||||||
|
// The union of every sport's snapshot hours — the set the scheduler must fire
|
||||||
|
// at so no sport's slot is missed. Kept for the cross-check that this union is
|
||||||
|
// a subset of the scheduler grid.
|
||||||
|
const ALL_HOURS = Object.freeze(
|
||||||
|
[...new Set(ALL_SPORTS.flatMap((s) => SPORT_CADENCE[s].hours))].sort((a, b) => a - b),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* sportsForHour — which sports are scheduled to snapshot at this UTC hour.
|
||||||
|
* @param {number} hour UTC hour 0..23
|
||||||
|
* @param {string[]} [sports] restrict to this candidate set (defaults to all)
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
function sportsForHour(hour, sports = ALL_SPORTS) {
|
||||||
|
const h = Number(hour);
|
||||||
|
return sports.filter((s) => SPORT_CADENCE[s] && SPORT_CADENCE[s].hours.includes(h));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** intradaySports — sports that opt into the 20-min intraday line refresh.
|
||||||
|
* Soccer is excluded on purpose (odds-api quota). */
|
||||||
|
function intradaySports(sports = ALL_SPORTS) {
|
||||||
|
return sports.filter((s) => SPORT_CADENCE[s] && SPORT_CADENCE[s].intraday);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** hoursFor — a single sport's snapshot hours (empty array if unknown). */
|
||||||
|
function hoursFor(sport) {
|
||||||
|
const c = SPORT_CADENCE[String(sport || '').toLowerCase()];
|
||||||
|
return c ? c.hours.slice() : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
SPORT_CADENCE,
|
||||||
|
ALL_SPORTS,
|
||||||
|
ALL_HOURS,
|
||||||
|
sportsForHour,
|
||||||
|
intradaySports,
|
||||||
|
hoursFor,
|
||||||
|
};
|
||||||
@@ -457,9 +457,16 @@ async function runSnapshot(sport, opts = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Run snapshots for every active sport sequentially (cron entrypoint). */
|
/** Run snapshots for every active sport sequentially (cron entrypoint). */
|
||||||
|
// Job 1 — `opts.sports` scopes the run to a subset (the scheduler passes the
|
||||||
|
// hour's per-sport cadence). Absent → every ACTIVE sport (the on-demand
|
||||||
|
// /api/internal/snapshot/all behaviour is unchanged). runSnapshot ignores the
|
||||||
|
// extra key.
|
||||||
async function runAllSnapshots(opts = {}) {
|
async function runAllSnapshots(opts = {}) {
|
||||||
|
// An EXPLICIT array is honored verbatim (even empty = run nothing); only an
|
||||||
|
// ABSENT `sports` key means "every active sport" (on-demand /all).
|
||||||
|
const sports = Array.isArray(opts.sports) ? opts.sports : ACTIVE_SPORTS;
|
||||||
const results = [];
|
const results = [];
|
||||||
for (const sp of ACTIVE_SPORTS) {
|
for (const sp of sports) {
|
||||||
results.push(await runSnapshot(sp, opts));
|
results.push(await runSnapshot(sp, opts));
|
||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ const HOURS_UTC = (process.env.SNAPSHOT_HOURS_UTC || '14,19,22,1,3')
|
|||||||
.map((n) => parseInt(n, 10))
|
.map((n) => parseInt(n, 10))
|
||||||
.filter((n) => Number.isInteger(n) && n >= 0 && n <= 23);
|
.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).
|
// Session 56 — missed-cron detection (pure, testable).
|
||||||
// The latest scheduled hour:00 (UTC) at or before `date`. null if none in 48h.
|
// The latest scheduled hour:00 (UTC) at or before `date`. null if none in 48h.
|
||||||
function mostRecentExpectedSlot(date, hours = HOURS_UTC) {
|
function mostRecentExpectedSlot(date, hours = HOURS_UTC) {
|
||||||
@@ -204,9 +211,12 @@ function startSnapshotScheduler(opts = {}) {
|
|||||||
}
|
}
|
||||||
} catch { /* alarm evaluation must never break the tick */ }
|
} catch { /* alarm evaluation must never break the tick */ }
|
||||||
try {
|
try {
|
||||||
const results = await runAll();
|
// 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');
|
const ok = results.filter((r) => r.status === 'ok');
|
||||||
console.log(`[snapshot] cron fired ${h}:00 UTC — ${ok.length}/${results.length} sports graded`);
|
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
|
// Session 63 (A1-S4) — after the day's FIRST slot, tell Kev the media
|
||||||
// pack is ready. One ping, only when something actually graded.
|
// pack is ready. One ping, only when something actually graded.
|
||||||
if (h === HOURS_UTC[0] && ok.length > 0) {
|
if (h === HOURS_UTC[0] && ok.length > 0) {
|
||||||
@@ -251,7 +261,9 @@ function startSnapshotScheduler(opts = {}) {
|
|||||||
if (slot === lastRefreshSlot) return;
|
if (slot === lastRefreshSlot) return;
|
||||||
lastRefreshSlot = slot;
|
lastRefreshSlot = slot;
|
||||||
try {
|
try {
|
||||||
const results = await refreshAll();
|
// 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 touched = results.filter((r) => r.status === 'ok');
|
||||||
const revised = results.reduce((n, r) => n + (r.revised || 0), 0);
|
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`);
|
if (touched.length > 0) console.log(`[intraday] refresh — ${touched.length} sports, ${revised} public revisions`);
|
||||||
@@ -263,6 +275,15 @@ function startSnapshotScheduler(opts = {}) {
|
|||||||
const interval = setInterval(() => { void tick(); void refreshTick(); }, 60_000);
|
const interval = setInterval(() => { void tick(); void refreshTick(); }, 60_000);
|
||||||
if (interval.unref) interval.unref();
|
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)`}`);
|
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)`}`);
|
||||||
|
// 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 +
|
// Session 61 — settlement is scheduled IN THIS tick (settleAllOutcomes +
|
||||||
// settleAllLedgers run FIRST at every snapshot slot, before grading). It
|
// settleAllLedgers run FIRST at every snapshot slot, before grading). It
|
||||||
// was invisible at boot, which made "is settlement scheduled?" unanswerable
|
// was invisible at boot, which made "is settlement scheduled?" unanswerable
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Job 1 — per-sport snapshot cadence config. Locks the posting-rhythm map and
|
||||||
|
// the two quota-discipline invariants so a future edit can't silently
|
||||||
|
// reintroduce baseball's one-size-fits-all rhythm.
|
||||||
|
|
||||||
|
const cadence = require('../../src/config/sportCadence');
|
||||||
|
const { HOURS_UTC } = require('../../src/snapshotScheduler');
|
||||||
|
|
||||||
|
describe('sportCadence', () => {
|
||||||
|
test('every sport is a subset of the scheduler firing grid (else it never runs)', () => {
|
||||||
|
// The scheduler only fires at HOURS_UTC; a sport hour outside that set would
|
||||||
|
// be silently dropped. This invariant is the whole reason the design keeps
|
||||||
|
// the union inside the grid.
|
||||||
|
for (const orphan of cadence.ALL_HOURS.filter((h) => !HOURS_UTC.includes(h))) {
|
||||||
|
throw new Error(`hour ${orphan} is scheduled for some sport but not in HOURS_UTC=${HOURS_UTC}`);
|
||||||
|
}
|
||||||
|
expect(cadence.ALL_HOURS.every((h) => HOURS_UTC.includes(h))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('WNBA skips 14:00 UTC (props not posted) — the wasted "wnba:0" slot', () => {
|
||||||
|
expect(cadence.sportsForHour(14)).toContain('mlb');
|
||||||
|
expect(cadence.sportsForHour(14)).not.toContain('wnba');
|
||||||
|
// but WNBA DOES run at its real hours
|
||||||
|
expect(cadence.sportsForHour(22)).toContain('wnba');
|
||||||
|
expect(cadence.sportsForHour(1)).toContain('wnba');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('soccer runs only its two lean odds-api slots, never the full grid', () => {
|
||||||
|
expect(cadence.hoursFor('soccer')).toEqual([14, 19]);
|
||||||
|
expect(cadence.sportsForHour(22)).not.toContain('soccer');
|
||||||
|
expect(cadence.sportsForHour(1)).not.toContain('soccer');
|
||||||
|
expect(cadence.sportsForHour(3)).not.toContain('soccer');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('soccer is excluded from intraday (protects the 500/mo odds-api key)', () => {
|
||||||
|
expect(cadence.intradaySports()).not.toContain('soccer');
|
||||||
|
expect(cadence.intradaySports()).toEqual(expect.arrayContaining(['mlb', 'nba', 'wnba']));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sportsForHour respects a candidate subset and unknown hours yield none', () => {
|
||||||
|
expect(cadence.sportsForHour(19, ['wnba', 'soccer'])).toEqual(expect.arrayContaining(['wnba', 'soccer']));
|
||||||
|
expect(cadence.sportsForHour(7)).toEqual([]); // 7 UTC — nobody posts
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mlb keeps the full baseball grid', () => {
|
||||||
|
expect(cadence.hoursFor('mlb')).toEqual([14, 19, 22, 1, 3]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user