// Replay — historical playback. Big timeline scrubber as centerpiece. const { Card, Pill, fmtTs, fmtNum, fmtPct } = window.UI; const RC2 = window.Recharts; // adapt a backend apiEvent to the page-shape `{id, at, kind, payload}`. // payload is delivered as a JSON RawMessage; parse it once so the row // renderer can read `payload.account`, `.coin`, `.score`, etc. function adaptReplayEvent(e) { let payload = {}; try { if (typeof e.payload === 'string') payload = JSON.parse(e.payload); else if (e.payload && typeof e.payload === 'object') payload = e.payload; } catch (_) { payload = {}; } return { id: e.id, at: e.at_ms, kind: e.kind, payload, }; } function PageReplay({ app }) { const accounts = app.accounts || []; const [accountId, setAccountId] = React.useState(app.account); React.useEffect(() => { setAccountId(app.account); }, [app.account]); const acct = accounts.find(a => a.id === accountId) || null; const accountID = acct && acct.accountID; const { data: rawEvents, error, loading, refresh } = window.API.useAPI( () => accountID ? window.API.get('/api/v1/replay/events', { account_id: accountID, limit: 2000 }) : Promise.resolve([]), [accountID], ); // No mock fallback: when the event log is empty (fresh DB / no // strategy run yet) the page renders an empty-state card. const events = (rawEvents && Array.isArray(rawEvents)) ? rawEvents.map(adaptReplayEvent) : []; const haveEvents = events.length >= 2; if (!haveEvents) { return (

replay

deterministic playback of recorded event log · {' '} {loading && loading…} {error && api error} {!loading && !error && no events recorded yet}

no events yet for {acct ? acct.id : 'this account'}.
the event log fills as the strategy + dispatcher loop emits decisions, order placements, and risk verdicts (M11.2). once that ships, every decision is replayable here at adjustable speed.
); } const tMin = events[0].at; const tMax = events[events.length-1].at; const span = Math.max(1, tMax - tMin); const [cursor, setCursor] = React.useState(tMin + span * 0.65); const [playing, setPlaying] = React.useState(false); const [speed, setSpeed] = React.useState(4); const [kindFilter, setKindFilter] = React.useState('all'); React.useEffect(() => { if (!playing) return; const id = setInterval(() => { setCursor(c => { const next = c + speed * 1000 * 0.5; if (next > tMax) { setPlaying(false); return tMax; } return next; }); }, 80); return () => clearInterval(id); }, [playing, speed, tMax]); const buckets = React.useMemo(() => { const N = 80; const bs = new Array(N).fill(0); events.forEach(e => { const idx = Math.min(N-1, Math.floor(((e.at - tMin) / span) * N)); bs[idx]++; }); const max = Math.max(1, ...bs); return bs.map(v => v / max); }, [events, tMin, span]); const allKinds = React.useMemo(() => Array.from(new Set(events.map(e => e.kind))), [events]); const streamed = events.filter(e => e.at <= cursor && (kindFilter === 'all' || e.kind === kindFilter)).slice(-20).reverse(); // Simulated state derived from events (M11.2 will reconstruct from // event_log replay). For now we use the last `trade_completed` event // before the cursor; equity sim shows a constant line until those // events are emitted. const lastTradeEv = [...events].reverse().find(e => e.at <= cursor && (e.kind === 'trade_completed' || e.kind === 'jump_confirmed')); const simCoin = (lastTradeEv && lastTradeEv.payload && lastTradeEv.payload.to_coin) || (acct && acct.current_coin) || '—'; const simEquity = []; // empty until event_log carries equity ticks const simEquityNow = 0; const simEquityStart = 0; return (

replay

deterministic playback of recorded event log · simulated state shown · {' '} {loading && loading…} {error && api error} {!loading && !error && live}

range {fmtTs(tMin)} {fmtTs(tMax)}
{/* Centerpiece scrubber */}
{/* heatmap row */}
{buckets.map((d, i) => { const cIdx = Math.floor(((cursor - tMin) / span) * buckets.length); const passed = i <= cIdx; const alpha = 0.15 + d * 0.7; const color = passed ? `rgba(95,207,149,${alpha})` : `rgba(122,174,216,${alpha * 0.55})`; return
; })}
{/* cursor line */}
{/* range axis */}
{[0, 0.25, 0.5, 0.75, 1].map(t => ( {fmtTs(tMin + span * t, { short: true })} ))}
{/* scrubber */} setCursor(+e.target.value)} className="w-full accent-amb-0 h-1"/> {/* controls */}
setCursor(tMin)} title="rewind"> setCursor(c => Math.max(tMin, c - 60000))}>−1m setPlaying(p => !p)} primary> {playing ? : } setCursor(c => Math.min(tMax, c + 60000))}>+1m setCursor(tMax)} title="end">
speed {[1,4,16,64].map(s => ( ))}
cursor {fmtTs(cursor)}
{/* Simulated dashboard widgets at cursor */}
simulated coin
{simCoin}
at cursor · {acct.bridge} bridge
simulated equity
{fmtNum(simEquityNow, 0)}
= simEquityStart ? 'text-pos-0' : 'text-neg-0'}`}> {fmtPct((simEquityNow - simEquityStart) / simEquityStart, 2)} window
last trade
{lastTrade?.from} {lastTrade?.to}
{lastTrade && fmtTs(lastTrade.ts)}
fmtTs(t,{short:true})} stroke="#3a424d" tick={{fontSize:10, fontFamily:'JetBrains Mono', fill:'#5b6573'}} axisLine={{stroke:'#1f2630'}} tickLine={false} minTickGap={50}/> fmtNum(v,0)} domain={['auto','auto']}/>
{/* Event log stream */} e.at <= cursor).length}`} padded={false} action={ }>
{streamed.map((e, i) => (
{fmtTs(e.at, { short: true })} {e.kind} {e.payload.account} {e.payload.coin} = 0 ? 'text-pos-0' : 'text-neg-0'}`}> {(e.payload.score * 100).toFixed(3)}% id={e.id}
))}
); } function CtrlBtn({ children, onClick, primary, title }) { return ( ); } window.PageReplay = PageReplay;