Files
vyndr/tests/unit/closingCapture.test.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

205 lines
8.1 KiB
JavaScript

/**
* Session 64 — CLOSING-LINE CAPTURE (capture only; no CLV metric).
*
* Written BEFORE the capture logic. The first property proven is the refusal:
* a prop we cannot legitimately price at the close must record `missed_close`
* and NO price. Substituting a stale or mid-day line would manufacture a CLV
* proof out of a number that was never the close — the exact failure this
* whole substrate exists to prevent.
*/
const cap = require('../../src/services/closingCapture');
const prop = (o = {}) => ({
player: 'José Ramírez', stat_type: 'hits', line: 1.5,
over_odds: -120, under_odds: 100, book: 'draftkings',
game_time: '2026-07-21T23:10:00Z',
home_team: 'CLE', away_team: 'MIN',
...o,
});
const NOW = new Date('2026-07-21T22:55:00Z'); // 15 min before lock
describe('THE REFUSAL — never fabricate a close', () => {
test('a prop with NO bound game_time records missed_close, no price', () => {
const rows = cap.buildCaptureRows('mlb', [prop({ game_time: null })], { now: NOW });
// BOTH sides are recorded as missed — a refusal is per-side, exactly like a
// capture, so the join finds an explicit answer for either side.
expect(rows).toHaveLength(2);
expect(rows[0].missed_reason).toBe('unbound_game_time');
expect(rows[0].over_odds).toBeNull();
expect(rows[0].under_odds).toBeNull();
expect(rows[0].line).toBeNull();
});
test('a DOUBLEHEADER prop is ambiguous — recorded, never attributed', () => {
const rows = cap.buildCaptureRows('mlb', [prop({ game_ambiguous: true })], { now: NOW });
expect(rows[0].missed_reason).toBe('doubleheader_ambiguous');
expect(rows[0].over_odds).toBeNull();
});
test('a game already LOCKED (past start) records missed_close, not a stale line', () => {
const late = new Date('2026-07-21T23:30:00Z'); // 20 min AFTER first pitch
const rows = cap.buildCaptureRows('mlb', [prop()], { now: late });
expect(rows[0].missed_reason).toBe('missed_window');
expect(rows[0].over_odds).toBeNull();
});
test('a prop far from lock is NOT captured at all (not yet the close)', () => {
const early = new Date('2026-07-21T12:00:00Z'); // 11h before
const rows = cap.buildCaptureRows('mlb', [prop()], { now: early });
expect(rows).toHaveLength(0);
});
test('no capture row ever carries a price without a real close', () => {
const rows = cap.buildCaptureRows('mlb', [
prop({ game_time: null }), prop({ game_ambiguous: true }),
], { now: NOW });
for (const r of rows) {
expect(r.missed_reason).toBeTruthy();
expect(r.over_odds).toBeNull();
expect(r.under_odds).toBeNull();
}
});
});
describe('CAPTURE — inside the pre-lock window', () => {
test('captures BOTH side prices raw (de-vig is computed downstream, not here)', () => {
const [r] = cap.buildCaptureRows('mlb', [prop()], { now: NOW });
expect(r.missed_reason).toBeNull();
expect(r.over_odds).toBe(-120);
expect(r.under_odds).toBe(100);
expect(r.line).toBe(1.5);
});
test('a one-sided price is NOT a usable close — recorded as incomplete', () => {
const [r] = cap.buildCaptureRows('mlb', [prop({ under_odds: null })], { now: NOW });
expect(r.missed_reason).toBe('one_sided_price');
expect(r.over_odds).toBeNull();
});
test('rows carry the natural JOIN KEY minus line (a close moves off the graded line)', () => {
const [r] = cap.buildCaptureRows('mlb', [prop()], { now: NOW });
expect(r.sport).toBe('mlb');
expect(r.player_key).toBe('jose ramirez');
expect(r.stat).toBe('hits');
expect(r.game_date).toBe('2026-07-21');
expect(r).toHaveProperty('side');
});
test('both sides of a prop are captured as separate joinable rows', () => {
const rows = cap.buildCaptureRows('mlb', [prop()], { now: NOW });
expect(rows.map((r) => r.side).sort()).toEqual(['over', 'under']);
});
test('every row is timestamped and immutable-by-construction (append-only)', () => {
const [r] = cap.buildCaptureRows('mlb', [prop()], { now: NOW });
expect(r.captured_at).toBe(NOW.toISOString());
expect(r.book).toBe('draftkings');
});
test('sharp vs book close are distinguished (different CLV questions)', () => {
const rows = cap.buildCaptureRows('mlb', [prop({ book: 'pinnacle' })], { now: NOW });
expect(rows[0].line_type).toBe('sharp');
const rows2 = cap.buildCaptureRows('mlb', [prop({ book: 'draftkings' })], { now: NOW });
expect(rows2[0].line_type).toBe('book');
});
});
describe('capture-rate alarm (silent-failure discipline)', () => {
test('alarms when most in-window props failed to capture', () => {
const r = cap.captureRateAlarm({ eligible: 100, captured: 40, missed: 60 });
expect(r.alarm).toBe(true);
});
test('quiet on a healthy pass', () => {
expect(cap.captureRateAlarm({ eligible: 100, captured: 95, missed: 5 }).alarm).toBe(false);
});
test('nothing eligible never alarms', () => {
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);
});
});