diff --git a/dashboard-next/src/app/page.tsx b/dashboard-next/src/app/page.tsx
index 174cc5d..3fdf5a8 100644
--- a/dashboard-next/src/app/page.tsx
+++ b/dashboard-next/src/app/page.tsx
@@ -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() {
No trades recorded yet
)}
+ {/* Live L2 Order Book + Trade Tape (all strategies, live tab only) */}
+ {detailTab === "live" && (
+
+
+
+ )}
>
)}
diff --git a/dashboard-next/src/components/orderbook-depth-map.tsx b/dashboard-next/src/components/orderbook-depth-map.tsx
new file mode 100644
index 0000000..af8ddd8
--- /dev/null
+++ b/dashboard-next/src/components/orderbook-depth-map.tsx
@@ -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(null);
+ const botCanvas = useRef(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 (
+
+ {/* Top: L2 Volume Profile */}
+
+
+ {noL2 && (
+
+
+ {connected ? "Waiting for L2 data..." : "Connecting to Hyperliquid..."}
+
+
+ )}
+ {error && (
+
+ {error} — reconnecting...
+
+ )}
+
+
+ {/* Bottom: Trade Tape */}
+
+
+ {trades.length < 2 && !error && (
+
+ Waiting for trades...
+
+ )}
+
+
+ );
+}
diff --git a/dashboard-next/src/lib/hyperliquid-ws.ts b/dashboard-next/src/lib/hyperliquid-ws.ts
new file mode 100644
index 0000000..77434d5
--- /dev/null
+++ b/dashboard-next/src/lib/hyperliquid-ws.ts
@@ -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(null);
+ const l2Ref = useRef(null);
+ const tradesRef = useRef([]);
+ const reconnectTimer = useRef | undefined>(undefined);
+ const subscribed = useRef(false);
+
+ const [l2, setL2] = useState(null);
+ const [trades, setTrades] = useState([]);
+ const [connected, setConnected] = useState(false);
+ const [error, setError] = useState(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 };
+}
\ No newline at end of file
diff --git a/dashboard/static/index.html b/dashboard/static/index.html
index 0e40cbc..2615bb0 100644
--- a/dashboard/static/index.html
+++ b/dashboard/static/index.html
@@ -1,675 +1 @@
-
-
-
-
-
-FTDT Quant Lab — Professional Dashboard
-
-
-
-
-
-
-
-
FTDT Quant LabPer-Strategy Dashboard
-
-
—
-
-
-
-
-
-
-
-
-
-
-
-
No open positions
-
No open orders
-
-
-
-
-
-
-
-
-
-
-
Trade History
-
| Time | Side | Size | Price | PnL | Fee | Reason / Signal |
|---|
-
-
-
-
-
-
-
\ No newline at end of file
+FTDT Quant LabFTDT Quant Lab
Live Testnet · Equity $—
OFFLINE
\ No newline at end of file