Session D (night2): Phase 2.5 — intraday refresh + directional movement

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>
This commit is contained in:
Kev
2026-07-11 00:51:00 -04:00
parent 80481d6f6a
commit 9ae64516e6
7 changed files with 453 additions and 3 deletions
+17
View File
@@ -180,6 +180,23 @@ router.post('/outcomes/all', async (req, res) => {
}
});
/**
* POST /api/internal/refresh/all (Session 60, Phase 2.5) — manual intraday
* odds-only refresh: STEAM/VALUE movement, public revisions, closing
* capture. Same behavior as the in-process 20-min cadence.
*/
router.post('/refresh/all', async (req, res) => {
const refresh = require('../services/intradayRefreshService');
try {
const results = await refresh.runAllIntradayRefreshes();
return res.json({ ok: true, results });
} catch (err) {
const message = err && err.message ? err.message : String(err);
console.error('[internal/refresh/all] failed:', message);
return res.status(500).json({ ok: false, error: message });
}
});
/**
* POST /api/internal/ledger/settle (Session 58, Phase 1) — settle the
* persistent ledger (outcome + actual_value + CLV) across every sport.
+217
View File
@@ -0,0 +1,217 @@
'use strict';
/**
* intradayRefreshService — Phase 2.5 (Session 60, night2/D).
*
* A lightweight ODDS-ONLY refresh during slate hours (~noonmidnight ET).
* No full re-grade run: it re-reads the market, computes the signed delta
* per graded prop RELATIVE TO THE GRADED SIDE, and acts on DIRECTION:
*
* moved WITH the grade (market chasing our number) → STEAM ▲ +N badge.
* Good for the record; entry edge compressed. NO re-grade.
* moved AGAINST ≥ 1.0 → re-grade THAT
* PROP ONLY at the current line:
* grade holds → VALUE ▲ (better number, same read)
* grade drops → PUBLIC revision: grade updates with
* revised_from_grade set (original preserved, struck through in
* the UI + ledger). Never a silent regrade — Ledger ethos.
*
* Every displayed line stays a REAL book value from this refresh — the
* refresh CAPTURES market numbers, never computes them. Each run also
* re-captures closing_line/odds (ledgerService.captureClosing): the last
* pre-game write IS the close, now at refresh fidelity.
*
* QUOTA MATH (zero out-of-pocket): one getOdds call per sport per run.
* 20-min cadence × 12 slate hours = 36 runs/day/sport × 4 sports =
* ≤144 PropLine requests/day — against 9,000/day free capacity (3 keys ×
* 3,000). Re-grades are internal feature computation (free) and bounded
* to props that moved against ≥ 1.0.
*
* Everything injectable → unit-tested with zero network.
*/
const { nameKey } = require('../utils/playerName');
const STEAM_NOISE = 0.5; // ignore movement below this (both directions)
const REGRADE_TRIGGER = 1.0; // moved-against threshold that triggers a re-grade
const SNAP_TTL = 24 * 3600; // keep in sync with snapshotService
const TICKER_MOVE_CAP = 6;
const GRADE_RANK = { 'A+': 0, A: 1, 'A-': 2, 'B+': 3, B: 4, 'B-': 5, 'C+': 6, C: 7, 'C-': 8, D: 9, F: 10 };
const rank = (g) => (g && GRADE_RANK[g] !== undefined ? GRADE_RANK[g] : 99);
const sideOver = (dir) => String(dir || 'over').toLowerCase() !== 'under';
function indexOddsProps(props) {
const map = {};
for (const p of props || []) {
if (!p || !p.player || !p.stat_type) continue;
const k = `${nameKey(p.player)}|${String(p.stat_type).toLowerCase()}`;
if (!map[k]) map[k] = p;
}
return map;
}
/** Signed movement RELATIVE TO THE GRADED SIDE: positive = toward (with).
* Strict null-safe parse — Number(null) is 0, a fabricated line. */
function signedDelta(side, lockedLine, currentLine) {
if (lockedLine == null || currentLine == null) return null;
const locked = Number(lockedLine);
const current = Number(currentLine);
if (!Number.isFinite(locked) || !Number.isFinite(current)) return null;
const raw = current - locked;
return Math.round((sideOver(side) ? raw : -raw) * 100) / 100;
}
/**
* Run one intraday refresh for a sport. Returns
* { sport, status, checked, steam, value, revised } — never throws.
*/
async function runIntradayRefresh(sport, opts = {}) {
const sp = String(sport || '').toLowerCase();
const deps = {
getOdds: opts.getOdds || require('./oddsService').getOdds,
analyze: opts.analyze || require('./intelligence/analyzeViaEngine1').analyzeViaEngine1,
cacheGet: opts.cacheGet || require('../utils/redis').cacheGet,
cacheSet: opts.cacheSet || require('../utils/redis').cacheSet,
ledger: opts.ledger || require('./ledgerService'),
pushTickerItems: opts.pushTickerItems || require('./snapshotService').pushTickerItems,
now: opts.now || (() => new Date().toISOString()),
};
const ts = deps.now();
const snap = await deps.cacheGet(`snapshot:${sp}:latest`);
if (!snap || !Array.isArray(snap.grades) || snap.grades.length === 0) {
return { sport: sp, status: 'skipped', reason: 'no snapshot', checked: 0, steam: 0, value: 0, revised: 0 };
}
let odds;
try {
odds = await deps.getOdds(sp);
} catch (e) {
return { sport: sp, status: 'error', reason: e.message, checked: 0, steam: 0, value: 0, revised: 0 };
}
const props = odds && Array.isArray(odds.props) ? odds.props : [];
if (props.length === 0) {
return { sport: sp, status: 'skipped', reason: 'no odds', checked: 0, steam: 0, value: 0, revised: 0 };
}
const byKey = indexOddsProps(props);
let steam = 0; let value = 0; let revised = 0; let checked = 0;
const moveEvents = [];
const grades = [];
for (const g of snap.grades) {
const player = g.player || g.player_name;
const stat = String(g.stat_type || g.stat || '').toLowerCase();
const locked = g.gradedAt && g.gradedAt.line != null ? g.gradedAt.line : g.line;
const prop = byKey[`${nameKey(player)}|${stat}`];
// Prop gone from the feed (game started / market pulled) → frozen as-is.
if (!prop || prop.line == null || locked == null) { grades.push(g); continue; }
checked += 1;
const delta = signedDelta(g.direction, locked, prop.line);
if (delta == null || Math.abs(delta) < STEAM_NOISE) {
grades.push({ ...g, movement: null });
continue;
}
const current = Number(prop.line);
if (delta > 0) {
// Moved WITH the grade — the market is chasing our number.
steam += 1;
grades.push({ ...g, movement: { kind: 'steam', delta, currentLine: current, at: ts } });
if (Math.abs(delta) >= REGRADE_TRIGGER) {
moveEvents.push(moveEvent(sp, g, locked, current, ts));
}
continue;
}
// Moved AGAINST the grade.
if (Math.abs(delta) < REGRADE_TRIGGER) {
grades.push({ ...g, movement: { kind: 'against', delta, currentLine: current, at: ts } });
continue;
}
// ≥ 1.0 against → re-grade THIS PROP ONLY at the current (real) line.
let res = null;
try {
res = await deps.analyze({
player, stat_type: stat, line: current,
direction: g.direction || 'over', sport: sp, book: prop.book,
});
} catch { /* re-grade unavailable → treated as hold below */ }
moveEvents.push(moveEvent(sp, g, locked, current, ts));
if (!res || !res.grade || res.insufficient_data || rank(res.grade) <= rank(g.grade)) {
// Grade holds (or the model refuses to re-read) → better entry, same read.
value += 1;
grades.push({ ...g, movement: { kind: 'value', delta, currentLine: current, at: ts } });
continue;
}
// Grade DROPS → public revision. Original grade preserved once, forever.
revised += 1;
const fromGrade = g.revised_from_grade || g.grade;
grades.push({
...g,
grade: res.grade,
revised_from_grade: fromGrade,
movement: { kind: 'revised', delta, currentLine: current, at: ts },
});
try {
await deps.ledger.applyRevision(sp, {
playerKey: nameKey(player), stat, line: Number(locked),
side: sideOver(g.direction) ? 'over' : 'under',
newGrade: res.grade, fromGrade,
});
} catch (e) {
console.warn(`[intraday] ledger revision failed for ${player}:`, e.message);
}
}
// Write back: refreshed movement state + the higher-fidelity close.
const updated = { ...snap, grades, refreshed_at: ts };
await deps.cacheSet(`snapshot:${sp}:latest`, updated, SNAP_TTL);
await deps.cacheSet(`grades:${sp}`, { grades, updated_at: snap.updated_at, refreshed_at: ts, source: snap.source || 'refresh' }, SNAP_TTL);
try { await deps.ledger.captureClosing(sp, props); } catch { /* best-effort */ }
if (moveEvents.length > 0) {
await deps.pushTickerItems(moveEvents.slice(0, TICKER_MOVE_CAP), deps);
}
return { sport: sp, status: 'ok', checked, steam, value, revised };
}
function moveEvent(sport, g, locked, current, ts) {
const s = sideOver(g.direction) ? 'o' : 'u';
const arrow = current > locked ? '▲' : '▼';
const diff = Math.round((current - locked) * 100) / 100;
const last = String(g.player || g.player_name || '').trim().split(/\s+/).pop() || '';
return {
tag: 'MOVE', color: 'var(--amber)', ts, sport,
text: `${last} ${s}${locked}${s}${current} ${arrow}${diff > 0 ? '+' : ''}${diff}`,
};
}
/** Slate hours: noonmidnight ET. */
function inSlateHours(date = new Date()) {
const h = Number(new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York', hour: 'numeric', hour12: false }).format(date));
return h >= 12 && h <= 23;
}
async function runAllIntradayRefreshes(opts = {}) {
const sports = opts.sports || require('./snapshotService').ACTIVE_SPORTS;
const results = [];
for (const sp of sports) {
try { results.push(await runIntradayRefresh(sp, opts)); }
catch (e) { results.push({ sport: sp, status: 'error', reason: e.message }); }
}
return results;
}
module.exports = {
runIntradayRefresh,
runAllIntradayRefreshes,
inSlateHours,
__internals: { signedDelta, indexOddsProps, moveEvent, rank, STEAM_NOISE, REGRADE_TRIGGER },
};
+23
View File
@@ -318,6 +318,28 @@ async function settleLedger(sport, opts = {}) {
return { settled, pending };
}
/**
* Phase 2.5 (Session 60) — a PUBLIC grade revision. The intraday refresh
* re-graded a prop whose line moved ≥1.0 against the graded side and the
* grade dropped: update the locked row's grade and set revised_from_grade
* ONCE (the original letter is preserved forever — revisions are public,
* never silent). Only unsettled public rows for today are touched.
*/
async function applyRevision(sport, { playerKey, stat, line, side, newGrade, fromGrade }, opts = {}) {
if (!opts.sb && !isConfigured()) return { skipped: 'supabase not configured' };
const sb = opts.sb || defaultClient();
const { error } = await sb.from('ledger_entries')
.update({ grade: newGrade, revised_from_grade: fromGrade })
.is('user_id', null)
.eq('sport', String(sport).toLowerCase())
.eq('player_key', playerKey)
.eq('stat', stat)
.eq('line', line)
.eq('side', side)
.is('outcome', null);
return error ? { error: error.message } : { revised: true };
}
async function settleAllLedgers(opts = {}) {
const sports = opts.sports || ['mlb', 'nba', 'wnba', 'soccer'];
const results = [];
@@ -400,6 +422,7 @@ module.exports = {
captureClosing,
settleLedger,
settleAllLedgers,
applyRevision,
getModelAggregate,
MIN_AGG_SAMPLE,
__internals: {
+32 -3
View File
@@ -115,10 +115,39 @@ function startSnapshotScheduler(opts = {}) {
}
};
const interval = setInterval(tick, 60_000);
// Session 60 (night2/D) — Phase 2.5 intraday refresh. Every
// REFRESH_MINUTES during slate hours (noonmidnight ET), odds-only:
// STEAM/VALUE badges, ≥1.0-against re-grades with public revisions,
// refresh-fidelity closing capture, higher-frequency ticker MOVEs.
// Skips the top of scheduled snapshot hours (the full run owns those).
// Kill switch: INTRADAY_REFRESH=0.
const REFRESH_MINUTES = Math.max(5, parseInt(process.env.INTRADAY_REFRESH_MINUTES || '20', 10) || 20);
const refreshAll = opts.runAllIntradayRefreshes || require('./services/intradayRefreshService').runAllIntradayRefreshes;
const inSlateHours = opts.inSlateHours || require('./services/intradayRefreshService').inSlateHours;
let lastRefreshSlot = null;
const refreshTick = async () => {
if (process.env.INTRADAY_REFRESH === '0') return;
const d = now();
if (!inSlateHours(d)) return;
if (d.getUTCMinutes() % REFRESH_MINUTES !== 0) return;
if (d.getUTCMinutes() === 0 && HOURS_UTC.includes(d.getUTCHours())) return; // full snapshot owns this slot
const slot = `${d.toISOString().slice(0, 13)}-${d.getUTCMinutes()}`;
if (slot === lastRefreshSlot) return;
lastRefreshSlot = slot;
try {
const results = await refreshAll();
const touched = results.filter((r) => r.status === 'ok');
const revised = results.reduce((n, r) => n + (r.revised || 0), 0);
if (touched.length > 0) console.log(`[intraday] refresh — ${touched.length} sports, ${revised} public revisions`);
} catch (e) {
console.warn('[intraday] refresh failed:', e.message);
}
};
const interval = setInterval(() => { void tick(); void refreshTick(); }, 60_000);
if (interval.unref) interval.unref();
console.log(`[snapshotScheduler] armed — SNAPSHOT_CRON=${process.env.SNAPSHOT_CRON}, hours=${HOURS_UTC.join(',')} UTC`);
return { interval, tick };
console.log(`[snapshotScheduler] armed — SNAPSHOT_CRON=${process.env.SNAPSHOT_CRON}, hours=${HOURS_UTC.join(',')} UTC, intraday=${process.env.INTRADAY_REFRESH === '0' ? 'off' : `${REFRESH_MINUTES}m (slate hours)`}`);
return { interval, tick, refreshTick };
}
module.exports = { startSnapshotScheduler, HOURS_UTC, mostRecentExpectedSlot, isSnapshotOverdue };
+133
View File
@@ -0,0 +1,133 @@
// 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 — noonmidnight 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
});
});
+27
View File
@@ -17,6 +17,28 @@ export interface StripProp {
awaiting?: boolean; // not yet graded by a snapshot → "Awaiting next scan"
// Session 55 — settled outcome from the self-learning loop (once the game is final).
outcome?: { result: 'hit' | 'miss' | 'push' | string; actual?: number | null } | null;
// Session 60 (Phase 2.5) — intraday movement + public revision.
movement?: { kind: 'steam' | 'value' | 'against' | 'revised' | string; delta: number; currentLine: number } | null;
revisedFrom?: string | null;
}
/** Phase 2.5 movement chip: STEAM ▲ (market chasing), VALUE ▲ (better
* entry, read holds). Revisions render on the grade itself. */
function MovementChip({ p }: { p: StripProp }) {
const m = p.movement;
if (!m || (m.kind !== 'steam' && m.kind !== 'value')) return null;
const steam = m.kind === 'steam';
return (
<span
className="mono"
title={steam ? 'Market moved toward the graded side since lock' : 'Line moved against the grade — re-graded and the read holds: better entry'}
style={{ fontSize: 9.5, fontWeight: 800, letterSpacing: '0.06em', padding: '1px 5px', borderRadius: 3,
color: steam ? 'var(--amber, #FFB347)' : 'var(--g-a, #00D4A0)',
border: `1px solid ${steam ? 'rgba(255,179,71,.4)' : 'rgba(0,212,160,.4)'}` }}
>
{steam ? `STEAM ▲ ${m.delta > 0 ? '+' : ''}${m.delta}` : 'VALUE ▲ better entry'}
</span>
);
}
export interface StripArchetype {
primary: string;
@@ -232,7 +254,12 @@ export default function StatStrip({
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<div className="mono" style={{ display: 'inline-flex', alignItems: 'center', gap: 7, fontSize: 12, color: '#B8BCC8' }}>
<span style={{ color: '#fff' }}>{p.stat} {p.side}{p.line}</span>
{/* Phase 2.5 — a revised grade is PUBLIC: original struck through. */}
{p.revisedFrom && (
<span className="mono" title="Grade revised after the line moved against the read — original preserved" style={{ fontSize: 10.5, color: 'var(--text-2)', textDecoration: 'line-through' }}>{p.revisedFrom}</span>
)}
{p.grade && <GradeBadge grade={p.grade} size="sm" />}
<MovementChip p={p} />
<OutcomeChip p={p} />
{!p.outcome && <ParlayBtn p={p} />}
{!p.outcome && <BookItTeaser p={p} />}
+4
View File
@@ -316,6 +316,10 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
// Session 55 — settled outcome from the self-learning loop (hit/miss/push
// + actual stat) once the game completes. null until settled.
outcome: rec.outcome ? { result: rec.outcome.result, actual: rec.outcome.actual } : null,
// Session 60 (Phase 2.5) — intraday movement (STEAM/VALUE/revised)
// + the ORIGINAL grade when a public revision happened.
movement: rec.movement || null,
revisedFrom: rec.revised_from_grade || null,
});
} else {
byPlayer[pk].props.push({