From 9ae64516e66fadb1222b8ab601081ac772ad7ce1 Mon Sep 17 00:00:00 2001 From: Kev Date: Sat, 11 Jul 2026 00:51:00 -0400 Subject: [PATCH] =?UTF-8?q?Session=20D=20(night2):=20Phase=202.5=20?= =?UTF-8?q?=E2=80=94=20intraday=20refresh=20+=20directional=20movement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/routes/internal.js | 17 ++ src/services/intradayRefreshService.js | 217 +++++++++++++++++++++++++ src/services/ledgerService.js | 23 +++ src/snapshotScheduler.js | 35 +++- tests/unit/intradayRefresh.test.js | 133 +++++++++++++++ web/src/components/vyndr/StatStrip.tsx | 27 +++ web/src/lib/slateAdapter.js | 4 + 7 files changed, 453 insertions(+), 3 deletions(-) create mode 100644 src/services/intradayRefreshService.js create mode 100644 tests/unit/intradayRefresh.test.js diff --git a/src/routes/internal.js b/src/routes/internal.js index df9b399..e83ce59 100644 --- a/src/routes/internal.js +++ b/src/routes/internal.js @@ -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. diff --git a/src/services/intradayRefreshService.js b/src/services/intradayRefreshService.js new file mode 100644 index 0000000..3fb29c9 --- /dev/null +++ b/src/services/intradayRefreshService.js @@ -0,0 +1,217 @@ +'use strict'; + +/** + * intradayRefreshService — Phase 2.5 (Session 60, night2/D). + * + * A lightweight ODDS-ONLY refresh during slate hours (~noon–midnight 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: noon–midnight 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 }, +}; diff --git a/src/services/ledgerService.js b/src/services/ledgerService.js index 68c4208..b507e01 100644 --- a/src/services/ledgerService.js +++ b/src/services/ledgerService.js @@ -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: { diff --git a/src/snapshotScheduler.js b/src/snapshotScheduler.js index 1b1f0a4..87e3dea 100644 --- a/src/snapshotScheduler.js +++ b/src/snapshotScheduler.js @@ -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 (noon–midnight 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 }; diff --git a/tests/unit/intradayRefresh.test.js b/tests/unit/intradayRefresh.test.js new file mode 100644 index 0000000..7be8e28 --- /dev/null +++ b/tests/unit/intradayRefresh.test.js @@ -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 — 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 + }); +}); diff --git a/web/src/components/vyndr/StatStrip.tsx b/web/src/components/vyndr/StatStrip.tsx index eddde53..4138475 100644 --- a/web/src/components/vyndr/StatStrip.tsx +++ b/web/src/components/vyndr/StatStrip.tsx @@ -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 ( + + {steam ? `STEAM ▲ ${m.delta > 0 ? '+' : ''}${m.delta}` : 'VALUE ▲ better entry'} + + ); } export interface StripArchetype { primary: string; @@ -232,7 +254,12 @@ export default function StatStrip({
{p.stat} {p.side}{p.line} + {/* Phase 2.5 — a revised grade is PUBLIC: original struck through. */} + {p.revisedFrom && ( + {p.revisedFrom} + )} {p.grade && } + {!p.outcome && } {!p.outcome && } diff --git a/web/src/lib/slateAdapter.js b/web/src/lib/slateAdapter.js index accdbc2..e4c08ba 100644 --- a/web/src/lib/slateAdapter.js +++ b/web/src/lib/slateAdapter.js @@ -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({