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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user