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
This commit is contained in:
ramseshk
2026-08-05 06:11:33 +00:00
parent 156ea40e78
commit 8855c013a6
5 changed files with 934 additions and 0 deletions
+217
View File
@@ -0,0 +1,217 @@
/**
* Order Book Depth Map — Data Utilities
*
* Transforms raw Hyperliquid L2 snapshots into surface matrices
* for 3D visualization.
*
* Architecture:
* Ring buffer stores last N snapshots.
* Each snapshot: { bids: [px, sz][], asks: [px, sz][], mid: number, ts: number }
* Output: { x: bps[], y: snapshot_index[], z: size[][] }
*
* Ring-buffer design:
* - Fixed capacity (default 60 = ~1 minute at 1s updates)
* - O(1) append via write pointer
* - No allocations on append → suitable for 60fps streaming
*/
export interface L2Level {
px: number;
sz: number;
}
export interface L2Snapshot {
bids: L2Level[]; // sorted descending by price
asks: L2Level[]; // sorted ascending by price
mid: number;
ts: number;
}
export interface SurfaceData {
/** Distance from mid in basis points (X-axis) */
x: number[];
/** Snapshot index or cumulative bid count (Y-axis) */
y: number[];
/** Resting size matrix: z[row][col] — rows = snapshots, cols = bps */
z: number[][];
}
export interface ImbalanceMetrics {
/** Current imbalance: (V_bid - V_ask) / (V_bid + V_ask) */
imbalance: number;
bidVolume: number;
askVolume: number;
wallSide: "bid" | "ask" | "none";
wallStrength: number;
snapshots: number;
}
/**
* Ring buffer for L2 snapshots.
* Fixed capacity, overwrite oldest on overflow.
*/
export class L2RingBuffer {
private buffer: L2Snapshot[];
private capacity: number;
private writeIdx: number;
private count: number;
constructor(capacity: number = 60) {
this.capacity = capacity;
this.buffer = new Array(capacity);
this.writeIdx = 0;
this.count = 0;
}
push(snapshot: L2Snapshot): void {
this.buffer[this.writeIdx] = snapshot;
this.writeIdx = (this.writeIdx + 1) % this.capacity;
if (this.count < this.capacity) this.count++;
}
/** Returns snapshots oldest-first */
snapshot(): L2Snapshot[] {
if (this.count === 0) return [];
const start = this.count < this.capacity ? 0 : this.writeIdx;
const result: L2Snapshot[] = [];
for (let i = 0; i < this.count; i++) {
result.push(this.buffer[(start + i) % this.capacity]);
}
return result;
}
get size(): number {
return this.count;
}
clear(): void {
this.writeIdx = 0;
this.count = 0;
}
}
/**
* Convert L2 snapshots → surface matrix.
*
* X-axis: distance from mid in basis points
* Y-axis: snapshot index (0 = oldest, N = newest)
* Z-axis: resting size at that bps level
*
* @param snapshots Ring buffer contents (oldest first)
* @param bpsRange ±bps from mid to cover (default: 50)
* @param resolution Number of bps steps (default: 100)
*/
export function l2SnapshotsToSurface(
snapshots: L2Snapshot[],
bpsRange: number = 50,
resolution: number = 100,
): SurfaceData {
const bpsStep = (bpsRange * 2) / resolution;
const x: number[] = [];
for (let i = 0; i < resolution; i++) {
x.push(-bpsRange + i * bpsStep);
}
const y = snapshots.map((_, i) => i);
const z: number[][] = [];
for (const snap of snapshots) {
const row = new Array(resolution).fill(0);
const mid = snap.mid;
// Fill bid side (negative bps)
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) {
row[idx] += bid.sz;
}
}
// Fill ask side (positive bps)
for (const ask of snap.asks) {
const bps = ((ask.px - mid) / mid) * 10000;
const idx = Math.round((bps + bpsRange) / bpsStep);
if (idx >= 0 && idx < resolution) {
row[idx] += ask.sz;
}
}
z.push(row);
}
return { x, y, z };
}
/**
* Compute imbalance metrics from latest snapshot.
*/
export function computeImbalance(snapshot: L2Snapshot): ImbalanceMetrics {
const bidVolume = snapshot.bids.reduce((sum, b) => sum + b.sz * b.px, 0);
const askVolume = snapshot.asks.reduce((sum, a) => sum + a.sz * a.px, 0);
const total = bidVolume + askVolume;
const imbalance = total > 0 ? (bidVolume - askVolume) / total : 0;
// Wall detection: find side with largest concentration
const maxBidSz = Math.max(...snapshot.bids.map(b => b.sz), 0);
const maxAskSz = Math.max(...snapshot.asks.map(a => a.sz), 0);
const wallSide: "bid" | "ask" | "none" =
maxBidSz > maxAskSz * 1.3 ? "bid" :
maxAskSz > maxBidSz * 1.3 ? "ask" : "none";
const wallStrength = Math.max(maxBidSz, maxAskSz);
return {
imbalance: Math.round(imbalance * 10000) / 10000,
bidVolume: Math.round(bidVolume * 100) / 100,
askVolume: Math.round(askVolume * 100) / 100,
wallSide,
wallStrength: Math.round(wallStrength * 10000) / 10000,
snapshots: 1,
};
}
/**
* Generate synthetic L2 data for testing/development.
* Produces realistic order-book shapes with price movement.
*/
export function generateSyntheticSnapshots(
count: number = 60,
basePrice: number = 97800,
): L2Snapshot[] {
const snapshots: L2Snapshot[] = [];
let price = basePrice;
let trend = 0;
for (let i = 0; i < count; i++) {
// Random walk with mean reversion
trend += (Math.random() - 0.5) * 2;
trend *= 0.95; // decay
price += trend * 50;
price += (basePrice - price) * 0.01; // mean reversion
const mid = price;
const bids: L2Level[] = [];
const asks: L2Level[] = [];
// Generate 20 levels on each side
for (let j = 0; j < 20; j++) {
const bps = (j + 1) * 2.5;
const bidPx = mid * (1 - bps / 10000);
const askPx = mid * (1 + bps / 10000);
// Realistic size distribution: thicker near mid, thinner further out
// Add wall at certain levels
const baseSize = Math.exp(-j * 0.15) * 5;
const bidWall = j === 3 ? Math.random() * 15 : 0; // occasional wall at 10bps
const askWall = j === 5 ? Math.random() * 12 : 0;
const noise = (Math.random() - 0.5) * 2;
bids.push({ px: Math.round(bidPx * 10) / 10, sz: Math.max(0.01, baseSize + bidWall + noise) });
asks.push({ px: Math.round(askPx * 10) / 10, sz: Math.max(0.01, baseSize + askWall + noise) });
}
snapshots.push({ bids, asks, mid, ts: Date.now() + i * 1000 });
}
return snapshots;
}