'use strict'; /** * CLOSING-LINE CAPTURE (Session 64) — CAPTURE ONLY. No CLV metric here. * * WHY THIS EXISTS: CLV is currently un-backtestable. `ledger_entries * .closing_line` equals the locked line on 92% of rows — not because lines * never move (56 rows DID move) but because `captureClosing` overwrites a * single field with whatever the current props say and, when a prop fails to * match, silently leaves the earlier value in place. There is no timestamp and * no provenance, so "captured a genuine close" is indistinguishable from * "never successfully updated". That ambiguity is the actual C4 defect. * * This module writes an IMMUTABLE, append-only capture instead: one row per * (prop identity × side × book × capture moment), with BOTH raw side prices so * the existing de-vig engine can compute a fair closing probability later. * * THE JOIN: rows carry (sport, player_key, stat, side, game_date) — the natural * key MINUS `line`, deliberately. A closing line that equals the graded line is * the boring case; the whole point of CLV is the case where it MOVED, so `line` * cannot be part of the key. Verified safe: across current retention, all 164 * identity groups have exactly ONE line per (sport, player_key, stat, side, * game_date) — zero ambiguity. * * THE REFUSAL: a prop we cannot legitimately price at the close records * `missed_reason` and NO price. Substituting a stale or mid-day line would * manufacture a CLV proof from a number that was never the close. */ const { nameKey } = require('../utils/playerName'); // How close to first pitch/tip counts as "the close". Our odds refresh runs // every ~20 min during slate hours, so this window is sized to catch the last // observable line without reaching back into mid-day pricing. const WINDOW_MINUTES_BEFORE = Number(process.env.CLOSING_WINDOW_MIN || 45); // Sharp/no-vig reference book — "beat the market", distinct from "beat our book". const SHARP_BOOKS = new Set(['pinnacle']); const CAPTURE_RATE_FLOOR = Number(process.env.CLOSING_CAPTURE_FLOOR || 0.6); function etDate(iso) { if (!iso) return null; const t = new Date(iso); if (Number.isNaN(t.getTime())) return null; return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit', }).format(t); } function numOrNull(v) { if (v == null || v === '') return null; const n = Number(v); return Number.isFinite(n) ? n : null; } /** A refusal row: identity + why, never a price. */ function missedRow(sport, p, side, reason, nowIso) { return { sport, player_key: nameKey(p.player), player_name: p.player, stat: p.stat_type, side, game_date: etDate(p.game_time) || null, game_time: p.game_time || null, book: p.book || null, line_type: SHARP_BOOKS.has(String(p.book || '').toLowerCase()) ? 'sharp' : 'book', line: null, over_odds: null, under_odds: null, captured_at: nowIso, missed_reason: reason, }; } /** * Build capture rows for a slate at a moment in time. PURE — no I/O, so the * refusal rules are unit-testable and cannot silently depend on a live feed. * * Returns [] for props that are not yet near lock (nothing to record — it is * simply not the close yet). Returns refusal rows for props inside the window * that cannot be priced honestly. */ function buildCaptureRows(sport, props, opts = {}) { const now = opts.now || new Date(); const nowIso = now.toISOString(); const windowMs = (opts.windowMinutes ?? WINDOW_MINUTES_BEFORE) * 60_000; const out = []; for (const p of props || []) { if (!p || !p.player || !p.stat_type) continue; const sides = ['over', 'under']; // No bound game time → we cannot know when the close IS. Record it. if (!p.game_time || !etDate(p.game_time)) { for (const s of sides) out.push(missedRow(sport, p, s, 'unbound_game_time', nowIso)); continue; } // Doubleheader: two locks, no game number on the prop → unattributable. if (p.game_ambiguous) { for (const s of sides) out.push(missedRow(sport, p, s, 'doubleheader_ambiguous', nowIso)); continue; } const lockMs = new Date(p.game_time).getTime(); const delta = lockMs - now.getTime(); if (delta > windowMs) continue; // not the close yet — say nothing if (delta < 0) { // already started for (const s of sides) out.push(missedRow(sport, p, s, 'missed_window', nowIso)); continue; } const over = numOrNull(p.over_odds); const under = numOrNull(p.under_odds); const line = numOrNull(p.line); // A one-sided price cannot produce a fair (de-vigged) closing probability, // so it is not a usable close. Recorded as such rather than half-stored. if (over == null || under == null || line == null) { for (const s of sides) out.push(missedRow(sport, p, s, 'one_sided_price', nowIso)); continue; } for (const s of sides) { out.push({ ...missedRow(sport, p, s, null, nowIso), line, over_odds: over, under_odds: under, }); } } return out; } /** * RETRY — bounded, lock-walled (Session 64 Phase 2). * * The closing capture gets a retry the snapshot path deliberately does NOT: * a snapshot can be re-run at the next slot, but a MISSED CLOSE IS PERMANENT. * The odds feed flaked once on a dry induce, so the fetch is hardened here and * ONLY here. * * Three hard rules, each test-driven: * - BOUNDED attempts with a short backoff — the window is minutes wide, so all * attempts must fit inside it. Never infinite. * - HARD LOCK-WALL: inside `lockWallMinutes` of first pitch (or past it) we * stop and record missed_close. A price captured AT or AFTER lock is NOT a * close, and storing one as if it were would fabricate the CLV baseline. * - NO BOUND LOCK TIME → not close-capturable at all. Record missed_close * immediately; never burn retries on a prop whose close we cannot time. */ function missedFrom(sport, props, reason, nowIso) { const out = []; for (const p of props || []) { if (!p || !p.player || !p.stat_type) continue; for (const side of ['over', 'under']) out.push(missedRow(sport, p, side, reason, nowIso)); } return out; } async function captureWithRetry(sport, opts = {}) { const nowFn = opts.now || (() => new Date()); const attemptsMax = Number.isFinite(opts.attempts) ? opts.attempts : 3; const backoffMs = Number.isFinite(opts.backoffMs) ? opts.backoffMs : 5_000; const lockWallMinutes = Number.isFinite(opts.lockWallMinutes) ? opts.lockWallMinutes : 2; const sleep = opts.sleep || ((ms) => new Promise((r) => setTimeout(r, ms))); const fallback = opts.fallbackProps || []; // No bound lock time → the close is untimeable. Refuse immediately. if (!opts.nextLockAt) { return { rows: missedFrom(sport, fallback, 'unbound_game_time', nowFn().toISOString()), attempts: 0, gave_up: true, reason: 'no_bound_lock', }; } let lastErr = null; for (let i = 1; i <= attemptsMax; i += 1) { const minsToLock = (new Date(opts.nextLockAt).getTime() - nowFn().getTime()) / 60_000; if (!(minsToLock > lockWallMinutes)) { return { rows: missedFrom(sport, fallback, 'missed_window', nowFn().toISOString()), attempts: i - 1, gave_up: true, reason: 'lock_wall', }; } try { const props = await opts.fetchProps(); if (Array.isArray(props) && props.length) { return { rows: buildCaptureRows(sport, props, { now: nowFn(), windowMinutes: opts.windowMinutes }), attempts: i, gave_up: false, reason: null, }; } lastErr = 'empty_response'; } catch (e) { lastErr = e && e.message ? e.message : String(e); } if (i < attemptsMax) await sleep(backoffMs); } // Exhausted inside the window: record the refusal, never a substituted line. return { rows: missedFrom(sport, fallback, 'fetch_failed', nowFn().toISOString()), attempts: attemptsMax, gave_up: true, reason: lastErr, }; } /** Silent-failure discipline: a capture pass that mostly misses is a broken * pipe, and a missing close cannot be recovered later. */ function captureRateAlarm({ eligible = 0, captured = 0 } = {}, opts = {}) { const floor = Number.isFinite(opts.floor) ? opts.floor : CAPTURE_RATE_FLOOR; if (!eligible) return { alarm: false, reason: null, rate: null }; const rate = captured / eligible; return { alarm: rate < floor, rate, reason: rate < floor ? `closing capture rate ${Math.round(rate * 100)}% of ${eligible} eligible props (floor ${Math.round(floor * 100)}%) — tonight's closes are unrecoverable` : null, }; } /** Persist append-only. Never updates an existing row: a capture is a * historical fact, and rewriting it would destroy the provenance that C4 * lacked in the first place. */ async function persist(rows, deps = {}) { const out = { attempted: rows ? rows.length : 0, written: 0, skipped: false, error: null }; if (!out.attempted) return out; try { const getClient = deps.getClient || require('../utils/supabase').getSupabaseServiceClient; const sb = getClient(); if (!sb) { out.skipped = true; return out; } const CHUNK = 250; for (let i = 0; i < rows.length; i += CHUNK) { const chunk = rows.slice(i, i + CHUNK); const { error } = await sb.from('closing_captures').insert(chunk); if (error) { out.error = error.message; break; } out.written += chunk.length; } } catch (e) { out.error = e && e.message ? e.message : String(e); } return out; } module.exports = { buildCaptureRows, captureWithRetry, captureRateAlarm, persist, WINDOW_MINUTES_BEFORE, SHARP_BOOKS, };