9ae64516e6
Odds-only refresh every 20 min during slate hours (noon–midnight ET), in-process (INTRADAY_REFRESH=0 kill switch; skips full-snapshot slots): - signed delta RELATIVE TO THE GRADED SIDE; direction is the signal. - WITH the grade → STEAM badge, never a re-grade. - AGAINST >=1.0 → re-grade THAT PROP ONLY at the real current line: holds/refuses → VALUE (better entry, same read); drops → PUBLIC revision (grade updates, revised_from_grade preserves the ORIGINAL forever, UI strikethrough on slate strips + ledger cards). - Every run recaptures closing_line/odds → the close is now refresh- fidelity; SYNC goes live by dropping SNAPSHOT_EXPECTED_INTERVAL to 1200. - Ticker MOVE events feed from the refresh (>=1.0 moves). - QUOTA MATH: 36 runs/day/sport x 4 sports <= 144 PropLine calls/day vs 9,000/day free capacity (3 keys x 3,000). Re-grades are internal compute. - POST /api/internal/refresh/all for manual runs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
134 lines
6.3 KiB
JavaScript
134 lines
6.3 KiB
JavaScript
// Session 60 (night2/D) — Phase 2.5 intraday refresh. Direction is the
|
||
// signal: WITH the grade = STEAM (no re-grade); AGAINST ≥1.0 = re-grade
|
||
// that prop only (holds → VALUE, drops → PUBLIC revision). All lines are
|
||
// real feed values; the refresh recaptures the close.
|
||
|
||
const { runIntradayRefresh, inSlateHours, __internals } = require('../../src/services/intradayRefreshService');
|
||
const { signedDelta } = __internals;
|
||
|
||
const NOW = '2026-07-11T20:00:00.000Z';
|
||
|
||
function harness({ grades, oddsProps, analyzeResult }) {
|
||
const store = new Map([[`snapshot:mlb:latest`, { sport: 'mlb', updated_at: NOW, grades, deltas: [] }]]);
|
||
const calls = { analyze: [], revisions: [], closing: 0, ticker: [] };
|
||
const deps = {
|
||
cacheGet: async (k) => store.get(k) ?? null,
|
||
cacheSet: async (k, v) => { store.set(k, v); },
|
||
getOdds: async () => ({ props: oddsProps }),
|
||
analyze: async (prop) => { calls.analyze.push(prop); return analyzeResult; },
|
||
ledger: {
|
||
applyRevision: async (sport, rev) => { calls.revisions.push(rev); return { revised: true }; },
|
||
captureClosing: async () => { calls.closing += 1; return { updated: 0 }; },
|
||
},
|
||
pushTickerItems: async (events) => { calls.ticker.push(...events); },
|
||
now: () => NOW,
|
||
};
|
||
return { deps, store, calls };
|
||
}
|
||
|
||
const GRADE = {
|
||
player: 'Aaron Judge', stat_type: 'home_runs', line: 0.5, direction: 'over',
|
||
grade: 'A', confidence: 78, gradedAt: { line: 0.5, odds: -115, timestamp: NOW },
|
||
};
|
||
const prop = (line) => ({ player: 'Aaron Judge', stat_type: 'home_runs', line, book: 'draftkings' });
|
||
|
||
describe('signedDelta — relative to the graded side', () => {
|
||
test('over: rising line = positive (market chasing the over)', () => {
|
||
expect(signedDelta('over', 1.5, 2.5)).toBe(1);
|
||
expect(signedDelta('over', 1.5, 1.0)).toBe(-0.5);
|
||
});
|
||
test('under: falling line = positive', () => {
|
||
expect(signedDelta('under', 1.5, 1.0)).toBe(0.5);
|
||
expect(signedDelta('under', 1.5, 2.5)).toBe(-1);
|
||
});
|
||
test('missing values → null, never a fabricated zero', () => {
|
||
expect(signedDelta('over', null, 2)).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('runIntradayRefresh — directional handling', () => {
|
||
test('moved WITH the grade → STEAM, NO re-grade', async () => {
|
||
const { deps, store, calls } = harness({ grades: [GRADE], oddsProps: [prop(1.5)], analyzeResult: null });
|
||
const res = await runIntradayRefresh('mlb', deps);
|
||
expect(res).toMatchObject({ status: 'ok', steam: 1, value: 0, revised: 0 });
|
||
expect(calls.analyze).toHaveLength(0); // steam never re-grades
|
||
const snap = store.get('snapshot:mlb:latest');
|
||
expect(snap.grades[0].movement).toMatchObject({ kind: 'steam', delta: 1, currentLine: 1.5 });
|
||
expect(calls.ticker.length).toBeGreaterThan(0); // ≥1.0 move → ticker MOVE
|
||
expect(calls.ticker[0].text).toContain('o0.5 → o1.5');
|
||
});
|
||
|
||
test('moved AGAINST ≥1.0, re-grade HOLDS → VALUE (better entry)', async () => {
|
||
const { deps, store, calls } = harness({
|
||
grades: [{ ...GRADE, gradedAt: { line: 2.5, odds: -110, timestamp: NOW }, line: 2.5 }],
|
||
oddsProps: [prop(1.5)],
|
||
analyzeResult: { grade: 'A', confidence: 70 }, // same grade at the new line
|
||
});
|
||
const res = await runIntradayRefresh('mlb', deps);
|
||
expect(res).toMatchObject({ value: 1, revised: 0 });
|
||
expect(calls.analyze).toHaveLength(1);
|
||
expect(calls.analyze[0].line).toBe(1.5); // re-graded at the REAL current line
|
||
expect(store.get('snapshot:mlb:latest').grades[0].movement.kind).toBe('value');
|
||
expect(calls.revisions).toHaveLength(0);
|
||
});
|
||
|
||
test('moved AGAINST ≥1.0, grade DROPS → PUBLIC revision (original preserved)', async () => {
|
||
const { deps, store, calls } = harness({
|
||
grades: [{ ...GRADE, gradedAt: { line: 2.5, odds: -110, timestamp: NOW }, line: 2.5 }],
|
||
oddsProps: [prop(1.5)],
|
||
analyzeResult: { grade: 'C', confidence: 40 },
|
||
});
|
||
const res = await runIntradayRefresh('mlb', deps);
|
||
expect(res).toMatchObject({ revised: 1 });
|
||
const g = store.get('snapshot:mlb:latest').grades[0];
|
||
expect(g.grade).toBe('C');
|
||
expect(g.revised_from_grade).toBe('A'); // the original, forever
|
||
expect(calls.revisions[0]).toMatchObject({ newGrade: 'C', fromGrade: 'A', line: 2.5, side: 'over' });
|
||
});
|
||
|
||
test('a second drop keeps the ORIGINAL revised_from_grade', async () => {
|
||
const already = { ...GRADE, grade: 'B', revised_from_grade: 'A', gradedAt: { line: 2.5, odds: null, timestamp: NOW }, line: 2.5 };
|
||
const { deps, store } = harness({ grades: [already], oddsProps: [prop(1.5)], analyzeResult: { grade: 'C' } });
|
||
await runIntradayRefresh('mlb', deps);
|
||
expect(store.get('snapshot:mlb:latest').grades[0].revised_from_grade).toBe('A');
|
||
});
|
||
|
||
test('an insufficient re-read HOLDS the original grade (never blanks it)', async () => {
|
||
const { deps, store } = harness({
|
||
grades: [{ ...GRADE, gradedAt: { line: 2.5, odds: null, timestamp: NOW }, line: 2.5 }],
|
||
oddsProps: [prop(1.5)],
|
||
analyzeResult: { grade: null, insufficient_data: true },
|
||
});
|
||
const res = await runIntradayRefresh('mlb', deps);
|
||
expect(res.value).toBe(1);
|
||
expect(store.get('snapshot:mlb:latest').grades[0].grade).toBe('A');
|
||
});
|
||
|
||
test('prop gone from the feed → frozen untouched; close recaptured every run', async () => {
|
||
const { deps, store, calls } = harness({ grades: [GRADE], oddsProps: [prop(0.5)], analyzeResult: null });
|
||
const res = await runIntradayRefresh('mlb', deps);
|
||
expect(res.checked).toBe(1);
|
||
expect(store.get('snapshot:mlb:latest').grades[0].movement).toBeNull(); // <0.5 noise
|
||
expect(calls.closing).toBe(1); // refresh-fidelity closing capture
|
||
});
|
||
|
||
test('no snapshot → honest skip, zero quota spent', async () => {
|
||
const calls = { odds: 0 };
|
||
const res = await runIntradayRefresh('mlb', {
|
||
cacheGet: async () => null,
|
||
cacheSet: async () => {},
|
||
getOdds: async () => { calls.odds += 1; return { props: [] }; },
|
||
ledger: {}, pushTickerItems: async () => {}, now: () => NOW,
|
||
});
|
||
expect(res.status).toBe('skipped');
|
||
expect(calls.odds).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('inSlateHours — noon–midnight ET', () => {
|
||
test('3 PM ET yes, 4 AM ET no', () => {
|
||
expect(inSlateHours(new Date('2026-07-11T19:00:00Z'))).toBe(true); // 3 PM EDT
|
||
expect(inSlateHours(new Date('2026-07-11T08:00:00Z'))).toBe(false); // 4 AM EDT
|
||
});
|
||
});
|