20ee340cef
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).
649 lines
20 KiB
Python
649 lines
20 KiB
Python
"""
|
|
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)
|