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
This commit is contained in:
ramseshk
2026-08-05 06:24:09 +00:00
parent 8855c013a6
commit 7fd289f562
4 changed files with 244 additions and 485 deletions
+175 -124
View File
@@ -1,45 +1,57 @@
"use client"; "use client";
import { useEffect, useRef, useCallback } from "react"; import { useEffect, useRef } from "react";
import type { SurfaceData, ImbalanceMetrics } from "@/lib/depth-map-utils"; import type { DualSurfaceData, ImbalanceMetrics } from "@/lib/depth-map-utils";
/** /**
* Order Book Depth Map — Plotly.js 3D Surface * Order Book Depth Map — Plotly.js 3D Subplots
* *
* Renders a professional quant-grade depth map with: * Two synchronized 3D surfaces side by side:
* - 3D surface: X=distance from mid (bps), Y=snapshots, Z=resting size * Left: BID depth (negative bps, green-warm colorscale)
* - Color scale: warm orange/yellow peaks on dark background * Right: ASK depth (positive bps, red-warm colorscale)
* - Live imbalance annotation
* - Formula overlay
* - Smooth camera controls with hover tooltips
* *
* Performance: * Features:
* - 100×60 grid = 6000 vertices — well within Plotly limits * - Synchronized camera across both subplots using scene anchors
* - Update at 1-2 Hz recommended (depth maps don't need 60fps) * - Independent colorbars per side (green for bids, red for asks)
* - Use uirevision to prevent camera reset on updates * - Live imbalance overlay with formula + wall detection
* - Contour projection on Z=0 plane
*/ */
interface DepthMapPlotlyProps { interface Props {
surface: SurfaceData | null; dual: DualSurfaceData | null;
metrics: ImbalanceMetrics | null; metrics: ImbalanceMetrics | null;
height?: number; height?: number;
} }
// Inline Plotly — avoids extra npm dependency for types const BID_COLORSCALE = [
declare global { [0, "rgb(5,10,25)"],
interface Window { [0.25, "rgb(10,30,70)"],
Plotly: any; [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<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const plotlyRef = useRef<any>(null); const plotlyRef = useRef<any>(null);
const revisionRef = useRef(0); const revisionRef = useRef(0);
const initRef = useRef(false);
// Load Plotly script once // Load Plotly once
useEffect(() => { useEffect(() => {
if (window.Plotly) return; if ((window as any).Plotly) return;
const script = document.createElement("script"); const script = document.createElement("script");
script.src = "https://cdn.plot.ly/plotly-3.0.0.min.js"; script.src = "https://cdn.plot.ly/plotly-3.0.0.min.js";
script.async = true; script.async = true;
@@ -48,83 +60,110 @@ export function DepthMapPlotly({ surface, metrics, height = 420 }: DepthMapPlotl
return () => { script.remove(); }; return () => { script.remove(); };
}, []); }, []);
// Initialize or update chart // Render subplots
useEffect(() => { 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 bidTrace: any = {
const trace: any = {
type: "surface", type: "surface",
x: surface.x, x: dual.bid.x,
y: surface.y, y: dual.bid.y,
z: surface.z, z: dual.bid.z,
colorscale: [ colorscale: BID_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,
colorbar: { colorbar: {
title: { text: "Resting Size", font: { color: "#888", size: 10 } }, title: { text: "BID Size (BTC)", font: { color: "#6f6", size: 10 } },
tickfont: { color: "#666", size: 9 }, tickfont: { color: "#888", size: 8 },
thickness: 12, thickness: 10,
len: 0.6, len: 0.45,
x: 1.02, 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 = { const layout: any = {
title: { title: {
text: "ORDER BOOK IMBALANCE • BTC-USD-PERP", text: "ORDER BOOK IMBALANCE • BTC-USD-PERP",
font: { size: 12, color: "#ccc", family: "Inter, sans-serif" }, font: { size: 12, color: "#ccc", family: "Inter, sans-serif" },
x: 0.05, x: 0.02,
y: 0.98, y: 0.98,
}, },
paper_bgcolor: "rgba(0,0,0,0)", paper_bgcolor: "rgba(0,0,0,0)",
plot_bgcolor: "rgba(0,0,0,0)", plot_bgcolor: "rgba(0,0,0,0)",
scene: { grid: { rows: 1, columns: 2, pattern: "independent", roworder: "top to bottom" },
xaxis: { scene: sceneLayout("BID DEPTH (Liquidity Wall)", "bps from Mid →"),
title: { text: "Distance from Mid (bps)", font: { size: 9, color: "#666" } }, scene2: sceneLayout("ASK DEPTH (Sell Pressure)", "← bps from Mid"),
gridcolor: "rgba(255,255,255,0.05)", margin: { l: 0, r: 10, t: 30, b: 0 },
zerolinecolor: "rgba(255,255,255,0.15)", uirevision: `obi-subplots-v1`,
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}`,
autosize: true, autosize: true,
font: { color: "#888" }, font: { color: "#888" },
}; };
@@ -132,65 +171,77 @@ export function DepthMapPlotly({ surface, metrics, height = 420 }: DepthMapPlotl
const config = { const config = {
displayModeBar: true, displayModeBar: true,
modeBarButtonsToRemove: ["sendDataToCloud", "zoom2d", "pan2d", "select2d", "lasso2d", "autoScale2d"], modeBarButtonsToRemove: ["sendDataToCloud", "zoom2d", "pan2d", "select2d", "lasso2d", "autoScale2d"],
modeBarButtonsToAdd: [],
displaylogo: false, displaylogo: false,
responsive: true, 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) { if (plotlyRef.current) {
Plotly.react(containerRef.current, [trace], layout, config); Plotly.react(containerRef.current, [bidTrace, askTrace], layout, config).then(syncCameras);
} else { } else {
Plotly.newPlot(containerRef.current, [trace], layout, config); Plotly.newPlot(containerRef.current, [bidTrace, askTrace], layout, config).then(syncCameras);
plotlyRef.current = true; 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 ( return (
<div className="relative"> <div className="relative">
<div ref={containerRef} style={{ width: "100%", height }} /> <div ref={containerRef} style={{ width: "100%", height }} />
{/* Imbalance Overlay */} {/* Imbalance Overlay — placed between the two subplots */}
{metrics && ( {metrics && (
<div className="absolute top-3 right-4 z-10 flex flex-col gap-3"> <div className="absolute top-3 left-1/2 -translate-x-1/2 z-10 flex flex-col gap-2 pointer-events-none">
{/* Imbalance gauge */} <div className="bg-black/75 backdrop-blur-lg rounded-lg px-4 py-2.5 border border-white/10 text-center">
<div className="bg-black/60 backdrop-blur rounded-lg px-3 py-2 border border-white/10"> <p className="text-[8px] text-muted-foreground uppercase tracking-widest mb-0.5">Live Imbalance</p>
<p className="text-[9px] text-muted-foreground uppercase tracking-wider">Imbalance</p> <div className="flex items-center justify-center gap-2">
<div className="flex items-center gap-2"> <span className={`text-xl font-mono font-bold ${metrics.imbalance > 0 ? "text-green-400" : "text-red-400"}`}>
<span className={`text-lg font-mono font-bold ${metrics.imbalance > 0 ? "text-green-400" : "text-red-400"}`}> {metrics.imbalance > 0 ? "+" : ""}{metrics.imbalance.toFixed(3)}
{metrics.imbalance > 0 ? "+" : ""}{metrics.imbalance.toFixed(2)}
</span> </span>
{metrics.wallSide !== "none" && (
<span className="text-[9px] text-amber-400/80">
Wall on {metrics.wallSide === "bid" ? "BIDS" : "ASKS"}
</span>
)}
</div> </div>
{/* Mini bar gauge */} {metrics.wallSide !== "none" && (
<div className="w-full h-1.5 bg-white/10 rounded-full mt-1 overflow-hidden"> <span className={`text-[9px] ${metrics.wallSide === "bid" ? "text-green-400/80" : "text-red-400/80"}`}>
Wall stacked on {metrics.wallSide === "bid" ? "BIDS ▲" : "ASKS ▼"}
</span>
)}
{/* Gauge bar */}
<div className="w-32 h-1 bg-white/10 rounded-full mt-1.5 mx-auto overflow-hidden">
<div <div
className={`h-full rounded-full transition-all duration-300 ${metrics.imbalance >= 0 ? "bg-green-500" : "bg-red-500"}`} className={`h-full rounded-full ${metrics.imbalance >= 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}%` }} 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}%` }}
/> />
</div> </div>
</div> </div>
<div className="bg-black/75 backdrop-blur-lg rounded-lg px-3 py-1.5 border border-white/10 text-center">
{/* Formula */} <p className="text-[8px] text-muted-foreground font-mono leading-relaxed">
<div className="bg-black/60 backdrop-blur rounded-lg px-3 py-2 border border-white/10">
<p className="text-[9px] text-muted-foreground font-mono leading-relaxed">
I = (V<sub>b</sub> V<sub>a</sub>) / (V<sub>b</sub> + V<sub>a</sub>) I = (V<sub>b</sub> V<sub>a</sub>) / (V<sub>b</sub> + V<sub>a</sub>)
</p> </p>
<p className="text-[8px] text-muted-foreground/50 mt-0.5">
= ({metrics.bidVolume.toFixed(1)} {metrics.askVolume.toFixed(1)}) / {((metrics.bidVolume + metrics.askVolume)).toFixed(1)}
</p>
</div> </div>
</div> </div>
)} )}
@@ -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<HTMLDivElement>(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<void> =>
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 (
<div className="relative">
<div ref={containerRef} style={{ width: "100%", height }} className="overflow-hidden rounded-lg" />
{/* Imbalance Overlay (same layout as Plotly version) */}
{metrics && (
<div className="absolute top-3 right-4 z-10 flex flex-col gap-3 pointer-events-none">
<div className="bg-black/60 backdrop-blur rounded-lg px-3 py-2 border border-white/10">
<p className="text-[9px] text-muted-foreground uppercase tracking-wider">Imbalance</p>
<div className="flex items-center gap-2">
<span className={`text-lg font-mono font-bold ${metrics.imbalance > 0 ? "text-green-400" : "text-red-400"}`}>
{metrics.imbalance > 0 ? "+" : ""}{metrics.imbalance.toFixed(2)}
</span>
{metrics.wallSide !== "none" && (
<span className="text-[9px] text-amber-400/80">
Wall on {metrics.wallSide === "bid" ? "BIDS" : "ASKS"}
</span>
)}
</div>
<div className="w-full h-1.5 bg-white/10 rounded-full mt-1 overflow-hidden">
<div
className={`h-full rounded-full transition-all ${metrics.imbalance >= 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}%` }}
/>
</div>
</div>
<div className="bg-black/60 backdrop-blur rounded-lg px-3 py-2 border border-white/10">
<p className="text-[9px] text-muted-foreground font-mono">
I = (V<sub>b</sub> V<sub>a</sub>) / (V<sub>b</sub> + V<sub>a</sub>)
</p>
</div>
</div>
)}
</div>
);
}
+25 -91
View File
@@ -2,18 +2,16 @@
import { useState, useEffect, useMemo, useRef } from "react"; import { useState, useEffect, useMemo, useRef } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { DepthMapPlotly } from "@/components/depth-map-plotly"; import { DepthMapPlotly } from "@/components/depth-map-plotly";
import { DepthMapThree } from "@/components/depth-map-three";
import { EquityChart } from "@/components/equity-chart"; import { EquityChart } from "@/components/equity-chart";
import { import {
type L2Snapshot, type SurfaceData, type ImbalanceMetrics, type L2Snapshot, type DualSurfaceData, type ImbalanceMetrics,
L2RingBuffer, l2SnapshotsToSurface, computeImbalance, generateSyntheticSnapshots, L2RingBuffer, l2SnapshotsToDualSurface, computeImbalance, generateSyntheticSnapshots,
} from "@/lib/depth-map-utils"; } from "@/lib/depth-map-utils";
import type { Strategy, Trade, LiveMetrics } from "@/lib/types"; import type { Strategy, Trade, LiveMetrics } from "@/lib/types";
import { TrendingUp, TrendingDown, Activity } from "lucide-react"; import { Activity } from "lucide-react";
interface OBIDetailProps { interface OBIDetailProps {
strategy: Strategy; strategy: Strategy;
@@ -25,80 +23,30 @@ interface OBIDetailProps {
} }
export function OBIDetail({ strategy, strategyName, equityData, trades, liveData, color }: 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 ringBuffer = useRef(new L2RingBuffer(60));
const [, setTick] = useState(0); const [, setTick] = useState(0);
const wsRef = useRef<WebSocket | null>(null);
// Connect to L2 snapshot WebSocket (or use synthetic data) // Generate synthetic L2 data for live visualization
useEffect(() => { useEffect(() => {
if (useSynthetic) { // Initial batch
const snaps = generateSyntheticSnapshots(60); const snaps = generateSyntheticSnapshots(60);
for (const s of snaps) ringBuffer.current.push(s); 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); setTick(t => t + 1);
// Keep generating synthetic data }, 2000);
const iv = setInterval(() => {
const newSnaps = generateSyntheticSnapshots(1);
ringBuffer.current.push(newSnaps[0]);
setTick(t => t + 1);
}, 2000);
return () => clearInterval(iv);
}
// Live: connect to the same WebSocket and extract L2 data return () => clearInterval(iv);
// 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]);
}, []); }, []);
// Actually recompute — use a manual state approach // Compute dual surface + metrics
const snapsNow = ringBuffer.current.snapshot(); const snapsNow = ringBuffer.current.snapshot();
const surfaceNow: SurfaceData | null = snapsNow.length >= 3 const dualNow: DualSurfaceData | null = snapsNow.length >= 3
? l2SnapshotsToSurface(snapsNow, 50, 80) ? l2SnapshotsToDualSurface(snapsNow, 50, 50)
: null; : null;
const metricsNow: ImbalanceMetrics | null = snapsNow.length > 0 const metricsNow: ImbalanceMetrics | null = snapsNow.length > 0
? computeImbalance(snapsNow[snapsNow.length - 1]) ? computeImbalance(snapsNow[snapsNow.length - 1])
@@ -108,10 +56,8 @@ export function OBIDetail({ strategy, strategyName, equityData, trades, liveData
const pnl = strategy.pnl ?? 0; const pnl = strategy.pnl ?? 0;
const pnlPct = strategy.pnl_pct ?? 0; const pnlPct = strategy.pnl_pct ?? 0;
const winRate = strategy.win_rate ?? 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 const btcPrice = liveData?.equity_history?.length
? liveData.equity_history[liveData.equity_history.length - 1].v ? liveData.equity_history[liveData.equity_history.length - 1].v
: null; : null;
@@ -130,26 +76,14 @@ export function OBIDetail({ strategy, strategyName, equityData, trades, liveData
Order Book Imbalance BTC-USD-PERP Order Book Imbalance BTC-USD-PERP
</h3> </h3>
<p className="text-[10px] text-muted-foreground mt-1"> <p className="text-[10px] text-muted-foreground mt-1">
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
</p> </p>
</div> </div>
<div className="flex items-center gap-2">
<Button size="sm" variant={engine3D === "plotly" ? "default" : "outline"} className="text-[10px] h-6 px-2" onClick={() => setEngine3D("plotly")}>
Plotly.js
</Button>
<Button size="sm" variant={engine3D === "three" ? "default" : "outline"} className="text-[10px] h-6 px-2" onClick={() => setEngine3D("three")}>
Three.js
</Button>
</div>
</div> </div>
{/* 3D Depth Map */} {/* 3D Subplots: Bid (left) + Ask (right) */}
<Card className="overflow-hidden border-border"> <Card className="overflow-hidden border-border">
{engine3D === "plotly" ? ( <DepthMapPlotly dual={dualNow} metrics={metricsNow} height={440} />
<DepthMapPlotly surface={surfaceNow} metrics={metricsNow} height={380} />
) : (
<DepthMapThree surface={surfaceNow} metrics={metricsNow} height={380} />
)}
</Card> </Card>
{/* Metrics Row */} {/* 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: "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: "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: "Hit Rate", v: `${Math.round(winRate * 100)}%` },
{ l: "Max DD", v: "—" }, { l: "Imbalance", v: metricsNow ? `${metricsNow.imbalance > 0 ? "+" : ""}${metricsNow.imbalance.toFixed(3)}` : "—", up: (metricsNow?.imbalance ?? 0) > 0 },
{ l: "Signal", v: metricsNow ? `${metricsNow.imbalance > 0 ? "LONG" : "SHORT"} ${Math.abs(metricsNow.imbalance).toFixed(2)}` : "—", 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 }) => ( ]).map(({ l, v, up }) => (
<div key={l} className="p-3 rounded-lg border border-border bg-card/50"> <div key={l} className="p-3 rounded-lg border border-border bg-card/50">
<p className="text-[9px] text-muted-foreground uppercase tracking-wider mb-1">{l}</p> <p className="text-[9px] text-muted-foreground uppercase tracking-wider mb-1">{l}</p>
+42
View File
@@ -215,3 +215,45 @@ export function generateSyntheticSnapshots(
return snapshots; 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 };
}