""" HFT Tick Visualization Dashboard — Plotly-based microstructural charts. Visualizes tick-level order book data, trades, and microstructural metrics from DuckDB (or Parquet directly for small datasets). Panels: 1. Price + Trade Markers (candlestick with buy/sell markers) 2. Bid/Ask Spread Dynamics (spread bps over time) 3. Top-of-Book Depth (bid size vs ask size stacked area) 4. Microprice vs Mid (two lines with deviation fill) 5. Order Flow Imbalance (OBI/OFI panel) 6. VPIN Toxicity (with threshold bands) 7. Inventory + PnL (from simulation results) 8. Markout Analysis (buy vs sell markout curves) 9. Event Timeline (fills, cancels, re-quotes) Usage: python -m backtests.tick_viz --coin BTC --start 2026-08-01 --end 2026-08-02 python -m backtests.tick_viz --db data/normalized/ftdt_tick.db --dashboard """ from __future__ import annotations import argparse import logging import sys from pathlib import Path from typing import Optional sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) import numpy as np import pandas as pd logger = logging.getLogger(__name__) REPORT_DIR = Path(__file__).resolve().parent.parent / "backtests" / "reports" REPORT_DIR.mkdir(parents=True, exist_ok=True) def load_tick_dataframe( data_dir: str, coin: str, start_date: str, end_date: str, ) -> tuple[pd.DataFrame, pd.DataFrame]: """Load L2 snapshots and trades from Parquet as pandas DataFrames.""" from data.store import read_range l2_msgs = read_range(data_dir, "l2book", coin.upper(), start_date, end_date) trade_msgs = read_range(data_dir, "trades", coin.upper(), start_date, end_date) l2_rows = [] for msg in l2_msgs: payload = msg.get("payload", {}) levels = payload.get("levels", []) msg_type = payload.get("type", "snapshot") if msg_type == "snapshot" and isinstance(levels, list) and len(levels) >= 2: bids = {} asks = {} for bid in levels[0]: sz = float(bid.get("sz", 0)) if sz > 0: bids[float(bid["px"])] = sz for ask in levels[1]: sz = float(ask.get("sz", 0)) if sz > 0: asks[float(ask["px"])] = sz if bids and asks: bid_prices = sorted(bids.keys(), reverse=True) ask_prices = sorted(asks.keys()) bb = bid_prices[0] ba = ask_prices[0] mid = (bb + ba) / 2.0 bd10 = sum(bids[px] for px in bid_prices[:10]) ad10 = sum(asks[px] for px in ask_prices[:10]) depth_total = bd10 + ad10 obi = (bd10 - ad10) / depth_total if depth_total > 0 else 0.0 w = bd10 / depth_total if depth_total > 0 else 0.5 micro = w * bb + (1 - w) * ba spread_bps = (ba - bb) / mid * 10000 if mid > 0 else 0 l2_rows.append({ "ts": msg.get("local_ts", 0.0), "exchange_ts_ms": msg.get("exchange_ts", 0) or 0, "best_bid": bb, "best_ask": ba, "mid": mid, "microprice": micro, "obi": obi, "spread_bps": spread_bps, "bid_depth_10": bd10, "ask_depth_10": ad10, }) trade_rows = [] for msg in trade_msgs: payload = msg.get("payload", {}) px = float(payload.get("px", 0)) sz = float(payload.get("sz", 0)) if px <= 0 or sz <= 0: continue side = str(payload.get("side", "?")) trade_rows.append({ "ts": msg.get("local_ts", 0.0), "exchange_ts_ms": msg.get("exchange_ts", 0) or 0, "price": px, "size": sz, "side": side, "aggressor": "buy" if side.upper() in ("B", "BUY") else "sell", }) l2_df = pd.DataFrame(l2_rows) trade_df = pd.DataFrame(trade_rows) if not l2_df.empty: l2_df = l2_df.sort_values("ts").reset_index(drop=True) if not trade_df.empty: trade_df = trade_df.sort_values("ts").reset_index(drop=True) logger.info("Loaded %d L2 snapshots, %d trades", len(l2_df), len(trade_df)) return l2_df, trade_df def plot_price_with_trades( l2_df: pd.DataFrame, trade_df: pd.DataFrame, title: str = "Price + Trade Markers", ): """Candlestick-style price with buy/sell trade markers.""" import plotly.graph_objects as go if l2_df.empty: return go.Figure() sample = l2_df if len(l2_df) > 5000: sample = l2_df.iloc[np.linspace(0, len(l2_df) - 1, 5000).astype(int)] fig = go.Figure() fig.add_trace( go.Scatter( x=sample["ts"].values - sample["ts"].iloc[0], y=sample["mid"].values, mode="lines", name="Mid Price", line=dict(color="#1f77b4", width=1), ) ) if not trade_df.empty: trade_sample = trade_df if len(trade_df) > 2000: trade_sample = trade_df.iloc[np.linspace(0, len(trade_df) - 1, 2000).astype(int)] buys = trade_sample[trade_sample["aggressor"] == "buy"] sells = trade_sample[trade_sample["aggressor"] == "sell"] t0 = l2_df["ts"].iloc[0] if not l2_df.empty else 0 if not buys.empty: fig.add_trace( go.Scatter( x=buys["ts"].values - t0, y=buys["price"].values, mode="markers", name="Buy", marker=dict(symbol="triangle-up", size=4, color="#2ca02c", opacity=0.6), ) ) if not sells.empty: fig.add_trace( go.Scatter( x=sells["ts"].values - t0, y=sells["price"].values, mode="markers", name="Sell", marker=dict(symbol="triangle-down", size=4, color="#d62728", opacity=0.6), ) ) fig.update_layout( title=title, xaxis_title="Time (seconds from start)", yaxis_title="Price", template="plotly_white", hovermode="x unified", legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1), margin=dict(l=60, r=30, t=50, b=60), ) return fig def plot_spread_dynamics( l2_df: pd.DataFrame, title: str = "Bid/Ask Spread Dynamics", ): """Spread in bps over time.""" import plotly.graph_objects as go if l2_df.empty: return go.Figure() sample = l2_df if len(l2_df) > 5000: step = max(1, len(l2_df) // 5000) sample = l2_df.iloc[::step] fig = go.Figure() fig.add_trace( go.Scatter( x=sample["ts"].values - l2_df["ts"].iloc[0], y=sample["spread_bps"].values, mode="lines", name="Spread (bps)", line=dict(color="#9467bd", width=1), fill="tozeroy", fillcolor="rgba(148,103,189,0.08)", ) ) avg_spread = l2_df["spread_bps"].mean() fig.add_hline(y=avg_spread, line=dict(color="#7f7f7f", width=0.5, dash="dash")) fig.add_annotation( x=0.02, y=avg_spread, xref="paper", text=f"Avg: {avg_spread:.1f} bps", showarrow=False, bgcolor="white", bordercolor="#ccc", ) fig.update_layout( title=title, xaxis_title="Time (seconds from start)", yaxis_title="Spread (bps)", template="plotly_white", hovermode="x unified", margin=dict(l=60, r=30, t=50, b=60), ) return fig def plot_depth_panel( l2_df: pd.DataFrame, title: str = "Top-of-Book Depth", ): """Bid depth vs ask depth stacked area chart.""" import plotly.graph_objects as go if l2_df.empty: return go.Figure() sample = l2_df if len(l2_df) > 5000: step = max(1, len(l2_df) // 5000) sample = l2_df.iloc[::step] fig = go.Figure() t = sample["ts"].values - l2_df["ts"].iloc[0] fig.add_trace( go.Scatter( x=t, y=sample["bid_depth_10"].values, mode="lines", name="Bid Depth", line=dict(color="#2ca02c", width=1), stackgroup="one", ) ) fig.add_trace( go.Scatter( x=t, y=sample["ask_depth_10"].values, mode="lines", name="Ask Depth", line=dict(color="#d62728", width=1), stackgroup="one", ) ) fig.update_layout( title=title, xaxis_title="Time (seconds from start)", yaxis_title="Depth (size)", template="plotly_white", hovermode="x unified", legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1), margin=dict(l=60, r=30, t=50, b=60), ) return fig def plot_microprice_vs_mid( l2_df: pd.DataFrame, title: str = "Microprice vs Mid Price", ): """Microprice vs mid with deviation fill.""" import plotly.graph_objects as go if l2_df.empty: return go.Figure() sample = l2_df if len(l2_df) > 5000: step = max(1, len(l2_df) // 5000) sample = l2_df.iloc[::step] t = sample["ts"].values - l2_df["ts"].iloc[0] fig = go.Figure() fig.add_trace( go.Scatter( x=t, y=sample["mid"].values, mode="lines", name="Mid Price", line=dict(color="#7f7f7f", width=1, dash="dot"), ) ) fig.add_trace( go.Scatter( x=t, y=sample["microprice"].values, mode="lines", name="Microprice", line=dict(color="#1f77b4", width=1.5), fill="tonexty", fillcolor="rgba(31,119,180,0.1)", ) ) fig.update_layout( title=title, xaxis_title="Time (seconds from start)", yaxis_title="Price", template="plotly_white", hovermode="x unified", legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1), margin=dict(l=60, r=30, t=50, b=60), ) return fig def plot_obi_panel( l2_df: pd.DataFrame, title: str = "Order Flow Imbalance", ): """OBI with zero line.""" import plotly.graph_objects as go if l2_df.empty: return go.Figure() sample = l2_df if len(l2_df) > 5000: step = max(1, len(l2_df) // 5000) sample = l2_df.iloc[::step] fig = go.Figure() fig.add_trace( go.Scatter( x=sample["ts"].values - l2_df["ts"].iloc[0], y=sample["obi"].values, mode="lines", name="OBI", line=dict(color="#ff7f0e", width=1), fill="tozeroy", fillcolor="rgba(255,127,14,0.08)", ) ) fig.add_hline(y=0, line=dict(color="black", width=0.5)) fig.update_layout( title=title, xaxis_title="Time (seconds from start)", yaxis_title="Imbalance [-1, 1]", template="plotly_white", hovermode="x unified", margin=dict(l=60, r=30, t=50, b=60), ) return fig def plot_vpin_panel( l2_df: pd.DataFrame, threshold: float = 0.30, alarm: float = 0.50, title: str = "VPIN Toxicity", ): """VPIN computed from rolling buy/sell volume in OBI data.""" import plotly.graph_objects as go if l2_df.empty or len(l2_df) < 50: return go.Figure() sample = l2_df if len(l2_df) > 10000: step = max(1, len(l2_df) // 10000) sample = l2_df.iloc[::step] vpin_vals = [] for i in range(50, len(sample)): window = sample.iloc[i - 50:i] bid_d = window["bid_depth_10"].sum() ask_d = window["ask_depth_10"].sum() total = bid_d + ask_d vpin_vals.append(abs(bid_d - ask_d) / total if total > 0 else 0) if len(vpin_vals) < 2: return go.Figure() t = sample["ts"].iloc[50:].values - l2_df["ts"].iloc[0] fig = go.Figure() fig.add_trace( go.Scatter( x=t, y=vpin_vals, mode="lines", name="VPIN", line=dict(color="#d62728", width=1), fill="tozeroy", fillcolor="rgba(214,39,40,0.08)", ) ) fig.add_hline(y=threshold, line=dict(color="#ff7f0e", width=0.5, dash="dash"), annotation_text=f"Threshold ({threshold})") fig.add_hline(y=alarm, line=dict(color="#d62728", width=0.5, dash="dash"), annotation_text=f"Alarm ({alarm})") fig.update_layout( title=title, xaxis_title="Time (seconds from start)", yaxis_title="VPIN", template="plotly_white", hovermode="x unified", margin=dict(l=60, r=30, t=50, b=60), ) return fig def plot_markout_curves( l2_df: pd.DataFrame, trade_df: pd.DataFrame, title: str = "Markout Analysis", ): """Buy vs sell markout at multiple horizons.""" import plotly.graph_objects as go if l2_df.empty or trade_df.empty: return go.Figure() horizons = [100, 500, 1000, 5000, 10000, 30000, 60000] buy_means = [] sell_means = [] l2_times = l2_df["ts"].values if "ts" in l2_df else l2_df["exchange_ts_ms"].values / 1000.0 mids = l2_df["mid"].values for horizon in horizons: horizon_s = horizon / 1000.0 b_mark = [] s_mark = [] for _, trade in trade_df.iterrows(): trade_ts = trade["ts"] if "ts" in trade else trade["exchange_ts_ms"] / 1000.0 px = trade["price"] agg = trade["aggressor"] future_idx = np.searchsorted(l2_times, trade_ts + horizon_s) if future_idx < len(mids): mid_before = px mid_after = mids[future_idx] if mid_before > 0: markout = (mid_after - mid_before) / mid_before * 10000 if agg == "buy": b_mark.append(markout) else: s_mark.append(markout) buy_means.append(np.mean(b_mark) if b_mark else 0) sell_means.append(np.mean(s_mark) if s_mark else 0) fig = go.Figure() fig.add_trace( go.Scatter( x=horizons, y=buy_means, mode="lines+markers", name="After Buy", line=dict(color="#2ca02c", width=2), ) ) fig.add_trace( go.Scatter( x=horizons, y=sell_means, mode="lines+markers", name="After Sell", line=dict(color="#d62728", width=2), ) ) fig.add_hline(y=0, line=dict(color="black", width=0.5)) fig.update_layout( title=title, xaxis_title="Horizon (ms)", yaxis_title="Mean Markout (bps)", xaxis_type="log", template="plotly_white", legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1), margin=dict(l=60, r=30, t=50, b=60), ) return fig def plot_event_timeline( tick_result: dict, title: str = "Simulation Event Timeline", ): """Fills, cancels, and key events from tick runner result.""" import plotly.graph_objects as go equity = tick_result.get("equity_curve", []) if not equity: return go.Figure() times = [p.get("t", i) for i, p in enumerate(equity)] values = [p.get("v", 0) for p in equity] fig = go.Figure() fig.add_trace( go.Scatter( x=times, y=values, mode="lines", name="Equity", line=dict(color="#1f77b4", width=1.5), fill="tozeroy", fillcolor="rgba(31,119,180,0.05)", ) ) n_trades = tick_result.get("total_trades", 0) toxic = tick_result.get("toxic_fills", 0) cancels = tick_result.get("cancels", 0) net_pnl = tick_result.get("pnl_breakdown", {}).get("net_pnl", 0) fig.add_annotation( x=0.98, y=0.95, xref="paper", yref="paper", text=f"Trades: {n_trades}
Toxic: {toxic}
Cancels: {cancels}
" f"Net PnL: ${net_pnl:.4f}", showarrow=False, bgcolor="white", bordercolor="#ccc", xanchor="right", yanchor="top", ) fig.update_layout( title=title, xaxis_title="Time", yaxis_title="Equity ($)", template="plotly_white", hovermode="x unified", margin=dict(l=60, r=30, t=50, b=60), ) return fig def tick_dashboard( l2_df: pd.DataFrame, trade_df: pd.DataFrame, tick_result: Optional[dict] = None, coin: str = "BTC", vpin_threshold: float = 0.30, vpin_alarm: float = 0.50, ): """Full 9-panel HFT dashboard.""" figs = [] figs.append(plot_price_with_trades(l2_df, trade_df, title=f"{coin} — Price & Trade Markers")) figs.append(plot_spread_dynamics(l2_df, title=f"{coin} — Spread Dynamics")) figs.append(plot_depth_panel(l2_df, title=f"{coin} — Top-of-Book Depth")) figs.append(plot_microprice_vs_mid(l2_df, title=f"{coin} — Microprice vs Mid")) figs.append(plot_obi_panel(l2_df, title=f"{coin} — Order Flow Imbalance")) figs.append(plot_vpin_panel(l2_df, vpin_threshold, vpin_alarm, title=f"{coin} — VPIN Toxicity")) if tick_result: figs.append(plot_event_timeline(tick_result, title=f"{coin} — PnL Timeline")) figs.append(plot_markout_curves(l2_df, trade_df, title=f"{coin} — Markout Analysis")) return figs def save_tick_dashboard( l2_df: pd.DataFrame, trade_df: pd.DataFrame, tick_result: Optional[dict] = None, coin: str = "BTC", output_dir: str = "", ) -> str: """Save the full tick dashboard as a standalone HTML file.""" import plotly.io as pio from datetime import datetime, timezone figs = tick_dashboard(l2_df, trade_df, tick_result, coin) ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") fname = f"tick_dashboard_{coin}_{ts}.html" out = Path(output_dir) if output_dir else REPORT_DIR out.mkdir(parents=True, exist_ok=True) fpath = out / fname html_parts = ["", f"HFT Dashboard — {coin}", "", "", f"

HFT Microstructure Dashboard — {coin}

", f"

{ts}

"] for i, fig in enumerate(figs): html_parts.append(f'
') html_parts.append(pio.to_html(fig, include_plotlyjs="cdn", full_html=False)) html_parts.append("
") html_parts.append("") with open(fpath, "w") as f: f.write("\n".join(html_parts)) logger.info("Tick dashboard saved to %s", fpath) return str(fpath) def cmd_tick_viz(args): """CLI entry: load data and generate dashboard.""" l2_df, trade_df = load_tick_dataframe( args.data_dir, args.coin, args.start_date, args.end_date, ) if l2_df.empty: print("No L2 data available. Run 'python -m cli collect --mainnet' first.") return tick_result = None if args.tick_result: import json try: with open(args.tick_result) as f: tick_result = json.load(f) except Exception: pass fpath = save_tick_dashboard( l2_df, trade_df, tick_result, args.coin, output_dir=args.output_dir, ) print(f"Dashboard: {fpath}") if __name__ == "__main__": p = argparse.ArgumentParser(description="HFT Tick Visualization") p.add_argument("--data-dir", default="data/raw") p.add_argument("--coin", default="BTC") p.add_argument("--start-date", default="2026-08-01") p.add_argument("--end-date", default="2026-08-02") p.add_argument("--output-dir", default="") p.add_argument("--tick-result", help="Path to tick_runner JSON result for PnL panel") args = p.parse_args() logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s", datefmt="%H:%M:%S") cmd_tick_viz(args)