SPB Git forge
1commits 1branches 0releases
189.0 KBsize
maindefault branch
6 h agolast push
Python 54.7% TypeScript 42.5% SQL 1.9%
13.9 KB · 178 lines tsx
Raw Blame History
1"use client";2import { useQuery } from "@tanstack/react-query";3import { useRouter, useSearchParams } from "next/navigation";4import { Suspense, useCallback, useMemo, useState } from "react";5import { CandleChart, Sparkline } from "@/components/charts";6import { useSystemStatus } from "@/components/shell";7import { Badge, Card, Empty, ErrorBox, Input, PageHeader, Select, Spinner, Table, Td, Th, cx } from "@/components/ui";8import { api, qs } from "@/lib/api";9import { fmtAge, fmtBps, fmtDate, fmtNum, fmtPct, fmtPrice, fmtTs } from "@/lib/format";10import { useMarketStream } from "@/lib/sse";11import type { Book, Candle, Gap, Pair, Quality, Trade } from "@/lib/types";1213const INTERVALS = [{ v: 1, l: "1 min" }, { v: 5, l: "5 min" }, { v: 60, l: "1 hour" }, { v: 1440, l: "1 day" }];1415function MarketExplorer() {16  const params = useSearchParams();17  const sys = useSystemStatus();18  const universe = useMemo(() => sys.data?.universe ?? [], [sys.data]);19  const symbol = params.get("symbol") ?? universe[0] ?? "BTC/USD";20  const interval = Number(params.get("interval") ?? 60);21  if (!sys.data) return <Spinner />;22  return <MarketView key={`${symbol}|${interval}`} symbol={symbol} interval={interval} universe={universe} intervals={sys.data.intervals} />;23}2425function MarketView({ symbol, interval, universe, intervals }: { symbol: string; interval: number; universe: string[]; intervals: number[] }) {26  const params = useSearchParams();27  const router = useRouter();28  const [search, setSearch] = useState("");29  const setParam = useCallback((k: string, v: string) => {30    const p = new URLSearchParams(params.toString());31    p.set(k, v);32    router.replace(`/market?${p.toString()}`);33  }, [params, router]);3435  const pairs = useQuery({ queryKey: ["pairs", search], queryFn: () => api<{ items: Pair[] }>(`/api/pairs?${qs({ q: search || undefined, all: !!search })}`), enabled: search.length >= 2 });36  const candles = useQuery({ queryKey: ["candles", symbol, interval], queryFn: () => api<{ items: Candle[] }>(`/api/market/candles?${qs({ symbol, interval, limit: 1500 })}`), refetchInterval: interval * 60_000 });37  const bookQ = useQuery({ queryKey: ["book", symbol], queryFn: () => api<Book>(`/api/market/book?symbol=${encodeURIComponent(symbol)}`), retry: false });38  const tradesQ = useQuery({ queryKey: ["trades", symbol], queryFn: () => api<{ items: Trade[] }>(`/api/market/trades?${qs({ symbol, limit: 60 })}`) });39  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 });40  const quality = useQuery({ queryKey: ["quality"], queryFn: () => api<{ items: Quality[]; gaps: Gap[] }>("/api/market/quality"), refetchInterval: 60_000 });41  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 });4243  const [ticker, setTicker] = useState<Record<string, unknown> | null>(null);44  const [liveBook, setBook] = useState<Book | null>(null);45  const [liveTape, setTape] = useState<Trade[]>([]);46  const [liveBar, setLiveBar] = useState<Candle | null>(null);47  // live stream state overrides the REST snapshot once it arrives (component is keyed by symbol+interval, so it resets)48  const book = liveBook ?? bookQ.data ?? null;49  const tape = liveTape.length ? liveTape : tradesQ.data?.items ?? [];5051  const streamOk = useMarketStream([symbol], ["ticker", "book", "trade", "ohlc"], {52    ticker: (ev) => { const d = ev.data[0]; if (d) setTicker(d); },53    book: (ev) => { const d = ev.data[0] as unknown as Book | undefined; if (d) setBook(d); },54    trade: (ev) => {55      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 ?? "") }));56      setTape((prev) => [...rows.reverse(), ...prev].slice(0, 80));57    },58    ohlc: (ev) => {59      const d = ev.data[0];60      if (!d) return;61      const ts = Math.floor(Date.parse(String(d.interval_begin)) / 1000);62      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 };63      if (interval === 1) setLiveBar(bar);64      else {65        const step = interval * 60;66        const bucket = Math.floor(ts / step) * step;67        setLiveBar((prev) => {68          const base = candles.data?.items.find((c) => c.ts === bucket);69          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 };70          return { ...cur, high: Math.max(cur.high, bar.high), low: Math.min(cur.low, bar.low), close: bar.close, closed: false };71        });72      }73    },74  });7576  const q = quality.data?.items.find((x) => x.symbol === symbol && x.interval_min === interval);77  const gaps = quality.data?.gaps.filter((g) => g.symbol === symbol && g.interval_min === interval) ?? [];78  const bestBid = book?.best_bid, bestAsk = book?.best_ask;79  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;8081  return (82    <>83      <PageHeader title="Market Explorer" subtitle="Candles accumulated from Kraken REST OHLC (≤720 bars per poll), live updates from WebSocket v2." actions={<Badge tone={streamOk ? "up" : "warn"}>{streamOk ? "stream connected" : "stream reconnecting"}</Badge>} />84      <div className="mb-3 flex flex-wrap items-end gap-3">85        <div className="relative w-64">86          <label className="mb-1 block text-xs font-medium text-fg-muted" htmlFor="sym">Symbol</label>87          <Input id="sym" placeholder="Search pairs (e.g. DOGE)…" value={search} onChange={(e) => setSearch(e.target.value)} aria-autocomplete="list" aria-controls="sym-list" />88          {search.length >= 2 && (89            <ul id="sym-list" role="listbox" className="absolute z-20 mt-1 max-h-64 w-full overflow-auto rounded-md border border-line-strong bg-bg-elev shadow-xl scroll-thin">90              {pairs.data?.items.length ? pairs.data.items.map((p) => (91                <li key={p.pair_id} role="option" aria-selected={p.wsname === symbol}>92                  <button className="flex w-full items-center justify-between px-3 py-1.5 text-left text-xs hover:bg-bg-sub" onClick={() => { setParam("symbol", p.wsname); setSearch(""); }}>93                    <span className="font-medium">{p.wsname}</span><span className="text-fg-dim">{p.status}{p.in_universe ? " · universe" : " · not streamed"}</span>94                  </button>95                </li>96              )) : <li className="px-3 py-2 text-xs text-fg-dim">{pairs.isLoading ? "Searching…" : "No match"}</li>}97            </ul>98          )}99        </div>100        <div className="flex gap-1" role="group" aria-label="Universe">101          {universe.map((s) => <button key={s} onClick={() => setParam("symbol", s)} className={cx("rounded-md border px-2.5 py-1.5 text-xs", s === symbol ? "border-accent bg-accent-soft text-fg" : "border-line-strong text-fg-muted hover:text-fg")}>{s}</button>)}102        </div>103        <label className="text-xs font-medium text-fg-muted">Interval104          <Select className="mt-1 w-28" value={interval} onChange={(e) => setParam("interval", e.target.value)}>{INTERVALS.filter((i) => intervals.includes(i.v) || i.v === interval).map((i) => <option key={i.v} value={i.v}>{i.l}</option>)}</Select>105        </label>106        <div className="ml-auto flex items-center gap-2 text-xs">107          {q ? <Badge tone={q.stale ? "warn" : "up"}>{q.bars} bars · {q.coverage_days}d · {q.gaps} gaps{q.lag_bars > 0 ? ` · lag ${q.lag_bars}` : ""}</Badge> : <Badge tone="muted">no stored bars for {symbol} @ {interval}m</Badge>}108        </div>109      </div>110111      <div className="grid gap-4 xl:grid-cols-[1fr_320px]">112        <div className="flex flex-col gap-4">113          <Card title={<span className="flex items-center gap-3">{symbol} <span className="num text-base">{ticker ? fmtPrice(ticker.last) : fmtPrice(candles.data?.items.at(-1)?.close)}</span>{ticker && <span className={cx("num text-xs", Number(ticker.change_pct) >= 0 ? "text-up" : "text-down")}>{fmtPct(ticker.change_pct, 2, true)} 24h</span>}</span>} subtitle={ticker ? `bid ${fmtPrice(ticker.bid)} · ask ${fmtPrice(ticker.ask)} · 24h vol ${fmtNum(ticker.volume, 0)} · vwap ${fmtPrice(ticker.vwap)}` : "waiting for ticker…"}>114            {candles.isLoading && <Spinner />}115            {candles.error && <ErrorBox error={candles.error} />}116            {candles.data && (candles.data.items.length ? <CandleChart candles={candles.data.items} live={liveBar} /> : <Empty title="No candles stored yet">This symbol is not in the ingestion universe, or the worker has not polled this interval yet.</Empty>)}117          </Card>118          <div className="grid gap-4 md:grid-cols-2">119            <Card title="Microstructure (last hour)" subtitle="1-second samples of the checksum-verified local book.">120              {micro.data?.items.length ? (121                <div className="grid grid-cols-2 gap-3 text-xs">122                  <div><div className="text-fg-dim">Spread (bps)</div><Sparkline values={micro.data.items.map((i) => i.spread_bps)} color="#f59e0b" /><div className="num">{fmtBps(micro.data.items.at(-1)?.spread_bps, 2)} now</div></div>123                  <div><div className="text-fg-dim">Imbalance (bid−ask)/(bid+ask)</div><Sparkline values={micro.data.items.map((i) => i.imbalance)} color="#4f8cff" /><div className="num">{fmtNum(micro.data.items.at(-1)?.imbalance, 3)} now</div></div>124                </div>125              ) : <Empty title="No samples yet">Samples are written by the ingestion worker while the book stream is live.</Empty>}126            </Card>127            <Card title="Data quality" subtitle="Coverage grows from first deployment; Kraken REST only serves the last 720 bars.">128              {q ? (129                <dl className="grid grid-cols-2 gap-y-1 text-xs">130                  <dt className="text-fg-dim">First bar</dt><dd className="num">{fmtDate(q.first_ts)}</dd>131                  <dt className="text-fg-dim">Last closed bar</dt><dd className="num">{fmtTs(q.last_ts)}</dd>132                  <dt className="text-fg-dim">Closed bars</dt><dd className="num">{q.bars}</dd>133                  <dt className="text-fg-dim">Missing bars</dt><dd className="num">{q.missing_bars} in {q.gaps} gaps</dd>134                  <dt className="text-fg-dim">Lag</dt><dd className="num">{q.lag_bars} bars {q.stale && <Badge tone="warn">stale</Badge>}</dd>135                </dl>136              ) : <Empty title="No quality record" />}137              {gaps.length > 0 && <ul className="mt-2 max-h-24 overflow-auto text-[11px] text-fg-dim scroll-thin">{gaps.slice(0, 10).map((g) => <li key={g.gap_start}>{fmtTs(g.gap_start)} → {fmtTs(g.gap_end)} ({g.missing_bars} bars)</li>)}</ul>}138            </Card>139          </div>140        </div>141        <div className="flex flex-col gap-4">142          <Card title="Order book" subtitle={book ? <span>spread {fmtBps(book.spread_bps, 2)} · imbalance <span className="num">{fmtNum(book.imbalance, 3)}</span> · {book.valid ? <Badge tone="up">checksum ok</Badge> : <Badge tone="down">checksum mismatch</Badge>}{book.age_s != null && ` · ${fmtAge(book.age_s)}`}</span> : "waiting for book…"}>143            {book ? (144              <div className="grid grid-cols-2 gap-2 text-[11px]">145                <div>146                  <div className="mb-1 flex justify-between text-fg-dim"><span>Bid</span><span>Qty</span></div>147                  {book.bids.slice(0, 15).map(([p, qty]) => (148                    <div key={p} className="relative flex justify-between py-px num"><span className="absolute inset-y-0 right-0 bg-up/15" style={{ width: `${(qty / maxDepth) * 100}%` }} /><span className="relative text-up">{fmtPrice(p)}</span><span className="relative">{fmtNum(qty, 4)}</span></div>149                  ))}150                </div>151                <div>152                  <div className="mb-1 flex justify-between text-fg-dim"><span>Ask</span><span>Qty</span></div>153                  {book.asks.slice(0, 15).map(([p, qty]) => (154                    <div key={p} className="relative flex justify-between py-px num"><span className="absolute inset-y-0 left-0 bg-down/15" style={{ width: `${(qty / maxDepth) * 100}%` }} /><span className="relative text-down">{fmtPrice(p)}</span><span className="relative">{fmtNum(qty, 4)}</span></div>155                  ))}156                </div>157                <div className="col-span-2 mt-1 border-t border-line pt-1 text-fg-dim">mid <span className="num text-fg">{bestBid && bestAsk ? fmtPrice((bestBid + bestAsk) / 2) : "—"}</span>{pairMeta.data && <> · tick {pairMeta.data.tick_size} · min {pairMeta.data.ordermin} {pairMeta.data.base} / {pairMeta.data.costmin} {pairMeta.data.quote}</>}</div>158              </div>159            ) : bookQ.isLoading ? <Spinner /> : <Empty title="No book">Only universe symbols are streamed.</Empty>}160          </Card>161          <Card title="Trades" subtitle="Taker side; live via WebSocket.">162            {tape.length ? (163              <Table className="max-h-80 overflow-y-auto">164                <thead><tr><Th>Time</Th><Th align="right">Price</Th><Th align="right">Qty</Th><Th>Side</Th></tr></thead>165                <tbody>{tape.map((t) => <tr key={`${t.trade_id}-${t.ts}`}><Td num>{new Date(t.ts * 1000).toISOString().slice(11, 19)}</Td><Td align="right" num className={t.side === "buy" ? "text-up" : "text-down"}>{fmtPrice(t.price)}</Td><Td align="right" num>{fmtNum(t.volume, 4)}</Td><Td>{t.side}</Td></tr>)}</tbody>166              </Table>167            ) : <Empty title="No trades yet" />}168          </Card>169        </div>170      </div>171    </>172  );173}174175export default function MarketPage() {176  return <Suspense fallback={<Spinner />}><MarketExplorer /></Suspense>;177}178