Live L2 Order Book + Trade Tape visualization (Bookmap-style)
New components:
- hyperliquid-ws.ts: WebSocket hook for Hyperliquid L2 + trades
- Auto-reconnect, ring buffer (500 trades)
- Computes imbalance, total bid/ask volume, mid, spread
- Type-safe interfaces: L2Snapshot, TradeTapeEntry
- orderbook-depth-map.tsx: Dual-panel Canvas 2D visualization
- Top panel (~55%): L2 volume profile histogram
- Green bid bars (#00C853), red ask bars (#FF1744)
- Yellow mid line (#FFEB3B) with floating price labels
- Price axis, volume scale, orange mid marker
- Quant overlay system: fair value, VWAP, signals
- Bottom panel (~45%): Live trade tape
- Amber trade path (#FFAB00)
- Buy/sell markers (green/red dots sized by trade size)
- Latest trade callout with side + price
- Dark theme (#000000), monospace fonts, zero flicker
Integration:
- Added to all strategy detail views (live tab only)
- Renders below trade history table
- WebSocket connects on mount, reconnects on error
Visual specification per user request:
- Bid/ask bars: neon green/red on pure black
- Mid line: yellow dashed with floating labels
- Trade path: amber staircase with colored markers
- No grid clutter, professional trading terminal aesthetic
This commit is contained in:
@@ -12,6 +12,7 @@ import { StrategyCard } from "@/components/strategy-card";
|
||||
import { EquityChart } from "@/components/equity-chart";
|
||||
import { PositionsPanel } from "@/components/positions-panel";
|
||||
import { OBIDetail } from "@/components/obi-detail";
|
||||
import OrderBookDepthMap from "@/components/orderbook-depth-map";
|
||||
import { useLiveMetrics, usePaperMetrics, fetchHistorical, fetchBacktestDetail, recalcBacktest } from "@/lib/api";
|
||||
import type { Strategy, BacktestSummary, BacktestFull, Trade, Position, Order } from "@/lib/types";
|
||||
|
||||
@@ -274,6 +275,12 @@ export default function Dashboard() {
|
||||
<p className="text-xs text-muted-foreground text-center py-12">No trades recorded yet</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Live L2 Order Book + Trade Tape (all strategies, live tab only) */}
|
||||
{detailTab === "live" && (
|
||||
<div className="mt-6">
|
||||
<OrderBookDepthMap coin="BTC" height={480} topRatio={0.55} />
|
||||
</div>
|
||||
)}
|
||||
<div className="h-8" />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useMemo } from "react";
|
||||
import { useHyperliquidWebSocket, type L2Snapshot, type TradeTapeEntry } from "@/lib/hyperliquid-ws";
|
||||
|
||||
// ═══════════════════════ Color Palette ═══════════════════════
|
||||
const BID_COLOR = "#00C853";
|
||||
const ASK_COLOR = "#FF1744";
|
||||
const MID_COLOR = "#FFEB3B";
|
||||
const TRADE_PATH = "#FFAB00";
|
||||
const TEXT_COLOR = "#CCCCCC";
|
||||
const TEXT_BRIGHT = "#FFFFFF";
|
||||
const BG_COLOR = "#000000";
|
||||
const GRID_COLOR = "rgba(255,255,255,0.04)";
|
||||
|
||||
// ═══════════════════════ Quant Overlay Types ═══════════════════════
|
||||
export interface QuantOverlay {
|
||||
/** Horizontal line at a fair value price */
|
||||
fairValue?: number;
|
||||
/** VWAP band: { mid, upper, lower } */
|
||||
vwap?: { mid: number; upper: number; lower: number };
|
||||
/** Imbalance annotation point */
|
||||
imbalance?: { value: number; label: string };
|
||||
/** Custom signal markers at specific prices */
|
||||
signals?: { px: number; label: string; color: string }[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
coin?: string;
|
||||
height?: number;
|
||||
topRatio?: number; // fraction for L2 panel (0-1)
|
||||
overlays?: QuantOverlay;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function OrderBookDepthMap({
|
||||
coin = "BTC",
|
||||
height = 600,
|
||||
topRatio = 0.55,
|
||||
overlays,
|
||||
className = "",
|
||||
}: Props) {
|
||||
const topCanvas = useRef<HTMLCanvasElement>(null);
|
||||
const botCanvas = useRef<HTMLCanvasElement>(null);
|
||||
const topH = Math.round(height * topRatio);
|
||||
const botH = height - topH - 2;
|
||||
|
||||
const { l2, trades, connected, error } = useHyperliquidWebSocket(coin);
|
||||
|
||||
// ── L2 Profile Render ──
|
||||
useEffect(() => {
|
||||
const canvas = topCanvas.current;
|
||||
if (!canvas || !l2) return;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const W = canvas.clientWidth;
|
||||
const H = canvas.clientHeight;
|
||||
canvas.width = W * dpr;
|
||||
canvas.height = H * dpr;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = BG_COLOR;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
const margin = { top: 20, bottom: 30, left: 60, right: 60 };
|
||||
const plotW = W - margin.left - margin.right;
|
||||
const plotH = H - margin.top - margin.bottom;
|
||||
|
||||
// Price range: center on mid, show ±2% on each side
|
||||
const mid = l2.mid;
|
||||
const priceRange = mid * 0.04; // ±2%
|
||||
const pMin = mid - priceRange;
|
||||
const pMax = mid + priceRange;
|
||||
|
||||
// Find max volume for scaling
|
||||
const allVols = [
|
||||
...l2.bids.slice(0, 100).map((l) => l.sz),
|
||||
...l2.asks.slice(0, 100).map((l) => l.sz),
|
||||
];
|
||||
const maxVol = Math.max(...allVols, 1);
|
||||
const volScale = Math.max(maxVol * 1.2, 10);
|
||||
|
||||
const priceToX = (px: number) => margin.left + ((px - pMin) / (pMax - pMin)) * plotW;
|
||||
const volToH = (sz: number) => (sz / volScale) * plotH;
|
||||
|
||||
// Grid lines
|
||||
ctx.strokeStyle = GRID_COLOR;
|
||||
ctx.lineWidth = 1;
|
||||
const gridSteps = 10;
|
||||
for (let i = 0; i <= gridSteps; i++) {
|
||||
const y = margin.top + (i / gridSteps) * plotH;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(margin.left, y);
|
||||
ctx.lineTo(margin.left + plotW, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Draw bid bars (green, right-to-left from mid)
|
||||
for (const bid of l2.bids.slice(0, 100)) {
|
||||
if (bid.px > mid + 50) continue; // Skip far bids
|
||||
const x = priceToX(bid.px);
|
||||
const barW = Math.max(1, plotW / 200);
|
||||
const barH = volToH(bid.sz);
|
||||
const y = margin.top + plotH - barH;
|
||||
|
||||
ctx.fillStyle = BID_COLOR;
|
||||
ctx.fillRect(x - barW / 2, y, barW, barH);
|
||||
}
|
||||
|
||||
// Draw ask bars (red, left-to-right from mid)
|
||||
for (const ask of l2.asks.slice(0, 100)) {
|
||||
if (ask.px < mid - 50) continue;
|
||||
const x = priceToX(ask.px);
|
||||
const barW = Math.max(1, plotW / 200);
|
||||
const barH = volToH(ask.sz);
|
||||
const y = margin.top + plotH - barH;
|
||||
|
||||
ctx.fillStyle = ASK_COLOR;
|
||||
ctx.fillRect(x - barW / 2, y, barW, barH);
|
||||
}
|
||||
|
||||
// Mid-price line
|
||||
const midX = priceToX(mid);
|
||||
ctx.strokeStyle = MID_COLOR;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(midX, margin.top);
|
||||
ctx.lineTo(midX, margin.top + plotH);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Volume scale labels (right side)
|
||||
ctx.fillStyle = TEXT_COLOR;
|
||||
ctx.font = "9px monospace";
|
||||
ctx.textAlign = "right";
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const vol = Math.round((volScale * i) / 4);
|
||||
const y = margin.top + plotH - (i / 4) * plotH;
|
||||
ctx.fillText(vol.toLocaleString(), W - 4, y + 3);
|
||||
}
|
||||
|
||||
// Price labels (bottom)
|
||||
ctx.textAlign = "center";
|
||||
const priceLabels = 6;
|
||||
for (let i = 0; i <= priceLabels; i++) {
|
||||
const px = pMin + (i / priceLabels) * priceRange;
|
||||
const x = priceToX(px);
|
||||
ctx.fillText(px.toFixed(1), x, H - 4);
|
||||
}
|
||||
|
||||
// Mid price marker (floating)
|
||||
ctx.fillStyle = TEXT_BRIGHT;
|
||||
ctx.font = "bold 11px monospace";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(mid.toFixed(1), midX, margin.top + plotH / 2 - 12);
|
||||
ctx.fillText(mid.toFixed(1), midX, margin.top + plotH / 2 + 18);
|
||||
|
||||
// Orange dot at mid baseline
|
||||
ctx.fillStyle = "#FF9100";
|
||||
ctx.beginPath();
|
||||
ctx.arc(midX, margin.top + plotH, 3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// ── Quant Overlays ──
|
||||
if (overlays) {
|
||||
// Fair value line
|
||||
if (overlays.fairValue) {
|
||||
const fvX = priceToX(overlays.fairValue);
|
||||
ctx.strokeStyle = "rgba(33, 150, 243, 0.7)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([3, 6]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(fvX, margin.top);
|
||||
ctx.lineTo(fvX, margin.top + plotH);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
ctx.fillStyle = "#2196F3";
|
||||
ctx.font = "9px monospace";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText("FV", fvX, margin.top - 4);
|
||||
}
|
||||
|
||||
// VWAP bands
|
||||
if (overlays.vwap) {
|
||||
for (const [px, color] of [
|
||||
[overlays.vwap.upper, "rgba(255,152,0,0.4)"],
|
||||
[overlays.vwap.mid, "rgba(255,152,0,0.6)"],
|
||||
[overlays.vwap.lower, "rgba(255,152,0,0.4)"],
|
||||
] as const) {
|
||||
const vx = priceToX(px);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(vx, margin.top);
|
||||
ctx.lineTo(vx, margin.top + plotH);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// Signal markers
|
||||
if (overlays.signals) {
|
||||
for (const sig of overlays.signals) {
|
||||
const sx = priceToX(sig.px);
|
||||
ctx.fillStyle = sig.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(sx, margin.top + 15, 4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = TEXT_BRIGHT;
|
||||
ctx.font = "8px monospace";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(sig.label, sx, margin.top + 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Header
|
||||
ctx.fillStyle = TEXT_COLOR;
|
||||
ctx.font = "10px monospace";
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillText(`L2 Order Book \u00B7 ${coin}-USD \u00B7 LIVE`, 8, 12);
|
||||
ctx.fillStyle = connected ? "#00C853" : "#FF1744";
|
||||
ctx.fillText(connected ? "\u25CF" : "\u25CF", W - 18, 12);
|
||||
}, [l2, connected, coin, overlays, topH]);
|
||||
|
||||
// ── Trade Tape Render ──
|
||||
useEffect(() => {
|
||||
const canvas = botCanvas.current;
|
||||
if (!canvas || trades.length < 2) return;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const W = canvas.clientWidth;
|
||||
const H = canvas.clientHeight;
|
||||
canvas.width = W * dpr;
|
||||
canvas.height = H * dpr;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = "#0A0A0A"; // Slightly lighter than pure black
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
const margin = { top: 20, bottom: 15, left: 8, right: 8 };
|
||||
const plotW = W - margin.left - margin.right;
|
||||
const plotH = H - margin.top - margin.bottom;
|
||||
|
||||
// Find price range
|
||||
const prices = trades.map((t) => t.px);
|
||||
const pMin = Math.min(...prices);
|
||||
const pMax = Math.max(...prices);
|
||||
const pRange = pMax - pMin || 1;
|
||||
const pPad = pRange * 0.1 || 10;
|
||||
const pLo = pMin - pPad;
|
||||
const pHi = pMax + pPad;
|
||||
|
||||
const priceToY = (px: number) => margin.top + plotH - ((px - pLo) / (pHi - pLo)) * plotH;
|
||||
|
||||
// Draw trade path
|
||||
ctx.strokeStyle = TRADE_PATH;
|
||||
ctx.lineWidth = 1.2;
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < trades.length; i++) {
|
||||
const x = margin.left + (i / trades.length) * plotW;
|
||||
const y = priceToY(trades[i].px);
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Draw individual trade markers
|
||||
const maxSz = Math.max(...trades.map((t) => t.sz), 1);
|
||||
for (const trade of trades) {
|
||||
const idx = trades.indexOf(trade);
|
||||
const x = margin.left + (idx / trades.length) * plotW;
|
||||
const y = priceToY(trade.px);
|
||||
const r = Math.max(1, (trade.sz / maxSz) * 3 + 1);
|
||||
|
||||
const color = trade.side === "buy" ? "#66BB6A" : "#EF5350";
|
||||
ctx.fillStyle = color;
|
||||
ctx.globalAlpha = 0.7;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
// Latest trade marker
|
||||
const lastTrade = trades[trades.length - 1];
|
||||
const lx = margin.left + ((trades.length - 1) / trades.length) * plotW;
|
||||
const ly = priceToY(lastTrade.px);
|
||||
ctx.strokeStyle = lastTrade.side === "buy" ? "#00E676" : "#FF5252";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(lx, ly, 4, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
// Latest price label
|
||||
ctx.fillStyle = TEXT_BRIGHT;
|
||||
ctx.font = "10px monospace";
|
||||
ctx.textAlign = "left";
|
||||
const sideLabel = lastTrade.side === "buy" ? "B" : "S";
|
||||
const sideColor = lastTrade.side === "buy" ? "#00E676" : "#FF5252";
|
||||
ctx.fillStyle = sideColor;
|
||||
ctx.fillText(`${sideLabel} ${lastTrade.px.toFixed(1)}`, 8, 12);
|
||||
ctx.fillStyle = TEXT_COLOR;
|
||||
ctx.fillText(` | ${lastTrade.sz.toFixed(4)}`, 80, 12);
|
||||
|
||||
// Header
|
||||
ctx.fillStyle = TEXT_COLOR;
|
||||
ctx.font = "9px monospace";
|
||||
ctx.textAlign = "right";
|
||||
ctx.fillText(`Trades \u00B7 ${trades.length}`, W - 8, 12);
|
||||
}, [trades]);
|
||||
|
||||
// ── Empty states ──
|
||||
const noL2 = !l2 && !error;
|
||||
|
||||
return (
|
||||
<div className={`bg-black ${className}`} style={{ height }}>
|
||||
{/* Top: L2 Volume Profile */}
|
||||
<div style={{ height: topH }} className="relative">
|
||||
<canvas ref={topCanvas} className="w-full h-full" />
|
||||
{noL2 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-gray-500 text-xs font-mono">
|
||||
{connected ? "Waiting for L2 data..." : "Connecting to Hyperliquid..."}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="absolute top-1 right-1 text-red-500 text-[9px] font-mono">
|
||||
{error} — reconnecting...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom: Trade Tape */}
|
||||
<div style={{ height: botH }} className="relative">
|
||||
<canvas ref={botCanvas} className="w-full h-full" />
|
||||
{trades.length < 2 && !error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-gray-600 text-xs font-mono">Waiting for trades...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useCallback, useEffect, useState } from "react";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export interface L2Level {
|
||||
px: number;
|
||||
sz: number;
|
||||
n: number; // number of orders
|
||||
}
|
||||
|
||||
export interface L2Book {
|
||||
coin: string;
|
||||
levels: [L2Level[], L2Level[]]; // [bids, asks]
|
||||
time: number;
|
||||
}
|
||||
|
||||
export interface Trade {
|
||||
coin: string;
|
||||
side: string; // "A" = ask (sell), "B" = bid (buy)
|
||||
px: number;
|
||||
sz: number;
|
||||
hash: string;
|
||||
tid: number;
|
||||
time: number;
|
||||
}
|
||||
|
||||
export interface L2Snapshot {
|
||||
bids: { px: number; sz: number }[];
|
||||
asks: { px: number; sz: number }[];
|
||||
mid: number;
|
||||
spread: number;
|
||||
totalBidVol: number;
|
||||
totalAskVol: number;
|
||||
imbalance: number;
|
||||
time: number;
|
||||
}
|
||||
|
||||
export interface TradeTapeEntry {
|
||||
px: number;
|
||||
sz: number;
|
||||
side: "buy" | "sell";
|
||||
time: number;
|
||||
}
|
||||
|
||||
// ── WebSocket Hook ──
|
||||
|
||||
interface HyperliquidData {
|
||||
l2: L2Snapshot | null;
|
||||
trades: TradeTapeEntry[];
|
||||
connected: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function useHyperliquidWebSocket(coin: string = "BTC"): HyperliquidData {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const l2Ref = useRef<L2Snapshot | null>(null);
|
||||
const tradesRef = useRef<TradeTapeEntry[]>([]);
|
||||
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const subscribed = useRef(false);
|
||||
|
||||
const [l2, setL2] = useState<L2Snapshot | null>(null);
|
||||
const [trades, setTrades] = useState<TradeTapeEntry[]>([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
// Already connected — just resubscribe
|
||||
wsRef.current.send(JSON.stringify({ type: "subscribe", subscription: { type: "l2Book", coin } }));
|
||||
wsRef.current.send(JSON.stringify({ type: "subscribe", subscription: { type: "trades", coin } }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Close stale connection
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
|
||||
const ws = new WebSocket("wss://api.hyperliquid.xyz/ws");
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
setConnected(true);
|
||||
setError(null);
|
||||
subscribed.current = false;
|
||||
// Subscribe
|
||||
ws.send(JSON.stringify({ type: "subscribe", subscription: { type: "l2Book", coin } }));
|
||||
ws.send(JSON.stringify({ type: "subscribe", subscription: { type: "trades", coin } }));
|
||||
subscribed.current = true;
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.channel === "l2Book" && msg.data?.levels) {
|
||||
const levels = msg.data.levels as [L2Level[], L2Level[]];
|
||||
const bids = (levels[0] || []).map((l) => ({ px: parseFloat(String(l.px)), sz: parseFloat(String(l.sz)) }));
|
||||
const asks = (levels[1] || []).map((l) => ({ px: parseFloat(String(l.px)), sz: parseFloat(String(l.sz)) }));
|
||||
|
||||
const bestBid = bids[0]?.px ?? 0;
|
||||
const bestAsk = asks[0]?.px ?? 0;
|
||||
const mid = (bestBid + bestAsk) / 2;
|
||||
const spread = bestAsk - bestBid;
|
||||
|
||||
// Calculate volume totals (top 20 levels)
|
||||
const topBids = bids.slice(0, 20);
|
||||
const topAsks = asks.slice(0, 20);
|
||||
const totalBidVol = topBids.reduce((s, l) => s + l.sz, 0);
|
||||
const totalAskVol = topAsks.reduce((s, l) => s + l.sz, 0);
|
||||
const imbalance = totalBidVol + totalAskVol > 0
|
||||
? (totalBidVol - totalAskVol) / (totalBidVol + totalAskVol)
|
||||
: 0;
|
||||
|
||||
const snapshot: L2Snapshot = {
|
||||
bids, asks, mid, spread,
|
||||
totalBidVol, totalAskVol, imbalance,
|
||||
time: Date.now(),
|
||||
};
|
||||
l2Ref.current = snapshot;
|
||||
setL2(snapshot);
|
||||
} else if (msg.channel === "trades" && Array.isArray(msg.data)) {
|
||||
const newTrades: TradeTapeEntry[] = msg.data.map((t: Trade) => ({
|
||||
px: parseFloat(String(t.px)),
|
||||
sz: parseFloat(String(t.sz)),
|
||||
side: t.side === "B" ? "buy" : "sell",
|
||||
time: t.time || Date.now(),
|
||||
}));
|
||||
// Append to ring buffer — keep last ~500 trades
|
||||
tradesRef.current = [...tradesRef.current, ...newTrades].slice(-500);
|
||||
setTrades([...tradesRef.current]);
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
setError("WebSocket error");
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setConnected(false);
|
||||
// Auto-reconnect after 2s
|
||||
reconnectTimer.current = setTimeout(connect, 2000);
|
||||
};
|
||||
}, [coin]);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
return () => {
|
||||
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
return { l2, trades, connected, error };
|
||||
}
|
||||
+1
-675
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user