spb/crypto-trade-bot
Public
Python 54.7%
TypeScript 42.5%
SQL 1.9%
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