diff --git a/dashboard-next/src/components/depth-map-plotly.tsx b/dashboard-next/src/components/depth-map-plotly.tsx index a214d5b..1070084 100644 --- a/dashboard-next/src/components/depth-map-plotly.tsx +++ b/dashboard-next/src/components/depth-map-plotly.tsx @@ -1,169 +1,136 @@ "use client"; -import { useEffect, useRef } from "react"; -import type { DualSurfaceData, ImbalanceMetrics } from "@/lib/depth-map-utils"; +import { useEffect, useRef, useState, useCallback } from "react"; +import type { SurfaceData, ImbalanceMetrics } from "@/lib/depth-map-utils"; /** - * Order Book Depth Map — Plotly.js 3D Subplots + * Order Book Depth Map — Plotly.js 3D Surface * - * Two synchronized 3D surfaces side by side: - * Left: BID depth (negative bps, green-warm colorscale) - * Right: ASK depth (positive bps, red-warm colorscale) + * Single unified surface: X = distance from mid (bps, -50 to +50), + * Y = snapshot index (oldest → newest), Z = resting size (BTC). * - * 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 + * Warm amber/gold colorscale on dark background. + * Live imbalance overlay with formula + wall detection. */ interface Props { - dual: DualSurfaceData | null; + surface: SurfaceData | null; metrics: ImbalanceMetrics | null; height?: number; } -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)"], +const COLORSCALE = [ + [0, "rgb(8,8,18)"], + [0.2, "rgb(18,18,48)"], + [0.4, "rgb(50,25,90)"], + [0.6, "rgb(140,60,30)"], + [0.75, "rgb(210,110,30)"], + [0.88, "rgb(245,170,45)"], + [0.96, "rgb(255,220,100)"], + [1, "rgb(255,245,190)"], ]; -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) { +export function DepthMapPlotly({ surface, metrics, height = 440 }: Props) { const containerRef = useRef(null); - const plotlyRef = useRef(null); - const revisionRef = useRef(0); - const initRef = useRef(false); + const plotlyReady = useRef(false); + const [loaded, setLoaded] = useState(false); - // Load Plotly once + // Load Plotly CDN once useEffect(() => { - 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; - script.onload = () => revisionRef.current++; - document.head.appendChild(script); - return () => { script.remove(); }; + if ((window as any).Plotly) { + setLoaded(true); + return; + } + const s = document.createElement("script"); + s.src = "https://cdn.plot.ly/plotly-3.0.0.min.js"; + s.async = true; + s.onload = () => setLoaded(true); + document.head.appendChild(s); + return () => { s.remove(); }; }, []); - // Render subplots + // Render / update chart useEffect(() => { - if (!containerRef.current || !dual || !(window as any).Plotly) return; + if (!containerRef.current || !loaded || !surface) return; const Plotly = (window as any).Plotly; + if (!Plotly) return; - const bidTrace: any = { + const cw = containerRef.current.clientWidth || 800; + + // Build trace — ensure no NaN/Infinity values + const cleanZ = surface.z.map(row => + row.map(v => (isFinite(v) && v > 0 ? v : 0)) + ); + + const trace = { type: "surface", - x: dual.bid.x, - y: dual.bid.y, - z: dual.bid.z, - colorscale: BID_COLORSCALE, - colorbar: { - 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", - }, + x: surface.x, + y: surface.y, + z: cleanZ, + colorscale: COLORSCALE, contours: { - z: { show: true, usecolormap: true, highlightcolor: "rgba(100,255,100,0.2)", project: { z: true } }, + z: { + show: true, + usecolormap: true, + highlightcolor: "rgba(255,255,255,0.25)", + 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", + lighting: { + ambient: 0.5, + diffuse: 0.7, + specular: 0.25, + roughness: 0.45, + fresnel: 0.15, + }, + lightposition: { x: 150, y: 250, z: 350 }, 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", + title: { text: "Resting Size", font: { color: "#999", size: 10 } }, + tickfont: { color: "#777", size: 8 }, + thickness: 14, + len: 0.65, + x: 1.02, }, - 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.02, + x: 0.03, y: 0.98, }, paper_bgcolor: "rgba(0,0,0,0)", plot_bgcolor: "rgba(0,0,0,0)", - 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`, + scene: { + xaxis: { + title: { text: "Distance from Mid (bps)", font: { size: 9, color: "#666" } }, + gridcolor: "rgba(255,255,255,0.04)", + zerolinecolor: "rgba(255,255,255,0.12)", + tickfont: { size: 8, color: "#555" }, + range: [-55, 55], + }, + yaxis: { + title: { text: "Snapshot Index (oldest → newest)", 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.5, y: 1.2, z: 0.95 }, + center: { x: 0, y: 0, z: -0.08 }, + }, + aspectmode: "manual", + aspectratio: { x: 1.5, y: 1.0, z: 0.55 }, + bgcolor: "rgba(0,0,0,0)", + }, + margin: { l: 0, r: 30, t: 32, b: 0 }, + uirevision: "obi-surface-v2", autosize: true, font: { color: "#888" }, }; @@ -175,71 +142,59 @@ export function DepthMapPlotly({ dual, metrics, height = 440 }: Props) { responsive: true, }; - // 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, [bidTrace, askTrace], layout, config).then(syncCameras); + if (plotlyReady.current) { + Plotly.react(containerRef.current, [trace], layout, config); } else { - Plotly.newPlot(containerRef.current, [bidTrace, askTrace], layout, config).then(syncCameras); - plotlyRef.current = true; + Plotly.newPlot(containerRef.current, [trace], layout, config); + plotlyReady.current = true; } - }, [dual]); + }, [surface, loaded]); + + // Resize on container width change + useEffect(() => { + const obs = new ResizeObserver(() => { + const Plotly = (window as any).Plotly; + if (containerRef.current && Plotly) { + Plotly.Plots.resize(containerRef.current); + } + }); + if (containerRef.current) obs.observe(containerRef.current); + return () => obs.disconnect(); + }, []); return (
- {/* Imbalance Overlay — placed between the two subplots */} + {/* Imbalance Overlay */} {metrics && ( -
-
-

Live Imbalance

-
- 0 ? "text-green-400" : "text-red-400"}`}> +
+
+

Live Imbalance

+
+ 0.005 ? "text-green-400" : metrics.imbalance < -0.005 ? "text-red-400" : "text-zinc-400"}`} + > {metrics.imbalance > 0 ? "+" : ""}{metrics.imbalance.toFixed(3)}
{metrics.wallSide !== "none" && ( - - Wall stacked on {metrics.wallSide === "bid" ? "BIDS ▲" : "ASKS ▼"} - +

+ Wall: {metrics.wallSide.toUpperCase()}S ({(metrics.wallStrength ?? 0).toFixed(1)}) +

)} - {/* Gauge bar */} -
+
= 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}%` }} + style={{ + width: `${Math.min(Math.abs(metrics.imbalance) * 350, 100)}%`, + marginLeft: metrics.imbalance >= 0 ? "50%" : `${50 - Math.min(Math.abs(metrics.imbalance) * 350, 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 1adc2e7..8976e7c 100644 --- a/dashboard-next/src/components/obi-detail.tsx +++ b/dashboard-next/src/components/obi-detail.tsx @@ -7,8 +7,8 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { DepthMapPlotly } from "@/components/depth-map-plotly"; import { EquityChart } from "@/components/equity-chart"; import { - type L2Snapshot, type DualSurfaceData, type ImbalanceMetrics, - L2RingBuffer, l2SnapshotsToDualSurface, computeImbalance, generateSyntheticSnapshots, + type L2Snapshot, type SurfaceData, type ImbalanceMetrics, + L2RingBuffer, l2SnapshotsToSurface, computeImbalance, generateSyntheticSnapshots, } from "@/lib/depth-map-utils"; import type { Strategy, Trade, LiveMetrics } from "@/lib/types"; import { Activity } from "lucide-react"; @@ -45,8 +45,8 @@ export function OBIDetail({ strategy, strategyName, equityData, trades, liveData // Compute dual surface + metrics const snapsNow = ringBuffer.current.snapshot(); - const dualNow: DualSurfaceData | null = snapsNow.length >= 3 - ? l2SnapshotsToDualSurface(snapsNow, 50, 50) + const surfaceNow: SurfaceData | null = snapsNow.length >= 3 + ? l2SnapshotsToSurface(snapsNow, 50, 60) : null; const metricsNow: ImbalanceMetrics | null = snapsNow.length > 0 ? computeImbalance(snapsNow[snapsNow.length - 1]) @@ -83,7 +83,7 @@ export function OBIDetail({ strategy, strategyName, equityData, trades, liveData {/* 3D Subplots: Bid (left) + Ask (right) */} - + {/* Metrics Row */}