feat: VBT visualization + validation pipeline, HFT tick viz, DuckDB loader

Track 1 — VBT Candle-Frequency Pipeline:
- backtests/vbt_validator.py: VBTValidator with 11 checks — timestamp monotonicity,
  duplicates, NaN, data gaps, lookahead bias, signal alignment, density,
  coincident entry/exit, min trade count, fee application, benchmark comparison.
  ValidationReport dataclass with errors/warnings/stats. Validates VBT results
  or raw signal arrays.
- backtests/vbt_viz.py: VBTVisualizer with 10+ Plotly chart methods — equity
  curve with benchmark, drawdown, rolling Sharpe/Sortino/vol, trade markers,
  returns distribution with normal fit, monthly PnL heatmap, gross vs net,
  holding periods, parameter sensitivity heatmaps, dashboard compositor,
  HTML save (self-contained, CDN Plotly). All methods handle empty/null inputs.
- backtests/vbt_report.py: Markdown + HTML report generator — structured
  sections for implementation summary, performance metrics, cost analysis,
  validation results, signal analysis, known limitations, next steps.
  batch_report() for mass report generation from results directory.
- backtests/vbt_runner.py: Added run_benchmark() (buy-and-hold VBT portfolio),
  validate() (integrated VBTValidator), run_with_report() (fetch→validate→
  backtest→visualize→save in one call).

Track 2 — HFT Tick Pipeline:
- backtests/tick_viz.py: 9-panel HFT dashboard — price+trade markers,
  spread dynamics, top-of-book depth, microprice vs mid, OBI/OFI panel,
  VPIN toxicity with thresholds, event timeline (PnL from tick_runner),
  markout curves at 6 horizons. Parquet→pandas→Plotly pipeline.
  Dark-themed HTML output for microstructure review.
- data/duckdb_load.py: Parquet→DuckDB loader — creates l2_snapshots,
  trades, funding tables with schema. Pre-computed 1s rollup views for
  microprice, OFI, trade imbalance. Markout queries directly in SQL.
  Incremental loading with load_state tracking.

CLI Integration:
- cli.py: Added 'report' (full VBT report), 'validate' (check existing
  results), 'hft' (tick dashboard generation) commands. Fixed argparse
  help string escaping.

355 tests passing (34 new).
This commit is contained in:
ramseshk
2026-08-11 12:22:11 +08:00
parent 09cb0d42b5
commit 20ee340cef
8 changed files with 3332 additions and 2 deletions
+648
View File
@@ -0,0 +1,648 @@
"""
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}<br>Toxic: {toxic}<br>Cancels: {cancels}<br>"
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 = ["<html><head>",
f"<title>HFT Dashboard — {coin}</title>",
"<style>body{font-family:system-ui,sans-serif;max-width:1600px;"
"margin:0 auto;padding:20px;background:#1a1a2e;color:#eee;}"
".chart{margin:20px 0;background:#16213e;border-radius:8px;"
"box-shadow:0 2px 8px rgba(0,0,0,0.3);padding:10px;}"
"h1{color:#e94560;}</style>",
"</head><body>",
f"<h1>HFT Microstructure Dashboard — {coin}</h1>",
f"<p>{ts}</p>"]
for i, fig in enumerate(figs):
html_parts.append(f'<div class="chart">')
html_parts.append(pio.to_html(fig, include_plotlyjs="cdn", full_html=False))
html_parts.append("</div>")
html_parts.append("</body></html>")
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)