diff --git a/src/config/sportCadence.js b/src/config/sportCadence.js new file mode 100644 index 0000000..5d9231c --- /dev/null +++ b/src/config/sportCadence.js @@ -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, +}; diff --git a/src/services/snapshotService.js b/src/services/snapshotService.js index 6fb7d7d..7e7af25 100644 --- a/src/services/snapshotService.js +++ b/src/services/snapshotService.js @@ -457,9 +457,16 @@ async function runSnapshot(sport, opts = {}) { } /** 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 = {}) { + // 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 = []; - for (const sp of ACTIVE_SPORTS) { + for (const sp of sports) { results.push(await runSnapshot(sp, opts)); } return results; diff --git a/src/snapshotScheduler.js b/src/snapshotScheduler.js index 1674c53..134fd96 100644 --- a/src/snapshotScheduler.js +++ b/src/snapshotScheduler.js @@ -17,6 +17,13 @@ const HOURS_UTC = (process.env.SNAPSHOT_HOURS_UTC || '14,19,22,1,3') .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) { @@ -204,9 +211,12 @@ function startSnapshotScheduler(opts = {}) { } } catch { /* alarm evaluation must never break the tick */ } 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'); - 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 // pack is ready. One ping, only when something actually graded. if (h === HOURS_UTC[0] && ok.length > 0) { @@ -251,7 +261,9 @@ function startSnapshotScheduler(opts = {}) { if (slot === lastRefreshSlot) return; lastRefreshSlot = slot; 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 revised = results.reduce((n, r) => n + (r.revised || 0), 0); 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); 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)`}`); + // 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 diff --git a/tests/unit/sportCadence.test.js b/tests/unit/sportCadence.test.js new file mode 100644 index 0000000..1316ca7 --- /dev/null +++ b/tests/unit/sportCadence.test.js @@ -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]); + }); +});