55157b3288
PHASE 1 — CLOSE-CAPTURE RETRY, test-first. The closing capture gets a
retry the snapshot path deliberately does not: a snapshot re-runs at the
next slot, but a MISSED CLOSE IS PERMANENT, and the feed flaked once on a
dry induce. Three hard rules, each driven by a test written before the
logic:
- BOUNDED attempts (default 3) with short backoff so every attempt fits
inside the window. Never infinite.
- HARD LOCK-WALL: inside lockWallMinutes of first pitch (or past it) it
stops and records missed_close. A price captured AT or AFTER lock is
NOT a close; storing one would fabricate the CLV baseline.
- NO BOUND LOCK TIME -> refuse immediately, never burn retries on a prop
whose close cannot be timed.
On exhaustion it records missed_close with NO price — never a stale,
mid-day or post-lock line.
PHASE 3 — MLB opp_rank_stat DERIVED, contract-locked. MLB previously had
no opponent metric at all (ESPN's MLB team endpoint carries none), so
engine1's +/-1.0 opponent factor never fired for the sport carrying most
of our volume. Derived from data we already ingest: statsapi team pitching
splits, all 30 teams in ONE free unauthenticated call.
THE SHARED CONTRACT is documented and TESTED, not assumed: 0-1 scale,
HIGH (>=0.70) = WEAK opponent, LOW (<=0.30) = TOUGH — identical to WNBA's
live semantics. Polarity is the highest-risk part: backwards polarity does
not fail loudly, it silently adjusts every MLB grade the wrong way. A test
asserts MLB polarity EQUALS WNBA polarity using engine1's own thresholds.
PROVEN AGAINST THE LIVE FEED:
Colorado Rockies BAA .286 -> opp_rank 0.983 (weak, fires weak_opponent)
LA Dodgers BAA .215 -> opp_rank 0.017 (tough, fires top_opponent)
POLARITY HOLDS: true
HONEST NULLS, tested: thin league baseline, thin opponent sample, unmapped
stat, unknown opponent, or a missing field all return NULL with a reason —
we are FIXING a silent null, so it is never replaced by a confident guess
off three games. opponentStrengthHealth pages on an empty source AND on
derived-null-for-a-sport-we-expect-to-derive.
Suite 285/3435 green, build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
248 lines
9.6 KiB
JavaScript
248 lines
9.6 KiB
JavaScript
'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,
|
||
};
|