feat: cream terminal redesign, English-only UI, autopilot launch fixes

- Redesign dashboard into a cream-paper + vermilion IBM Plex Mono terminal
  (live L2 order book, cost/liq map, WS K-line, signal matrix, orchestration
  topology, risk radar, execution log, current positions, equity curve)
- Convert all user-facing UI and backend strings/prompts from Chinese to
  English (multi-language retained, default English)
- Add /api/statistics/full endpoint + full-stats frontend wiring
- Fix Autopilot launch: reuse the existing trader instead of creating
  duplicates (eliminates repeat ~35s create cost and stale-trader 404s);
  launch sends 5m scan interval
- Fix unreadable toasts: cream theme with high-contrast text + per-type accent
- Silence background dashboard polls (getTraderConfig) to stop error-toast spam
This commit is contained in:
tinkle-community
2026-06-30 16:03:52 +08:00
parent eba28bcf0e
commit 110bf52908
149 changed files with 6835 additions and 3611 deletions

View File

@@ -0,0 +1,566 @@
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'
// 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)}%`
}
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 })
}
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,
account,
positions,
decisions,
}: TerminalDashboardProps) {
const traderId = selectedTrader?.trader_id || selectedTraderId
useTick(1000)
const clock = new Date().toLocaleTimeString('en-GB', { hour12: false })
const { data: fullStats } = useSWR(
traderId ? ['full-stats', traderId] : null,
() => api.getFullStats(traderId!, true),
{ refreshInterval: 30000, shouldRetryOnError: false }
)
const { data: equity } = useSWR(
traderId ? ['equity-history', traderId] : null,
() => api.getEquityHistory(traderId!, true),
{ refreshInterval: 30000, shouldRetryOnError: false }
)
const { data: history } = useSWR(
traderId ? ['pos-history', traderId] : null,
() => api.getPositionHistory(traderId!, 50, true),
{ refreshInterval: 60000, shouldRetryOnError: false }
)
const { data: config } = 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(
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(
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 }
)
// 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<string | null>(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<string, 'long' | 'short'>()
;(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<string, 'long' | 'short'>()
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<string, 'long' | 'short'>()
;(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 equityPath = useMemo(() => {
if (!equity || equity.length < 2) return null
const vals = equity.map((e: { total_equity: number }) => e.total_equity)
const min = Math.min(...vals)
const max = Math.max(...vals)
const span = max - min || 1
const W = 680
const H = 80
return vals
.map((v: number, i: number) => {
const x = (i / (vals.length - 1)) * W
const y = H - ((v - min) / span) * (H - 10) - 5
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`
})
.join(' ')
}, [equity])
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<HTMLElement | null>(null)
useEffect(() => {
setNavSlot(document.getElementById('dash-header-slot'))
}, [])
return (
<div className="nofx-terminal" style={{ minHeight: '100vh', padding: 0 }}>
{/* centered, capped content column — no border (keeps it from feeling
embedded) but bounded so the aspect-ratio SVGs don't balloon on wide screens */}
{navSlot &&
createPortal(
<span className="nofx-terminal" style={{ background: 'transparent', display: 'flex', alignItems: 'center', gap: 12, marginLeft: 16, paddingLeft: 16, borderLeft: '1px solid rgba(26,24,19,0.15)', fontSize: 11 }}>
<span className="tm-sc" style={{ color: 'var(--tm-muted)' }}>orchestration</span>
{traders && traders.length > 0 && (
<select value={traderId || ''} onChange={(e) => onTraderSelect(e.target.value)} className="tm-mono"
style={{ background: 'var(--tm-panel)', color: 'var(--tm-ink)', border: '1px solid var(--tm-hair)', borderRadius: 0, fontSize: 11, padding: '3px 6px' }}>
{traders.map((t) => (<option key={t.trader_id} value={t.trader_id} style={{ color: '#111' }}>{t.trader_name}</option>))}
</select>
)}
<span style={{ color: running ? 'var(--tm-up)' : 'var(--tm-muted)' }}>{running ? '● running' : '○ stopped'}</span>
<span className="tm-sc" style={{ color: 'var(--tm-muted)' }}>cycle</span><span className="tm-mono" style={{ color: 'var(--tm-ink)' }}>{status?.call_count ?? '—'}</span>
<span className="tm-px" style={{ fontSize: 12, color: 'var(--tm-ink)' }}>{clock}</span>
</span>,
navSlot,
)}
<div className="tm-box" style={{ maxWidth: 1280, margin: '0 auto', border: 'none' }}>
{/* config / identity strip — first row, flows directly under the global nav */}
<div className="tm-mono" style={{ display: 'flex', gap: 16, padding: '6px 14px', fontSize: 11, color: 'var(--tm-ink-2)', flexWrap: 'wrap' }}>
<span style={{ fontWeight: 500 }}>{selectedTrader?.trader_name ?? 'NOFX'}</span>
<span><span className="tm-sc">model </span>{(() => {
const raw = config?.ai_model || status?.ai_model || ''
if (!raw) return '—'
if (/claw402/i.test(raw)) return 'CLAW402'
return raw.length > 16 ? raw.slice(0, 16).toUpperCase() : raw.toUpperCase()
})()}</span>
<span><span className="tm-sc">strategy </span>{config?.strategy_name || selectedTrader?.strategy_name || '—'}</span>
<span><span className="tm-sc">lev </span>{config?.btc_eth_leverage ?? '—'}× / {config?.altcoin_leverage ?? '—'}×</span>
<span><span className="tm-sc">scan </span>{scanMin}m</span>
<span><span className="tm-sc">universe </span>{candidateCoins.length}</span>
<span><span className="tm-sc">positions </span>{positions?.length ?? 0}</span>
<span style={{ marginLeft: 'auto' }}><span className="tm-sc">next cycle </span>{countdown}</span>
</div>
<div className="tm-rule" />
{/* metric row */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)' }}>
{[
{ l: 'Equity', v: fmtUsd(account?.total_equity), c: 'var(--tm-ink)' },
{ l: 'Total P/L', v: `${fmtUsd(pnl, true)} (${fmtPct(pnlPct)})`, c: up ? 'var(--tm-up)' : 'var(--tm-dn)' },
{ l: 'Win rate', v: fullStats != null ? `${fullStats.win_rate.toFixed(1)}%` : '—', c: 'var(--tm-ink)' },
{ l: 'Profit factor', v: fullStats != null ? fullStats.profit_factor.toFixed(2) : '—', c: 'var(--tm-ink)' },
{ l: 'Max drawdown', v: fullStats != null ? `-${(fullStats.max_drawdown_pct * 100).toFixed(1)}%` : '—', c: 'var(--tm-dn)' },
].map((m, i) => (
<div key={m.l} style={{ padding: '12px 14px', borderRight: i < 4 ? cellBorder : 'none' }}>
<div className="tm-sc">{m.l}</div>
<div className="tm-mono" style={{ fontSize: 17, fontWeight: 500, color: m.c, marginTop: 3 }}>{m.v}</div>
</div>
))}
</div>
<div className="tm-rule" />
{/* trades summary */}
{fullStats != null && (
<>
<div className="tm-mono" style={{ display: 'flex', gap: 18, padding: '6px 14px', fontSize: 11, color: 'var(--tm-ink-2)', flexWrap: 'wrap' }}>
<span className="tm-sc">trades <b style={{ color: 'var(--tm-ink)' }}>{fullStats.total_trades}</b></span>
<span className="tm-sc tm-up">win {fullStats.win_trades}</span>
<span className="tm-sc tm-dn">loss {fullStats.loss_trades}</span>
<span className="tm-sc">sharpe <b style={{ color: 'var(--tm-ink)' }}>{fullStats.sharpe_ratio.toFixed(2)}</b></span>
<span className="tm-sc">avg win/loss <b style={{ color: 'var(--tm-ink)' }}>{fullStats.avg_win.toFixed(2)}/{fullStats.avg_loss.toFixed(2)}</b></span>
<span className="tm-sc">fees <b style={{ color: 'var(--tm-ink)' }}>{fmtUsd(fullStats.total_fee)}</b></span>
</div>
<div className="tm-rule" />
</>
)}
{/* ── row 1: cost/liq map · live L2 order book · signal matrix (instrument selector)
all three columns are locked to one fixed height so the row is always
balanced; the K-line flexes to fill any remaining space ── */}
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0,1.1fr) minmax(0,0.95fr) minmax(0,1.05fr)' }}>
<div style={{ ...sc, borderRight: cellBorder, height: ROW1_H, overflow: 'hidden' }}>
{/* cost/liq heatmap works for both synthetic (hip3_perp) and crypto
(perp) markets — pass the likely marketType; the component falls
back to the other one if the guess is wrong */}
<LiquidationMap
symbol={activeSym}
marketType={CRYPTO_MAJORS.has(activeSym) ? 'perp' : 'hip3_perp'}
height={ROW1_H - 130}
/>
</div>
<div style={{ ...sc, borderRight: cellBorder, height: ROW1_H, overflow: 'hidden' }}>
<OrderBook symbol={activeSym} markPrice={positions?.find((p) => baseLabel(p.symbol) === activeSym)?.entry_price} />
</div>
<div style={{ ...sc, height: ROW1_H, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<SignalMatrix items={signalRank?.items} active={activeSym} onSelect={setSelectedSym} />
{/* the live K-line always sits under the selector and flexes to fill */}
<div className="tm-rule" style={{ margin: '10px 0 8px' }} />
<div style={{ flex: 1, minHeight: 0 }}>
<KlineChart symbol={activeSym} fill />
</div>
</div>
</div>
<div className="tm-rule" />
{/* orchestration topology — second row, full width (the agent workflow) */}
<div style={sc}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 4 }}>
<span className="tm-px" style={{ fontSize: 12 }}>Orchestration topology</span>
<span className="tm-sc">Orchestration topology · net inflow signal execute hold</span>
</div>
<OrchestrationTopology
layers={[
{
key: 'flow',
title: 'FLOW',
zh: 'flow',
items: [
...(flow?.data?.inflow ?? []).map((i) => ({ symbol: i.symbol, dir: 'long' as const })),
...(flow?.data?.outflow ?? []).map((i) => ({ symbol: i.symbol, dir: 'short' as const })),
],
},
{
key: 'signal',
title: 'SIGNAL',
zh: 'signal',
items: (signalRank?.items ?? []).map((s) => ({
symbol: s.symbol,
dir: (s.bias || '').toLowerCase() === 'bearish' ? ('short' as const) : ('long' as const),
})),
},
{
// every candidate the AI actually judged this cycle (its full decision set)
key: 'decision',
title: 'DECISION',
zh: 'decision',
items: candidateCoins.map((c) => ({ symbol: c, dir: dirFor(c) })),
},
{
// executed & live: every open position is an executed order, so
// EXECUTE mirrors the live book (this cycle's fills plus anything
// still open from prior cycles) and flows straight into HOLD
key: 'exec',
title: 'EXECUTE',
zh: 'execute',
items: (positions ?? []).map((p) => ({
symbol: p.symbol,
dir: (p.side || '').toLowerCase().includes('short') ? ('short' as const) : ('long' as const),
})),
},
{
key: 'hold',
title: 'HOLD',
zh: 'hold',
items: (positions ?? []).map((p) => ({
symbol: p.symbol,
dir: (p.side || '').toLowerCase().includes('short') ? ('short' as const) : ('long' as const),
})),
},
]}
/>
</div>
<div className="tm-rule" />
{/* ── row 3: execution log · risk radar · recent trades ── */}
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0,1.1fr) minmax(0,1fr) minmax(0,1fr)' }}>
<div style={{ ...sc, borderRight: cellBorder }}>
<ExecutionLog decisions={decisions} height={432} />
</div>
<div style={{ ...sc, borderRight: cellBorder }}>
<RiskRadar positions={positions} account={account} config={config} fullStats={fullStats} />
</div>
<div style={sc}>
{/* live open positions (the book right now) */}
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 6 }}>
<span className="tm-px" style={{ fontSize: 11 }}>Positions</span>
<span className="tm-sc">Current positions · live</span>
<span className="tm-sc" style={{ marginLeft: 'auto' }}>{positions?.length ?? 0} open</span>
</div>
{positions && positions.length > 0 ? (
<table className="tm-mono" style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11 }}>
<thead>
<tr className="tm-sc" style={{ fontSize: 9 }}>
<td style={{ padding: '0 0 3px' }}>symbol</td>
<td style={{ padding: '0 0 3px' }}>side</td>
<td style={{ padding: '0 0 3px', textAlign: 'right' }}>lev</td>
<td style={{ padding: '0 0 3px', textAlign: 'right' }}>PnL</td>
<td style={{ padding: '0 0 3px', textAlign: 'right' }}>return%</td>
</tr>
</thead>
<tbody>
{positions.map((p, i) => {
const long = /long|buy/i.test(p.side)
const win = (p.unrealized_pnl ?? 0) >= 0
return (
<tr key={`${p.symbol}-${i}`} style={{ borderTop: '1px solid var(--tm-hair)' }}>
<td style={{ padding: '5px 0', fontWeight: 500 }}>{baseLabel(p.symbol)}</td>
<td style={{ padding: '5px 0' }} className={long ? 'tm-up' : 'tm-dn'}>{long ? 'long' : 'short'}</td>
<td style={{ padding: '5px 0', textAlign: 'right', color: 'var(--tm-muted)' }}>{p.leverage}×</td>
<td style={{ padding: '5px 0', textAlign: 'right' }} className={win ? 'tm-up' : 'tm-dn'}>{fmtUsd(p.unrealized_pnl, true)}</td>
<td style={{ padding: '5px 0', textAlign: 'right' }} className={win ? 'tm-up' : 'tm-dn'}>{(p.unrealized_pnl_pct ?? 0).toFixed(2)}%</td>
</tr>
)
})}
</tbody>
</table>
) : <div className="tm-sc" style={{ padding: '8px 0' }}>No open positions.</div>}
<div className="tm-rule" style={{ margin: '12px 0 10px' }} />
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 6 }}>
<span className="tm-px" style={{ fontSize: 11 }}>Recent trades</span>
<span className="tm-sc">Recent closes · symbol/side/time/pnl</span>
</div>
{recentTrades.length > 0 ? (
<table className="tm-mono" style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11 }}>
<tbody>
{recentTrades.map((p) => {
const win = p.realized_pnl >= 0
return (
<tr key={p.id} style={{ borderTop: '1px solid var(--tm-hair)' }}>
<td style={{ padding: '5px 0', fontWeight: 500 }}>{baseLabel(p.symbol)}</td>
<td style={{ padding: '5px 0' }} className={p.side === 'long' || p.side === 'LONG' ? 'tm-up' : 'tm-dn'}>{p.side.toLowerCase()}</td>
<td style={{ padding: '5px 0', color: 'var(--tm-muted)' }}>{fmtTime(p.exit_time)}</td>
<td style={{ padding: '5px 0', textAlign: 'right' }} className={win ? 'tm-up' : 'tm-dn'}>{fmtUsd(p.realized_pnl, true)}</td>
</tr>
)
})}
</tbody>
</table>
) : <div className="tm-sc" style={{ padding: '8px 0' }}>No closed trades yet.</div>}
</div>
</div>
<div className="tm-rule" />
{/* equity curve */}
{equityPath && (
<>
<div style={{ ...sc, paddingBottom: 4, display: 'flex', alignItems: 'baseline', gap: 8 }}>
<span className="tm-px" style={{ fontSize: 11 }}>Equity curve</span>
<span className="tm-sc">Account equity curve · all {equity?.length ?? 0} pts</span>
</div>
<div style={{ padding: '0 14px 10px' }}>
<svg width="100%" height={88} viewBox="0 0 680 80" preserveAspectRatio="none" role="img" aria-label="Equity curve" style={{ display: 'block' }}>
<line x1="0" y1="79" x2="680" y2="79" stroke="var(--tm-hair)" vectorEffect="non-scaling-stroke" />
<path d={equityPath} fill="none" stroke="var(--tm-red)" strokeWidth={2} vectorEffect="non-scaling-stroke" />
</svg>
</div>
<div className="tm-rule" />
</>
)}
{/* market net inflow — real Vergex flow-markets via claw402 */}
<div style={sc}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 8 }}>
<span className="tm-px" style={{ fontSize: 12 }}>Market net inflow</span>
<span className="tm-sc">Market net inflow · {flow?.data?.window || '1h'} · Vergex</span>
<span className="tm-sc" style={{ marginLeft: 'auto' }}>{flowItems.length} markets</span>
</div>
<FlowMarkets items={flowItems} window={flow?.data?.window} />
</div>
<div className="tm-rule" />
{/* by-symbol stats | latest decision */}
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0,1fr) minmax(0,1.3fr)' }}>
<div style={{ ...sc, borderRight: cellBorder }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 8 }}>
<span className="tm-px" style={{ fontSize: 11 }}>By symbol</span>
<span className="tm-sc">By-symbol history · trades/win/pnl</span>
</div>
{symbolStats.length > 0 ? symbolStats.map((s) => (
<div key={s.symbol} style={{ marginBottom: 7 }}>
<div className="tm-mono" style={{ display: 'flex', fontSize: 11, marginBottom: 2 }}>
<span style={{ fontWeight: 500 }}>{baseLabel(s.symbol)}</span>
<span className="tm-sc" style={{ marginLeft: 8 }}>{s.total_trades} trades · {s.win_rate.toFixed(0)}% win</span>
<span className={s.total_pnl >= 0 ? 'tm-up' : 'tm-dn'} style={{ marginLeft: 'auto' }}>{fmtUsd(s.total_pnl, true)}</span>
</div>
<div style={{ height: 4, background: 'var(--tm-hair)' }}>
<div style={{ height: 4, width: `${(s.total_trades / maxSymTrades) * 100}%`, background: s.total_pnl >= 0 ? 'var(--tm-up)' : 'var(--tm-dn)' }} />
</div>
</div>
)) : <div className="tm-sc">No symbol history.</div>}
</div>
<div style={sc}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 6 }}>
<span className="tm-px" style={{ fontSize: 11 }}>Latest decision</span>
<span className="tm-sc">AI rationale</span>
{latest && <span className="tm-sc" style={{ marginLeft: 'auto' }}>{fmtTime(latest.timestamp)}{latest.decisions?.[0]?.confidence != null ? ` · conf ${latest.decisions[0].confidence}` : ''}</span>}
</div>
{latest ? (
<>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 8 }}>
{(latest.decisions ?? []).slice(0, 5).map((d, i) => (
<span key={i} className="tm-mono" style={{ fontSize: 11, background: 'var(--tm-red-soft)', color: '#7a1f16', padding: '2px 9px' }}>{d.action} {baseLabel(d.symbol)}</span>
))}
</div>
<p style={{ fontSize: 12, lineHeight: 1.6, color: 'var(--tm-ink-2)', margin: '0 0 8px', borderLeft: '2px solid var(--tm-red)', paddingLeft: 12 }}>
{latest.decisions?.[0]?.reasoning || latest.cot_trace?.slice(0, 320) || 'No reasoning recorded.'}
</p>
{(latest.execution_log ?? []).length > 0 && (
<div className="tm-mono" style={{ fontSize: 10, color: 'var(--tm-muted)' }}>
{(latest.execution_log ?? []).slice(0, 3).map((l, i) => <div key={i}>{l}</div>)}
</div>
)}
</>
) : <div className="tm-sc">No decisions recorded yet.</div>}
</div>
</div>
</div>
</div>
)
}
export default TerminalDashboard