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
-
-
- -
-
-
-

Strategy Detail

-
- - - - -
-
-
-
-
-
-

Trade History

-
TimeSideSizePricePnLFeeReason / Signal
-
-
-
- - - - \ No newline at end of file +FTDT Quant Lab

FTDT Quant Lab

Live Testnet · Equity $—

OFFLINE
\ No newline at end of file