'use strict'; /** * LOCK-LINE CAPTURE — the lock-time analog of `closingCapture` (measurement-only). * * WHY: the over-side skew audit (2026-07-29) returned SURVIVES-BASELINE for the * champion's takeable-MLB-over edge, but the confirming check — was our locked line * stale-high vs consensus AT LOCK — is BLOCKED because multi-book lines at lock were * never persisted (`bookprices` is Redis, current-only). This persists them: as a grade * LOCKS, each book's line + both-side prices for that graded prop is written to * `lock_lines`, timestamped at the lock moment. A future audit joins it to * `closing_captures` to run the blocked staleness check. * * STRUCTURAL FENCE (enforced, not a comment): * 1. This module only READS the `props` array + a set of graded keys and RETURNS rows / * writes its OWN table (`lock_lines`). It never mutates `props`. * 2. `lock_lines` is read by NOTHING on the grade path — not gradeSlateService, * snapshotService's dedup/indexOdds, any challenger, the selector, or the ledger. * A test greps the grade-path files and asserts zero `lock_lines`/`lockLineCapture` * references; migration 033 puts RLS-with-no-policies on the table (service-role * only). * 3. In runSnapshot the persist is a best-effort leaf whose result is used by nothing * downstream — the graded slate is byte-identical whether or not it runs (locked by * a runSnapshot grade-identity test). * * NO TTL RACE: rows are built from the in-memory `props` array synchronously at the lock * moment (`ts`), never by re-reading the Redis `bookprices` key later — so the snapshot * we persist is exactly the one that locked, not a later/expired one. * * HONEST-ABSENT: a graded prop with only one priced book persists as ONE row. Never a * fabricated second book. A book row with neither price is not a line and is skipped. */ const { nameKey, normalizeName } = require('../utils/playerName'); // Sharp/no-vig reference book (mirrors closingCapture). Tagged so the audit can weight // pinnacle as the sharp consensus vs the retail books. const SHARP_BOOKS = new Set(['pinnacle']); 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; } /** Canonical graded-prop key (player + stat), matching how the ledger/snapshot key. */ function lockKey(player, stat) { return `${nameKey(player)}|${String(stat || '').toLowerCase()}`; } /** * Build flat lock-line rows for the GRADED props only (targeted → low volume). One row * per (prop × book) carrying both over/under prices. PURE — no I/O; never mutates props. * * @param {string} sport * @param {Array} props multi-book flat props (oddsNormalizer shape) * @param {Set} gradedKeys Set of `${nameKey}|${stat}` for props that locked a grade * @param {Object} [opts] { lockedAt } — the exact lock timestamp (snapshot ts) */ function buildLockRows(sport, props, gradedKeys, opts = {}) { const lockedAt = opts.lockedAt || new Date().toISOString(); const seen = new Set(); // dedupe (prop × book) within this lock moment const out = []; for (const p of props || []) { if (!p || !p.player || !p.stat_type || !p.book) continue; const stat = String(p.stat_type).toLowerCase(); const k = lockKey(p.player, stat); if (gradedKeys && !gradedKeys.has(k)) continue; // graded props only const over = numOrNull(p.over_odds); const under = numOrNull(p.under_odds); if (over == null && under == null) continue; // not a real line — honest-absent, skip const dedupe = `${k}|${p.book}`; if (seen.has(dedupe)) continue; seen.add(dedupe); out.push({ sport, player_key: nameKey(p.player), player_name: normalizeName(p.player).display || p.player, stat, game_date: etDate(p.game_time), game_time: p.game_time || null, book: p.book, line_type: SHARP_BOOKS.has(String(p.book || '').toLowerCase()) ? 'sharp' : 'book', line: numOrNull(p.line), over_odds: over, under_odds: under, locked_at: lockedAt, }); } return out; } /** * Persist append-only. Idempotent via the UNIQUE key (a snapshot retry never * double-inserts). Best-effort — a persistence failure never breaks the snapshot. */ 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('lock_lines').upsert(chunk, { onConflict: 'sport,player_key,stat,game_date,book,locked_at', ignoreDuplicates: true, }); 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 = { buildLockRows, persist, lockKey, SHARP_BOOKS };