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:
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user