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,
};
+150
View File
@@ -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 : 01
* 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` (01), 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,
};
+84
View File
@@ -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);
});
});
+94
View File
@@ -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 01, 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);
});
});