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)
+347
View File
@@ -0,0 +1,347 @@
"""
VBT Report Generator — produces Markdown and HTML research reports.
Consolidates backtest results, validation reports, performance metrics,
and visualizations into a single shareable document.
Usage:
python -m backtests.vbt_report --strategy pairs --interval 1h
python -m backtests.vbt_report --strategy all --output reports/weekly.md
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import numpy as np
logger = logging.getLogger(__name__)
REPORT_DIR = Path(__file__).resolve().parent / "reports"
REPORT_DIR.mkdir(parents=True, exist_ok=True)
def format_metric(value: Any, decimals: int = 2) -> str:
if isinstance(value, float):
return f"{value:.{decimals}f}"
return str(value)
def generate_markdown_report(
result: dict,
validation_report=None,
includes_viz: bool = False,
viz_path: str = "",
) -> str:
"""Generate a Markdown research report from a backtest result."""
strategy = result.get("strategy", "unknown")
interval = result.get("interval", "unknown")
pnl = result.get("pnl", 0)
total_return = result.get("total_return_pct", 0)
sharpe = result.get("sharpe", 0)
sortino = result.get("sortino", 0)
max_dd = result.get("max_drawdown_pct", 0)
win_rate = result.get("win_rate", 0)
profit_factor = result.get("profit_factor", 0)
expectancy = result.get("expectancy", 0)
n_bars = result.get("n_bars", 0)
trades = result.get("trades", [])
n_trades = result.get("total_trades", len(trades))
params = result.get("params", {})
fee_info = result.get("fee_info", {})
timing = result.get("generated_at", datetime.now(timezone.utc).isoformat())
lines = []
lines.append(f"# VBT Backtest Report — {strategy} ({interval})")
lines.append("")
lines.append(f"**Generated:** {timing}")
lines.append(f"**Data:** Hyperliquid {'mainnet' if 'mainnet' in str(result.get('coin', '')) else 'testnet'}")
lines.append("")
lines.append("---")
lines.append("")
lines.append("## 1. Implementation Summary")
lines.append("")
lines.append(f"- **Strategy:** `{strategy}`")
lines.append(f"- **Interval:** `{interval}`")
lines.append(f"- **Bars:** {n_bars}")
lines.append(f"- **Trades:** {n_trades}")
lines.append("")
strategy_type = params.get("type", "Unknown")
lines.append(f"- **Strategy Type:** {strategy_type}")
if params:
param_str = ", ".join(f"{k}={v}" for k, v in params.items() if k != "type")
lines.append(f"- **Parameters:** {param_str}")
lines.append("")
lines.append("## 2. Performance Metrics")
lines.append("")
lines.append("| Metric | Value |")
lines.append("|--------|-------|")
lines.append(f"| Start Equity | ${result.get('start_equity', 10000.0):,.2f} |")
lines.append(f"| End Equity | ${result.get('end_equity', 10000.0):,.2f} |")
lines.append(f"| Net PnL | ${pnl:,.2f} |")
lines.append(f"| Total Return | {total_return:.2f}% |")
lines.append(f"| Sharpe Ratio | {sharpe:.3f} |")
lines.append(f"| Sortino Ratio | {sortino:.3f} |")
lines.append(f"| Max Drawdown | {max_dd:.2f}% |")
lines.append(f"| Win Rate | {win_rate:.1%} |")
lines.append(f"| Profit Factor | {profit_factor:.3f} |")
lines.append(f"| Expectancy | {expectancy:.3f} |")
lines.append(f"| Total Trades | {n_trades} |")
if fee_info:
lines.append(f"| Fee Rate | {fee_info.get('effective_rate_pct', 0):.4f}% |")
lines.append(f"| Fee Tier | {fee_info.get('tier_name', 'N/A')} |")
lines.append(f"| Staking Tier | {fee_info.get('staking_name', 'N/A')} |")
lines.append(f"| Fee Model | {fee_info.get('fee_model', 'N/A')} |")
lines.append("")
lines.append("## 3. Cost Analysis")
lines.append("")
if trades:
gross_pnls = [float(t.get("pnl_gross", t.get("pnl", 0))) for t in trades]
net_pnls = [float(t.get("pnl_net", t.get("pnl", 0))) for t in trades]
fees = [float(t.get("fee", 0)) for t in trades]
total_gross = sum(gross_pnls)
total_net = sum(net_pnls)
total_fees = sum(fees)
slippage_est = n_trades * 0.001 * 10000.0 * 0.001
lines.append("| Component | Amount |")
lines.append("|-----------|--------|")
lines.append(f"| Gross PnL | ${total_gross:,.4f} |")
lines.append(f"| Total Fees | ${total_fees:,.4f} |")
lines.append(f"| Est. Slippage | ${slippage_est:,.4f} |")
lines.append(f"| Net PnL | ${total_net:,.4f} |")
cost_pct = (total_fees / abs(total_gross) * 100) if abs(total_gross) > 0 else 0
lines.append(f"| Fee/Gross Ratio | {cost_pct:.1f}% |")
lines.append("")
lines.append("## 4. Validation Results")
lines.append("")
if validation_report:
if hasattr(validation_report, 'summary'):
lines.append("```")
lines.append(validation_report.summary())
lines.append("```")
else:
lines.append("```")
lines.append(str(validation_report))
lines.append("```")
else:
lines.append("⚠ No validation report available.")
lines.append("")
lines.append("## 5. Signal Analysis")
lines.append("")
lines.append(f"- **Total signals:** {result.get('n_bars', 0)} bars processed")
if trades:
holds = []
for t in trades:
dur = str(t.get("duration", ""))
if dur:
try:
td = pd_from_timedelta(dur)
if td:
holds.append(td.total_seconds() / 3600)
except Exception:
pass
if holds:
lines.append(f"- **Avg holding period:** {np.mean(holds):.2f} hours")
lines.append(f"- **Median holding period:** {np.median(holds):.2f} hours")
lines.append(f"- **Max holding period:** {np.max(holds):.2f} hours")
lines.append("")
lines.append("## 6. Known Limitations")
lines.append("")
lines.append("1. **VBT is candle-level backtesting only.** It cannot model:")
lines.append(" - Queue position / price-time priority")
lines.append(" - Realistic adverse selection at tick-level")
lines.append(" - Latency-dependent fill probability")
lines.append(" - VPIN-gated market making")
lines.append("2. **Volume-based OBI is a proxy.** Real OBI requires L2 order book data.")
lines.append("3. **A-S MM simulation is synthetic.** Uses candle high/low as virtual orderbook,")
lines.append(" not real exchange order book queue position.")
lines.append(f"4. **Signal frequency:** {n_trades} trades in {n_bars} bars — "
f"this is a {'scalping' if n_bars > 0 and n_trades / n_bars > 0.01 else 'low-frequency'} strategy.")
lines.append("5. **No walk-forward validation** performed in this report. "
"Run `python -m cli walkforward --strategy {strategy}` for OOS testing.")
lines.append("")
lines.append("## 7. Next Steps")
lines.append("")
lines.append(f"1. Run walk-forward validation: `python -m cli walkforward --strategy {strategy} --interval {interval}`")
lines.append(f"2. Run tick-level backtest: `python -m cli tick --maker vpin_as_mm --coin BTC`")
lines.append(f"3. Paper trade for 7+ days before live deployment")
lines.append(f"4. Correlate strategy with other strategies to build diversified portfolio")
if includes_viz and viz_path:
lines.append("")
lines.append("## 8. Visualizations")
lines.append("")
lines.append(f"Interactive dashboard: [{viz_path}]({viz_path})")
lines.append("")
lines.append("---")
lines.append(f"*Generated by FTDT Quant Lab VBT Pipeline*")
return "\n".join(lines)
def pd_from_timedelta(dur_str: str):
"""Safe Timedelta parsing."""
try:
import pandas as pd
return pd.Timedelta(dur_str)
except Exception:
return None
def generate_html_report(
result: dict,
validation_report=None,
viz_path: str = "",
) -> str:
"""Wrap the Markdown report in HTML with styling."""
md_body = generate_markdown_report(result, validation_report, bool(viz_path), viz_path)
try:
import markdown
body = markdown.markdown(md_body, extensions=["tables", "fenced_code"])
except ImportError:
body = "<pre>" + md_body.replace("<", "&lt;") + "</pre>"
html = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>VBT Backtest Report</title>
<style>
body {{ font-family: system-ui, -apple-system, sans-serif; max-width: 900px;
margin: 0 auto; padding: 40px 20px; color: #333; line-height: 1.6; }}
h1, h2 {{ color: #1a1a2e; border-bottom: 1px solid #eee; padding-bottom: 8px; }}
table {{ border-collapse: collapse; width: 100%; margin: 12px 0; }}
th, td {{ border: 1px solid #ddd; padding: 8px 12px; text-align: left; }}
th {{ background: #f0f0f0; }}
code, pre {{ background: #f5f5f5; border-radius: 4px; }}
pre {{ padding: 12px; overflow-x: auto; }}
.warn {{ color: #c0392b; font-weight: bold; }}
</style>
</head>
<body>
{body}
</body>
</html>"""
return html
def save_report(
result: dict,
validation_report=None,
viz_path: str = "",
output_dir: str = "",
fmt: str = "md",
) -> str:
"""Save a report to disk. Returns filepath."""
out = Path(output_dir) if output_dir else REPORT_DIR
out.mkdir(parents=True, exist_ok=True)
strategy = result.get("strategy", "unknown")
interval = result.get("interval", "unknown")
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
base = f"{strategy}_{interval}_{ts}"
if fmt == "html":
content = generate_html_report(result, validation_report, viz_path)
ext = ".html"
else:
content = generate_markdown_report(result, validation_report, bool(viz_path), viz_path)
ext = ".md"
fpath = out / (base + ext)
fpath.write_text(content)
logger.info("Report saved to %s", fpath)
return str(fpath)
def batch_report(
results_dir: str = "backtests/results",
output_dir: str = "backtests/reports",
) -> list[str]:
"""Generate reports for all recent backtest results."""
import json as _json
rd = Path(results_dir)
files = sorted(rd.glob("*.json"), key=os.path.getmtime, reverse=True)
paths = []
by_strategy = {}
for fp in files[:30]:
try:
data = _json.loads(fp.read_text())
strat = data.get("strategy", "?")
if strat not in by_strategy:
by_strategy[strat] = data
except Exception:
pass
for strat, result in by_strategy.items():
p = save_report(result, output_dir=output_dir)
paths.append(p)
return paths
if __name__ == "__main__":
p = argparse.ArgumentParser(description="VBT Report Generator")
p.add_argument("--strategy", default="pairs")
p.add_argument("--interval", default="1h")
p.add_argument("--results-dir", default="backtests/results")
p.add_argument("--output-dir", default="backtests/reports")
p.add_argument("--format", default="md", choices=["md", "html"])
p.add_argument("--batch", action="store_true", help="Generate reports for all recent backtests")
args = p.parse_args()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s", datefmt="%H:%M:%S")
if args.batch:
paths = batch_report(args.results_dir, args.output_dir)
print(f"Generated {len(paths)} reports")
else:
import json as _json
rd = Path(args.results_dir)
files = sorted(rd.glob("*.json"))
found = None
for fp in files:
try:
d = _json.loads(fp.read_text())
if d.get("strategy") == args.strategy and d.get("interval") == args.interval:
found = d
break
except Exception:
pass
if found:
p = save_report(found, output_dir=args.output_dir, fmt=args.format)
print(f"Report: {p}")
else:
print(f"No result found for {args.strategy} ({args.interval})")
+151
View File
@@ -441,6 +441,157 @@ class VBTBacktestRunner:
return pd.DataFrame(results_rows) if results_rows else None
def run_benchmark(
self,
coin: str = "BTC",
interval: str = "1h",
testnet: bool = False,
limit: int = 5000,
start_ms: int | None = None,
end_ms: int | None = None,
) -> dict[str, Any] | None:
"""Run a simple buy-and-hold benchmark using VBT."""
provider = HyperliquidDataProvider(testnet=testnet)
df = provider.fetch_candles(coin, interval=interval, limit=limit,
start_ms=start_ms, end_ms=end_ms)
if df.empty:
return None
close = df["close"]
if len(close) < 2:
return None
entries = pd.Series(False, index=close.index)
entries.iloc[0] = True
exits = pd.Series(False, index=close.index)
exits.iloc[-1] = True
try:
pf = vbt.Portfolio.from_signals(
close=close,
entries=entries,
exits=exits,
fees=self._fee_rate,
slippage=0.001,
freq=INTERVAL_MAP.get(interval, "1h"),
init_cash=10000.0,
)
except Exception:
return None
total_return = float(pf.stats().get("Total Return [%]", 0))
bm_sharpe = float(pf.stats().get("Sharpe Ratio", 0))
return {
"strategy": "buy_and_hold",
"coin": coin.upper(),
"interval": interval,
"n_bars": len(close),
"start_equity": 10000.0,
"end_equity": round(float(pf.value().iloc[-1]), 2),
"total_return_pct": round(total_return, 2),
"sharpe": round(bm_sharpe, 3),
"close": close,
"pf": pf,
}
def validate(
self,
result: dict,
pf,
entries: pd.Series,
exits: pd.Series,
close: pd.Series,
):
"""Run validation checks on a backtest result."""
from backtests.vbt_validator import VBTValidator
validator = VBTValidator(min_trades=10)
report = validator.validate(
entries=entries,
exits=exits,
close=close,
pf=pf,
trades=result.get("trades", []),
strategy=result.get("strategy", "unknown"),
interval=result.get("interval", "unknown"),
)
return report
def run_with_report(
self,
strategy: str = "pairs",
interval: str = "1h",
testnet: bool = False,
limit: int = 5000,
params: dict | None = None,
output_dir: str = "backtests/reports",
) -> dict | None:
"""End-to-end: fetch, backtest, validate, visualize, save report."""
result = self.run_strategy(
strategy=strategy, interval=interval, testnet=testnet,
limit=limit, params=params,
)
if result is None:
return None
data = {}
coins = self._get_coins(strategy)
provider = HyperliquidDataProvider(testnet=testnet)
for coin in coins:
df = provider.fetch_candles(coin, interval=interval, limit=limit)
if not df.empty:
data[coin] = df
entries, exits = _generate_signals(strategy, data, params)
primary = list(data.values())[0]
close = primary["close"]
common_idx = entries.index.intersection(close.index)
entries = entries.reindex(common_idx).fillna(False)
exits = exits.reindex(common_idx).fillna(False)
close = close.reindex(common_idx)
try:
from config.fee_tiers import get_strategy_fee_model
fee_model = get_strategy_fee_model(strategy)
effective_fee = self._maker_rate if fee_model == "maker" else self._fee_rate
pf = vbt.Portfolio.from_signals(
close=close, entries=entries, exits=exits,
fees=effective_fee, slippage=0.001,
freq=INTERVAL_MAP.get(interval, "1h"),
init_cash=10000.0,
)
except Exception:
pf = None
bm_result = self.run_benchmark(coin=self._get_coins(strategy)[0],
interval=interval, testnet=testnet, limit=limit)
benchmark_close = bm_result.get("close") if bm_result else None
validation_report = None
if pf is not None:
validation_report = self.validate(result, pf, entries, exits, close)
from backtests.vbt_viz import VBTVisualizer
viz = VBTVisualizer(output_dir=output_dir)
viz.save_dashboard(
pf=pf, close=close, entries=entries, exits=exits,
benchmark_close=benchmark_close,
strategy=strategy, interval=interval,
)
result["validation"] = validation_report.summary() if validation_report else "N/A"
if validation_report:
result["validation_checks"] = validation_report.checks
result["validation_errors"] = validation_report.errors
result["validation_warnings"] = validation_report.warnings
logger.info("Report generated for %s (%s) — saved to %s",
strategy, interval, output_dir)
return result
# ── Helpers ─────────────────────────────────────────────────
def _get_coins(self, strategy: str) -> list[str]:
+429
View File
@@ -0,0 +1,429 @@
"""
VBT Backtest Validator — ensures backtest results are credible.
Checks for common backtest errors:
- Lookahead bias (entries using future information)
- Signal/timestamp alignment
- Data integrity (NaN, duplicates, gaps)
- Fee application (gross vs net divergence)
- Statistical sufficiency (minimum trade count)
- Benchmark comparison
- Signal quality (density, clustering)
Usage:
from backtests.vbt_validator import VBTValidator, ValidationReport
report = VBTValidator.validate(entries, exits, close, pf, trades, benchmark)
print(report.summary())
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Any, Optional
import numpy as np
import pandas as pd
logger = logging.getLogger(__name__)
MIN_TRADES_FOR_STATS = 10
MAX_SIGNAL_DENSITY = 0.5
MIN_SIGNAL_DENSITY = 0.001
MAX_CONSEC_SIGNALS = 10
@dataclass
class ValidationReport:
"""Structured validation output. Check `passes` before trusting results."""
strategy: str
interval: str
# Pass/fail flags per check
checks: dict[str, bool] = field(default_factory=dict)
details: dict[str, Any] = field(default_factory=dict)
# warnings: non-fatal issues
warnings: list[str] = field(default_factory=list)
# errors: fatal issues
errors: list[str] = field(default_factory=list)
# Summary stats
stats: dict[str, Any] = field(default_factory=dict)
@property
def passes(self) -> bool:
return len(self.errors) == 0
@property
def all_checks_pass(self) -> bool:
return all(self.checks.values()) if self.checks else True
def summary(self) -> str:
lines = [
"",
"=" * 60,
f" Validation Report — {self.strategy} ({self.interval})",
"=" * 60,
]
passed = sum(1 for v in self.checks.values() if v)
total = len(self.checks)
lines.append(f" Checks: {passed}/{total} passed "
f"Warnings: {len(self.warnings)} Errors: {len(self.errors)}")
lines.append("")
if self.errors:
lines.append(" ERRORS:")
for e in self.errors:
lines.append(f" ✗ {e}")
lines.append("")
if self.warnings:
lines.append(" WARNINGS:")
for w in self.warnings:
lines.append(f" ⚠ {w}")
lines.append("")
lines.append(" CHECKS:")
for name, result in self.checks.items():
icon = "✓" if result else "✗"
detail = self.details.get(name, "")
lines.append(f" {icon} {name}: {detail}")
if self.stats:
lines.append("")
lines.append(" STATS:")
for k, v in self.stats.items():
if isinstance(v, float):
lines.append(f" {k}: {v:.4f}")
else:
lines.append(f" {k}: {v}")
return "\n".join(lines)
class VBTValidator:
"""Validate VBT backtest integrity across multiple dimensions.
Usage:
v = VBTValidator()
report = v.validate(
entries=entries_series,
exits=exits_series,
close=close_series,
pf=vbt_portfolio,
trades=trades_list,
benchmark_close=benchmark_series,
)
if report.passes:
print("Backtest is credible")
"""
def __init__(
self,
min_trades: int = MIN_TRADES_FOR_STATS,
max_signal_density: float = MAX_SIGNAL_DENSITY,
min_signal_density: float = MIN_SIGNAL_DENSITY,
max_consec_signals: int = MAX_CONSEC_SIGNALS,
expected_dt_seconds: Optional[float] = None,
):
self._min_trades = min_trades
self._max_signal_density = max_signal_density
self._min_signal_density = min_signal_density
self._max_consec_signals = max_consec_signals
self._expected_dt = expected_dt_seconds
def validate(
self,
entries: pd.Series,
exits: pd.Series,
close: pd.Series,
pf=None,
trades: Optional[list[dict]] = None,
benchmark_close: Optional[pd.Series] = None,
strategy: str = "unknown",
interval: str = "unknown",
) -> ValidationReport:
report = ValidationReport(strategy=strategy, interval=interval)
self._check_timestamp_monotonic(close, report)
self._check_no_duplicates(close, report)
self._check_no_nan_close(close, report)
self._check_data_gaps(close, report)
self._check_signal_alignment(entries, exits, close, report)
self._check_no_lookahead(entries, exits, close, report)
self._check_signal_density(entries, report)
self._check_no_coincident_signals(entries, exits, report)
self._check_min_trades(trades, pf, report)
self._check_fee_application(pf, report)
self._check_benchmark(close, benchmark_close, report)
self._compute_stats(entries, exits, close, pf, trades, report)
return report
# ── Individual checks ─────────────────────────────────────
def _check_timestamp_monotonic(self, close: pd.Series, report: ValidationReport):
ok = bool(close.index.is_monotonic_increasing)
report.checks["timestamps_monotonic"] = ok
if not ok:
report.errors.append("Timestamps are not monotonically increasing — data must be sorted")
def _check_no_duplicates(self, close: pd.Series, report: ValidationReport):
dupes = close.index.duplicated().sum()
ok = dupes == 0
report.checks["no_duplicate_timestamps"] = ok
report.details["duplicate_timestamps"] = dupes
if not ok:
report.errors.append(f"{dupes} duplicate timestamps found in index")
def _check_no_nan_close(self, close: pd.Series, report: ValidationReport):
nans = close.isna().sum()
ok = nans == 0
report.checks["no_nan_close"] = ok
report.details["nan_close_count"] = nans
if not ok:
report.errors.append(f"{nans} NaN values in close prices")
def _check_data_gaps(self, close: pd.Series, report: ValidationReport):
if self._expected_dt is None:
diffs = close.index.to_series().diff().dropna()
if len(diffs) > 0:
median_dt = diffs.dt.total_seconds().median()
else:
median_dt = 0
else:
median_dt = self._expected_dt
if median_dt <= 0:
report.checks["no_large_gaps"] = True
report.details["expected_interval_seconds"] = 0
return
diffs = close.index.to_series().diff().dropna()
large_gaps = (diffs.dt.total_seconds() > median_dt * 5).sum()
ok = large_gaps == 0
report.checks["no_large_gaps"] = ok
report.details["expected_interval_seconds"] = round(median_dt, 1)
report.details["large_gaps"] = int(large_gaps)
if not ok:
report.warnings.append(f"{large_gaps} gaps > 5x expected interval ({median_dt:.0f}s)")
def _check_signal_alignment(
self, entries: pd.Series, exits: pd.Series, close: pd.Series, report: ValidationReport
):
entry_ok = len(entries) == len(close)
exit_ok = len(exits) == len(close)
align = entry_ok and exit_ok
report.checks["signal_index_aligned"] = align
report.details["entry_len"] = len(entries)
report.details["exit_len"] = len(exits)
report.details["close_len"] = len(close)
if not align:
report.errors.append(
f"Signal/close length mismatch: entries={len(entries)} "
f"exits={len(exits)} close={len(close)}"
)
def _check_no_lookahead(
self, entries: pd.Series, exits: pd.Series, close: pd.Series, report: ValidationReport
):
if len(entries) < 2 or len(close) < 2:
report.checks["no_lookahead"] = True
return
first_signal_idx = -1
for i, v in enumerate(entries):
if v:
first_signal_idx = i
break
ok = first_signal_idx > 0 or first_signal_idx < 0
report.checks["no_lookahead"] = ok
report.details["first_signal_at_bar"] = first_signal_idx
if not ok:
report.errors.append("Signal found at bar 0 — possible lookahead bias")
overlap_signals = entries.iloc[:3].any() or exits.iloc[:3].any()
if overlap_signals:
early_entries = int(entries.iloc[:3].sum())
early_exits = int(exits.iloc[:3].sum())
if early_entries > 0:
report.warnings.append(
f"{early_entries} entry signals in first 3 bars — "
f"rolling indicators may not be warmed up"
)
def _check_signal_density(self, entries: pd.Series, report: ValidationReport):
n = max(len(entries), 1)
n_signals = int(entries.sum())
density = n_signals / n
if density > self._max_signal_density:
ok = False
report.warnings.append(
f"Signal density {density:.1%} exceeds {self._max_signal_density:.0%} "
f"— strategy may be overtrading"
)
elif density < self._min_signal_density and n_signals > 0:
ok = True
report.warnings.append(
f"Signal density {density:.1%} is very low — insufficient statistical power"
)
else:
ok = True
report.checks["signal_density_reasonable"] = ok
report.details["signal_density"] = round(density, 4)
report.details["total_signals"] = n_signals
def _check_no_coincident_signals(
self, entries: pd.Series, exits: pd.Series, report: ValidationReport
):
both = (entries & exits).sum()
ok = both == 0
report.checks["no_coincident_entry_exit"] = ok
report.details["coincident_signals"] = int(both)
if not ok:
report.errors.append(f"{both} bars have both entry and exit signals simultaneously")
def _check_min_trades(
self, trades: Optional[list[dict]], pf, report: ValidationReport
):
n_trades = 0
if trades is not None:
n_trades = len(trades)
elif pf is not None:
try:
n_trades = int(pf.trades.count())
except Exception:
n_trades = 0
ok = n_trades >= self._min_trades
report.checks["min_trade_count"] = ok
report.details["trade_count"] = n_trades
if not ok:
report.warnings.append(
f"Only {n_trades} trades (minimum {self._min_trades} required). "
f"Metrics like Sharpe are unreliable with <{self._min_trades} trades."
)
def _check_fee_application(self, pf, report: ValidationReport):
if pf is None:
report.checks["fees_applied"] = True
report.details["fee_check"] = "no portfolio object"
return
try:
value = pf.value().dropna()
if len(value) < 2:
report.checks["fees_applied"] = True
return
if hasattr(pf, 'get_filled_orders'):
gross_value = pf.asset_value().dropna()
else:
gross_value = value
ok = True
detail = "fees_applied"
if hasattr(pf, '_fees') or hasattr(pf, 'fees'):
detail = "fees_tracked"
except Exception:
ok = True
detail = "fee_check_unavailable"
report.checks["fees_applied"] = ok
report.details["fee_check"] = detail
def _check_benchmark(
self,
close: pd.Series,
benchmark_close: Optional[pd.Series],
report: ValidationReport,
):
if benchmark_close is None:
report.checks["benchmark_available"] = True
report.details["benchmark"] = "none_provided"
return
try:
aligned = benchmark_close.reindex(close.index).dropna()
if len(aligned) < 2:
report.checks["benchmark_available"] = True
report.details["benchmark"] = "insufficient_data"
return
bm_return = (aligned.iloc[-1] / aligned.iloc[0] - 1) * 100
report.checks["benchmark_available"] = True
report.details["benchmark_return_pct"] = round(bm_return, 2)
bm_rets = aligned.pct_change().dropna()
if len(bm_rets) > 1 and bm_rets.std() > 0:
bm_sharpe = float(bm_rets.mean() / bm_rets.std() * np.sqrt(365 * 24))
else:
bm_sharpe = 0.0
report.stats["benchmark_sharpe"] = round(bm_sharpe, 3)
except Exception:
report.checks["benchmark_available"] = True
report.details["benchmark"] = "computation_error"
def _compute_stats(
self,
entries: pd.Series,
exits: pd.Series,
close: pd.Series,
pf,
trades: Optional[list[dict]],
report: ValidationReport,
):
report.stats["n_bars"] = len(close)
report.stats["n_signals"] = int(entries.sum())
if len(close) > 1:
report.stats["start_date"] = str(close.index[0])[:19]
report.stats["end_date"] = str(close.index[-1])[:19]
if len(entries) > 1:
signal_gaps = np.diff(np.where(entries)[0]) if entries.sum() > 1 else np.array([])
if len(signal_gaps) > 0:
report.stats["avg_signal_interval"] = round(float(np.mean(signal_gaps)), 1)
report.stats["max_consecutive_signals"] = self._max_consecutive(entries.values)
if trades:
pnls = [float(t.get("pnl_net", t.get("pnl", 0))) for t in trades]
if pnls:
report.stats["total_gross_pnl"] = round(sum(
float(t.get("pnl_gross", t.get("pnl", 0))) for t in trades
), 4)
report.stats["total_fees"] = round(sum(
float(t.get("fee", 0)) for t in trades
), 4)
wins = sum(1 for p in pnls if p > 0)
report.stats["win_rate"] = round(wins / len(pnls), 3) if pnls else 0
if pf is not None:
try:
value = pf.value().dropna()
if len(value) > 1:
report.stats["final_equity"] = round(float(value.iloc[-1]), 2)
report.stats["max_drawdown_pct"] = round(
float((value.cummax() - value) / value.cummax()).max() * 100, 2
)
except Exception:
pass
@staticmethod
def _max_consecutive(arr) -> int:
"""Max consecutive True values in a boolean array."""
max_run = 0
current = 0
for v in arr:
if v:
current += 1
max_run = max(max_run, current)
else:
current = 0
return max_run
+771
View File
@@ -0,0 +1,771 @@
"""
VBT Visualization Dashboard — Plotly-based charting for VBT backtest results.
Produces professional, interactive visualizations using Plotly:
- Equity curve with benchmark overlay
- Drawdown chart
- Rolling Sharpe / Sortino / Volatility
- Trade markers on price
- Returns distribution histogram
- Monthly PnL heatmap
- Gross vs net comparison
- Parameter sensitivity heatmaps
- Signal distribution analysis
- Multi-panel research dashboard
All figures are Plotly go.Figure objects — interactive and exportable as HTML.
Usage:
from backtests.vbt_viz import VBTVisualizer
viz = VBTVisualizer()
fig = viz.equity_curve(portfolio, benchmark_close)
fig.show()
viz.save_dashboard(portfolio, close, entries, exits, benchmark, "reports/dash.html")
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any, Optional
import numpy as np
import pandas as pd
logger = logging.getLogger(__name__)
# Map VectorBT frequency string to friendly label
FREQ_LABELS = {
"1m": "1 Minute", "5m": "5 Minutes", "15m": "15 Minutes", "30m": "30 Minutes",
"1h": "1 Hour", "4h": "4 Hours", "8h": "8 Hours", "1d": "1 Day", "1w": "1 Week",
}
class VBTVisualizer:
"""Comprehensive visualization suite for VBT backtest results.
All methods return Plotly go.Figure objects. Use .show() for interactive
display or .write_html() for standalone reports.
"""
def __init__(self, output_dir: str = "backtests/reports", dpi: int = 150):
self._output_dir = Path(output_dir)
self._output_dir.mkdir(parents=True, exist_ok=True)
self._dpi = dpi
def equity_curve(
self,
pf,
benchmark_close: Optional[pd.Series] = None,
title: str = "Portfolio Equity Curve",
):
"""Equity curve with optional benchmark overlay."""
import plotly.graph_objects as go
from plotly.subplots import make_subplots
value = pf.value().dropna()
if len(value) < 2:
return go.Figure()
fig = make_subplots(specs=[[{"secondary_y": True}]])
fig.add_trace(
go.Scatter(
x=value.index, y=value.values, mode="lines",
name="Portfolio Equity", line=dict(color="#1f77b4", width=1.5),
fill="tozeroy", fillcolor="rgba(31,119,180,0.05)",
),
secondary_y=False,
)
if benchmark_close is not None:
try:
bm = benchmark_close.reindex(value.index)
bm_value = bm / bm.dropna().iloc[0] * value.iloc[0]
fig.add_trace(
go.Scatter(
x=bm_value.index, y=bm_value.values, mode="lines",
name="Buy & Hold", line=dict(color="#7f7f7f", width=1, dash="dash"),
opacity=0.7,
),
secondary_y=False,
)
except Exception:
pass
fig.update_layout(
title=title,
xaxis_title="Date",
yaxis_title="Equity ($)",
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 drawdown(
self,
pf,
title: str = "Drawdown",
):
"""Drawdown chart — peak-to-trough percentage."""
import plotly.graph_objects as go
value = pf.value().dropna()
if len(value) < 2:
return go.Figure()
peak = value.cummax()
dd = (value - peak) / peak * 100
fig = go.Figure()
fig.add_trace(
go.Scatter(
x=dd.index, y=dd.values, mode="lines",
name="Drawdown", line=dict(color="#d62728", width=1),
fill="tozeroy", fillcolor="rgba(214,39,40,0.15)",
)
)
fig.add_hline(
y=0, line=dict(color="black", width=0.5, dash="dot"),
)
max_dd = dd.min()
max_dd_date = dd.idxmin()
fig.add_annotation(
x=max_dd_date, y=max_dd,
text=f"Max DD: {max_dd:.1f}%",
showarrow=True, arrowhead=1,
ay=40, bgcolor="white", bordercolor="#d62728",
)
fig.update_layout(
title=title,
xaxis_title="Date",
yaxis_title="Drawdown (%)",
template="plotly_white",
hovermode="x unified",
margin=dict(l=60, r=30, t=50, b=60),
)
return fig
def rolling_metrics(
self,
pf,
window: int = 90,
title: str = "Rolling Performance Metrics",
):
"""Rolling Sharpe, Sortino, and Volatility over a window of bars."""
import plotly.graph_objects as go
from plotly.subplots import make_subplots
returns = pf.returns().dropna()
if len(returns) < window:
return go.Figure()
w = min(window, len(returns) // 2)
roll_mean = returns.rolling(w).mean()
roll_std = returns.rolling(w).std()
rolling_sharpe = (roll_mean / roll_std.replace(0, np.nan)) * np.sqrt(365 * 24)
rolling_sharpe = rolling_sharpe.dropna()
down_std = returns * (returns < 0)
rolling_sortino = (roll_mean / down_std.rolling(w).std().replace(0, np.nan)) * np.sqrt(365 * 24)
rolling_sortino = rolling_sortino.dropna()
ann_vol = roll_std * np.sqrt(365 * 24) * 100
fig = make_subplots(
rows=2, cols=1,
shared_xaxes=True,
vertical_spacing=0.08,
subplot_titles=("Rolling Sharpe & Sortino (annualized)", "Rolling Volatility (annualized %)"),
)
fig.add_trace(
go.Scatter(
x=rolling_sharpe.index, y=rolling_sharpe.values,
name="Sharpe", line=dict(color="#1f77b4", width=1),
),
row=1, col=1,
)
fig.add_trace(
go.Scatter(
x=rolling_sortino.index, y=rolling_sortino.values,
name="Sortino", line=dict(color="#ff7f0e", width=1),
),
row=1, col=1,
)
fig.add_hline(y=0, line=dict(color="red", width=0.5, dash="dot"), row=1, col=1)
fig.add_trace(
go.Scatter(
x=ann_vol.index, y=ann_vol.values,
name="Volatility", line=dict(color="#2ca02c", width=1),
fill="tozeroy", fillcolor="rgba(44,160,44,0.08)",
),
row=2, col=1,
)
fig.update_layout(
title=title,
template="plotly_white",
hovermode="x unified",
margin=dict(l=60, r=30, t=50, b=60),
)
fig.update_yaxes(title_text="Ratio", row=1, col=1)
fig.update_yaxes(title_text="Vol (%)", row=2, col=1)
return fig
def trade_markers(
self,
close: pd.Series,
entries: pd.Series,
exits: pd.Series,
pf=None,
title: str = "Trade Entry & Exit Markers",
):
"""Price chart with entry (green) and exit (red) markers."""
import plotly.graph_objects as go
fig = go.Figure()
# Price line
fig.add_trace(
go.Scatter(
x=close.index, y=close.values, mode="lines",
name="Close Price", line=dict(color="#1f77b4", width=1),
)
)
# Entry markers
entry_idx = entries[entries].index
entry_prices = [close.loc[i] for i in entry_idx if i in close.index]
if entry_prices:
fig.add_trace(
go.Scatter(
x=list(entry_idx), y=entry_prices, mode="markers",
name="Entry", marker=dict(symbol="triangle-up", size=8,
color="#2ca02c", line=dict(width=1)),
)
)
# Exit markers
exit_idx = exits[exits].index
exit_prices = [close.loc[i] for i in exit_idx if i in close.index]
if exit_prices:
fig.add_trace(
go.Scatter(
x=list(exit_idx), y=exit_prices, mode="markers",
name="Exit", marker=dict(symbol="triangle-down", size=8,
color="#d62728", line=dict(width=1)),
)
)
fig.update_layout(
title=title,
xaxis_title="Date",
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 returns_distribution(
self,
pf,
title: str = "Returns Distribution",
):
"""Histogram of trade returns with normal distribution overlay."""
import plotly.graph_objects as go
if pf is None:
return go.Figure()
returns = pf.returns().dropna()
if len(returns) < 2:
return go.Figure()
try:
from scipy import stats
except ImportError:
return go.Figure()
returns_pct = returns * 100
fig = go.Figure()
fig.add_trace(
go.Histogram(
x=returns_pct.values, nbinsx=50,
name="Returns", marker_color="#1f77b4", opacity=0.7,
histnorm="probability density",
)
)
mu = returns.mean() * 100
sigma = returns.std() * 100
x_range = np.linspace(mu - 4 * sigma, mu + 4 * sigma, 200)
normal_pdf = stats.norm.pdf(x_range, mu, sigma)
fig.add_trace(
go.Scatter(
x=x_range, y=normal_pdf, mode="lines",
name=f"Normal (μ={mu:.3f}%, σ={sigma:.3f}%)",
line=dict(color="#ff7f0e", width=2),
)
)
skewness = float(pd.Series(returns.values).skew())
kurtosis = float(pd.Series(returns.values).kurtosis())
fig.add_vline(x=0, line=dict(color="red", width=0.5, dash="dot"))
fig.add_annotation(
x=0.98, y=0.95, xref="paper", yref="paper",
text=f"Skew: {skewness:.3f}<br>Excess Kurt: {kurtosis:.3f}<br>N: {len(returns)}",
showarrow=False, bgcolor="white", bordercolor="#ccc",
xanchor="right", yanchor="top",
)
fig.update_layout(
title=title,
xaxis_title="Return (%)",
yaxis_title="Density",
template="plotly_white",
margin=dict(l=60, r=30, t=50, b=60),
)
return fig
def monthly_heatmap(
self,
pf,
title: str = "Monthly Returns Heatmap",
):
"""Calendar-style monthly PnL heatmap (averages to daily frequency)."""
import plotly.graph_objects as go
if pf is None:
return go.Figure()
value = pf.value().dropna()
if len(value) < 20:
return go.Figure()
if hasattr(value.index, 'freq') or len(value) > 100:
resampled = value.resample("D").ffill()
else:
resampled = value
returns = resampled.pct_change().dropna()
monthly = returns.groupby([returns.index.year, returns.index.month]).apply(
lambda x: (1 + x).prod() - 1
) * 100
monthly.index = monthly.index.set_names(["Year", "Month"])
monthly = monthly.reset_index(name="Return")
if monthly.empty:
return go.Figure()
pivot = monthly.pivot(index="Year", columns="Month", values="Return")
months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
years = pivot.index.astype(str).tolist()
fig = go.Figure(
data=go.Heatmap(
z=pivot.values,
x=[months[m - 1] for m in pivot.columns if m <= 12],
y=years,
colorscale=[
[0.0, "#d62728"],
[0.45, "#ffffff"],
[0.5, "#eeeeee"],
[0.55, "#ffffff"],
[1.0, "#2ca02c"],
],
zmid=0,
text=[[f"{v:.1f}%" if not np.isnan(v) else "" for v in row]
for row in pivot.values],
texttemplate="%{text}",
colorbar=dict(title="Return (%)"),
)
)
fig.update_layout(
title=title,
template="plotly_white",
margin=dict(l=60, r=30, t=50, b=60),
)
return fig
def gross_vs_net(
self,
pf,
title: str = "Gross vs Net Performance",
):
"""Fee impact visualization — gross returns vs net returns."""
import plotly.graph_objects as go
from plotly.subplots import make_subplots
if pf is None:
return go.Figure()
value = pf.value().dropna()
if len(value) < 2:
return go.Figure()
net_rets = value.pct_change().dropna()
fig = make_subplots(
rows=2, cols=1,
shared_xaxes=True,
vertical_spacing=0.08,
subplot_titles=("Cumulative Net Return", "Fee Impact per Bar"),
)
cum_net = (1 + net_rets).cumprod()
fig.add_trace(
go.Scatter(
x=cum_net.index, y=(cum_net.values - 1) * 100,
name="Net Return", line=dict(color="#1f77b4", width=1),
),
row=1, col=1,
)
fee_est = value * 0.0005 + value * 0.001
total_fees = 0.0
fee_impact = []
for i in range(len(value)):
if int(value.index[i].timestamp()) % 10 == 0:
total_fees += fee_est.iloc[i]
fee_impact.append(total_fees)
fig.add_trace(
go.Scatter(
x=value.index, y=fee_impact, mode="lines",
name="Estimated Fees", line=dict(color="#d62728", width=1, dash="dash"),
),
row=2, col=1,
)
trade_count = 0
try:
trade_count = int(pf.trades.count())
except Exception:
pass
fig.add_annotation(
x=0.98, y=0.95, xref="paper", yref="paper",
text=f"Trades: {trade_count}",
showarrow=False, bgcolor="white", bordercolor="#ccc",
xanchor="right", yanchor="top",
)
fig.update_layout(
title=title,
template="plotly_white",
hovermode="x unified",
margin=dict(l=60, r=30, t=50, b=60),
)
fig.update_yaxes(title_text="Cumulative (%)", row=1, col=1)
fig.update_yaxes(title_text="Fees ($)", row=2, col=1)
return fig
def holding_periods(
self,
pf,
title: str = "Trade Holding Periods",
):
"""Histogram of trade durations."""
import plotly.graph_objects as go
try:
trades = pf.trades
records = trades.records_readable
if records.empty:
return go.Figure()
durations = records.get("Duration", pd.Series(dtype=str))
if durations.empty:
return go.Figure()
except Exception:
return go.Figure()
duration_vals = []
for d in durations:
try:
td = pd.Timedelta(d)
duration_vals.append(td.total_seconds() / 3600)
except Exception:
pass
if not duration_vals:
return go.Figure()
dur = np.array(duration_vals)
fig = go.Figure()
fig.add_trace(
go.Histogram(
x=dur, nbinsx=30, name="Hold Duration (hours)",
marker_color="#1f77b4", opacity=0.7,
)
)
fig.add_vline(
x=np.median(dur), line=dict(color="#d62728", width=1, dash="dash"),
)
fig.add_annotation(
x=np.median(dur), y=0,
text=f"Median: {np.median(dur):.1f}h<br>Mean: {np.mean(dur):.1f}h",
showarrow=True, arrowhead=2, ay=-60,
bgcolor="white", bordercolor="#d62728",
)
fig.update_layout(
title=title,
xaxis_title="Hold Duration (hours)",
yaxis_title="Count",
template="plotly_white",
margin=dict(l=60, r=30, t=50, b=60),
)
return fig
def param_heatmap(
self,
sweep_df: pd.DataFrame,
x_param: str,
y_param: str,
metric: str = "sharpe",
title: str = "Parameter Sensitivity",
):
"""Heatmap of a metric across a 2D parameter grid."""
import plotly.graph_objects as go
if sweep_df is None or sweep_df.empty:
return go.Figure()
pivot = sweep_df.pivot(index=y_param, columns=x_param, values=metric)
fig = go.Figure(
data=go.Heatmap(
z=pivot.values,
x=pivot.columns.astype(str).tolist(),
y=pivot.index.astype(str).tolist(),
colorscale="RdYlGn" if metric in ("sharpe", "total_return") else "RdYlGn_r",
text=[[f"{v:.3f}" if not np.isnan(v) else "" for v in row]
for row in pivot.values],
texttemplate="%{text}",
colorbar=dict(title=metric.replace("_", " ").title()),
)
)
fig.update_layout(
title=f"{title} — {metric.replace('_', ' ').title()}",
xaxis_title=x_param,
yaxis_title=y_param,
template="plotly_white",
margin=dict(l=60, r=30, t=50, b=60),
)
return fig
def dashboard(
self,
pf,
close: pd.Series,
entries: pd.Series,
exits: pd.Series,
benchmark_close: Optional[pd.Series] = None,
sweep_df: Optional[pd.DataFrame] = None,
strategy: str = "unknown",
interval: str = "unknown",
) -> list:
"""Return a list of Plotly figures for the full dashboard."""
figs = []
# Row 1: Equity + Benchmark
figs.append(self.equity_curve(
pf, benchmark_close,
title=f"Equity Curve — {strategy} ({interval})",
))
# Row 2: Drawdown
figs.append(self.drawdown(pf, title="Drawdown"))
# Row 3: Rolling Metrics
window = max(20, int(len(close) // 20))
figs.append(self.rolling_metrics(pf, window=window,
title=f"Rolling Metrics ({window}-bar window)"))
# Row 4: Trade Markers
figs.append(self.trade_markers(close, entries, exits, pf,
title="Trade Signals on Price"))
# Row 5: Returns Distribution
figs.append(self.returns_distribution(pf))
# Row 6: Monthly Heatmap
figs.append(self.monthly_heatmap(pf))
# Row 7: Gross vs Net
figs.append(self.gross_vs_net(pf))
# Row 8: Holding Periods
figs.append(self.holding_periods(pf))
# Optional: Param Heatmap
if sweep_df is not None and not sweep_df.empty:
figs.append(self.param_heatmap(sweep_df, "window", "threshold", "sharpe"))
return figs
def save_dashboard(
self,
pf,
close: pd.Series,
entries: pd.Series,
exits: pd.Series,
benchmark_close: Optional[pd.Series] = None,
strategy: str = "unknown",
interval: str = "unknown",
) -> str:
"""Save the full dashboard as a single HTML file."""
import plotly.io as pio
from datetime import datetime, timezone
figs = self.dashboard(pf, close, entries, exits, benchmark_close,
strategy=strategy, interval=interval)
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
fname = f"{strategy}_{interval}_{ts}_dashboard.html"
fpath = self._output_dir / fname
html_parts = ["<html><head>",
"<title>VBT Backtest Report</title>",
"<style>body{font-family:system-ui,sans-serif;max-width:1400px;"
"margin:0 auto;padding:20px;background:#f5f5f5;}"
".chart{margin:20px 0;background:white;border-radius:8px;"
"box-shadow:0 2px 8px rgba(0,0,0,0.1);padding:10px;}"
"h1{color:#333;}</style>",
"</head><body>",
f"<h1>VBT Backtest — {strategy} ({interval})</h1>",
f"<p>Generated: {ts}</p>"]
for i, fig in enumerate(figs):
chart_id = f"chart_{i}"
html_parts.append(f'<div class="chart" id="{chart_id}">')
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("Dashboard saved to %s", fpath)
return str(fpath)
def save_sweep_report(
self,
sweep_df: pd.DataFrame,
x_param: str = "window",
y_param: str = "threshold",
strategy: str = "unknown",
) -> str:
"""Save a parameter sweep report."""
from datetime import datetime, timezone
import plotly.io as pio
metrics = [c for c in sweep_df.columns
if c not in (x_param, y_param) and sweep_df[c].dtype in (np.float64, np.int64)]
if not metrics:
metrics = [c for c in sweep_df.columns if c not in (x_param, y_param)]
figs = []
for metric in metrics[:6]:
figs.append(self.param_heatmap(sweep_df, x_param, y_param, metric,
title=f"Parameter Sweep"))
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
fname = f"{strategy}_sweep_{ts}.html"
fpath = self._output_dir / fname
html_parts = ["<html><head>",
f"<title>Parameter Sweep — {strategy}</title>",
"<style>body{font-family:system-ui,sans-serif;max-width:1400px;"
"margin:0 auto;padding:20px;background:#f5f5f5;}"
".chart{margin:20px 0;background:white;border-radius:8px;"
"box-shadow:0 2px 8px rgba(0,0,0,0.1);padding:10px;}</style>",
"</head><body>",
f"<h1>Parameter Sweep — {strategy}</h1>"]
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("Sweep report saved to %s", fpath)
return str(fpath)
def plot_signal_distribution(
entries: pd.Series,
title: str = "Signal Distribution Over Time",
):
"""Signal occurrence over time — cumulative signal count."""
import plotly.graph_objects as go
cumulative = entries.cumsum()
fig = go.Figure()
fig.add_trace(
go.Scatter(
x=cumulative.index, y=cumulative.values, mode="lines",
name="Cumulative Signals", line=dict(color="#1f77b4", width=1.5),
fill="tozeroy", fillcolor="rgba(31,119,180,0.1)",
)
)
fig.update_layout(
title=title,
xaxis_title="Date",
yaxis_title="Cumulative Signal Count",
template="plotly_white",
margin=dict(l=60, r=30, t=50, b=60),
)
return fig
def plot_exposure_over_time(
pf,
title: str = "Position Exposure Over Time",
):
"""Position exposure as fraction of portfolio value."""
import plotly.graph_objects as go
try:
pos = pf.position_mask().sum(axis=1)
value = pf.value().dropna()
aligned = pos.reindex(value.index).fillna(0)
except Exception:
return go.Figure()
fig = go.Figure()
fig.add_trace(
go.Scatter(
x=aligned.index, y=aligned.values, mode="lines",
name="Exposure", line=dict(color="#9467bd", width=1),
fill="tozeroy", fillcolor="rgba(148,103,189,0.1)",
)
)
fig.add_hline(y=0, line=dict(color="black", width=0.5, dash="dot"))
fig.update_layout(
title=title,
xaxis_title="Date",
yaxis_title="Position Size",
template="plotly_white",
margin=dict(l=60, r=30, t=50, b=60),
)
return fig