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:
ramseshk
2026-08-05 07:42:27 +00:00
parent f7f47b5484
commit 2f74e076b4
4 changed files with 519 additions and 675 deletions
+163
View File
@@ -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 };
}