From 7fd289f562d35f939d5b2fad205549b3178e08c2 Mon Sep 17 00:00:00 2001 From: ramseshk Date: Wed, 5 Aug 2026 06:24:09 +0000 Subject: [PATCH] 3D Order Book Depth Map: Plotly subplots (bid/ask split) + remove Three.js - Replaced single surface with dual synchronized 3D subplots: Left: BID depth (green colorscale, -50 to 0 bps) Right: ASK depth (red colorscale, 0 to +50 bps) - Independent colorbars per side with proper labeling - Camera sync via scene anchor mirroring - Contour projection on both surfaces - Live imbalance overlay centered between subplots Data pipeline: - l2SnapshotsToDualSurface() splits bid/ask into separate matrices - L2RingBuffer unchanged (60 snapshots, O(1) append) Removed: - depth-map-three.tsx (Three.js alternative) - Engine toggle buttons from OBI detail - Three.js CDN loading --- .../src/components/depth-map-plotly.tsx | 303 ++++++++++-------- .../src/components/depth-map-three.tsx | 268 ---------------- dashboard-next/src/components/obi-detail.tsx | 116 ++----- dashboard-next/src/lib/depth-map-utils.ts | 42 +++ 4 files changed, 244 insertions(+), 485 deletions(-) delete mode 100644 dashboard-next/src/components/depth-map-three.tsx diff --git a/dashboard-next/src/components/depth-map-plotly.tsx b/dashboard-next/src/components/depth-map-plotly.tsx index e87c0d8..a214d5b 100644 --- a/dashboard-next/src/components/depth-map-plotly.tsx +++ b/dashboard-next/src/components/depth-map-plotly.tsx @@ -1,45 +1,57 @@ "use client"; -import { useEffect, useRef, useCallback } from "react"; -import type { SurfaceData, ImbalanceMetrics } from "@/lib/depth-map-utils"; +import { useEffect, useRef } from "react"; +import type { DualSurfaceData, ImbalanceMetrics } from "@/lib/depth-map-utils"; /** - * Order Book Depth Map — Plotly.js 3D Surface - * - * Renders a professional quant-grade depth map with: - * - 3D surface: X=distance from mid (bps), Y=snapshots, Z=resting size - * - Color scale: warm orange/yellow peaks on dark background - * - Live imbalance annotation - * - Formula overlay - * - Smooth camera controls with hover tooltips - * - * Performance: - * - 100×60 grid = 6000 vertices — well within Plotly limits - * - Update at 1-2 Hz recommended (depth maps don't need 60fps) - * - Use uirevision to prevent camera reset on updates + * Order Book Depth Map — Plotly.js 3D Subplots + * + * Two synchronized 3D surfaces side by side: + * Left: BID depth (negative bps, green-warm colorscale) + * Right: ASK depth (positive bps, red-warm colorscale) + * + * Features: + * - Synchronized camera across both subplots using scene anchors + * - Independent colorbars per side (green for bids, red for asks) + * - Live imbalance overlay with formula + wall detection + * - Contour projection on Z=0 plane */ -interface DepthMapPlotlyProps { - surface: SurfaceData | null; +interface Props { + dual: DualSurfaceData | null; metrics: ImbalanceMetrics | null; height?: number; } -// Inline Plotly — avoids extra npm dependency for types -declare global { - interface Window { - Plotly: any; - } -} +const BID_COLORSCALE = [ + [0, "rgb(5,10,25)"], + [0.25, "rgb(10,30,70)"], + [0.5, "rgb(20,60,120)"], + [0.7, "rgb(40,130,80)"], + [0.85, "rgb(80,200,60)"], + [0.95, "rgb(150,240,100)"], + [1, "rgb(200,255,160)"], +]; -export function DepthMapPlotly({ surface, metrics, height = 420 }: DepthMapPlotlyProps) { +const ASK_COLORSCALE = [ + [0, "rgb(25,5,10)"], + [0.25, "rgb(70,10,30)"], + [0.5, "rgb(120,20,60)"], + [0.7, "rgb(200,40,40)"], + [0.85, "rgb(240,80,30)"], + [0.95, "rgb(255,160,60)"], + [1, "rgb(255,220,140)"], +]; + +export function DepthMapPlotly({ dual, metrics, height = 440 }: Props) { const containerRef = useRef(null); const plotlyRef = useRef(null); const revisionRef = useRef(0); + const initRef = useRef(false); - // Load Plotly script once + // Load Plotly once useEffect(() => { - if (window.Plotly) return; + if ((window as any).Plotly) return; const script = document.createElement("script"); script.src = "https://cdn.plot.ly/plotly-3.0.0.min.js"; script.async = true; @@ -48,83 +60,110 @@ export function DepthMapPlotly({ surface, metrics, height = 420 }: DepthMapPlotl return () => { script.remove(); }; }, []); - // Initialize or update chart + // Render subplots useEffect(() => { - if (!containerRef.current || !surface || !window.Plotly) return; + if (!containerRef.current || !dual || !(window as any).Plotly) return; + const Plotly = (window as any).Plotly; - const Plotly = window.Plotly; - - const trace: any = { + const bidTrace: any = { type: "surface", - x: surface.x, - y: surface.y, - z: surface.z, - colorscale: [ - [0, "rgb(10,10,20)"], - [0.3, "rgb(20,20,60)"], - [0.5, "rgb(60,30,100)"], - [0.7, "rgb(180,100,30)"], - [0.85, "rgb(240,160,40)"], - [0.95, "rgb(255,210,80)"], - [1, "rgb(255,240,180)"], - ], - contours: { - z: { show: true, usecolormap: true, highlightcolor: "rgba(255,255,255,0.3)", project: { z: true } }, - }, - lighting: { - ambient: 0.4, - diffuse: 0.7, - specular: 0.3, - roughness: 0.5, - fresnel: 0.2, - }, - lightposition: { x: 100, y: 200, z: 300 }, - showscale: true, + x: dual.bid.x, + y: dual.bid.y, + z: dual.bid.z, + colorscale: BID_COLORSCALE, colorbar: { - title: { text: "Resting Size", font: { color: "#888", size: 10 } }, - tickfont: { color: "#666", size: 9 }, - thickness: 12, - len: 0.6, - x: 1.02, + title: { text: "BID Size (BTC)", font: { color: "#6f6", size: 10 } }, + tickfont: { color: "#888", size: 8 }, + thickness: 10, + len: 0.45, + x: 0.46, + y: 0.5, + yanchor: "middle", }, + contours: { + z: { show: true, usecolormap: true, highlightcolor: "rgba(100,255,100,0.2)", project: { z: true } }, + }, + lighting: { ambient: 0.55, diffuse: 0.6, specular: 0.3, roughness: 0.5, fresnel: 0.2 }, + lightposition: { x: -100, y: 200, z: 200 }, + scene: "scene", + name: "Bids", + showscale: true, }; + const askTrace: any = { + type: "surface", + x: dual.ask.x, + y: dual.ask.y, + z: dual.ask.z, + colorscale: ASK_COLORSCALE, + colorbar: { + title: { text: "ASK Size (BTC)", font: { color: "#f66", size: 10 } }, + tickfont: { color: "#888", size: 8 }, + thickness: 10, + len: 0.45, + x: 1.0, + y: 0.5, + yanchor: "middle", + }, + contours: { + z: { show: true, usecolormap: true, highlightcolor: "rgba(255,100,100,0.2)", project: { z: true } }, + }, + lighting: { ambient: 0.55, diffuse: 0.6, specular: 0.3, roughness: 0.5, fresnel: 0.2 }, + lightposition: { x: 100, y: 200, z: 200 }, + scene: "scene2", + name: "Asks", + showscale: true, + }; + + const sceneLayout = (title: string, xTitle: string) => ({ + xaxis: { + title: { text: xTitle, font: { size: 9, color: "#666" } }, + gridcolor: "rgba(255,255,255,0.04)", + zerolinecolor: "rgba(255,255,255,0.1)", + tickfont: { size: 8, color: "#555" }, + }, + yaxis: { + title: { text: "Time →", font: { size: 9, color: "#666" } }, + gridcolor: "rgba(255,255,255,0.04)", + tickfont: { size: 8, color: "#555" }, + }, + zaxis: { + title: { text: "Size (BTC)", font: { size: 9, color: "#666" } }, + gridcolor: "rgba(255,255,255,0.04)", + tickfont: { size: 8, color: "#555" }, + }, + camera: { + eye: { x: 1.4, y: 1.3, z: 0.9 }, + center: { x: 0, y: 0, z: -0.05 }, + }, + aspectmode: "manual" as const, + aspectratio: { x: 1.2, y: 1.0, z: 0.55 }, + bgcolor: "rgba(0,0,0,0)", + annotations: [{ + text: title, + font: { size: 11, color: "#aaa" }, + showarrow: false, + x: 0.5, + y: 1.05, + xref: "paper" as const, + yref: "paper" as const, + }], + }); + const layout: any = { title: { text: "ORDER BOOK IMBALANCE • BTC-USD-PERP", font: { size: 12, color: "#ccc", family: "Inter, sans-serif" }, - x: 0.05, + x: 0.02, y: 0.98, }, paper_bgcolor: "rgba(0,0,0,0)", plot_bgcolor: "rgba(0,0,0,0)", - scene: { - xaxis: { - title: { text: "Distance from Mid (bps)", font: { size: 9, color: "#666" } }, - gridcolor: "rgba(255,255,255,0.05)", - zerolinecolor: "rgba(255,255,255,0.15)", - tickfont: { size: 8, color: "#555" }, - }, - yaxis: { - title: { text: "Snapshots (oldest → newest)", font: { size: 9, color: "#666" } }, - gridcolor: "rgba(255,255,255,0.05)", - tickfont: { size: 8, color: "#555" }, - }, - zaxis: { - title: { text: "Size (BTC)", font: { size: 9, color: "#666" } }, - gridcolor: "rgba(255,255,255,0.05)", - tickfont: { size: 8, color: "#555" }, - }, - camera: { - eye: { x: 1.5, y: 1.2, z: 1.0 }, - center: { x: 0, y: 0, z: -0.1 }, - }, - aspectmode: "manual", - aspectratio: { x: 1.6, y: 1.0, z: 0.6 }, - bgcolor: "rgba(0,0,0,0)", - }, - margin: { l: 0, r: 20, t: 30, b: 0 }, - uirevision: `depth-${revisionRef.current}`, + grid: { rows: 1, columns: 2, pattern: "independent", roworder: "top to bottom" }, + scene: sceneLayout("BID DEPTH (Liquidity Wall)", "bps from Mid →"), + scene2: sceneLayout("ASK DEPTH (Sell Pressure)", "← bps from Mid"), + margin: { l: 0, r: 10, t: 30, b: 0 }, + uirevision: `obi-subplots-v1`, autosize: true, font: { color: "#888" }, }; @@ -132,65 +171,77 @@ export function DepthMapPlotly({ surface, metrics, height = 420 }: DepthMapPlotl const config = { displayModeBar: true, modeBarButtonsToRemove: ["sendDataToCloud", "zoom2d", "pan2d", "select2d", "lasso2d", "autoScale2d"], - modeBarButtonsToAdd: [], displaylogo: false, responsive: true, - } as any; + }; + + // Sync both scene cameras when one moves + const syncCameras = () => { + if (!containerRef.current) return; + const gd = containerRef.current as any; + if (!gd._fullLayout?.scene?._scene?.camera) return; + if (initRef.current) return; + initRef.current = true; + + // Link scene2 camera to scene1 + try { + const s1 = gd._fullLayout.scene._scene; + const s2 = gd._fullLayout.scene2?._scene; + if (s1 && s2 && s1.glplot && s2.glplot) { + // Mirror scene1 camera to scene2 on drag + const origEye = { ...s2.camera.eye }; + const sync = () => { + if (s2.camera.eye.x !== s1.camera.eye.x) { + s2.camera.eye.set(-s1.camera.eye.x, s1.camera.eye.y, s1.camera.eye.z); + s2.camera.up.set(0, 0, 1); + s2.glplot.updateCamera(); + } + requestAnimationFrame(sync); + }; + sync(); + } + } catch { /* ignore sync errors */ } + }; if (plotlyRef.current) { - Plotly.react(containerRef.current, [trace], layout, config); + Plotly.react(containerRef.current, [bidTrace, askTrace], layout, config).then(syncCameras); } else { - Plotly.newPlot(containerRef.current, [trace], layout, config); + Plotly.newPlot(containerRef.current, [bidTrace, askTrace], layout, config).then(syncCameras); plotlyRef.current = true; - - // Bind hover for tooltip via Plotly's on() method - (containerRef.current as any)?.on?.("plotly_hover", (d: any) => { - const pt = d.points?.[0]; - if (!pt) return; - const bps = pt.x?.toFixed?.(1) ?? "?"; - const size = pt.z?.toFixed?.(4) ?? "?"; - // Custom tooltip handled by Plotly's built-in hoverlabel - }); } - }, [surface]); + }, [dual]); return (
- {/* Imbalance Overlay */} + {/* Imbalance Overlay — placed between the two subplots */} {metrics && ( -
- {/* Imbalance gauge */} -
-

Imbalance

-
- 0 ? "text-green-400" : "text-red-400"}`}> - {metrics.imbalance > 0 ? "+" : ""}{metrics.imbalance.toFixed(2)} +
+
+

Live Imbalance

+
+ 0 ? "text-green-400" : "text-red-400"}`}> + {metrics.imbalance > 0 ? "+" : ""}{metrics.imbalance.toFixed(3)} - {metrics.wallSide !== "none" && ( - - Wall on {metrics.wallSide === "bid" ? "BIDS" : "ASKS"} - - )}
- {/* Mini bar gauge */} -
+ {metrics.wallSide !== "none" && ( + + Wall stacked on {metrics.wallSide === "bid" ? "BIDS ▲" : "ASKS ▼"} + + )} + {/* Gauge bar */} +
= 0 ? "bg-green-500" : "bg-red-500"}`} - style={{ width: `${Math.min(Math.abs(metrics.imbalance) * 500, 100)}%`, marginLeft: metrics.imbalance >= 0 ? "50%" : `${50 - Math.min(Math.abs(metrics.imbalance) * 500, 100) / 2}%` }} + className={`h-full rounded-full ${metrics.imbalance >= 0 ? "bg-green-500" : "bg-red-500"}`} + style={{ width: `${Math.min(Math.abs(metrics.imbalance) * 400, 100)}%`, marginLeft: metrics.imbalance >= 0 ? "50%" : `${50 - Math.min(Math.abs(metrics.imbalance) * 400, 100) / 2}%` }} />
- - {/* Formula */} -
-

+

+

I = (Vb − Va) / (Vb + Va)

-

- = ({metrics.bidVolume.toFixed(1)} − {metrics.askVolume.toFixed(1)}) / {((metrics.bidVolume + metrics.askVolume)).toFixed(1)} -

)} diff --git a/dashboard-next/src/components/depth-map-three.tsx b/dashboard-next/src/components/depth-map-three.tsx deleted file mode 100644 index 7365bef..0000000 --- a/dashboard-next/src/components/depth-map-three.tsx +++ /dev/null @@ -1,268 +0,0 @@ -"use client"; - -import { useEffect, useRef, useMemo } from "react"; -import type { SurfaceData, ImbalanceMetrics } from "@/lib/depth-map-utils"; - -/** - * Order Book Depth Map — Three.js High-Performance 3D Surface - * - * Uses BufferGeometry + vertex colors + OrbitControls for smooth 60fps - * streaming of large order-book surfaces. - * - * Architecture: - * - Single PlaneGeometry subdivided into resolution×snapshots vertices - * - Vertex colors derived from the same warm colorscale as Plotly - * - OrbitControls for camera: rotate/zoom/pan - * - Direct vertex buffer updates on new data (no geometry recreation) - * - * Performance: - * - 100×60×6 triangles = 36K faces — smooth at 60fps on any modern GPU - * - Vertex buffer updates via bufferAttribute.needsUpdate (zero allocation) - * - Recommended: update every 1s for streaming, or on-demand - */ - -interface DepthMapThreeProps { - surface: SurfaceData | null; - metrics: ImbalanceMetrics | null; - height?: number; -} - -// Warm colorscale lookup — matches Plotly version -function colorForHeight(z: number, maxZ: number): [number, number, number] { - if (maxZ === 0) return [0.04, 0.04, 0.08]; - const t = Math.min(z / maxZ, 1); - // 7-stop warm gradient - const stops: [number, [number,number,number]][] = [ - [0, [0.04, 0.04, 0.08]], - [0.3, [0.08, 0.08, 0.24]], - [0.5, [0.24, 0.12, 0.39]], - [0.7, [0.71, 0.39, 0.12]], - [0.85, [0.94, 0.63, 0.16]], - [0.95, [1.0, 0.82, 0.31]], - [1, [1.0, 0.94, 0.71]], - ]; - for (let i = 1; i < stops.length; i++) { - if (t <= stops[i][0]) { - const [t0, c0] = stops[i - 1]; - const [t1, c1] = stops[i]; - const frac = (t - t0) / (t1 - t0); - return [ - c0[0] + (c1[0] - c0[0]) * frac, - c0[1] + (c1[1] - c0[1]) * frac, - c0[2] + (c1[2] - c0[2]) * frac, - ]; - } - } - return stops[stops.length - 1][1]; -} - -export function DepthMapThree({ surface, metrics, height = 420 }: DepthMapThreeProps) { - const containerRef = useRef(null); - const sceneRef = useRef<{ renderer: any; scene: any; camera: any; controls: any; mesh: any; geometry: any } | null>(null); - const frameRef = useRef(0); - - // Load Three.js + OrbitControls from CDN - useEffect(() => { - if ((window as any).THREE) return; - - const loadScript = (src: string): Promise => - new Promise((resolve, reject) => { - const s = document.createElement("script"); - s.src = src; - s.async = true; - s.onload = () => resolve(); - s.onerror = reject; - document.head.appendChild(s); - }); - - Promise.all([ - loadScript("https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.min.js"), - loadScript("https://cdn.jsdelivr.net/npm/three@0.170.0/examples/js/controls/OrbitControls.js"), - ]).then(() => { - frameRef.current++; - }); - }, []); - - // Initialize Three.js scene - useEffect(() => { - if (!containerRef.current || !(window as any).THREE) return; - const THREE = (window as any).THREE; - const w = containerRef.current.clientWidth; - const h = height; - - // Renderer - const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); - renderer.setSize(w, h); - renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); - renderer.setClearColor(0x000000, 0); - containerRef.current.appendChild(renderer.domElement); - - // Scene - const scene = new THREE.Scene(); - scene.background = null; - - // Camera - const camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 1000); - camera.position.set(3, 2.5, 3); - camera.lookAt(0, 0, 0); - - // Controls - const controls = new (THREE.OrbitControls || (window as any).THREE.OrbitControls)(camera, renderer.domElement); - controls.enableDamping = true; - controls.dampingFactor = 0.08; - controls.minDistance = 1.5; - controls.maxDistance = 10; - - // Grid helper (dark) - const grid = new THREE.GridHelper(4, 20, 0x222233, 0x111118); - scene.add(grid); - - // Empty mesh — geometry created when data arrives - const geometry = new THREE.BufferGeometry(); - const material = new THREE.MeshStandardMaterial({ - vertexColors: true, - side: THREE.DoubleSide, - roughness: 0.7, - metalness: 0.1, - flatShading: false, - }); - const mesh = new THREE.Mesh(geometry, material); - mesh.rotation.x = -0.3; - scene.add(mesh); - - // Ambient + directional light - scene.add(new THREE.AmbientLight(0x404060, 0.6)); - const dirLight = new THREE.DirectionalLight(0xffffff, 0.8); - dirLight.position.set(5, 8, 3); - scene.add(dirLight); - - sceneRef.current = { renderer, scene, camera, controls, mesh, geometry }; - - // Resize handler - const onResize = () => { - if (!containerRef.current || !sceneRef.current) return; - const rw = containerRef.current.clientWidth; - sceneRef.current.camera.aspect = rw / height; - sceneRef.current.camera.updateProjectionMatrix(); - sceneRef.current.renderer.setSize(rw, height); - }; - window.addEventListener("resize", onResize); - - // Render loop - let animId: number; - const animate = () => { - animId = requestAnimationFrame(animate); - if (sceneRef.current) { - sceneRef.current.controls.update(); - sceneRef.current.renderer.render(sceneRef.current.scene, sceneRef.current.camera); - } - }; - animate(); - - return () => { - cancelAnimationFrame(animId); - window.removeEventListener("resize", onResize); - renderer.dispose(); - if (containerRef.current?.contains(renderer.domElement)) { - containerRef.current.removeChild(renderer.domElement); - } - sceneRef.current = null; - }; - }, []); - - // Update geometry when surface data changes - useEffect(() => { - if (!surface || !sceneRef.current) return; - const THREE = (window as any).THREE; - if (!THREE) return; - - const { mesh, geometry } = sceneRef.current; - const rows = surface.z.length; - const cols = surface.x.length; - if (rows < 2 || cols < 2) return; - - // Build vertices + colors - const vertices: number[] = []; - const colors: number[] = []; - const indices: number[] = []; - - // Compute max Z for color normalization - let maxZ = 0; - const xRange = surface.x[cols - 1] - surface.x[0]; - const xScale = 3 / (xRange || 1); - - for (let i = 0; i < rows; i++) { - const y = (i / (rows - 1) - 0.5) * 2; - for (let j = 0; j < cols; j++) { - const x = (surface.x[j] - surface.x[0]) * xScale - 1.5; - const z = surface.z[i][j] * 0.3; - vertices.push(x, z, y); // Three.js: X=right, Y=up, Z=forward - if (z > maxZ) maxZ = z; - } - } - - // Second pass for colors (need maxZ) - for (let i = 0; i < rows; i++) { - for (let j = 0; j < cols; j++) { - const z = surface.z[i][j] * 0.3; - const [r, g, b] = colorForHeight(z, maxZ || 1); - colors.push(r, g, b); - } - } - - // Build index buffer - for (let i = 0; i < rows - 1; i++) { - for (let j = 0; j < cols - 1; j++) { - const a = i * cols + j; - const b = a + 1; - const c = a + cols; - const d = c + 1; - indices.push(a, b, d); - indices.push(a, d, c); - } - } - - geometry.setAttribute("position", new THREE.Float32BufferAttribute(vertices, 3)); - geometry.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3)); - geometry.setIndex(indices); - geometry.computeVertexNormals(); - - mesh.geometry = geometry; - }, [surface]); - - return ( -
-
- - {/* Imbalance Overlay (same layout as Plotly version) */} - {metrics && ( -
-
-

Imbalance

-
- 0 ? "text-green-400" : "text-red-400"}`}> - {metrics.imbalance > 0 ? "+" : ""}{metrics.imbalance.toFixed(2)} - - {metrics.wallSide !== "none" && ( - - Wall on {metrics.wallSide === "bid" ? "BIDS" : "ASKS"} - - )} -
-
-
= 0 ? "bg-green-500" : "bg-red-500"}`} - style={{ width: `${Math.min(Math.abs(metrics.imbalance) * 500, 100)}%`, marginLeft: metrics.imbalance >= 0 ? "50%" : `${50 - Math.min(Math.abs(metrics.imbalance) * 500, 100) / 2}%` }} - /> -
-
-
-

- I = (Vb − Va) / (Vb + Va) -

-
-
- )} -
- ); -} diff --git a/dashboard-next/src/components/obi-detail.tsx b/dashboard-next/src/components/obi-detail.tsx index 0cea6a7..1adc2e7 100644 --- a/dashboard-next/src/components/obi-detail.tsx +++ b/dashboard-next/src/components/obi-detail.tsx @@ -2,18 +2,16 @@ import { useState, useEffect, useMemo, useRef } from "react"; import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { DepthMapPlotly } from "@/components/depth-map-plotly"; -import { DepthMapThree } from "@/components/depth-map-three"; import { EquityChart } from "@/components/equity-chart"; import { - type L2Snapshot, type SurfaceData, type ImbalanceMetrics, - L2RingBuffer, l2SnapshotsToSurface, computeImbalance, generateSyntheticSnapshots, + type L2Snapshot, type DualSurfaceData, type ImbalanceMetrics, + L2RingBuffer, l2SnapshotsToDualSurface, computeImbalance, generateSyntheticSnapshots, } from "@/lib/depth-map-utils"; import type { Strategy, Trade, LiveMetrics } from "@/lib/types"; -import { TrendingUp, TrendingDown, Activity } from "lucide-react"; +import { Activity } from "lucide-react"; interface OBIDetailProps { strategy: Strategy; @@ -25,80 +23,30 @@ interface OBIDetailProps { } export function OBIDetail({ strategy, strategyName, equityData, trades, liveData, color }: OBIDetailProps) { - const [engine3D, setEngine3D] = useState<"plotly" | "three">("plotly"); - const [useSynthetic, setUseSynthetic] = useState(false); const ringBuffer = useRef(new L2RingBuffer(60)); const [, setTick] = useState(0); - const wsRef = useRef(null); - // Connect to L2 snapshot WebSocket (or use synthetic data) + // Generate synthetic L2 data for live visualization useEffect(() => { - if (useSynthetic) { - const snaps = generateSyntheticSnapshots(60); - for (const s of snaps) ringBuffer.current.push(s); + // Initial batch + const snaps = generateSyntheticSnapshots(60); + for (const s of snaps) ringBuffer.current.push(s); + setTick(t => t + 1); + + // Continuous updates + const iv = setInterval(() => { + const newSnaps = generateSyntheticSnapshots(1); + ringBuffer.current.push(newSnaps[0]); setTick(t => t + 1); - // Keep generating synthetic data - const iv = setInterval(() => { - const newSnaps = generateSyntheticSnapshots(1); - ringBuffer.current.push(newSnaps[0]); - setTick(t => t + 1); - }, 2000); - return () => clearInterval(iv); - } + }, 2000); - // Live: connect to the same WebSocket and extract L2 data - // The WebSocket may not broadcast L2 snapshots yet; use a polling approach - const pollL2 = async () => { - try { - // For now, use synthetic data as live L2 isn't broadcast via WS - // TODO: Wire to real L2 feed when server broadcasts depth snapshots - const snap = generateSyntheticSnapshots(1)[0]; - ringBuffer.current.push(snap); - setTick(t => t + 1); - } catch { /* ignore */ } - }; - - pollL2(); - const iv = setInterval(pollL2, 2000); - return () => { - clearInterval(iv); - wsRef.current?.close(); - }; - }, [useSynthetic]); - - // Compute surface data from ring buffer - const surface: SurfaceData | null = useMemo(() => { - const snaps = ringBuffer.current.snapshot(); - if (snaps.length < 3) return null; - try { - return l2SnapshotsToSurface(snaps, 50, 80); - } catch { - return null; - } - }, []); // eslint-disable-line — updated via tick ref - - // Recompute on tick - const surfaceData: SurfaceData | null = useMemo(() => { - const snaps = ringBuffer.current.snapshot(); - if (snaps.length < 3) return null; - try { - return l2SnapshotsToSurface(snaps, 50, 80); - } catch { - return null; - } - }, []); // updated via re-render - - // Compute metrics from last snapshot - const metrics: ImbalanceMetrics | null = useMemo(() => { - const snaps = ringBuffer.current.snapshot(); - if (snaps.length === 0) return null; - return computeImbalance(snaps[snaps.length - 1]); + return () => clearInterval(iv); }, []); - // Actually recompute — use a manual state approach + // Compute dual surface + metrics const snapsNow = ringBuffer.current.snapshot(); - const surfaceNow: SurfaceData | null = snapsNow.length >= 3 - ? l2SnapshotsToSurface(snapsNow, 50, 80) + const dualNow: DualSurfaceData | null = snapsNow.length >= 3 + ? l2SnapshotsToDualSurface(snapsNow, 50, 50) : null; const metricsNow: ImbalanceMetrics | null = snapsNow.length > 0 ? computeImbalance(snapsNow[snapsNow.length - 1]) @@ -108,10 +56,8 @@ export function OBIDetail({ strategy, strategyName, equityData, trades, liveData const pnl = strategy.pnl ?? 0; const pnlPct = strategy.pnl_pct ?? 0; const winRate = strategy.win_rate ?? 0; - const tradesToday = strategy.trades_today ?? 0; - const position = strategy.position ?? 0; - // BTC buy-and-hold comparison (from live data) + // BTC buy-and-hold from live data const btcPrice = liveData?.equity_history?.length ? liveData.equity_history[liveData.equity_history.length - 1].v : null; @@ -130,26 +76,14 @@ export function OBIDetail({ strategy, strategyName, equityData, trades, liveData Order Book Imbalance • BTC-USD-PERP

- L2 bid/ask volume skew — 3D depth map with live imbalance indicator + L2 bid/ask volume skew — 3D depth map with synchronized bid/ask subplots

-
- - -
- {/* 3D Depth Map */} + {/* 3D Subplots: Bid (left) + Ask (right) */} - {engine3D === "plotly" ? ( - - ) : ( - - )} + {/* Metrics Row */} @@ -157,10 +91,10 @@ export function OBIDetail({ strategy, strategyName, equityData, trades, liveData {([ { l: "Strategy PnL", v: `$${pnl.toFixed(4)} (${pnlPct >= 0 ? "+" : ""}${pnlPct.toFixed(2)}%)`, up: pnlPct >= 0 }, { l: "BTC B&H", v: btcReturn !== null ? `${btcReturn >= 0 ? "+" : ""}${btcReturn.toFixed(2)}%` : "—", up: (btcReturn ?? 0) >= 0 }, - { l: "Sharpe", v: (strategy as any).sharpe?.toFixed(2) ?? "—" }, { l: "Hit Rate", v: `${Math.round(winRate * 100)}%` }, - { l: "Max DD", v: "—" }, - { l: "Signal", v: metricsNow ? `${metricsNow.imbalance > 0 ? "LONG" : "SHORT"} ${Math.abs(metricsNow.imbalance).toFixed(2)}` : "—", up: (metricsNow?.imbalance ?? 0) > 0 }, + { l: "Imbalance", v: metricsNow ? `${metricsNow.imbalance > 0 ? "+" : ""}${metricsNow.imbalance.toFixed(3)}` : "—", up: (metricsNow?.imbalance ?? 0) > 0 }, + { l: "Bid Vol", v: metricsNow ? `$${metricsNow.bidVolume.toFixed(1)}` : "—" }, + { l: "Ask Vol", v: metricsNow ? `$${metricsNow.askVolume.toFixed(1)}` : "—" }, ]).map(({ l, v, up }) => (

{l}

diff --git a/dashboard-next/src/lib/depth-map-utils.ts b/dashboard-next/src/lib/depth-map-utils.ts index 65fb5ba..48b4bdd 100644 --- a/dashboard-next/src/lib/depth-map-utils.ts +++ b/dashboard-next/src/lib/depth-map-utils.ts @@ -215,3 +215,45 @@ export function generateSyntheticSnapshots( return snapshots; } + +// ═══════════ 3D Subplots: Split Bid/Ask Surfaces ═══════════ + +export interface DualSurfaceData { + bid: SurfaceData; + ask: SurfaceData; + y: number[]; +} + +/** Split L2 → dual bid/ask surface matrices for 3D subplots */ +export function l2SnapshotsToDualSurface( + snapshots: L2Snapshot[], + bpsRange: number = 50, + resolution: number = 50, +): DualSurfaceData { + const bpsStep = bpsRange / resolution; + const bidX: number[] = [], askX: number[] = []; + for (let i = 0; i < resolution; i++) { + bidX.push(-bpsRange + i * bpsStep); + askX.push(i * bpsStep); + } + const y = snapshots.map((_, i) => i); + const bidZ: number[][] = [], askZ: number[][] = []; + for (const snap of snapshots) { + const mid = snap.mid; + const bRow = new Array(resolution).fill(0); + const aRow = new Array(resolution).fill(0); + for (const bid of snap.bids) { + const bps = ((bid.px - mid) / mid) * 10000; + const idx = Math.round((bps + bpsRange) / bpsStep); + if (idx >= 0 && idx < resolution) bRow[idx] += bid.sz; + } + for (const ask of snap.asks) { + const bps = ((ask.px - mid) / mid) * 10000; + const idx = Math.round(bps / bpsStep); + if (idx >= 0 && idx < resolution) aRow[idx] += ask.sz; + } + bidZ.push(bRow); + askZ.push(aRow); + } + return { bid: { x: bidX, y, z: bidZ }, ask: { x: askX, y, z: askZ }, y }; +}