Files
nofx/web/src/components/terminal/TerminalDashboard.tsx
tinkle-community eabd279d10 feat: show hold duration in the recent trades panel
Adds a HOLD column (45m / 2h10 / 1d3h) plus a header row to the dashboard's
recent-closes table. Makes the hold-time-vs-PnL pattern (short holds bleed,
1h+ holds carry the edge) visible per trade instead of only in the aggregate
Edge Profile panel.
2026-07-15 19:36:58 +09:00

627 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<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 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 }}>
{/* discreet, unlabelled showcase toggle (Shift+D also works) */}
<button
type="button"
onClick={() => setDemo((v) => !v)}
aria-label="toggle presentation mode"
style={{
position: 'fixed',
right: 10,
bottom: 10,
zIndex: 9999,
width: 12,
height: 12,
padding: 0,
borderRadius: '50%',
border: 'none',
cursor: 'pointer',
background: on ? 'var(--tm-up)' : 'rgba(26,24,19,0.2)',
opacity: on ? 0.55 : 0.18,
}}
/>
{/* 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' }}>
{/* runtime health banner — AI fee wallet dry / safe mode would otherwise
only be visible in server logs while the bot silently idles */}
{!on && status && (status.safe_mode || status.ai_wallet_status === 'empty' || status.ai_wallet_status === 'low') && (
<div className="tm-mono" style={{ display: 'flex', gap: 10, alignItems: 'center', margin: '8px 14px 0', padding: '8px 12px', fontSize: 11, border: '1px solid var(--tm-down)', color: 'var(--tm-down)', background: 'rgba(200,60,40,0.06)', flexWrap: 'wrap' }}>
<span style={{ fontWeight: 600 }}>
{status.ai_wallet_status === 'empty'
? 'AI fee wallet is out of USDC — decisions are failing.'
: status.ai_wallet_status === 'low'
? `AI fee wallet is low (${(status.ai_wallet_balance_usdc ?? 0).toFixed(2)} USDC) — top up soon.`
: 'Safe mode: AI failed repeatedly, no new positions are being opened.'}
</span>
<span style={{ color: 'var(--tm-ink-2)' }}>
{status.ai_wallet_status === 'empty' || status.ai_wallet_status === 'low'
? 'Deposit Base USDC to the Claw402 wallet, the trader recovers automatically.'
: status.safe_mode_reason || ''}
</span>
</div>
)}
{/* first-run reassurance — a fresh autopilot looks idle for its first
minute (the AI is reading the market); tell newcomers what to expect */}
{!on && status?.is_running && (status.call_count ?? 0) <= 1 && !status.safe_mode && (
<div className="tm-mono" style={{ display: 'flex', gap: 10, alignItems: 'center', margin: '8px 14px 0', padding: '8px 12px', fontSize: 11, border: '1px solid var(--tm-up)', color: 'var(--tm-ink)', background: 'rgba(40,140,80,0.06)', flexWrap: 'wrap' }}>
<span style={{ fontWeight: 600, color: 'var(--tm-up)' }}>Your AI is live.</span>
<span style={{ color: 'var(--tm-ink-2)' }}>
It reads the whole market before acting the first decision usually lands within a minute or two and will appear in the Execution Log below. You can stop it anytime from the Config page.
</span>
</div>
)}
{/* 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 — "Total P/L" is equity-based (includes unrealized);
"Realized P/L" is closed-trades only and matches PF/win-rate/sharpe,
so the two never read as contradicting each other */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)' }}>
{[
{ l: 'Equity', v: fmtUsd(account?.total_equity), c: 'var(--tm-ink)' },
{ l: 'Total P/L · incl. unrealized', v: `${fmtUsd(pnl, true)} (${fmtPct(pnlPct)})`, c: up ? 'var(--tm-up)' : 'var(--tm-dn)' },
{
l: 'Realized P/L · closed trades',
v: fullStats != null ? fmtUsd(fullStats.total_pnl, true) : '—',
c: fullStats != null && fullStats.total_pnl >= 0 ? 'var(--tm-up)' : 'var(--tm-dn)',
},
{ l: 'Profit factor', v: fullStats != null ? fullStats.profit_factor.toFixed(2) : '—', c: 'var(--tm-ink)' },
// max_drawdown_pct is already a percent (18.5 = -18.5%)
{ l: 'Max drawdown', v: fullStats != null ? `-${fullStats.max_drawdown_pct.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} ({fullStats.win_rate.toFixed(1)}%)</span>
<span className="tm-sc tm-dn">loss {fullStats.loss_trades}</span>
{/* fee-drag chain: gross realized fees = net realized */}
<span className="tm-sc">gross <b style={{ color: 'var(--tm-ink)' }}>{fmtUsd(fullStats.total_pnl + fullStats.total_fee, true)}</b></span>
<span className="tm-sc">fees <b style={{ color: 'var(--tm-ink)' }}>-{fmtUsd(fullStats.total_fee)}</b></span>
<span className="tm-sc">net <b style={{ color: fullStats.total_pnl >= 0 ? 'var(--tm-up)' : 'var(--tm-dn)' }}>{fmtUsd(fullStats.total_pnl, true)}</b></span>
<span className="tm-sc">sharpe/trade <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>
</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}
demo={on}
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} demo={on} 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} max={18} 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 demo={on} />
</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·lev</td>
<td style={{ padding: '0 0 3px', textAlign: 'right' }}>entry</td>
<td style={{ padding: '0 0 3px', textAlign: 'right' }}>size</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
const notional = Math.abs(p.quantity ?? 0) * (p.mark_price || p.entry_price || 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'} <span style={{ color: 'var(--tm-muted)' }}>{p.leverage}×</span></td>
<td style={{ padding: '5px 0', textAlign: 'right', color: 'var(--tm-ink-2)' }}>{fmtPx(p.entry_price)}</td>
<td style={{ padding: '5px 0', textAlign: 'right', color: 'var(--tm-ink-2)' }}>{fmtUsd(notional)}</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/hold/pnl</span>
</div>
{recentTrades.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' }}>hold</td>
<td style={{ padding: '0 0 3px' }}> closed</td>
<td style={{ padding: '0 0 3px', textAlign: 'right' }}>PnL</td>
</tr>
</thead>
<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', textAlign: 'right', color: 'var(--tm-ink-2)' }}>{fmtHold(p.entry_time, p.exit_time)}</td>
<td style={{ padding: '5px 0 5px 6px', 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" />
{/* market net inflow (Vergex) · by-symbol history · edge profile — footer */}
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0,1.2fr) minmax(0,0.9fr) minmax(0,0.9fr)' }}>
<div style={{ ...sc, borderRight: cellBorder }}>
<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 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: 8, marginBottom: 8 }}>
<span className="tm-px" style={{ fontSize: 11 }}>Edge profile</span>
<span className="tm-sc">Net by hold time &amp; side · after fees</span>
</div>
<EdgeProfile positions={history?.positions} />
</div>
</div>
</div>
</div>
)
}
export default TerminalDashboard