diff --git a/src/services/ledgerService.js b/src/services/ledgerService.js index 8fc54c0..5b6d518 100644 --- a/src/services/ledgerService.js +++ b/src/services/ledgerService.js @@ -100,13 +100,17 @@ function teamOpponentFor(g, prop) { return { team, opponent: null }; } -/** Index odds props by nameKey|stat for lock/closing lookups. */ +/** Index odds props by nameKey|stat for lock/closing lookups. + * Session 61 — prefer a book row with BOTH sides priced (same rule as + * snapshotService.indexOdds): fewer genuinely-absent locked/closing odds + * when another book carried the side. Real rows only, never synthesized. */ function indexProps(props) { const map = {}; + const bothSides = (p) => p && p.over_odds != null && p.under_odds != null; 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; + if (!map[k] || (!bothSides(map[k]) && bothSides(p))) map[k] = p; } return map; } diff --git a/src/services/snapshotService.js b/src/services/snapshotService.js index b8537de..4479b43 100644 --- a/src/services/snapshotService.js +++ b/src/services/snapshotService.js @@ -52,12 +52,17 @@ async function mapLimit(items, concurrency, fn) { return out; } -/** Index original odds props by propKey-ish (player|stat) for odds lookup. */ +/** Index original odds props by propKey-ish (player|stat) for odds lookup. + * Session 61 — prefer a book row with BOTH sides priced: the first-seen row + * sometimes carried only one side (e.g. betmgm SB unders with no juice), + * which locked a NULL odds even though another book priced it. Still a + * REAL book row, never synthesized. */ function indexOdds(props) { const map = {}; + const bothSides = (p) => p && p.over_odds != null && p.under_odds != null; for (const p of props || []) { const k = `${norm(p.player)}|${String(p.stat_type || '').toLowerCase()}`; - map[k] = p; + if (!map[k] || (!bothSides(map[k]) && bothSides(p))) map[k] = p; } return map; } diff --git a/src/snapshotScheduler.js b/src/snapshotScheduler.js index 87e3dea..f894123 100644 --- a/src/snapshotScheduler.js +++ b/src/snapshotScheduler.js @@ -147,6 +147,11 @@ 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)`}`); + // 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. + console.log(`[settlement] armed — outcomes + ledger settle pass runs FIRST at each snapshot slot (${HOURS_UTC.join(',')} UTC), idempotent re-runs`); return { interval, tick, refreshTick }; } diff --git a/tests/unit/ledgerService.test.js b/tests/unit/ledgerService.test.js index 2a90261..1e84d0b 100644 --- a/tests/unit/ledgerService.test.js +++ b/tests/unit/ledgerService.test.js @@ -252,3 +252,22 @@ describe('getModelAggregate — per-tier calibration (n≥20 per tier)', () => { expect(agg.by_tier.B.hit_pct).toBeNull(); // under 20 → building }); }); + +// Session 61 — the odds index prefers a fully-priced book row (the null +// locked_odds rows from day one: first-seen betmgm SB unders had no juice +// while another book priced both sides). +describe('indexProps — prefers a book row with both sides priced', () => { + const { indexProps } = ledger.__internals; + test('a later both-sided row replaces a one-sided first row', () => { + const oneSided = { player: 'A Guy', stat_type: 'stolen_bases', line: 0.5, book: 'betmgm', over_odds: 120, under_odds: null }; + const bothSided = { player: 'A Guy', stat_type: 'stolen_bases', line: 0.5, book: 'fanduel', over_odds: 130, under_odds: -170 }; + const idx = indexProps([oneSided, bothSided]); + expect(idx['a guy|stolen_bases'].book).toBe('fanduel'); + }); + test('a both-sided first row is never displaced', () => { + const bothSided = { player: 'A Guy', stat_type: 'hits', line: 1.5, book: 'fanduel', over_odds: -110, under_odds: -110 }; + const oneSided = { player: 'A Guy', stat_type: 'hits', line: 1.5, book: 'betmgm', over_odds: -115, under_odds: null }; + const idx = indexProps([bothSided, oneSided]); + expect(idx['a guy|hits'].book).toBe('fanduel'); + }); +});