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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user