Arm harness on our scheduler + start closing-line capture (capture only)

PART A — HARNESS ARMED ON OUR OWN INFRA. snapshotScheduler now runs the
nightly backtest at HARNESS_HOUR_UTC (default 14), appends to
harness_results, and pages via opsWatch.harnessStaleAlarm — a validator
that stops running looks exactly like one that keeps passing. No external
dependency: the join is plain SQL through the service client and the
harness is a pure function. POST /api/internal/harness/run induces the
same code path on demand, because a scheduled mechanism is verified by
inducing it, never by waiting for a slot.

PART B PHASE 0 — GATE PASSED for what is capturable:
- C4 diagnosed: closing_line is ONE overwritable field with no timestamp
  and no provenance. captureClosing writes the current line and, when a
  prop fails to match, silently leaves the earlier value (= the lock) in
  place — so "captured a real close" is indistinguishable from "never
  updated". It is 92% equal, not 100%: 56 rows DID record movement, so
  the defect is provenance, not the value.
- Feeds: normalized props already carry BOTH raw side prices per book,
  with game_time, and the intraday refresh polls every ~20 min during
  slate hours — so the last observable pre-lock line is available.
- SHARP close: pinnacle is in ALLOWED_BOOKS -> a no-vig reference is
  capturable ("beat the market").
- ODAWA: NOT capturable. 'odawa' exists only as a UI preference option in
  onboarding/settings; it is in no adapter, no ALLOWED_BOOKS, no feed. An
  un-capturable source is a finding, not a gap to paper over.
- JOIN: must drop `line` from the natural key, because a close that MOVED
  off the graded line is the entire point of CLV. Verified safe — all 164
  current identity groups have exactly ONE line per
  (sport, player_key, stat, side, game_date). Zero ambiguity.

PART B PHASE 1 — CAPTURE ONLY, built test-first. The refusal was proven
before the capture logic existed: unbound game_time, doubleheader
ambiguity, a missed pre-lock window, or a one-sided price all record
missed_reason with NO price. A stale or mid-day line substituted for a
close would manufacture a CLV proof from a number that was never the
close.

migration 029 closing_captures: append-only, never overwritten (that is
the provenance C4 lacked), BOTH raw side prices so the existing de-vig
engine can compute a fair closing probability later, sharp vs book line
types kept distinct. Wired into the intraday refresh with a capture-rate
alarm — a missed close is unrecoverable.

NO CLV metric built, as ordered. This starts the clock.

Suite 284/3417 green, build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
This commit is contained in:
Kev
2026-07-20 12:05:16 -04:00
parent e809a0eb3c
commit 77a58e4113
6 changed files with 499 additions and 0 deletions
+175
View File
@@ -0,0 +1,175 @@
'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;
}
/** 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, captureRateAlarm, persist,
WINDOW_MINUTES_BEFORE, SHARP_BOOKS,
};