"use client"; import { useQuery } from "@tanstack/react-query"; import { useRouter, useSearchParams } from "next/navigation"; import { Suspense, useCallback, useMemo, useState } from "react"; import { CandleChart, Sparkline } from "@/components/charts"; import { useSystemStatus } from "@/components/shell"; import { Badge, Card, Empty, ErrorBox, Input, PageHeader, Select, Spinner, Table, Td, Th, cx } from "@/components/ui"; import { api, qs } from "@/lib/api"; import { fmtAge, fmtBps, fmtDate, fmtNum, fmtPct, fmtPrice, fmtTs } from "@/lib/format"; import { useMarketStream } from "@/lib/sse"; import type { Book, Candle, Gap, Pair, Quality, Trade } from "@/lib/types"; const INTERVALS = [{ v: 1, l: "1 min" }, { v: 5, l: "5 min" }, { v: 60, l: "1 hour" }, { v: 1440, l: "1 day" }]; function MarketExplorer() { const params = useSearchParams(); const sys = useSystemStatus(); const universe = useMemo(() => sys.data?.universe ?? [], [sys.data]); const symbol = params.get("symbol") ?? universe[0] ?? "BTC/USD"; const interval = Number(params.get("interval") ?? 60); if (!sys.data) return ; return ; } function MarketView({ symbol, interval, universe, intervals }: { symbol: string; interval: number; universe: string[]; intervals: number[] }) { const params = useSearchParams(); const router = useRouter(); const [search, setSearch] = useState(""); const setParam = useCallback((k: string, v: string) => { const p = new URLSearchParams(params.toString()); p.set(k, v); router.replace(`/market?${p.toString()}`); }, [params, router]); const pairs = useQuery({ queryKey: ["pairs", search], queryFn: () => api<{ items: Pair[] }>(`/api/pairs?${qs({ q: search || undefined, all: !!search })}`), enabled: search.length >= 2 }); const candles = useQuery({ queryKey: ["candles", symbol, interval], queryFn: () => api<{ items: Candle[] }>(`/api/market/candles?${qs({ symbol, interval, limit: 1500 })}`), refetchInterval: interval * 60_000 }); const bookQ = useQuery({ queryKey: ["book", symbol], queryFn: () => api(`/api/market/book?symbol=${encodeURIComponent(symbol)}`), retry: false }); const tradesQ = useQuery({ queryKey: ["trades", symbol], queryFn: () => api<{ items: Trade[] }>(`/api/market/trades?${qs({ symbol, limit: 60 })}`) }); const micro = useQuery({ queryKey: ["micro", symbol], queryFn: () => api<{ items: { ts: number; spread_bps: number | null; imbalance: number | null; best_bid: number | null; best_ask: number | null }[] }>(`/api/market/microstructure?${qs({ symbol, seconds: 3600 })}`), refetchInterval: 30_000 }); const quality = useQuery({ queryKey: ["quality"], queryFn: () => api<{ items: Quality[]; gaps: Gap[] }>("/api/market/quality"), refetchInterval: 60_000 }); const pairMeta = useQuery({ queryKey: ["pair-meta", symbol], queryFn: async () => (await api<{ items: Pair[] }>(`/api/pairs?${qs({ q: symbol.split("/")[0], all: true })}`)).items.find((p) => p.wsname === symbol) ?? null }); const [ticker, setTicker] = useState | null>(null); const [liveBook, setBook] = useState(null); const [liveTape, setTape] = useState([]); const [liveBar, setLiveBar] = useState(null); // live stream state overrides the REST snapshot once it arrives (component is keyed by symbol+interval, so it resets) const book = liveBook ?? bookQ.data ?? null; const tape = liveTape.length ? liveTape : tradesQ.data?.items ?? []; const streamOk = useMarketStream([symbol], ["ticker", "book", "trade", "ohlc"], { ticker: (ev) => { const d = ev.data[0]; if (d) setTicker(d); }, book: (ev) => { const d = ev.data[0] as unknown as Book | undefined; if (d) setBook(d); }, trade: (ev) => { const rows = ev.data.map((d) => ({ trade_id: Number(d.trade_id), ts: Date.parse(String(d.timestamp)) / 1000, price: Number(d.price), volume: Number(d.qty), side: d.side as "buy" | "sell", order_type: String(d.ord_type ?? "") })); setTape((prev) => [...rows.reverse(), ...prev].slice(0, 80)); }, ohlc: (ev) => { const d = ev.data[0]; if (!d) return; const ts = Math.floor(Date.parse(String(d.interval_begin)) / 1000); const bar: Candle = { ts, open: Number(d.open), high: Number(d.high), low: Number(d.low), close: Number(d.close), vwap: Number(d.vwap), volume: Number(d.volume), count: Number(d.trades), closed: false }; if (interval === 1) setLiveBar(bar); else { const step = interval * 60; const bucket = Math.floor(ts / step) * step; setLiveBar((prev) => { const base = candles.data?.items.find((c) => c.ts === bucket); const cur = prev && prev.ts === bucket ? prev : base ?? { ts: bucket, open: bar.open, high: bar.high, low: bar.low, close: bar.close, vwap: bar.vwap, volume: 0, count: 0, closed: false }; return { ...cur, high: Math.max(cur.high, bar.high), low: Math.min(cur.low, bar.low), close: bar.close, closed: false }; }); } }, }); const q = quality.data?.items.find((x) => x.symbol === symbol && x.interval_min === interval); const gaps = quality.data?.gaps.filter((g) => g.symbol === symbol && g.interval_min === interval) ?? []; const bestBid = book?.best_bid, bestAsk = book?.best_ask; const maxDepth = book ? Math.max(...book.bids.slice(0, 15).map((b) => b[1]), ...book.asks.slice(0, 15).map((a) => a[1]), 1e-9) : 1; return ( <> {streamOk ? "stream connected" : "stream reconnecting"}} />
setSearch(e.target.value)} aria-autocomplete="list" aria-controls="sym-list" /> {search.length >= 2 && (
    {pairs.data?.items.length ? pairs.data.items.map((p) => (
  • )) :
  • {pairs.isLoading ? "Searching…" : "No match"}
  • }
)}
{universe.map((s) => )}
{q ? {q.bars} bars · {q.coverage_days}d · {q.gaps} gaps{q.lag_bars > 0 ? ` · lag ${q.lag_bars}` : ""} : no stored bars for {symbol} @ {interval}m}
{symbol} {ticker ? fmtPrice(ticker.last) : fmtPrice(candles.data?.items.at(-1)?.close)}{ticker && = 0 ? "text-up" : "text-down")}>{fmtPct(ticker.change_pct, 2, true)} 24h}} subtitle={ticker ? `bid ${fmtPrice(ticker.bid)} · ask ${fmtPrice(ticker.ask)} · 24h vol ${fmtNum(ticker.volume, 0)} · vwap ${fmtPrice(ticker.vwap)}` : "waiting for ticker…"}> {candles.isLoading && } {candles.error && } {candles.data && (candles.data.items.length ? : This symbol is not in the ingestion universe, or the worker has not polled this interval yet.)}
{micro.data?.items.length ? (
Spread (bps)
i.spread_bps)} color="#f59e0b" />
{fmtBps(micro.data.items.at(-1)?.spread_bps, 2)} now
Imbalance (bid−ask)/(bid+ask)
i.imbalance)} color="#4f8cff" />
{fmtNum(micro.data.items.at(-1)?.imbalance, 3)} now
) : Samples are written by the ingestion worker while the book stream is live.}
{q ? (
First bar
{fmtDate(q.first_ts)}
Last closed bar
{fmtTs(q.last_ts)}
Closed bars
{q.bars}
Missing bars
{q.missing_bars} in {q.gaps} gaps
Lag
{q.lag_bars} bars {q.stale && stale}
) : } {gaps.length > 0 &&
    {gaps.slice(0, 10).map((g) =>
  • {fmtTs(g.gap_start)} → {fmtTs(g.gap_end)} ({g.missing_bars} bars)
  • )}
}
spread {fmtBps(book.spread_bps, 2)} · imbalance {fmtNum(book.imbalance, 3)} · {book.valid ? checksum ok : checksum mismatch}{book.age_s != null && ` · ${fmtAge(book.age_s)}`} : "waiting for book…"}> {book ? (
BidQty
{book.bids.slice(0, 15).map(([p, qty]) => (
{fmtPrice(p)}{fmtNum(qty, 4)}
))}
AskQty
{book.asks.slice(0, 15).map(([p, qty]) => (
{fmtPrice(p)}{fmtNum(qty, 4)}
))}
mid {bestBid && bestAsk ? fmtPrice((bestBid + bestAsk) / 2) : "—"}{pairMeta.data && <> · tick {pairMeta.data.tick_size} · min {pairMeta.data.ordermin} {pairMeta.data.base} / {pairMeta.data.costmin} {pairMeta.data.quote}}
) : bookQ.isLoading ? : Only universe symbols are streamed.}
{tape.length ? ( {tape.map((t) => )}
TimePriceQtySide
{new Date(t.ts * 1000).toISOString().slice(11, 19)}{fmtPrice(t.price)}{fmtNum(t.volume, 4)}{t.side}
) : }
); } export default function MarketPage() { return }>; }