larp-quant-agent/src/pages/Terminal.tsx
zefiroff 6574fdd6eb
All checks were successful
deploy / deploy (push) Successful in 7s
site: drop paper / dry-run / demo / disclaimer wording across the site
Removed the "PAPER · DRY-RUN" badges (terminal StatusBar + landing footer),
the "paper · dry-run · not advice" terminal footer line, the landing
"Looping demo / no orders are signed / paper-traded / not financial advice"
copy, and the onboarding/AgentFlow "paper-traded — no real orders" lines.

L5 execution nodes now tag "exec" instead of "paper" (Orchestra, Agent flow,
Live feed) and the Orchestra status readout labels that column "execution".
Roadmap "soon/planned" tags and the data-freshness "seed" chip are kept (they
flag unbuilt features / non-live data, not a demo).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:45:22 +04:00

565 lines
30 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, useState } from 'react'
import { Link } from 'react-router-dom'
import { LogoMark } from '@/components/brand/Logo'
import { TVChart, ChartSkeleton } from '@/components/viz/TVChart'
import { LiveFeed } from '@/components/viz/LiveFeed'
import { Orchestra } from '@/components/viz/Orchestra'
import { AgentWorkflow } from '@/components/viz/AgentWorkflow'
import { Onboarding, TOUR_EVENT } from '@/components/Onboarding'
import { useScreen } from '@/hooks/useHyperliquid'
import { fmtPrice, fmtPct, fmtCompact } from '@/lib/format'
import type { Direction, Vote } from '@/lib/signals'
const COINS = ['BTC', 'ETH', 'SOL']
const TIMEFRAMES = ['1m', '5m', '15m', '1h', '4h', '1d'] as const
const AGENT_META: Record<string, { label: string; reads: string }> = {
TrendAgent: { label: 'Trend', reads: 'EMA 9/21 · RSI' },
BreakoutAgent: { label: 'Breakout', reads: 'Bollinger %B' },
MeanReversionAgent: { label: 'Mean-Rev', reads: 'RSI extremes' },
FundingAgent: { label: 'Funding', reads: 'funding-Z' },
}
const AGENT_RULES: Record<string, string> = {
TrendAgent: 'Trend — LONG when EMA9 > EMA21 and RSI > 50; SHORT on the reverse; else SKIP.',
BreakoutAgent: 'Breakout — LONG when price pushes the upper Bollinger band (%B > 0.92); SHORT at the lower (< 0.08).',
MeanReversionAgent: 'Mean-Rev — fade extremes: LONG when RSI < 32 in the lower band; SHORT when RSI > 68 at the upper.',
FundingAgent: 'Funding — fade crowded funding: SHORT when funding-Z > 1.5; LONG when < 1.5.',
}
const METRIC_INFO: Record<string, string> = {
Mark: 'Mark price — the fair price Hyperliquid uses for PnL & liquidation, not the last trade.',
'24h Δ': 'Price change over the last 24 hours.',
Funding: 'Funding rate — periodic longs↔shorts payment. Positive = longs pay (crowded longs).',
ATR: 'Average True Range — volatility; it scales the verdict TP / SL.',
'Open interest': 'Open interest — total USD value of open perpetual contracts.',
'24h volume': '24-hour traded volume, in USD.',
'RSI (14)': 'RSI — momentum 0100; >70 overbought, <30 oversold. Feeds Mean-Rev.',
'Bollinger %B': 'Where price sits in the Bollinger band (0 = lower, 1 = upper). Feeds Breakout.',
'EMA 9/21': 'Fast vs slow moving average — trend direction. Feeds Trend.',
'Funding-Z': 'Normalised funding skew. Feeds the Funding agent.',
'MACD hist': 'MACD histogram — momentum (MACD line minus its signal).',
'Net vote': 'Aggregate of the 4 agents (LONG +, SHORT ). Drives the verdict.',
}
const INK = 'var(--color-bone)'
const clampN = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n))
const dirColor = (d: Direction) =>
d === 'LONG' ? 'var(--color-long)' : d === 'SHORT' ? 'var(--color-short)' : 'var(--color-muted)'
const dirBg = (d: Direction) =>
d === 'LONG' ? 'var(--color-long-dim)' : d === 'SHORT' ? 'var(--color-short-dim)' : 'var(--color-track)'
const sign = (n: number) => (n >= 0 ? '+' : '')
type Inspect = (s: string | null) => void
/* ── primitives ───────────────────────────────────────────────────────────── */
function Panel({
title,
meta,
className = '',
children,
}: {
title: React.ReactNode
meta?: React.ReactNode
className?: string
children: React.ReactNode
}) {
return (
<section className={`overflow-hidden rounded-md border border-line bg-panel ${className}`}>
<header className="flex items-center justify-between gap-3 border-b border-line-soft px-3 py-2">
<span className="label !text-[0.625rem]">{title}</span>
{meta != null && <span className="font-mono text-micro text-faint">{meta}</span>}
</header>
{children}
</section>
)
}
function Cell({
label,
value,
tone,
sub,
bar,
barTone,
info,
onInspect,
loading,
}: {
label: string
value: string
tone?: string
sub?: string
bar?: number
barTone?: string
info?: string
onInspect?: Inspect
loading?: boolean
}) {
return (
<div
className={`bg-inset px-3 py-2 transition-colors ${info && !loading ? 'cursor-help hover:bg-panel-2' : ''}`}
onMouseEnter={info && onInspect && !loading ? () => onInspect(info) : undefined}
onMouseLeave={info && onInspect && !loading ? () => onInspect(null) : undefined}
>
<div className="label-sm !text-[0.5625rem]">{label}</div>
{loading ? (
<div className="skeleton mt-1.5 h-3.5 w-3/5" />
) : (
<div className="tnum mt-1 font-mono text-data font-medium" style={{ color: tone ?? INK }}>
{value}
</div>
)}
{sub && !loading && <div className="mt-0.5 font-mono text-micro text-faint">{sub}</div>}
{bar != null && !loading && (
<div className="mt-1.5 h-0.5 w-full overflow-hidden rounded-full" style={{ background: 'var(--color-track)' }}>
<div
className="h-full rounded-full"
style={{ width: `${clampN(bar, 0, 100)}%`, background: barTone ?? 'var(--color-bone-dim)', transition: 'width .5s cubic-bezier(.22,1,.36,1)' }}
/>
</div>
)}
</div>
)
}
function DivergeBar({ direction, score }: { direction: Direction; score: number }) {
const col = dirColor(direction)
return (
<div className="relative h-2 flex-1 overflow-hidden rounded-full" style={{ background: 'var(--color-track)' }}>
<div className="absolute inset-y-0 left-1/2 w-px" style={{ background: 'var(--color-line-strong)' }} />
{direction === 'LONG' && (
<div className="absolute inset-y-0.5 rounded-r-full" style={{ left: '50%', width: `${score * 50}%`, background: col, transition: 'width .5s cubic-bezier(.22,1,.36,1)' }} />
)}
{direction === 'SHORT' && (
<div className="absolute inset-y-0.5 rounded-l-full" style={{ right: '50%', width: `${score * 50}%`, background: col, transition: 'width .5s cubic-bezier(.22,1,.36,1)' }} />
)}
{direction === 'SKIP' && (
<div className="absolute inset-y-0.5 left-1/2 w-1 -translate-x-1/2 rounded-full" style={{ background: 'var(--color-muted)' }} />
)}
</div>
)
}
/* ── command bar ──────────────────────────────────────────────────────────── */
function StatusBar({ live, ago, stale, onReload }: { live: boolean; ago: number | null; stale: boolean; onReload: () => void }) {
const [now, setNow] = useState(() => new Date())
const [spin, setSpin] = useState(false)
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000)
return () => clearInterval(id)
}, [])
const reload = () => {
onReload()
setSpin(true)
setTimeout(() => setSpin(false), 600)
}
const clock = now.toISOString().slice(11, 19)
const statusCol = stale ? 'var(--color-accent)' : live ? 'var(--color-long)' : 'var(--color-muted)'
const statusText = stale ? 'DATA STALE' : live ? 'LIVE · HL' : 'CONNECTING'
return (
<header className="sticky top-0 z-50 border-b border-line bg-paper/90 backdrop-blur-md">
<div className="mx-auto flex h-10 max-w-none items-center justify-between gap-4 px-4 sm:px-6">
<div className="flex items-center gap-2.5">
<Link to="/" className="flex items-center gap-2.5" aria-label="Home">
<LogoMark className="h-5 w-5" />
<span className="font-display text-sm font-bold tracking-tight text-bone">
Quant<span className="text-muted"> Agent</span>
</span>
</Link>
<span className="label-sm hidden border-l border-line-soft pl-2.5 !text-faint sm:inline">Terminal</span>
</div>
<div className="flex items-center gap-3 font-mono text-micro uppercase tracking-[0.1em]">
<span className="flex items-center gap-1.5" style={{ color: statusCol }}>
<span className={stale ? 'inline-block h-1.5 w-1.5 rounded-full' : 'blink inline-block h-1.5 w-1.5 rounded-full'} style={{ background: statusCol }} />
{statusText}
</span>
<button type="button" onClick={reload} title="Refresh now (r)" aria-label="Refresh now" className="flex cursor-pointer items-center gap-1 text-faint transition-colors hover:text-bone">
<span className={spin ? 'inline-block animate-spin' : 'inline-block'} style={{ animationDuration: '0.6s' }} aria-hidden></span>
{ago != null && <span className="tnum hidden sm:inline">{ago}s</span>}
</button>
<span className="tnum hidden text-bone-dim md:inline">{clock} UTC</span>
</div>
</div>
</header>
)
}
/* ── compact verdict bar (the call — dense, not a hero) ───────────────────── */
function VerdictBar({ decision, confidence, sizePct, tpPct, slPct, net, votes, reasoning, live, loading }: {
decision: Direction; confidence: number; sizePct: number; tpPct: number; slPct: number; net: number; votes: Vote[]; reasoning: string; live: boolean; loading: boolean
}) {
if (loading) {
return (
<div className="flex items-center gap-4 rounded-md border border-line bg-panel py-2.5 pl-3 pr-4" style={{ borderLeft: '2px solid var(--color-line-strong)' }} aria-busy="true">
<span className="label-sm !text-faint">Verdict</span>
<div className="skeleton h-6 w-24" />
<div className="skeleton h-3.5 w-12" />
<div className="skeleton h-3.5 w-14" />
<div className="skeleton hidden h-3.5 w-12 sm:block" />
<div className="skeleton hidden h-3.5 w-14 sm:block" />
<div className="skeleton hidden h-3.5 w-14 sm:block" />
</div>
)
}
const active = votes.filter((v) => v.direction !== 'SKIP')
const agree = active.filter((v) => v.direction === decision)
const rr = slPct ? tpPct / slPct : 0
const col = dirColor(decision)
const Sep = () => <span className="hidden h-4 w-px bg-line-soft sm:block" />
const Field = ({ k, v, c }: { k: string; v: string; c?: string }) => (
<span className="flex items-baseline gap-1.5">
<span className="label-sm !text-[0.5625rem] !text-faint">{k}</span>
<span className="tnum font-mono text-data font-medium" style={{ color: c ?? INK }}>{v}</span>
</span>
)
return (
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-md border border-line bg-panel py-2.5 pl-3 pr-4" style={{ borderLeft: `2px solid ${col}` }}>
<span className="label-sm !text-faint">Verdict</span>
<span className="font-display text-2xl font-bold leading-none tracking-tight" style={{ color: col }}>{decision}</span>
<Field k="conf" v={`${Math.round(confidence * 100)}%`} c={col} />
<Field k="net" v={`${sign(net)}${net.toFixed(2)}`} c={net > 0 ? 'var(--color-long)' : net < 0 ? 'var(--color-short)' : INK} />
<span className="font-mono text-micro text-muted">{decision === 'SKIP' ? 'no consensus' : `${agree.length}/${active.length} agree`}</span>
<Sep />
<Field k="size" v={`${sizePct}%`} />
<Field k="TP" v={fmtPct(tpPct)} c="var(--color-long)" />
<Field k="SL" v={fmtPct(-slPct)} c="var(--color-short)" />
<Field k="R:R" v={rr ? rr.toFixed(1) : '—'} />
{!live && (
<span className="label-sm rounded-sm px-1.5 py-0.5 !text-accent" style={{ background: 'var(--color-accent-dim)' }}>seed</span>
)}
<span className="ml-auto hidden max-w-sm truncate font-mono text-micro text-muted xl:block">{reasoning}</span>
</div>
)
}
/* ── committee (selectable, dense) ────────────────────────────────────────── */
function Committee({ votes, net, activeAgent, onSelect, onHover, loading }: {
votes: Vote[]; net: number; activeAgent?: string | null; onSelect?: (a: string | null) => void; onHover?: (a: string | null) => void; loading?: boolean
}) {
if (loading) {
return (
<Panel title="Committee · analytics" meta="4 · L3" className="flex h-full flex-col">
<div className="flex flex-1 flex-col divide-y divide-line-soft" aria-busy="true">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="grid grid-cols-[5rem_1fr] gap-x-3 px-3 py-2.5">
<div className="space-y-1.5 self-center">
<div className="skeleton h-3 w-12" />
<div className="skeleton h-2 w-16" />
</div>
<div className="flex items-center gap-2.5">
<div className="skeleton h-2 flex-1" />
<div className="skeleton h-3 w-10" />
</div>
</div>
))}
</div>
</Panel>
)
}
const active = votes.filter((v) => v.direction !== 'SKIP')
const lean = active.length ? clampN(net / active.length, -1, 1) : 0
return (
<Panel title="Committee · analytics" meta={`${votes.length} · L3`} className="flex h-full flex-col">
<div className="flex flex-1 flex-col divide-y divide-line-soft">
{votes.map((v) => {
const meta = AGENT_META[v.agent] ?? { label: v.agent.replace('Agent', ''), reads: '' }
const signal = v.direction !== 'SKIP'
const col = dirColor(v.direction)
const on = activeAgent === v.agent
return (
<button
key={v.agent}
type="button"
onClick={() => onSelect?.(on ? null : v.agent)}
onMouseEnter={() => onHover?.(v.agent)}
onMouseLeave={() => onHover?.(null)}
aria-pressed={on}
className={['grid w-full flex-1 cursor-pointer grid-cols-[5rem_1fr] content-center gap-x-3 border-l-2 px-3 py-2.5 text-left transition-colors', on ? 'border-accent bg-panel-2' : 'border-transparent hover:bg-panel-2'].join(' ')}
>
<div className="self-center">
<div className="font-mono text-data font-medium text-bone">{meta.label}</div>
<div className="label-sm !text-[0.5625rem] !text-faint">{meta.reads}</div>
</div>
<div className="flex items-center gap-2.5">
<DivergeBar direction={v.direction} score={v.score} />
<span className="tnum w-6 shrink-0 text-right font-mono text-micro text-muted">{Math.round(v.score * 100)}</span>
<span className="w-12 shrink-0 rounded-sm px-1 py-0.5 text-center font-mono text-[0.5625rem] font-semibold uppercase tracking-[0.04em]" style={{ color: signal ? col : 'var(--color-muted)', background: dirBg(v.direction) }}>
{signal ? v.direction : 'skip'}
</span>
</div>
<p className="col-start-2 mt-1 font-mono text-micro leading-snug text-bone-dim">{v.reason}</p>
{on && AGENT_RULES[v.agent] && (
<p className="reveal col-start-2 mt-1 font-mono text-micro leading-snug text-faint" style={{ animationDuration: '0.4s' }}>{AGENT_RULES[v.agent]}</p>
)}
</button>
)
})}
</div>
<div className="grid grid-cols-[5rem_1fr] items-center gap-x-3 border-t border-line-strong bg-inset px-3 py-2.5">
<span className="label-sm !text-[0.5625rem] !text-bone-dim">net lean</span>
<div className="flex items-center gap-2.5">
<div className="relative h-2 flex-1 overflow-hidden rounded-full" style={{ background: 'var(--color-track)' }}>
<div className="absolute inset-y-0 left-1/2 w-px" style={{ background: 'var(--color-line-strong)' }} />
{lean >= 0 ? (
<div className="absolute inset-y-0.5 rounded-r-full" style={{ left: '50%', width: `${lean * 50}%`, background: 'var(--color-long)' }} />
) : (
<div className="absolute inset-y-0.5 rounded-l-full" style={{ right: '50%', width: `${-lean * 50}%`, background: 'var(--color-short)' }} />
)}
<div className="absolute inset-y-0 w-0.5" style={{ left: `${50 + lean * 50}%`, background: 'var(--color-accent)' }} />
</div>
<span className="tnum w-6 shrink-0 text-right font-mono text-micro text-bone">{net.toFixed(1)}</span>
<span className="w-12 shrink-0" />
</div>
</div>
</Panel>
)
}
/* ── help card (opened from the ? in the toolbar) ─────────────────────────── */
const GUIDE = [
{ t: 'The verdict', d: "The committee's single call — LONG (up), SHORT (down) or SKIP (no edge), with confidence and the suggested size / TP / SL." },
{ t: 'The committee', d: 'Four strategies each vote. The graph shows them feeding the verdict; the list shows each vote and why.' },
{ t: 'Interact', d: 'Switch timeframe & coin up top, hover any field to inspect it, click an agent to pin it, ↻ to refresh. Keys: 1/2/3 coin · r refresh · Esc clear.' },
]
function GuideCard({ onClose }: { onClose: () => void }) {
return (
<section className="overflow-hidden rounded-md border border-line bg-panel">
<div className="flex items-center justify-between gap-3 border-b border-line-soft px-3 py-2">
<span className="label !text-bone">How to read this terminal</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => { onClose(); window.dispatchEvent(new Event(TOUR_EVENT)) }}
className="rounded-sm border border-line px-2 py-0.5 font-mono text-micro uppercase tracking-[0.08em] text-muted transition-colors hover:border-accent hover:text-accent"
>
replay intro
</button>
<button type="button" onClick={onClose} className="rounded-sm border border-line px-2 py-0.5 font-mono text-micro uppercase tracking-[0.08em] text-muted transition-colors hover:border-line-strong hover:text-bone">close </button>
</div>
</div>
<div className="grid grid-cols-1 gap-px bg-line-soft sm:grid-cols-3">
{GUIDE.map((g, i) => (
<div key={g.t} className="bg-inset px-3 py-2.5">
<div className="flex items-center gap-2">
<span className="tnum flex h-4 w-4 items-center justify-center rounded-full font-mono text-[0.6rem] font-bold" style={{ background: 'var(--color-accent)', color: 'var(--color-ink)' }}>{i + 1}</span>
<span className="font-mono text-data font-semibold text-bone">{g.t}</span>
</div>
<p className="mt-1.5 font-mono text-micro leading-relaxed text-bone-dim">{g.d}</p>
</div>
))}
</div>
</section>
)
}
/* ── page ─────────────────────────────────────────────────────────────────── */
export function Terminal() {
const [coin, setCoin] = useState('BTC')
const [tf, setTf] = useState<string>('1h')
const [selectedAgent, setSelectedAgent] = useState<string | null>(null)
const [hoverAgent, setHoverAgent] = useState<string | null>(null)
const [inspect, setInspect] = useState<string | null>(null)
const [guideOpen, setGuideOpen] = useState(false)
const activeAgent = hoverAgent ?? selectedAgent
const { data, live, updatedAt, error, reload } = useScreen(coin, tf, 12_000)
const ta = data?.ta
const v = data?.verdict
const stat = data?.stat
const votes = data?.votes ?? []
const candles = data?.candles ?? []
const [, setTick] = useState(0)
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 1000)
return () => clearInterval(id)
}, [])
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const el = e.target as HTMLElement | null
if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable)) return
if (e.metaKey || e.ctrlKey || e.altKey) return
if (e.key === '1' || e.key === '2' || e.key === '3') setCoin(COINS[+e.key - 1])
else if (e.key.toLowerCase() === 'r') reload()
else if (e.key === 'Escape') {
setSelectedAgent(null)
setGuideOpen(false)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [reload])
const ago = updatedAt ? Math.max(0, Math.round((Date.now() - updatedAt) / 1000)) : null
const stale = Boolean(error) || (ago != null && ago > 40)
const loading = updatedAt === 0 // no live data for this instrument yet → show skeletons
const changePct = stat?.changePct ?? 0
const changeTone = changePct >= 0 ? 'var(--color-long)' : 'var(--color-short)'
const funding = stat?.funding ?? 0
const fundingTone = funding >= 0 ? 'var(--color-short)' : 'var(--color-long)'
const macdHist = ta?.macdHist ?? 0
const macdTone = macdHist >= 0 ? 'var(--color-long)' : 'var(--color-short)'
const emaBull = (ta?.ema9 ?? 0) >= (ta?.ema21 ?? 0)
const emaTone = emaBull ? 'var(--color-long)' : 'var(--color-short)'
const net = v?.net ?? 0
const netTone = net > 0.001 ? 'var(--color-long)' : net < -0.001 ? 'var(--color-short)' : INK
const rsi = ta?.rsi ?? 50
const rsiTone = rsi < 32 ? 'var(--color-long)' : rsi > 68 ? 'var(--color-short)' : INK
const decision = (v?.decision ?? 'SKIP') as Direction
const marketMeta = live ? 'LIVE · HL' : updatedAt > 0 ? 'STALE' : 'CONNECTING…'
// brief live summary per Orchestra status category (live / computed / the call / paper)
const orchestraStatus = {
live: live
? `$${fmtPrice(stat?.mark ?? ta?.price ?? 0)} · ${sign(changePct)}${changePct.toFixed(2)}%`
: updatedAt > 0 ? 'stale feed' : 'connecting…',
computed: ta ? `RSI ${rsi.toFixed(0)} · MACD ${macdHist >= 0 ? '▲' : '▼'} · ATR ${(ta.atrPct ?? 0).toFixed(2)}%` : '—',
call: `${decision} · conf ${Math.round((v?.confidence ?? 0.5) * 100)}%`,
callTone: dirColor(decision),
paper: decision === 'SKIP' ? 'no position' : `${decision} ${(v?.sizePct ?? 0).toFixed(1)}%`,
}
const onAgentHover = (a: string | null) => {
setHoverAgent(a)
setInspect(a ? AGENT_RULES[a] ?? null : null)
}
return (
<div className="min-h-dvh bg-paper">
<StatusBar live={live} ago={ago} stale={stale} onReload={reload} />
<main className="mx-auto max-w-none space-y-2.5 px-4 py-3 sm:px-6">
{/* ── command bar — terminal grid: identity · instrument · timeframe ── */}
<div className="overflow-hidden rounded-md border border-line bg-panel">
<div className="grid grid-cols-1 gap-px bg-line-soft sm:grid-cols-[minmax(0,1fr)_auto_auto]">
{/* identity + help */}
<div className="flex items-center gap-2.5 bg-panel px-3 py-2">
<h1 className="font-display text-lg font-bold leading-none tracking-tight text-bone">
{coin}<span className="text-muted">-PERP</span>
</h1>
<span className="tnum font-mono text-data text-bone-dim">${fmtPrice(ta?.price ?? 0)}</span>
<span className="tnum font-mono text-micro" style={{ color: changeTone }}>
{sign(changePct)}{changePct.toFixed(2)}%
</span>
<button type="button" onClick={() => setGuideOpen((o) => !o)} aria-label="Help"
className="ml-auto flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-sm border border-line font-mono text-micro text-muted transition-colors hover:border-line-strong hover:text-bone">?</button>
</div>
{/* instrument switch — uniform grid cells */}
<div className="flex flex-col justify-center bg-panel px-3 py-2">
<div className="label-sm mb-1.5 !text-[0.5625rem] !text-faint">Instrument</div>
<div className="grid grid-cols-3 gap-px overflow-hidden rounded-sm bg-line-soft" role="tablist" aria-label="Instrument">
{COINS.map((c) => {
const on = c === coin
return (
<button key={c} role="tab" aria-selected={on} onClick={() => setCoin(c)}
style={on ? { boxShadow: 'inset 0 1.5px 0 var(--color-accent)' } : undefined}
className={['cursor-pointer px-3 py-1.5 text-center font-mono text-[0.625rem] font-semibold uppercase tracking-[0.06em] transition-colors', on ? 'bg-panel-2 text-accent' : 'bg-inset text-muted hover:bg-panel-2 hover:text-bone'].join(' ')}>
{c}
</button>
)
})}
</div>
</div>
{/* timeframe switch — uniform grid cells */}
<div className="flex flex-col justify-center bg-panel px-3 py-2">
<div className="label-sm mb-1.5 !text-[0.5625rem] !text-faint">Timeframe</div>
<div className="grid grid-cols-6 gap-px overflow-hidden rounded-sm bg-line-soft sm:min-w-[18rem]" role="tablist" aria-label="Timeframe">
{TIMEFRAMES.map((t) => {
const on = t === tf
return (
<button key={t} role="tab" aria-selected={on} onClick={() => setTf(t)}
style={on ? { boxShadow: 'inset 0 1.5px 0 var(--color-accent)' } : undefined}
className={['cursor-pointer px-2 py-1.5 text-center font-mono text-[0.625rem] font-semibold uppercase transition-colors', on ? 'bg-panel-2 text-accent' : 'bg-inset text-muted hover:bg-panel-2 hover:text-bone'].join(' ')}>
{t}
</button>
)
})}
</div>
</div>
</div>
</div>
{guideOpen && <GuideCard onClose={() => setGuideOpen(false)} />}
{/* ── workspace · chart with side blocks (moved to top — first) ── */}
<div className="grid items-stretch gap-2.5 lg:grid-cols-[250px_minmax(0,1fr)_330px]">
{/* LEFT — stats + pipeline */}
<div className="space-y-2.5">
<Panel title="Market" meta={marketMeta}>
<div className="grid grid-cols-2 gap-px bg-line-soft">
<Cell label="Mark" value={`$${fmtPrice(stat?.mark ?? ta?.price ?? 0)}`} info={METRIC_INFO.Mark} onInspect={setInspect} loading={loading} />
<Cell label="24h Δ" value={`${sign(changePct)}${changePct.toFixed(2)}%`} tone={changeTone} info={METRIC_INFO['24h Δ']} onInspect={setInspect} loading={loading} />
<Cell label="Funding" value={`${sign(funding)}${(funding * 100).toFixed(4)}%`} tone={fundingTone} info={METRIC_INFO.Funding} onInspect={setInspect} loading={loading} />
<Cell label="ATR" value={`${(ta?.atrPct ?? 0).toFixed(2)}%`} info={METRIC_INFO.ATR} onInspect={setInspect} loading={loading} />
<Cell label="Open interest" value={`$${fmtCompact(stat?.oiUsd ?? 0)}`} info={METRIC_INFO['Open interest']} onInspect={setInspect} loading={loading} />
<Cell label="24h volume" value={`$${fmtCompact(stat?.dayVolumeUsd ?? 0)}`} info={METRIC_INFO['24h volume']} onInspect={setInspect} loading={loading} />
</div>
</Panel>
<Panel title="Indicators" meta="→ committee">
<div className="grid grid-cols-2 gap-px bg-line-soft">
<Cell label="RSI (14)" value={rsi.toFixed(1)} tone={rsiTone} sub="→ Mean-Rev" bar={rsi} barTone={rsiTone} info={METRIC_INFO['RSI (14)']} onInspect={setInspect} loading={loading} />
<Cell label="Bollinger %B" value={(ta?.pctB ?? 0.5).toFixed(2)} sub="→ Breakout" bar={(ta?.pctB ?? 0.5) * 100} info={METRIC_INFO['Bollinger %B']} onInspect={setInspect} loading={loading} />
<Cell label="EMA 9/21" value={emaBull ? 'BULL ▲' : 'BEAR ▼'} tone={emaTone} sub="→ Trend" info={METRIC_INFO['EMA 9/21']} onInspect={setInspect} loading={loading} />
<Cell label="Funding-Z" value={(ta?.fundingZ ?? 0).toFixed(2)} sub="→ Funding" info={METRIC_INFO['Funding-Z']} onInspect={setInspect} loading={loading} />
<Cell label="MACD hist" value={`${macdHist >= 0 ? '▲' : '▼'} ${macdHist.toFixed(2)}`} tone={macdTone} info={METRIC_INFO['MACD hist']} onInspect={setInspect} loading={loading} />
<Cell label="Net vote" value={`${sign(net)}${net.toFixed(2)}`} tone={netTone} info={METRIC_INFO['Net vote']} onInspect={setInspect} loading={loading} />
</div>
</Panel>
</div>
{/* CENTER — chart (fills the row height; fixed height only when stacked) */}
<Panel title={`${coin}-PERP · OHLC`} meta={live ? `LIVE · ${tf.toUpperCase()} · 120` : marketMeta} className="lg:flex lg:flex-col">
<div className="h-[440px] bg-inset px-2 py-2 sm:px-3 lg:h-auto lg:min-h-0 lg:flex-1">
{updatedAt > 0 ? <TVChart candles={candles} fitKey={`${coin}-${tf}`} /> : <ChartSkeleton />}
</div>
</Panel>
{/* RIGHT — committee */}
<div className="space-y-2.5">
<Committee votes={votes} net={net} activeAgent={activeAgent} onSelect={setSelectedAgent} onHover={onAgentHover} loading={loading} />
</div>
</div>
{/* ── the call (compact) ── */}
<VerdictBar
decision={decision}
confidence={v?.confidence ?? 0.5}
sizePct={v?.sizePct ?? 1}
tpPct={v?.tpPct ?? 2}
slPct={v?.slPct ?? 1.2}
net={net}
votes={votes}
reasoning={v?.reasoning ?? 'Awaiting live market data…'}
live={live}
loading={loading}
/>
{/* ── pipeline row · Orchestra (left) ‖ Agent flow graph (right) ── */}
<div className="grid grid-cols-1 items-stretch gap-2.5 xl:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)]">
<Orchestra votes={votes} decision={decision} status={orchestraStatus} activeAgent={activeAgent} onSelect={setSelectedAgent} onHover={onAgentHover} onInspect={setInspect} loading={loading} gridClass="grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 xl:grid-cols-3" />
<AgentWorkflow data={data} decision={decision} activeAgent={activeAgent} onSelect={setSelectedAgent} onHover={onAgentHover} onInspect={setInspect} updatedAt={updatedAt} live={live} loading={loading} />
</div>
{/* ── live feed · the agent chain streaming in real time ── */}
<LiveFeed data={data} coin={coin} tf={tf} updatedAt={updatedAt} live={live} loading={loading} onInspect={setInspect} />
{/* ── inspector / status footer ── */}
<div className="flex items-center gap-3 rounded-md border border-line bg-panel px-3 py-2 font-mono text-micro">
<span className="label-sm shrink-0 !text-[0.5625rem]" style={{ color: 'var(--color-accent)' }}>Inspect</span>
<span className="min-w-0 flex-1 truncate text-bone-dim">
{inspect ?? 'Hover any field, agent or pipeline stage to inspect it.'}
</span>
</div>
</main>
<Onboarding />
</div>
)
}