Files
vyndr/src/services/closingCapture.js
T
builtbykev 55157b3288 Close-capture retry (lock-walled) + MLB opp_rank_stat derivation
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
2026-07-20 12:27:47 -04:00

248 lines
9.6 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';
/**
* 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,
};