Files
vyndr/src/services/lockLineCapture.js
T
builtbykev c7067c80c4 Persist lock-time multi-book lines to lock_lines (unblocks the staleness audit)
The over-side skew audit's confirming check — was our locked line stale-high vs
consensus AT LOCK — was BLOCKED because multi-book lines at lock were never
persisted (bookprices is Redis current-only). This persists them.

- migration 033: lock_lines table (tracked + applied to prod). One row per
  (graded prop × book) with both odds + a lock timestamp. RLS enabled, NO
  policies -> service-role only (fence). UNIQUE key -> idempotent re-runs.
- lockLineCapture.js: buildLockRows (pure, graded-props only, honest-absent
  single-book) + idempotent upsert persist. Built from the in-memory props at
  the LOCK moment (ts) -> no Redis re-read, no TTL race.
- snapshotService: persist right after `enriched` (the lock moment; gradedAt
  uses the same ts). Best-effort + fenced.

FENCE (measurement-only): lock_lines is read by NOTHING on the grade path
(gradeSlateService, snapshot dedup/indexOdds, challengers, selector, ledger) —
a grep test asserts it, and RLS locks it to the service role. Grade byte-
identical proven: runSnapshot grades are identical with persist on/off (test).

Volume ~1.5-3k rows/day (graded props x books x 5 snapshots); weeks retained,
no pruning needed short-term. Does NOT retroactively fix the existing 62 rows —
future accrual only; confirmation still needs weeks of settled rows. Full suite
3842 green, web build exit 0. No grade/locked_odds/outcome/served surface changed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsztNChZ7vEvSR61AuMhD1
2026-07-29 02:47:47 -04:00

133 lines
5.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 };