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
This commit is contained in:
Kev
2026-07-20 12:27:47 -04:00
parent 2321267346
commit 55157b3288
4 changed files with 401 additions and 1 deletions
+73 -1
View File
@@ -131,6 +131,78 @@ function buildCaptureRows(sport, props, opts = {}) {
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 = {}) {
@@ -170,6 +242,6 @@ async function persist(rows, deps = {}) {
}
module.exports = {
buildCaptureRows, captureRateAlarm, persist,
buildCaptureRows, captureWithRetry, captureRateAlarm, persist,
WINDOW_MINUTES_BEFORE, SHARP_BOOKS,
};