import { useEffect, useMemo, useState } from 'react' import { createPortal } from 'react-dom' import type { CSSProperties } from 'react' import useSWR from 'swr' import { api } from '../../lib/api' import type { SystemStatus, AccountInfo, Position, DecisionRecord, TraderInfo, } from '../../types' import { OrchestrationTopology } from './OrchestrationTopology' import { OrderBook } from './OrderBook' import { LiquidationMap } from './LiquidationMap' import { KlineChart } from './KlineChart' import { ExecutionLog } from './ExecutionLog' import { SignalMatrix } from './SignalMatrix' import { RiskRadar } from './RiskRadar' import { EdgeProfile } from './EdgeProfile' 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. const CRYPTO_MAJORS = new Set([ 'BTC', 'ETH', 'SOL', 'HYPE', 'BNB', 'XRP', 'DOGE', 'AVAX', 'LINK', 'SUI', 'APT', 'ARB', 'OP', 'TON', 'ADA', 'TRX', 'LTC', 'BCH', 'NEAR', 'INJ', 'SEI', 'TIA', 'PEPE', 'WIF', 'BONK', 'AAVE', 'UNI', 'ENA', 'ONDO', 'JUP', 'PENDLE', 'KPEPE', 'ZEC', 'XPL', 'LIT', ]) // fixed height for the three row-1 panels so the row stays balanced at any width const ROW1_H = 500 import { FlowMarkets } from './FlowMarkets' import './terminal.css' interface TerminalDashboardProps { selectedTrader?: TraderInfo traders?: TraderInfo[] selectedTraderId?: string onTraderSelect: (traderId: string) => void status?: SystemStatus account?: AccountInfo positions?: Position[] decisions?: DecisionRecord[] } function fmtUsd(n: number | undefined, signed = false): string { if (n == null || Number.isNaN(n)) return '—' const sign = signed && n > 0 ? '+' : n < 0 ? '-' : '' return `${sign}$${Math.abs(n).toLocaleString('en-US', { maximumFractionDigits: 2 })}` } function fmtPct(n: number | undefined): string { if (n == null || Number.isNaN(n)) return '—' return `${n >= 0 ? '+' : ''}${n.toFixed(2)}%` } /** Price with magnitude-aware precision: 64,416 · 184.2 · 2.3775 · 0.0067 */ function fmtPx(n: number | undefined): string { if (n == null || Number.isNaN(n) || n === 0) return '—' const dp = n >= 1000 ? 0 : n >= 100 ? 1 : n >= 1 ? 2 : 4 return n.toLocaleString('en-US', { minimumFractionDigits: dp, maximumFractionDigits: dp }) } function baseLabel(raw?: string): string { if (!raw) return '' return raw.toUpperCase().replace(/^XYZ:/, '').replace(/[-_]/g, '').replace(/(USDT|USDC|USD)$/, '') } function parseScanMinutes(scan?: string): number { if (!scan) return 15 const m = scan.match(/(\d+)\s*m/i) if (m) return parseInt(m[1], 10) const h = scan.match(/(\d+)\s*h/i) if (h) return parseInt(h[1], 10) * 60 const n = parseInt(scan, 10) return Number.isFinite(n) && n > 0 ? n : 15 } function fmtTime(raw?: string | number): string { if (raw == null || raw === '') return '' let n = typeof raw === 'number' ? raw : Number(raw) if (Number.isFinite(n)) { if (n < 1e12) n *= 1000 return new Date(n).toLocaleString('en-GB', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }) } const d = new Date(raw as string) return Number.isNaN(d.getTime()) ? '' : d.toLocaleString('en-GB', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }) } /** Hold duration from entry/exit epoch-ms as a compact 45m / 2h10 / 1d3h. */ function fmtHold(entry?: number, exit?: number): string { if (!entry || !exit || exit <= entry) return '—' const mins = Math.round((exit - entry) / 60000) if (mins < 60) return `${mins}m` const h = Math.floor(mins / 60) const m = mins % 60 if (h < 24) return m ? `${h}h${m}` : `${h}h` const d = Math.floor(h / 24) return `${d}d${h % 24}h` } function useTick(ms = 1000) { const [, set] = useState(0) useEffect(() => { const id = setInterval(() => set((n) => n + 1), ms) return () => clearInterval(id) }, [ms]) } export function TerminalDashboard({ selectedTrader, traders, selectedTraderId, onTraderSelect, 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: realFullStats } = useSWR( traderId ? ['full-stats', traderId] : null, () => api.getFullStats(traderId!, true), { refreshInterval: 30000, shouldRetryOnError: false } ) const { data: realHistory } = useSWR( traderId ? ['pos-history', traderId] : null, () => api.getPositionHistory(traderId!, 50, true), { refreshInterval: 60000, shouldRetryOnError: false } ) const { data: realConfig } = useSWR( traderId ? ['trader-config', traderId] : null, () => api.getTraderConfig(traderId!, true), { refreshInterval: 120000, shouldRetryOnError: false } ) 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 { 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 // 1h trades, per flow-markets) so the shared order book ticks as fast as // possible. Falls back to any held synthetic, then SP500. const heatmapSymbol = useMemo(() => { const held = new Set( [...(positions ?? []).map((p) => p.symbol), ...candidateCoins] .map(baseLabel) .filter((b) => b && !CRYPTO_MAJORS.has(b)), ) const synthByActivity = flowItems .map((i) => ({ b: baseLabel(i.symbol), trades: i.trades || 0 })) .filter((x) => x.b && !CRYPTO_MAJORS.has(x.b)) .sort((a, b) => b.trades - a.trades) const busiestHeld = synthByActivity.find((x) => held.has(x.b)) if (busiestHeld) return busiestHeld.b if (held.size) return [...held][0] if (synthByActivity.length) return synthByActivity[0].b return 'SP500' }, [positions, candidateCoins, flowItems]) // user can click a signal-matrix cell to drive both the cost/liq map and the // order book. Default to the instrument the bot is ACTUALLY holding (first // open position, else this cycle's first candidate) so the price panels match // the real traded symbol; only fall back to the busiest synthetic if the bot // holds nothing. const [selectedSym, setSelectedSym] = useState(null) const defaultSym = useMemo(() => { // the bot's actual first open position (else this cycle's first candidate); // every market — synthetic or crypto — now has a cost/liq heatmap, so no // need to prefer one type. Falls back to the busiest synthetic if flat. const heldBases = [...(positions ?? []).map((p) => p.symbol), ...candidateCoins].map(baseLabel).filter(Boolean) return heldBases[0] || heatmapSymbol || 'SP500' }, [positions, candidateCoins, heatmapSymbol]) const activeSym = (selectedSym || defaultSym).toUpperCase() const pnl = account?.total_pnl ?? 0 const pnlPct = account?.total_pnl_pct ?? 0 const up = pnl >= 0 const running = status?.is_running // direction per symbol — priority: AI's actual decision > signal bias > // net flow > prevailing market majority (never blindly default to long). const dirFor = useMemo(() => { const dec = new Map() ;(latest?.decisions ?? []).forEach((d) => { const b = baseLabel(d.symbol) if (d.action === 'open_long' || d.action === 'close_short') dec.set(b, 'long') else if (d.action === 'open_short' || d.action === 'close_long') dec.set(b, 'short') }) const sig = new Map() let bull = 0 let bear = 0 ;(signalRank?.items ?? []).forEach((s) => { const b = baseLabel(s.symbol) const bias = (s.bias || '').toLowerCase() if (bias === 'bearish') { sig.set(b, 'short'); bear++ } else if (bias === 'bullish') { sig.set(b, 'long'); bull++ } }) const fl = new Map() ;(flow?.data?.inflow ?? []).forEach((i) => fl.set(baseLabel(i.symbol), 'long')) ;(flow?.data?.outflow ?? []).forEach((i) => fl.set(baseLabel(i.symbol), 'short')) const majority: 'long' | 'short' = bear > bull ? 'short' : 'long' return (sym: string): 'long' | 'short' => { const b = baseLabel(sym) return dec.get(b) ?? sig.get(b) ?? fl.get(b) ?? majority } }, [latest, signalRank, flow]) const scanMin = config?.scan_interval_minutes || parseScanMinutes(status?.scan_interval) const nextCycleMs = useMemo(() => { if (!latest?.timestamp) return null return new Date(latest.timestamp).getTime() + scanMin * 60_000 }, [latest?.timestamp, scanMin]) let countdown = '—' if (nextCycleMs) { const ms = nextCycleMs - Date.now() if (ms <= 0) countdown = 'due now' else { const s = Math.floor(ms / 1000) countdown = `${Math.floor(s / 60)}m ${s % 60}s` } } const recentTrades = (history?.positions ?? []).slice(0, 8) const symbolStats = useMemo( () => (history?.symbol_stats ?? []).slice().sort((a, b) => b.total_trades - a.total_trades).slice(0, 6), [history] ) const maxSymTrades = symbolStats.reduce((m, s) => Math.max(m, s.total_trades), 1) const sc: CSSProperties = { padding: '10px 14px' } const cellBorder = '1px solid var(--tm-hair)' // Portal the trader selector + run status into the global nav so the app has // a single top bar (no separate dashboard titlebar). const [navSlot, setNavSlot] = useState(null) useEffect(() => { setNavSlot(document.getElementById('dash-header-slot')) }, []) return (
{/* discreet, unlabelled showcase toggle (Shift+D also works) */}