All checks were successful
deploy / deploy (push) Successful in 7s
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>
565 lines
30 KiB
TypeScript
565 lines
30 KiB
TypeScript
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 0–100; >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>
|
||
)
|
||
}
|