Files
ftdt-quant-lab/dashboard-next/src/components/depth-map-plotly.tsx
T
ramseshk 8855c013a6 3D Order Book Depth Map: Plotly.js + Three.js live visualization
Architecture:
- depth-map-utils.ts: L2RingBuffer, l2SnapshotsToSurface, computeImbalance
  └─ O(1) ring buffer, 60-snapshot capacity
  └─ Surface matrix: ±50 bps × 100 resolution
  └─ Imbalance formula: I = (V_b-V_a)/(V_b+V_a) with wall detection

- depth-map-plotly.tsx: Plotly.js 3D Surface
  └─ 7-stop warm colorscale (dark→amber→gold)
  └─ contour projection, ambient+diffuse lighting
  └─ Live imbalance overlay: gauge bar + formula
  └─ uirevision for stable camera on updates

- depth-map-three.tsx: Three.js high-perf alternative
  └─ BufferGeometry + vertex colors + OrbitControls
  └─ 60fps suitable, WebGL renderer with alpha
  └─ Warm gradient matching Plotly colorscale
  └─ Double-sided faces, dark grid helper

- obi-detail.tsx: Combined strategy detail panel
  └─ Engine toggle: Plotly.js ↔ Three.js
  └─ Synthetic data generation for testing
  └─ 6-stat metrics row (PnL, BTC B&H, Sharpe, Hit Rate, Max DD, Signal)
  └─ Equity curve comparison + trade history table

- Page integration: OBI strategy triggers dedicated 3D view
2026-08-05 06:11:33 +00:00

200 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useRef, useCallback } from "react";
import type { SurfaceData, 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
*/
interface DepthMapPlotlyProps {
surface: SurfaceData | null;
metrics: ImbalanceMetrics | null;
height?: number;
}
// Inline Plotly — avoids extra npm dependency for types
declare global {
interface Window {
Plotly: any;
}
}
export function DepthMapPlotly({ surface, metrics, height = 420 }: DepthMapPlotlyProps) {
const containerRef = useRef<HTMLDivElement>(null);
const plotlyRef = useRef<any>(null);
const revisionRef = useRef(0);
// Load Plotly script once
useEffect(() => {
if (window.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(); };
}, []);
// Initialize or update chart
useEffect(() => {
if (!containerRef.current || !surface || !window.Plotly) return;
const Plotly = window.Plotly;
const trace: 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,
colorbar: {
title: { text: "Resting Size", font: { color: "#888", size: 10 } },
tickfont: { color: "#666", size: 9 },
thickness: 12,
len: 0.6,
x: 1.02,
},
};
const layout: any = {
title: {
text: "ORDER BOOK IMBALANCE • BTC-USD-PERP",
font: { size: 12, color: "#ccc", family: "Inter, sans-serif" },
x: 0.05,
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}`,
autosize: true,
font: { color: "#888" },
};
const config = {
displayModeBar: true,
modeBarButtonsToRemove: ["sendDataToCloud", "zoom2d", "pan2d", "select2d", "lasso2d", "autoScale2d"],
modeBarButtonsToAdd: [],
displaylogo: false,
responsive: true,
} as any;
if (plotlyRef.current) {
Plotly.react(containerRef.current, [trace], layout, config);
} else {
Plotly.newPlot(containerRef.current, [trace], layout, config);
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]);
return (
<div className="relative">
<div ref={containerRef} style={{ width: "100%", height }} />
{/* Imbalance Overlay */}
{metrics && (
<div className="absolute top-3 right-4 z-10 flex flex-col gap-3">
{/* Imbalance gauge */}
<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>
{/* Mini bar gauge */}
<div className="w-full h-1.5 bg-white/10 rounded-full mt-1 overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-300 ${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>
{/* Formula */}
<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>)
</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>
);
}