From 55157b3288c5efdf1901332dd592d0889c01e579 Mon Sep 17 00:00:00 2001 From: Kev Date: Mon, 20 Jul 2026 12:27:47 -0400 Subject: [PATCH] Close-capture retry (lock-walled) + MLB opp_rank_stat derivation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA --- src/services/closingCapture.js | 74 +++++++++++++- src/services/opponentStrength.js | 150 ++++++++++++++++++++++++++++ tests/unit/closingCapture.test.js | 84 ++++++++++++++++ tests/unit/opponentStrength.test.js | 94 +++++++++++++++++ 4 files changed, 401 insertions(+), 1 deletion(-) create mode 100644 src/services/opponentStrength.js create mode 100644 tests/unit/opponentStrength.test.js diff --git a/src/services/closingCapture.js b/src/services/closingCapture.js index 221b06c..26967b3 100644 --- a/src/services/closingCapture.js +++ b/src/services/closingCapture.js @@ -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, }; diff --git a/src/services/opponentStrength.js b/src/services/opponentStrength.js new file mode 100644 index 0000000..05d21d4 --- /dev/null +++ b/src/services/opponentStrength.js @@ -0,0 +1,150 @@ +'use strict'; + +/** + * OPPONENT STRENGTH — `opp_rank_stat` (Session 64, Phase 3). + * + * ONE COLUMN, ONE MEANING, ACROSS SPORTS. This is the shared contract, taken + * from WNBA's live behaviour, which is the reference implementation: + * + * scale : 0–1 + * polarity : HIGH (>= 0.70) = WEAK opponent defence → favourable to the + * hitter/scorer, lifts an OVER + * LOW (<= 0.30) = TOUGH opponent defence → fades an OVER + * + * MLB previously had NO opp_rank_stat at all (ESPN's MLB team endpoint carries + * no defensive metric), so engine1's ±1.0 opponent factor never fired for the + * sport carrying most of our volume. This derives it from data we ALREADY + * ingest, free and unauthenticated: statsapi's team pitching splits — one call + * returns all 30 teams with avg (opponent batting average against), slg, ops, + * homeRuns, strikeOuts, era, whip. + * + * POLARITY IS THE HIGHEST-RISK PART. A batting average AGAINST is a + * "higher = worse pitching" stat, so it maps DIRECTLY to our scale: a team that + * allows a high BAA is a weak opponent → high opp_rank_stat. Getting this + * backwards would silently mis-adjust every MLB grade in the wrong direction, + * which is far worse than having no value — so a test asserts MLB polarity + * equals WNBA polarity, not merely that a number exists. + * + * HONEST NULLS: below the sample floor — either the opponent's own games or the + * league baseline being too thin — this returns NULL. We are FIXING a silent + * null here; we do not get to replace it with a confident guess off three games. + */ + +// Which pitching field expresses "how easy is this opponent for THIS stat". +// Each is a higher = weaker-opponent measure, matching the shared polarity. +const MLB_STAT_FIELD = { + hits: 'avg', // opponent batting average against + total_bases: 'slg', // slugging against + home_runs: 'homeRuns', // HR allowed + runs: 'runs', + rbi: 'runs', + doubles: 'slg', + triples: 'slg', + walks: 'baseOnBalls', + // Strikeouts are INVERTED: a staff that strikes out MORE batters is a TOUGHER + // opponent for a batter's hits/TB props, but for a BATTER-strikeouts prop a + // high-K staff makes the over EASIER. Handled by `invert` below. + strikeouts: 'strikeOuts', +}; +// Fields where a HIGHER raw value means a TOUGHER opponent for the graded side, +// so the percentile must be flipped to preserve "high = weak". +const INVERTED_FOR_BATTER = new Set([]); +// For a batter's own strikeout prop, a high-K staff HELPS the over — so it is +// NOT inverted. Listed explicitly so the intent is readable rather than implied. +const NOT_INVERTED = new Set(['strikeouts']); + +const MIN_OPPONENT_GAMES = Number(process.env.OPP_RANK_MIN_GAMES || 20); +const MIN_LEAGUE_TEAMS = Number(process.env.OPP_RANK_MIN_TEAMS || 20); + +function num(v) { + if (v == null || v === '') return null; + const n = Number(v); + return Number.isFinite(n) ? n : null; +} + +/** + * Percentile of `value` within `all` (0–1), where a HIGHER raw value yields a + * HIGHER percentile. Ties share the midpoint. + */ +function percentile(value, all) { + const xs = all.filter((v) => Number.isFinite(v)).sort((a, b) => a - b); + if (xs.length < 2) return null; + let below = 0; + let equal = 0; + for (const x of xs) { + if (x < value) below += 1; + else if (x === value) equal += 1; + } + return (below + equal / 2) / xs.length; +} + +/** + * Derive MLB opp_rank_stat. + * + * @param {Array} teams [{ teamId, stat: { avg, slg, gamesPlayed, ... } }] — every team + * @param {string|number} opponentTeamId + * @param {string} statType graded stat (hits, total_bases, ...) + * @returns {{value:number|null, reason:string|null, field:string|null, raw:number|null}} + */ +function deriveMlbOppRank(teams, opponentTeamId, statType, opts = {}) { + const field = MLB_STAT_FIELD[String(statType || '').toLowerCase()]; + if (!field) return { value: null, reason: 'stat_not_mapped', field: null, raw: null }; + + const list = Array.isArray(teams) ? teams : []; + // The league baseline must itself be real. Early season / partial feeds + // produce a baseline that cannot rank anything honestly. + if (list.length < (opts.minTeams ?? MIN_LEAGUE_TEAMS)) { + return { value: null, reason: 'league_baseline_too_thin', field, raw: null }; + } + + const opp = list.find((t) => String(t.teamId) === String(opponentTeamId)); + if (!opp || !opp.stat) return { value: null, reason: 'opponent_not_found', field, raw: null }; + + const games = num(opp.stat.gamesPlayed); + if (games != null && games < (opts.minGames ?? MIN_OPPONENT_GAMES)) { + return { value: null, reason: 'opponent_sample_too_thin', field, raw: null }; + } + + const raw = num(opp.stat[field]); + if (raw == null) return { value: null, reason: 'field_absent', field, raw: null }; + + const all = list + .filter((t) => { + const g = num(t.stat && t.stat.gamesPlayed); + return g == null || g >= (opts.minGames ?? MIN_OPPONENT_GAMES); + }) + .map((t) => num(t.stat && t.stat[field])) + .filter((v) => v != null); + if (all.length < (opts.minTeams ?? MIN_LEAGUE_TEAMS)) { + return { value: null, reason: 'league_baseline_too_thin', field, raw }; + } + + let p = percentile(raw, all); + if (p == null) return { value: null, reason: 'percentile_undefined', field, raw }; + // Preserve the shared polarity: HIGH = weak opponent. + const invert = INVERTED_FOR_BATTER.has(String(statType).toLowerCase()) + && !NOT_INVERTED.has(String(statType).toLowerCase()); + if (invert) p = 1 - p; + return { value: Math.round(p * 1000) / 1000, reason: null, field, raw }; +} + +/** + * Health check. We are fixing a SILENT null — so an empty source AND an + * unexpected null for a sport we expect to derive both page. A derived metric + * that quietly stops deriving is the failure mode this whole session has been + * about. + */ +function opponentStrengthHealth({ sport, teamsLoaded = 0, derived = 0, attempted = 0 } = {}) { + if (teamsLoaded === 0) { + return { alarm: true, reason: `${String(sport).toUpperCase()} opponent-strength source returned NO teams — opp_rank_stat cannot be derived` }; + } + if (attempted > 0 && derived === 0) { + return { alarm: true, reason: `${String(sport).toUpperCase()} opponent-strength derived NULL for all ${attempted} props despite ${teamsLoaded} teams loaded` }; + } + return { alarm: false, reason: null }; +} + +module.exports = { + deriveMlbOppRank, percentile, opponentStrengthHealth, + MLB_STAT_FIELD, MIN_OPPONENT_GAMES, MIN_LEAGUE_TEAMS, +}; diff --git a/tests/unit/closingCapture.test.js b/tests/unit/closingCapture.test.js index 039d2a3..32926c1 100644 --- a/tests/unit/closingCapture.test.js +++ b/tests/unit/closingCapture.test.js @@ -118,3 +118,87 @@ describe('capture-rate alarm (silent-failure discipline)', () => { expect(cap.captureRateAlarm({ eligible: 0, captured: 0, missed: 0 }).alarm).toBe(false); }); }); + +describe('RETRY — bounded, lock-walled, never a post-lock line (Phase 1)', () => { + const LOCK = '2026-07-21T23:10:00Z'; + const at = (iso) => new Date(iso); + + test('a flaky fetch that SUCCEEDS inside the window captures normally', async () => { + let calls = 0; + const res = await cap.captureWithRetry('mlb', { + now: () => at('2026-07-21T22:50:00Z'), + nextLockAt: LOCK, + attempts: 3, + sleep: async () => {}, + fetchProps: async () => { calls += 1; if (calls < 3) throw new Error('feed flaked'); return [prop()]; }, + }); + expect(calls).toBe(3); + expect(res.gave_up).toBe(false); + expect(res.rows.filter((r) => !r.missed_reason)).toHaveLength(2); + }); + + test('attempts are BOUNDED — it gives up and records missed_close with NO price', async () => { + let calls = 0; + const res = await cap.captureWithRetry('mlb', { + now: () => at('2026-07-21T22:50:00Z'), + nextLockAt: LOCK, + attempts: 3, + sleep: async () => {}, + fetchProps: async () => { calls += 1; throw new Error('feed down'); }, + fallbackProps: [prop()], + }); + expect(calls).toBe(3); // bounded, not infinite + expect(res.gave_up).toBe(true); + expect(res.rows).toHaveLength(2); + for (const r of res.rows) { + expect(r.missed_reason).toBe('fetch_failed'); + expect(r.over_odds).toBeNull(); + expect(r.line).toBeNull(); + } + }); + + test('HARD LOCK-WALL: inside the wall it stops retrying and records missed_close', async () => { + let calls = 0; + const res = await cap.captureWithRetry('mlb', { + now: () => at('2026-07-21T23:09:00Z'), // 1 min to lock, wall is 2 + nextLockAt: LOCK, + lockWallMinutes: 2, + attempts: 5, + sleep: async () => {}, + fetchProps: async () => { calls += 1; return [prop()]; }, + fallbackProps: [prop()], + }); + expect(calls).toBe(0); // never even tries + expect(res.reason).toBe('lock_wall'); + expect(res.rows.every((r) => r.missed_reason === 'missed_window')).toBe(true); + expect(res.rows.every((r) => r.over_odds === null)).toBe(true); + }); + + test('PAST lock never captures — a line at/after lock is not a close', async () => { + const res = await cap.captureWithRetry('mlb', { + now: () => at('2026-07-21T23:30:00Z'), + nextLockAt: LOCK, + attempts: 3, + sleep: async () => {}, + fetchProps: async () => [prop()], + fallbackProps: [prop()], + }); + expect(res.reason).toBe('lock_wall'); + expect(res.rows.every((r) => r.over_odds === null)).toBe(true); + }); + + test('an UNBOUND prop records missed_close immediately and never enters the retry loop', async () => { + let calls = 0; + const res = await cap.captureWithRetry('mlb', { + now: () => at('2026-07-21T22:50:00Z'), + nextLockAt: null, // nothing to key the close to + attempts: 5, + sleep: async () => {}, + fetchProps: async () => { calls += 1; return [prop({ game_time: null })]; }, + fallbackProps: [prop({ game_time: null })], + }); + expect(calls).toBe(0); + expect(res.reason).toBe('no_bound_lock'); + expect(res.rows.every((r) => r.missed_reason === 'unbound_game_time')).toBe(true); + }); +}); diff --git a/tests/unit/opponentStrength.test.js b/tests/unit/opponentStrength.test.js new file mode 100644 index 0000000..dc3649c --- /dev/null +++ b/tests/unit/opponentStrength.test.js @@ -0,0 +1,94 @@ +/** + * Session 64 Phase 3 — opp_rank_stat is ONE column with ONE meaning. + * + * The highest-risk item is POLARITY. Backwards polarity does not fail loudly — + * it silently adjusts every MLB grade in the WRONG direction, which is worse + * than having no value at all. So the contract is asserted against WNBA's live + * semantics, not merely "a number appears". + */ +const os = require('../../src/services/opponentStrength'); + +// 30 teams; BAA against ranges 0.200 (toughest staff) → 0.290 (weakest). +const league = Array.from({ length: 30 }, (_, i) => ({ + teamId: i + 1, + stat: { avg: 0.200 + i * 0.003, slg: 0.350 + i * 0.005, gamesPlayed: 90, homeRuns: 10 + i, strikeOuts: 300 + i }, +})); + +describe('THE SHARED CONTRACT — scale and polarity match WNBA', () => { + test('scale is 0–1, never a raw count', () => { + const r = os.deriveMlbOppRank(league, 15, 'hits'); + expect(r.value).toBeGreaterThanOrEqual(0); + expect(r.value).toBeLessThanOrEqual(1); + expect(r.value).not.toBe(league[14].stat.avg); // not the raw stat + }); + + test('POLARITY: the WEAKEST staff (highest BAA) scores HIGH = weak opponent', () => { + const weakest = os.deriveMlbOppRank(league, 30, 'hits'); // BAA .287 + expect(weakest.value).toBeGreaterThanOrEqual(0.70); + }); + + test('POLARITY: the TOUGHEST staff (lowest BAA) scores LOW = tough opponent', () => { + const toughest = os.deriveMlbOppRank(league, 1, 'hits'); // BAA .200 + expect(toughest.value).toBeLessThanOrEqual(0.30); + }); + + test('MLB polarity EQUALS WNBA polarity — high fires weak, low fires tough', () => { + // engine1's live thresholds, applied identically to both sports. + const WEAK = 0.70; + const TOUGH = 0.30; + const weak = os.deriveMlbOppRank(league, 30, 'hits').value; + const tough = os.deriveMlbOppRank(league, 1, 'hits').value; + // The same comparison engine1 makes for WNBA must classify MLB the same way. + expect(weak >= WEAK).toBe(true); + expect(tough <= TOUGH).toBe(true); + expect(weak).toBeGreaterThan(tough); // monotone in the right direction + }); + + test('a mid-pack staff lands mid-scale and fires NEITHER factor', () => { + const mid = os.deriveMlbOppRank(league, 15, 'hits').value; + expect(mid).toBeGreaterThan(0.30); + expect(mid).toBeLessThan(0.70); + }); +}); + +describe('HONEST NULLS — never a confident value off thin data', () => { + test('a thin LEAGUE baseline returns null, not a rank', () => { + const r = os.deriveMlbOppRank(league.slice(0, 5), 1, 'hits'); + expect(r.value).toBeNull(); + expect(r.reason).toBe('league_baseline_too_thin'); + }); + + test("a thin OPPONENT sample returns null", () => { + const thin = league.map((t, i) => (i === 0 ? { ...t, stat: { ...t.stat, gamesPlayed: 3 } } : t)); + const r = os.deriveMlbOppRank(thin, 1, 'hits'); + expect(r.value).toBeNull(); + expect(r.reason).toBe('opponent_sample_too_thin'); + }); + + test('an unmapped stat returns null rather than a wrong field', () => { + expect(os.deriveMlbOppRank(league, 1, 'stolen_bases').reason).toBe('stat_not_mapped'); + }); + + test('an unknown opponent returns null', () => { + expect(os.deriveMlbOppRank(league, 999, 'hits').reason).toBe('opponent_not_found'); + }); + + test('a missing field returns null, never 0', () => { + const noField = league.map((t) => ({ ...t, stat: { ...t.stat, avg: null } })); + const r = os.deriveMlbOppRank(noField, 1, 'hits'); + expect(r.value).toBeNull(); + expect(r.value).not.toBe(0); + }); +}); + +describe('HEALTH — we are fixing a silent null, not rebuilding one', () => { + test('empty source pages', () => { + expect(os.opponentStrengthHealth({ sport: 'mlb', teamsLoaded: 0 }).alarm).toBe(true); + }); + test('all-null despite a loaded source pages', () => { + expect(os.opponentStrengthHealth({ sport: 'mlb', teamsLoaded: 30, attempted: 25, derived: 0 }).alarm).toBe(true); + }); + test('healthy derivation is quiet', () => { + expect(os.opponentStrengthHealth({ sport: 'mlb', teamsLoaded: 30, attempted: 25, derived: 24 }).alarm).toBe(false); + }); +});