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
+7
View File
@@ -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>
);
}
+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 };
}