diff --git a/web/src/components/terminal/KlineChart.tsx b/web/src/components/terminal/KlineChart.tsx index 45c08910..a9a3b034 100644 --- a/web/src/components/terminal/KlineChart.tsx +++ b/web/src/components/terminal/KlineChart.tsx @@ -3,6 +3,9 @@ import useSWR from 'swr' import { api } from '../../lib/api' import type { Kline } from '../../lib/api/data' import { Candles } from './Candles' +import { demoSeedPrice } from '../../lib/demo/demoUniverse' + +const drnd = (a: number, b: number) => a + Math.random() * (b - a) /** * KlineChart shows a live candlestick chart. History is seeded from the backend @@ -29,18 +32,87 @@ interface KlineChartProps { height?: number /** stretch the chart to fill the parent's remaining height */ fill?: boolean + /** showcase mode — drive a fast synthetic uptrend instead of the live feed */ + demo?: boolean } -export function KlineChart({ symbol, height = 360, fill = false }: KlineChartProps) { +export function KlineChart({ symbol, height = 360, fill = false, demo = false }: KlineChartProps) { const base = baseSymbol(symbol || '') // history seed (resynced occasionally; the WS carries the live bar) const { data: seed, isLoading } = useSWR( - base ? ['kline', base, INTERVAL] : null, + base && !demo ? ['kline', base, INTERVAL] : null, () => api.getKlines(base, INTERVAL, 'hyperliquid', MAX_BARS, true), { refreshInterval: 60000, revalidateOnFocus: false, shouldRetryOnError: false, keepPreviousData: true }, ) + // synthetic showcase candles — a fast, gently rising series + const [demoCandles, setDemoCandles] = useState([]) + useEffect(() => { + if (!demo || !base) return + const target = demoSeedPrice(base) + // A deliberately clean rising series: linear climb + a couple of gentle waves + // (natural pullbacks) + tiny noise and small wicks. The last close lands on + // `target` so it stays aligned with the order book + cost/liq mark. No bar + // rolling (which degraded into flat noise) — the forming bar ticks live and + // the whole series subtly refreshes occasionally. + const buildSeries = (): Kline[] => { + const now = Date.now() + const start = target * (1 - drnd(0.013, 0.02)) + const span = target - start + const phase = drnd(0, Math.PI * 2) + const bars: Kline[] = [] + let prevClose = start + for (let i = 0; i < MAX_BARS; i++) { + const t01 = i / (MAX_BARS - 1) + const close = start + span * t01 + span * 0.13 * Math.sin(t01 * 6.5 + phase) + (Math.random() - 0.5) * target * 0.0008 + const open = i === 0 ? start : prevClose + const wick = target * drnd(0.0003, 0.0009) + const tt = now - (MAX_BARS - i) * 60_000 + bars.push({ + openTime: tt, + closeTime: tt + 60_000, + open, + high: Math.max(open, close) + wick, + low: Math.min(open, close) - wick, + close, + volume: drnd(60, 360), + }) + prevClose = close + } + const last = bars[bars.length - 1] + last.close = target + last.high = Math.max(last.high, target) + last.low = Math.min(last.low, target) + return bars + } + setDemoCandles(buildSeries()) + let frame = 0 + const id = setInterval(() => { + frame++ + if (frame % 100 === 0) { + setDemoCandles(buildSeries()) // subtle full refresh (~25s) + return + } + setDemoCandles((prev) => { + if (!prev.length) return prev + const arr = prev.slice() + const i = arr.length - 1 + const last = { ...arr[i] } + // forming bar ticks gently around the current price; clean fixed-size wick + const close = target * (1 + drnd(-0.0009, 0.001)) + const wick = target * 0.0006 + last.close = close + last.high = Math.max(last.open, close) + wick + last.low = Math.min(last.open, close) - wick + last.volume += drnd(2, 14) + arr[i] = last + return arr + }) + }, 250) + return () => clearInterval(id) + }, [demo, base]) + // resolve the Hyperliquid coin id (xyz: dex membership) const [xyzSet, setXyzSet] = useState>(new Set()) useEffect(() => { @@ -65,7 +137,7 @@ export function KlineChart({ symbol, height = 360, fill = false }: KlineChartPro const [wsLive, setWsLive] = useState(false) const pending = useRef(null) useEffect(() => { - if (!coin) return + if (!coin || demo) return setLiveBar(null) let ws: WebSocket | null = null let raf: number | null = null @@ -117,7 +189,7 @@ export function KlineChart({ symbol, height = 360, fill = false }: KlineChartPro }, [coin]) // merge the live bar into the seeded history - const candles = useMemo(() => { + const realCandles = useMemo(() => { const hist = seed ?? [] if (!liveBar) return hist const arr = [...hist] @@ -127,10 +199,11 @@ export function KlineChart({ symbol, height = 360, fill = false }: KlineChartPro return arr.slice(-MAX_BARS) }, [seed, liveBar]) + const candles = demo ? demoCandles : realCandles const last = candles.length ? candles[candles.length - 1].close : 0 const first = candles.length ? candles[0].open : 0 const chg = first ? ((last - first) / first) * 100 : 0 - const live = wsLive && candles.length > 0 + const live = (demo || wsLive) && candles.length > 0 return (
diff --git a/web/src/components/terminal/LiquidationMap.tsx b/web/src/components/terminal/LiquidationMap.tsx index 324f0129..b6da3e40 100644 --- a/web/src/components/terminal/LiquidationMap.tsx +++ b/web/src/components/terminal/LiquidationMap.tsx @@ -2,6 +2,9 @@ import { useEffect, useMemo, useRef, useState } from 'react' import useSWR from 'swr' import { api } from '../../lib/api' import type { VergexHeatmapBin } from '../../lib/api/data' +import { demoSeedPrice, demoTick } from '../../lib/demo/demoUniverse' + +const lmRnd = (a: number, b: number) => a + Math.random() * (b - a) /** * LiquidationMap renders the vergex (claw402) cost / liquidation heatmap as a @@ -44,9 +47,11 @@ interface LiquidationMapProps { marketType?: string /** fixed height of the scrollable ladder (px); auto-centres on the mark */ height?: number + /** showcase mode — render a synthetic ladder centred on the demo seed price */ + demo?: boolean } -export function LiquidationMap({ symbol, marketType = 'hip3_perp', height = 460 }: LiquidationMapProps) { +export function LiquidationMap({ symbol, marketType = 'hip3_perp', height = 460, demo = false }: LiquidationMapProps) { // Synthetic markets live under marketType "hip3_perp"; crypto majors under // "perp". We try the caller's guess first and fall back to the other so the // heatmap resolves for ANY symbol that has one. @@ -54,15 +59,68 @@ export function LiquidationMap({ symbol, marketType = 'hip3_perp', height = 460 api.getVergexCostLiquidationHeatmap({ marketType: mt, symbol, chain: 'mainnet', liqBand: '15' }) const opts = { refreshInterval: 300000, revalidateOnFocus: false, keepPreviousData: true } - const primary = useSWR(symbol ? ['heatmap', marketType, symbol] : null, () => fetcher(marketType), opts) + const primary = useSWR(symbol && !demo ? ['heatmap', marketType, symbol] : null, () => fetcher(marketType), opts) const primaryHasBins = !!primary.data?.data?.bins?.length const altMt = marketType === 'perp' ? 'hip3_perp' : 'perp' - const needAlt = !primaryHasBins && !primary.isLoading && primary.data !== undefined + const needAlt = !demo && !primaryHasBins && !primary.isLoading && primary.data !== undefined const alt = useSWR(needAlt && symbol ? ['heatmap', altMt, symbol] : null, () => fetcher(altMt), opts) - const data = primaryHasBins ? primary.data : alt.data - const isLoading = primary.isLoading || (needAlt && alt.isLoading) - const error = primaryHasBins ? undefined : alt.error || primary.error + // showcase mode: drive a slow ticker so the synthetic ladder gently breathes + const [demoFrame, setDemoFrame] = useState(0) + useEffect(() => { + if (!demo) return + const id = setInterval(() => setDemoFrame((f) => f + 1), 420) + return () => clearInterval(id) + }, [demo]) + + // stable base ladder for the active symbol (regenerated only on symbol change), + // centred on the same seed price the order book / candles use so all three + // price panels stay consistent. + const demoBase = useMemo(() => { + if (!demo) return null + const base = (symbol || 'SP500').toUpperCase().replace(/^XYZ:/, '') + const mark = demoSeedPrice(base) + const tick = demoTick(mark) + const N = 44 + const scale = mark * 1.4e4 + const bins = [] as { px: number; lc: number; sc: number; ll: number; sl: number }[] + for (let i = -N / 2; i <= N / 2; i++) { + const px = +(mark + i * tick * 2).toFixed(tick < 1 ? 3 : 1) + const dist = Math.abs(i) / (N / 2) + const near = Math.max(0, 1 - dist) ** 1.4 + const far = dist ** 1.3 + const below = i < 0 + bins.push({ + px, + lc: (below ? near : near * 0.18) * scale * lmRnd(0.5, 1), + sc: (!below ? near : near * 0.18) * scale * lmRnd(0.5, 1), + ll: (below ? far : 0) * scale * lmRnd(0.4, 0.9), + sl: (!below ? far : 0) * scale * lmRnd(0.4, 0.9), + }) + } + return { base, mark, bins, costAddrs: Math.round(lmRnd(22000, 31000)), liqAddrs: Math.round(lmRnd(16000, 23000)) } + }, [demo, symbol]) + + // per-frame view: each bin breathes on its own phase (gentle ±, not a refresh) + const demoData = useMemo(() => { + if (!demoBase) return undefined + const f = demoFrame + const w = (v: number, amp: number, ph: number) => v * (1 + amp * Math.sin(f * 0.5 + ph)) + const bins: VergexHeatmapBin[] = demoBase.bins.map((b, i) => ({ + px: b.px, + longCost: w(b.lc, 0.12, i * 0.6), + shortCost: w(b.sc, 0.12, i * 0.9 + 1.7), + longLiq: w(b.ll, 0.16, i * 0.5 + 3.1), + shortLiq: w(b.sl, 0.16, i * 0.8 + 4.6), + }) as unknown as VergexHeatmapBin) + return { + data: { bins, markPrice: demoBase.mark, costAddrs: demoBase.costAddrs, liqAddrs: demoBase.liqAddrs, market: { symbol: demoBase.base } }, + } + }, [demoBase, demoFrame]) + + const data = demo ? demoData : primaryHasBins ? primary.data : alt.data + const isLoading = demo ? false : primary.isLoading || (needAlt && alt.isLoading) + const error = demo ? undefined : primaryHasBins ? undefined : alt.error || primary.error const [hover, setHover] = useState(null) const scrollRef = useRef(null) diff --git a/web/src/components/terminal/OrchestrationTopology.tsx b/web/src/components/terminal/OrchestrationTopology.tsx index 6f410ee7..2b8d3225 100644 --- a/web/src/components/terminal/OrchestrationTopology.tsx +++ b/web/src/components/terminal/OrchestrationTopology.tsx @@ -45,6 +45,14 @@ function baseSymbol(raw: string): string { return raw.toUpperCase().replace(/^XYZ:/, '').replace(/[-_]/g, '').replace(/(USDT|USDC|USD)$/, '') } +// stable pseudo-random in [0,1) from a string — gives each beam its own phase so +// the funnel flickers randomly (not in lockstep) while staying deterministic. +function hash01(s: string): number { + let h = 2166136261 + for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619) + return ((h >>> 0) % 10000) / 10000 +} + // evenly spread `count` solid items across `capacity` grid cells function scatter(count: number, capacity: number): Map { const m = new Map() @@ -175,20 +183,30 @@ export function OrchestrationTopology({ layers, className }: OrchestrationTopolo ))} - {engineTargets.map((n, i) => ( - - - - - - ))} - {edges.map((e, i) => ( - - - - - - ))} + {engineTargets.map((n, i) => { + const r = hash01(`e${i}-${n.x}-${n.y}`) + const dur = `${(0.42 + r * 0.55).toFixed(2)}s` + const begin = `${(r * 1.4).toFixed(2)}s` + return ( + + + + + + ) + })} + {edges.map((e) => { + const r = hash01(e.key) + const dur = `${(0.38 + r * 0.6).toFixed(2)}s` + const begin = `${(r * 1.6).toFixed(2)}s` + return ( + + + + + + ) + })} {cellsByLayer.map((layer, li) => layer.map((cell, ci) => { diff --git a/web/src/components/terminal/OrderBook.tsx b/web/src/components/terminal/OrderBook.tsx index 59a9d355..e0e3fc14 100644 --- a/web/src/components/terminal/OrderBook.tsx +++ b/web/src/components/terminal/OrderBook.tsx @@ -1,4 +1,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' +import { demoSeedPrice, demoTick } from '../../lib/demo/demoUniverse' + +const rnd = (a: number, b: number) => a + Math.random() * (b - a) /** * OrderBook renders a live L2 depth ladder for a single instrument, streamed @@ -55,9 +58,11 @@ interface OrderBookProps { symbol: string /** optional entry price to mark the user's position level on the ladder */ markPrice?: number + /** showcase mode — drive a fast synthetic book instead of the live WS feed */ + demo?: boolean } -export function OrderBook({ symbol, markPrice }: OrderBookProps) { +export function OrderBook({ symbol, markPrice, demo = false }: OrderBookProps) { const base = useMemo(() => baseSymbol(symbol || ''), [symbol]) const [xyzSet, setXyzSet] = useState>(new Set()) const [book, setBook] = useState(null) @@ -88,10 +93,48 @@ export function OrderBook({ symbol, markPrice }: OrderBookProps) { const coin = useMemo(() => resolveCoin(base, xyzSet), [base, xyzSet]) + // synthetic showcase feed — keeps price levels stable for stretches (so each + // row flashes independently as its size changes) with a gentle upward drift. + useEffect(() => { + if (!demo || !base) return + const seed = demoSeedPrice(base) + const tickSz = demoTick(seed) + const dp = tickSz < 1 ? 3 : 1 + let mid = seed + let frame = 0 + const mkSizes = () => + Array.from({ length: DEPTH }, (_, i) => +(rnd(0.4, 6) * (1 + i * 0.12)).toFixed(3)) + const askSz = mkSizes() + const bidSz = mkSizes() + const emit = () => { + const asks: Level[] = askSz.map((sz, i) => ({ px: +(mid + tickSz * (i + 1)).toFixed(dp), sz })) + const bids: Level[] = bidSz.map((sz, i) => ({ px: +(mid - tickSz * (i + 1)).toFixed(dp), sz })) + setBook({ coin: `xyz:${base}`, bids, asks }) + } + setStatus('live') + emit() + const id = setInterval(() => { + frame++ + const n = 2 + Math.floor(Math.random() * 3) + for (let k = 0; k < n; k++) { + const arr = Math.random() < 0.5 ? askSz : bidSz + const i = Math.floor(Math.random() * DEPTH) + arr[i] = +Math.max(0.05, arr[i] * rnd(0.6, 1.5)).toFixed(3) + } + // gentle mean-reverting wiggle around the seed (NO unbounded drift, so the + // order book stays aligned with the cost/liq map + candle over a long run) + if (frame % 5 === 0) { + mid = +(mid + (seed - mid) * 0.3 + (Math.random() - 0.5) * tickSz * 2).toFixed(dp) + } + emit() + }, 130) + return () => clearInterval(id) + }, [demo, base]) + // live L2 stream const pending = useRef(null) useEffect(() => { - if (!coin) return + if (!coin || demo) return let ws: WebSocket | null = null let raf: number | null = null let retry: ReturnType | null = null diff --git a/web/src/components/terminal/TerminalDashboard.tsx b/web/src/components/terminal/TerminalDashboard.tsx index 1a014eec..ba1f5acc 100644 --- a/web/src/components/terminal/TerminalDashboard.tsx +++ b/web/src/components/terminal/TerminalDashboard.tsx @@ -17,6 +17,7 @@ import { KlineChart } from './KlineChart' import { ExecutionLog } from './ExecutionLog' import { SignalMatrix } from './SignalMatrix' import { RiskRadar } from './RiskRadar' +import { useDemoEngine } from '../../lib/demo/useDemoEngine' // crypto majors trade on the Hyperliquid main dex (no hip3 cost/liq heatmap); // everything else in the universe is an xyz-dex synthetic market that does. @@ -88,50 +89,77 @@ export function TerminalDashboard({ traders, selectedTraderId, onTraderSelect, - status, - account, - positions, - decisions, + status: propStatus, + account: propAccount, + positions: propPositions, + decisions: propDecisions, }: TerminalDashboardProps) { const traderId = selectedTrader?.trader_id || selectedTraderId useTick(1000) const clock = new Date().toLocaleTimeString('en-GB', { hour12: false }) - const { data: fullStats } = useSWR( + const { data: realFullStats } = useSWR( traderId ? ['full-stats', traderId] : null, () => api.getFullStats(traderId!, true), { refreshInterval: 30000, shouldRetryOnError: false } ) - const { data: history } = useSWR( + const { data: realHistory } = useSWR( traderId ? ['pos-history', traderId] : null, () => api.getPositionHistory(traderId!, 50, true), { refreshInterval: 60000, shouldRetryOnError: false } ) - const { data: config } = useSWR( + const { data: realConfig } = useSWR( traderId ? ['trader-config', traderId] : null, () => api.getTraderConfig(traderId!, true), { refreshInterval: 120000, shouldRetryOnError: false } ) - - const latest = decisions && decisions.length > 0 ? decisions[0] : undefined - const candidateCoins = latest?.candidate_coins ?? [] - - const { data: flow } = useSWR( + const { data: realFlow } = useSWR( traderId ? ['flow-markets', traderId] : null, () => api.getFlowMarkets(selectedTrader?.ai_model, 'mainnet', '1h', 50, true), // paid x402 endpoint — poll slowly (5m) to conserve claw402 funds; the // topology beam animation is client-side and stays fast regardless { refreshInterval: 300000, shouldRetryOnError: false } ) - const flowItems = flow?.data?.inflow ?? [] - - const { data: signalRank } = useSWR( + const { data: realSignalRank } = useSWR( traderId ? ['signal-rank', traderId] : null, () => api.getSignalRanking(selectedTrader?.ai_model, 'mainnet', 'all', 30, true), // paid x402 endpoint — poll slowly (5m) to conserve claw402 funds { refreshInterval: 300000, shouldRetryOnError: false } ) + // Demo / showcase mode for product walkthroughs. Toggle with Shift+D (or the + // discreet corner dot). Generates a fast, profitable-looking US-equity dataset + // entirely client-side — it never touches the backend or any real account. + // When off, real data flows through unchanged. + const [demo, setDemo] = useState(false) + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.shiftKey && (e.key === 'D' || e.key === 'd')) { + const el = document.activeElement + if (el && /^(input|textarea|select)$/i.test(el.tagName)) return + setDemo((v) => !v) + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, []) + const sim = useDemoEngine(demo) + const on = demo && !!sim + + const status = on ? sim!.status : propStatus + const account = on ? sim!.account : propAccount + const positions = on ? sim!.positions : propPositions + const decisions = on ? sim!.decisions : propDecisions + const fullStats = on ? sim!.fullStats : realFullStats + const history = on ? sim!.history : realHistory + const config = on ? (sim!.config as unknown as typeof realConfig) : realConfig + const flow = on ? sim!.flow : realFlow + const signalRank = on ? sim!.signalRank : realSignalRank + + const latest = decisions && decisions.length > 0 ? decisions[0] : undefined + const candidateCoins = latest?.candidate_coins ?? [] + const flowItems = flow?.data?.inflow ?? [] + // Both the cost/liq map and the order book follow this symbol so they stay in // sync. The heatmap only covers hip3_perp synthetic markets, so we pick a // synthetic (non-crypto) the bot trades — preferring the BUSIEST one (most @@ -236,6 +264,26 @@ export function TerminalDashboard({ return (
+ {/* discreet, unlabelled showcase toggle (Shift+D also works) */} +
- baseLabel(p.symbol) === activeSym)?.entry_price} /> + baseLabel(p.symbol) === activeSym)?.entry_price} />
- + {/* the live K-line always sits under the selector and flexes to fill */}
- +
diff --git a/web/src/lib/demo/demoUniverse.ts b/web/src/lib/demo/demoUniverse.ts new file mode 100644 index 00000000..5b7bb146 --- /dev/null +++ b/web/src/lib/demo/demoUniverse.ts @@ -0,0 +1,47 @@ +/** + * Shared constants for the dashboard demo/showcase mode. US-equity-led synthetic + * universe (these are real xyz-dex markets so the cost/liq heatmap still resolves) + * plus plausible seed prices for the synthetic order book / candle feeds. + * + * This drives the "Demo" presentation mode only. It never touches the backend, + * the live trader, or any real account — it is purely a client-side animation + * layer for product walkthroughs. + */ + +export const DEMO_UNIVERSE = [ + // longs (bullish book) + 'SP500', 'NVDA', 'MU', 'GOOGL', 'TSM', 'META', 'AMD', 'AAPL', 'MSFT', 'AMZN', + 'NFLX', 'TSLA', 'AVGO', 'NBIS', 'XYZ100', 'PLTR', 'TXN', 'LRCX', 'AMAT', 'MRVL', + // short book (bearish names — see SHORT_SET in the engine) + 'INTC', 'SPCX', 'SKHX', 'SMCI', 'ARM', 'QCOM', 'COIN', 'ORCL', 'DRAM', 'CRM', + 'HOOD', 'SNOW', +] + +// Lead instrument for the price panels (real heatmap + resolvable book/candles). +export const DEMO_ACTIVE_SYMBOL = 'SP500' + +const DEMO_SEED_PX: Record = { + SP500: 6485, NVDA: 184.2, MU: 142.6, GOOGL: 351.8, TSM: 451.3, META: 723.5, + AMD: 168.4, AAPL: 245.9, MSFT: 498.2, AMZN: 228.4, NFLX: 921.5, TSLA: 412.8, + AVGO: 358.1, NBIS: 266.1, XYZ100: 1182, PLTR: 78.4, TXN: 205.3, LRCX: 102.6, + AMAT: 218.7, MRVL: 118.2, INTC: 41.2, SPCX: 64.3, SKHX: 88.7, SMCI: 44.1, + ARM: 162.4, QCOM: 178.9, COIN: 312.5, ORCL: 192.3, DRAM: 72.4, CRM: 342.1, + HOOD: 58.6, SNOW: 198.4, +} + +/** Stable plausible seed price for a base symbol (deterministic fallback). */ +export function demoSeedPrice(base: string): number { + const b = base.toUpperCase().replace(/^XYZ:/, '') + if (DEMO_SEED_PX[b]) return DEMO_SEED_PX[b] + let h = 0 + for (let i = 0; i < b.length; i++) h = (h * 31 + b.charCodeAt(i)) % 100000 + return 40 + (h % 900) +} + +/** Reasonable tick size for a price level given its magnitude. */ +export function demoTick(px: number): number { + if (px >= 1000) return 1 + if (px >= 100) return 0.1 + if (px >= 10) return 0.05 + return 0.01 +} diff --git a/web/src/lib/demo/useDemoEngine.ts b/web/src/lib/demo/useDemoEngine.ts new file mode 100644 index 00000000..89c8bb48 --- /dev/null +++ b/web/src/lib/demo/useDemoEngine.ts @@ -0,0 +1,456 @@ +import { useEffect, useRef, useState } from 'react' +import type { + AccountInfo, + Position, + DecisionRecord, + SystemStatus, + TraderFullStats, + PositionHistoryResponse, + HistoricalPosition, + SymbolStats, +} from '../../types' +import type { + FlowMarketsResponse, + FlowMarketItem, + SignalRankingResponse, + SignalRankItem, +} from '../api/data' +import { DEMO_UNIVERSE, DEMO_ACTIVE_SYMBOL, demoSeedPrice } from './demoUniverse' + +/** + * useDemoEngine — a client-side showcase data generator. When `active`, it + * synthesises a fast-evolving, profitable-looking US-equity trading dataset that + * mirrors the real dashboard data shapes, so every panel animates for a product + * walkthrough. Returns null when inactive (the dashboard then uses real data). + * + * No network, no backend, no real account — pure presentation layer. + */ + +const TICK_MS = 200 +const INITIAL = 1_000_000 + +export interface DemoDataset { + status: SystemStatus + account: AccountInfo + positions: Position[] + decisions: DecisionRecord[] + fullStats: TraderFullStats + history: PositionHistoryResponse + config: { + scan_interval_minutes: number + ai_model: string + strategy_name: string + btc_eth_leverage: number + altcoin_leverage: number + } + flow: FlowMarketsResponse + signalRank: SignalRankingResponse + activeSymbol: string +} + +interface PosState { + symbol: string + side: 'long' | 'short' + entry: number + mark: number + qty: number + lev: number +} +interface TradeState { + id: number + symbol: string + side: 'long' | 'short' + entry: number + exit: number + qty: number + pnl: number + fee: number + lev: number +} + +interface SimState { + frame: number + cycle: number + realized: number + fee: number + wins: number + losses: number + grossWin: number + grossLoss: number + nextId: number + decisionTs: number + positions: PosState[] + trades: TradeState[] + // per-symbol flow noise so the bars jiggle independently + flowNet: Record + signalScore: Record +} + +const rnd = (a: number, b: number) => a + Math.random() * (b - a) +const pick = (arr: T[]): T => arr[Math.floor(Math.random() * arr.length)] + +// A fixed "short book" — these symbols are short EVERYWHERE (flow outflow, +// bearish signal, short positions, decision candidates) so the topology's +// short row carries connected flow lines through every layer. The rest are long. +const SHORT_SET = new Set([ + 'INTC', 'SPCX', 'SKHX', 'SMCI', 'ARM', 'QCOM', 'COIN', 'ORCL', 'DRAM', 'CRM', 'HOOD', 'SNOW', +]) +const LONG_POOL = DEMO_UNIVERSE.filter((s) => !SHORT_SET.has(s)) + +function sideFor(symbol: string): 'long' | 'short' { + return SHORT_SET.has(symbol.toUpperCase()) ? 'short' : 'long' +} + +function newPosition(symbol: string): PosState { + const entry = demoSeedPrice(symbol) * rnd(0.985, 1.015) + const side = sideFor(symbol) + const lev = pick([10, 10, 15, 20, 20]) + const notional = rnd(35_000, 110_000) + return { symbol, side, entry, mark: entry, qty: notional / entry, lev } +} + +function initState(): SimState { + // index 0 = lead (drives the price panels). Then a deep US-equity book — a + // long bias plus a sizable short book so the topology fills both rows densely. + const longs = [DEMO_ACTIVE_SYMBOL, 'NVDA', 'GOOGL', 'TSM', 'META', 'AMD', 'MSFT', 'AMZN', 'AVGO'] + const shorts = ['INTC', 'SPCX', 'SKHX', 'SMCI', 'ARM', 'QCOM'] + const positions = [...longs, ...shorts].map((s) => { + const p = newPosition(s) + // start each slightly in profit so the board opens green + const fav = rnd(0.006, 0.03) + p.mark = p.side === 'long' ? p.entry * (1 + fav) : p.entry * (1 - fav) + return p + }) + const flowNet: Record = {} + const signalScore: Record = {} + DEMO_UNIVERSE.forEach((s, i) => { + flowNet[s] = rnd(120_000, 2_400_000) * (1 - i / (DEMO_UNIVERSE.length + 4)) + // short book scores negative (bearish), everything else positive (bullish) + signalScore[s] = SHORT_SET.has(s) ? rnd(-1.6, -0.4) : rnd(0.4, 2.0) + }) + return { + frame: 0, + cycle: 1, + realized: 312_000, + fee: 2840, + wins: 412, + losses: 214, + grossWin: 690_000, + grossLoss: 286_000, + nextId: 5000, + decisionTs: Date.now(), + positions, + trades: [], + flowNet, + signalScore, + } +} + +function upnl(p: PosState): number { + const notional = p.qty * p.entry + const move = (p.mark - p.entry) / p.entry + return notional * move * (p.side === 'long' ? 1 : -1) +} + +function step(S: SimState) { + S.frame++ + + // drift each position's mark with a favourable bias so the book trends green + for (const p of S.positions) { + const bias = p.side === 'long' ? 0.00028 : -0.00028 + p.mark *= 1 + bias + rnd(-0.0011, 0.0011) + // keep within a believable favourable band of entry + const move = (p.mark - p.entry) / p.entry + const dir = p.side === 'long' ? 1 : -1 + const signed = move * dir + if (signed < -0.014) p.mark = p.entry * (1 - dir * 0.014) + if (signed > 0.075) p.mark = p.entry * (1 + dir * 0.075) + } + + // roll a winning trade every ~16 frames. Only rotate a non-lead LONG slot: + // index 0 (lead, drives price panels) and the short book stay fixed so the + // order book stays on one symbol and the topology keeps its short flow lines. + const rotatable = S.positions + .map((p, i) => ({ p, i })) + .filter((x) => x.i !== 0 && x.p.side === 'long') + if (S.frame % 16 === 0 && rotatable.length) { + let bestIdx = rotatable[0].i + for (const x of rotatable) { + if (upnl(x.p) > upnl(S.positions[bestIdx])) bestIdx = x.i + } + const isWin = Math.random() < 0.7 + const closed = S.positions[bestIdx] + const raw = upnl(closed) + const pnl = isWin ? Math.max(Math.abs(raw), rnd(600, 5200)) : -rnd(250, 2100) + const fee = rnd(12, 95) + S.trades.unshift({ + id: S.nextId++, + symbol: closed.symbol, + side: closed.side, + entry: closed.entry, + exit: closed.mark, + qty: closed.qty, + pnl, + fee, + lev: closed.lev, + }) + if (S.trades.length > 40) S.trades.pop() + S.realized += pnl - fee + S.fee += fee + if (pnl >= 0) { + S.wins++ + S.grossWin += pnl + } else { + S.losses++ + S.grossLoss += -pnl + } + // reopen a fresh LONG US-equity position (avoid duplicates of current book) + const held = new Set(S.positions.map((p) => p.symbol)) + const candidates = LONG_POOL.filter((s) => !held.has(s)) + S.positions[bestIdx] = newPosition(candidates.length ? pick(candidates) : closed.symbol) + } + + // new orchestration cycle every ~24 frames + if (S.frame % 24 === 0) { + S.cycle++ + S.decisionTs = Date.now() + } + + // jiggle flow + signal noise so those panels stay alive (short book stays + // bearish, longs stay bullish — keeps signal/topology directions consistent) + for (const s of DEMO_UNIVERSE) { + S.flowNet[s] = Math.max(20_000, S.flowNet[s] * rnd(0.97, 1.035)) + const next = S.signalScore[s] + rnd(-0.08, 0.09) + S.signalScore[s] = SHORT_SET.has(s) + ? Math.max(-2, Math.min(-0.1, next)) + : Math.max(0.1, Math.min(2.4, next)) + } +} + +function build(S: SimState): DemoDataset { + const liveUpnl = S.positions.reduce((s, p) => s + upnl(p), 0) + const equity = INITIAL + S.realized + liveUpnl + const pnl = equity - INITIAL + const marginUsed = S.positions.reduce((s, p) => s + (p.qty * p.entry) / p.lev, 0) + const total = S.wins + S.losses + + const account = { + total_equity: equity, + wallet_balance: equity - liveUpnl, + unrealized_profit: liveUpnl, + total_unrealized_profit: liveUpnl, // RiskRadar reads this extra field + available_balance: Math.max(0, equity - marginUsed), + total_pnl: pnl, + total_pnl_pct: (pnl / INITIAL) * 100, + initial_balance: INITIAL, + daily_pnl: S.realized + liveUpnl, + position_count: S.positions.length, + margin_used: marginUsed, + margin_used_pct: Math.min(82, (marginUsed / equity) * 100 * 1.6), + } as unknown as AccountInfo + + const positions: Position[] = S.positions.map((p) => { + const u = upnl(p) + const notional = p.qty * p.entry + const margin = notional / p.lev + const liq = p.side === 'long' ? p.entry * (1 - 0.9 / p.lev) : p.entry * (1 + 0.9 / p.lev) + return { + symbol: p.symbol, + side: p.side, + entry_price: p.entry, + mark_price: p.mark, + quantity: p.qty, + leverage: p.lev, + unrealized_pnl: u, + unrealized_pnl_pct: (u / margin) * 100, + liquidation_price: liq, + margin_used: margin, + } + }) + + const winRate = total > 0 ? (S.wins / total) * 100 : 0 + const avgWin = S.wins > 0 ? S.grossWin / S.wins : 0 + const avgLoss = S.losses > 0 ? S.grossLoss / S.losses : 0 + const fullStats: TraderFullStats = { + total_trades: total, + win_trades: S.wins, + loss_trades: S.losses, + win_rate: winRate, + profit_factor: S.grossLoss > 0 ? S.grossWin / S.grossLoss : S.grossWin, + sharpe_ratio: 2.05 + Math.sin(S.frame / 90) * 0.12, + total_pnl: pnl, + total_fee: S.fee, + avg_win: avgWin, + avg_loss: avgLoss, + max_drawdown_pct: 0.052, + } + + // recent closed trades + const histPositions = S.trades.map( + (t) => + ({ + id: t.id, + symbol: t.symbol, + side: t.side, + quantity: t.qty, + entry_price: t.entry, + exit_price: t.exit, + exit_time: new Date(S.decisionTs - (S.nextId - t.id) * 47_000).toISOString(), + realized_pnl: t.pnl, + fee: t.fee, + leverage: t.lev, + status: 'closed', + close_reason: t.pnl >= 0 ? 'take_profit' : 'stop_loss', + }) as unknown as HistoricalPosition, + ) + + // per-symbol aggregates + const bySym = new Map() + for (const t of S.trades) { + const e = bySym.get(t.symbol) || { n: 0, w: 0, pnl: 0 } + e.n++ + if (t.pnl >= 0) e.w++ + e.pnl += t.pnl + bySym.set(t.symbol, e) + } + const symbolStats: SymbolStats[] = [...bySym.entries()] + .map(([symbol, e]) => ({ + symbol, + total_trades: e.n, + win_trades: e.w, + win_rate: e.n > 0 ? (e.w / e.n) * 100 : 0, + total_pnl: e.pnl, + avg_pnl: e.n > 0 ? e.pnl / e.n : 0, + avg_hold_mins: Math.round(rnd(6, 38)), + })) + .sort((a, b) => b.total_trades - a.total_trades) + + const history = { + positions: histPositions, + stats: null, + symbol_stats: symbolStats, + direction_stats: [], + } as unknown as PositionHistoryResponse + + // flow markets — long names show net BUYING (inflow), the short book shows net + // SELLING (outflow) so the topology's FLOW layer feeds the short row too. + const mkItem = (s: string, net: number): FlowMarketItem => { + const buyShare = net >= 0 ? 0.56 + Math.min(0.3, net / 6_000_000) : 0.4 + const gross = Math.abs(net) + return { + key: `xyz:${s}`, + marketType: 'hip3_perp', + symbol: s, + netFlow: String(Math.round(net)), + buyNotional: String(Math.round(gross * buyShare)), + sellNotional: String(Math.round(gross * (1 - buyShare))), + trades: Math.round(rnd(150, 9000)), + latestPrice: String(demoSeedPrice(s)), + } + } + const inflow: FlowMarketItem[] = LONG_POOL.slice() + .sort((a, b) => S.flowNet[b] - S.flowNet[a]) + .map((s) => mkItem(s, S.flowNet[s])) + const outflow: FlowMarketItem[] = [...SHORT_SET].map((s) => mkItem(s, -Math.abs(S.flowNet[s]) * 0.6)) + const flow: FlowMarketsResponse = { data: { by: 'netFlow', window: '1h', inflow, outflow } } + + // signal ranking — mostly bullish US equities + const ranked = [...DEMO_UNIVERSE].sort((a, b) => S.signalScore[b] - S.signalScore[a]) + const items: SignalRankItem[] = ranked.map((s, i) => { + const score = S.signalScore[s] + return { + rank: i + 1, + symbol: s, + market_type: 'hip3_perp', + bias: SHORT_SET.has(s) ? 'bearish' : 'bullish', + score: Math.round(score * 100) / 100, + category: 'us_equity', + } + }) + const signalRank: SignalRankingResponse = { items } + + // decision candidates — top longs plus the short book, so the DECISION layer + // (and execution log) carries shorts through to EXECUTE/HOLD. + const candidates = [...new Set([...ranked.slice(0, 8), ...SHORT_SET])] + const decisions: DecisionRecord[] = Array.from({ length: 4 }).map((_, k) => { + const cyc = S.cycle - k + const acts = S.positions.slice(0, 6).map((p) => ({ + action: 'hold', + symbol: p.symbol, + quantity: p.qty, + leverage: p.lev, + price: p.mark, + confidence: Math.round(rnd(62, 88)), + reasoning: 'Signal Lab confirms trend; cost/liq structure supports the level.', + timestamp: new Date(S.decisionTs - k * 300_000).toISOString(), + })) + return { + timestamp: new Date(S.decisionTs - k * 300_000).toISOString(), + cycle_number: cyc, + system_prompt: '', + input_prompt: '', + cot_trace: + 'US-equity tape is broadly bid: SP500 and semis (NVDA, MU, TSM) lead net inflow with bullish Signal Lab bias. Holding winners ≥ entry, trimming only on structure breaks.', + decision_json: '', + account_state: {} as never, + positions: [], + candidate_coins: candidates, + decisions: acts as never, + execution_log: S.positions + .slice(0, 6) + .map((p) => `${p.symbol} hold succeeded`), + success: true, + } as unknown as DecisionRecord + }) + + const status = { + is_running: true, + call_count: S.cycle, + scan_interval: '5m', + ai_model: 'claw402', + strategy_type: 'ai_trading', + } as unknown as SystemStatus + + return { + status, + account, + positions, + decisions, + fullStats, + history, + config: { + scan_interval_minutes: 5, + ai_model: 'claw402', + strategy_name: 'NOFX Claw402 Auto Strategy', + btc_eth_leverage: 10, + altcoin_leverage: 10, + }, + flow, + signalRank, + activeSymbol: DEMO_ACTIVE_SYMBOL, + } +} + +export function useDemoEngine(active: boolean): DemoDataset | null { + const ref = useRef(null) + const [, tick] = useState(0) + + useEffect(() => { + if (!active) { + ref.current = null + return + } + ref.current = initState() + tick((n) => n + 1) + const id = setInterval(() => { + if (ref.current) { + step(ref.current) + tick((n) => n + 1) + } + }, TICK_MS) + return () => clearInterval(id) + }, [active]) + + if (!active || !ref.current) return null + return build(ref.current) +}