8855c013a6
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
200 lines
6.8 KiB
TypeScript
200 lines
6.8 KiB
TypeScript
"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>
|
||
);
|
||
}
|