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 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 ───────────────────────────────────────────────── # ── Helpers ─────────────────────────────────────────────────
def _get_coins(self, strategy: str) -> list[str]: 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
+122 -2
View File
@@ -545,6 +545,98 @@ def cmd_funding(args):
print(f" Then 'python -m cli funding --coin BTC' to re-run.") print(f" Then 'python -m cli funding --coin BTC' to re-run.")
def cmd_report(args):
"""Generate a combined VBT report: backtest + validation + visualization."""
import json as _json
from backtests.vbt_runner import VBTBacktestRunner
runner = VBTBacktestRunner()
result = runner.run_with_report(
strategy=args.strategy,
interval=args.interval,
testnet=False,
limit=args.limit,
output_dir=args.output_dir,
)
if result:
print(f"Report generated for {args.strategy} ({args.interval})")
print(f" Strategy: {result['strategy']}")
print(f" Sharpe: {result.get('sharpe', 0):.3f}")
print(f" Net PnL: ${result.get('pnl', 0):.2f}")
print(f" Trades: {result.get('total_trades', 0)}")
print(f" Validation: {len(result.get('validation_errors', []))} errors, "
f"{len(result.get('validation_warnings', []))} warnings")
print(f" Output: {args.output_dir}/")
else:
print(f"No data available for {args.strategy}. "
f"Try: python -m cli backtest --strategy {args.strategy} --interval {args.interval}")
def cmd_validate(args):
"""Validate existing backtest results without re-running."""
import json as _json
from pathlib import Path
from backtests.vbt_validator import VBTValidator, ValidationReport
rd = Path(args.results_dir)
files = sorted(rd.glob("*.json"))
if not files:
print(f"No backtest results found in {args.results_dir}")
return
validator = VBTValidator()
total = 0
passed = 0
for fp in files:
try:
data = _json.loads(fp.read_text())
except Exception:
continue
strat = data.get("strategy", "?")
if args.strategy != "all" and args.strategy.lower() != strat.lower():
continue
total += 1
report = ValidationReport(
strategy=strat,
interval=data.get("interval", "?"),
)
trades = data.get("trades", [])
n_trades = data.get("total_trades", len(trades))
if n_trades < 10:
report.warnings.append(
f"{fp.name}: only {n_trades} trades — insufficient for stats"
)
sharpe = data.get("sharpe", 0)
if n_trades > 0 and abs(sharpe) > 5:
report.warnings.append(
f"{fp.name}: extreme Sharpe {sharpe:.2f} with {n_trades} trades"
)
if report.errors or report.warnings:
print(report.summary())
else:
passed += 1
print(f"\n{passed}/{total} backtests clear validation")
if total > 0 and passed == 0:
print("⚠ All backtests have warnings/errors. Review needed.")
print(f"\nFull validation requires re-running with VBTValidator.validate().")
print(f"Use: python -m cli report --strategy <name> for full validation.")
def cmd_hft_viz(args):
"""Generate HFT tick visualization dashboard."""
from backtests.tick_viz import cmd_tick_viz
cmd_tick_viz(args)
def main(): def main():
import argparse import argparse
p = argparse.ArgumentParser(description="FTDT Quant Lab CLI") p = argparse.ArgumentParser(description="FTDT Quant Lab CLI")
@@ -609,8 +701,8 @@ def main():
pt.add_argument("--skew-factor", type=float, default=0.5) pt.add_argument("--skew-factor", type=float, default=0.5)
pt.add_argument("--vpin-threshold", type=float, default=0.30) pt.add_argument("--vpin-threshold", type=float, default=0.30)
pt.add_argument("--vpin-alarm", type=float, default=0.50) pt.add_argument("--vpin-alarm", type=float, default=0.50)
pt.add_argument("--maker-fee", type=float, default=0.02, help="Maker fee in %") pt.add_argument("--maker-fee", type=float, default=0.02, help="Maker fee (e.g. 0.02 = 2bps)")
pt.add_argument("--taker-fee", type=float, default=0.05, help="Taker fee in %") pt.add_argument("--taker-fee", type=float, default=0.05, help="Taker fee (e.g. 0.05 = 5bps)")
pt.add_argument("--adverse-prob", type=float, default=0.15) pt.add_argument("--adverse-prob", type=float, default=0.15)
pt.add_argument("--cancel-after-ms", type=float, default=5000.0) pt.add_argument("--cancel-after-ms", type=float, default=5000.0)
pt.add_argument("--quote-refresh-ms", type=float, default=2000.0) pt.add_argument("--quote-refresh-ms", type=float, default=2000.0)
@@ -631,6 +723,28 @@ def main():
pf.add_argument("--start-date", default="2026-01-01") pf.add_argument("--start-date", default="2026-01-01")
pf.add_argument("--end-date", default="2030-01-01") pf.add_argument("--end-date", default="2030-01-01")
# report
prp = sp.add_parser("report", help="Generate VBT backtest report (Markdown + HTML + dashboard)")
prp.add_argument("--strategy", default="pairs")
prp.add_argument("--interval", default="1h")
prp.add_argument("--limit", type=int, default=5000)
prp.add_argument("--output-dir", default="backtests/reports")
prp.add_argument("--format", default="html", choices=["md", "html"])
# validate
pv = sp.add_parser("validate", help="Validate existing backtest results without re-running")
pv.add_argument("--strategy", default="all", help="Strategy name or 'all'")
pv.add_argument("--results-dir", default="backtests/results")
# hft
ph = sp.add_parser("hft", help="Generate HFT tick visualization dashboard")
ph.add_argument("--data-dir", default="data/raw")
ph.add_argument("--coin", default="BTC")
ph.add_argument("--start-date", default="2026-08-01")
ph.add_argument("--end-date", default="2026-08-02")
ph.add_argument("--tick-result", default=None, help="Path to tick_runner JSON result")
ph.add_argument("--output-dir", default="backtests/reports")
args = p.parse_args() args = p.parse_args()
import json as _json import json as _json
@@ -653,6 +767,12 @@ def main():
cmd_discover(args) cmd_discover(args)
elif args.command == "funding": elif args.command == "funding":
cmd_funding(args) cmd_funding(args)
elif args.command == "report":
cmd_report(args)
elif args.command == "validate":
cmd_validate(args)
elif args.command == "hft":
cmd_hft_viz(args)
if __name__ == "__main__": if __name__ == "__main__":
+487
View File
@@ -0,0 +1,487 @@
"""
DuckDB Tick Data Loader — Parquet → DuckDB for fast analytical queries.
Converts the raw Parquet store (gzip-compressed JSON payloads) into
a normalized DuckDB database with tables for L2 snapshots, trades,
funding rates, and pre-computed microstructural rollups.
Usage:
python data/duckdb_load.py --data-dir data/raw --db data/normalized/ftdt_tick.db
python data/duckdb_load.py --coin BTC --days 7
python data/duckdb_load.py --incremental # only load new data since last run
Tables created:
l2_snapshots — full book state at each update time
trades — aggressor-side classified trades
funding — funding rate history
l2_rollup_1s — pre-computed 1s microprice/OFI/VPIN rollups
"""
from __future__ import annotations
import argparse
import gzip
import json
import logging
import os
import sys
import time
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from typing import Optional
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import numpy as np
logger = logging.getLogger(__name__)
# Columns extracted from L2 snapshots
L2_SNAPSHOT_COLS = [
"exchange_ts_ms", "local_ts", "coin",
"best_bid", "best_ask", "mid_price", "microprice",
"obi", "spread_bps", "bid_depth_10", "ask_depth_10",
"bid_levels", "ask_levels",
]
# Columns extracted from trades
TRADE_COLS = [
"exchange_ts_ms", "local_ts", "coin",
"price", "size", "side", "aggressor",
]
class DuckDBLoader:
"""Load raw Parquet data into a DuckDB database."""
def __init__(self, db_path: str = "data/normalized/ftdt_tick.db"):
self._db_path = Path(db_path)
self._db_path.parent.mkdir(parents=True, exist_ok=True)
self._conn = None
@property
def conn(self):
if self._conn is None:
try:
import duckdb
self._conn = duckdb.connect(str(self._db_path))
logger.info("Connected to DuckDB: %s", self._db_path)
except ImportError:
raise ImportError(
"duckdb not installed. Run: pip install duckdb"
)
return self._conn
def init_schema(self):
"""Create tables if they don't exist."""
self.conn.execute("""
CREATE TABLE IF NOT EXISTS l2_snapshots (
exchange_ts_ms BIGINT NOT NULL,
local_ts DOUBLE NOT NULL,
coin VARCHAR NOT NULL,
best_bid DOUBLE NOT NULL,
best_ask DOUBLE NOT NULL,
mid_price DOUBLE,
microprice DOUBLE,
obi DOUBLE,
spread_bps DOUBLE,
bid_depth_10 DOUBLE,
ask_depth_10 DOUBLE,
bid_levels INTEGER,
ask_levels INTEGER,
PRIMARY KEY (coin, exchange_ts_ms)
)
""")
self.conn.execute("""
CREATE TABLE IF NOT EXISTS trades (
exchange_ts_ms BIGINT NOT NULL,
local_ts DOUBLE NOT NULL,
coin VARCHAR NOT NULL,
price DOUBLE NOT NULL,
size DOUBLE NOT NULL,
side VARCHAR,
aggressor VARCHAR,
)
""")
self.conn.execute("""
CREATE TABLE IF NOT EXISTS funding (
exchange_ts_ms BIGINT NOT NULL,
local_ts DOUBLE NOT NULL,
coin VARCHAR NOT NULL,
funding_rate DOUBLE NOT NULL,
mark_px DOUBLE,
annual_apr DOUBLE,
PRIMARY KEY (coin, exchange_ts_ms)
)
""")
self.conn.execute("""
CREATE TABLE IF NOT EXISTS load_state (
coin VARCHAR PRIMARY KEY,
channel VARCHAR,
last_loaded_ts BIGINT,
loaded_at TIMESTAMP DEFAULT now()
)
""")
logger.info("Schema initialized")
def load_l2(
self,
data_dir: str,
coin: str,
start_date: str,
end_date: str,
) -> int:
"""Load L2 book data from Parquet into DuckDB. Returns row count."""
from data.store import read_range
msgs = read_range(data_dir, "l2book", coin.upper(), start_date, end_date)
if not msgs:
return 0
rows = []
for msg in msgs:
payload = msg.get("payload", {})
levels = payload.get("levels", [])
msg_type = payload.get("type", "snapshot")
bids_dict = {}
asks_dict = {}
if msg_type == "snapshot" and isinstance(levels, list):
if len(levels) >= 1:
for bid in levels[0]:
sz = float(bid.get("sz", 0))
if sz > 0:
bids_dict[float(bid["px"])] = sz
if len(levels) >= 2:
for ask in levels[1]:
sz = float(ask.get("sz", 0))
if sz > 0:
asks_dict[float(ask["px"])] = sz
if not bids_dict or not asks_dict:
continue
bid_prices = sorted(bids_dict.keys(), reverse=True)
ask_prices = sorted(asks_dict.keys())
best_bid = bid_prices[0]
best_ask = ask_prices[0]
mid = (best_bid + best_ask) / 2.0
bid_depth_10 = sum(bids_dict[px] for px in bid_prices[:10])
ask_depth_10 = sum(asks_dict[px] for px in ask_prices[:10])
total_depth = bid_depth_10 + ask_depth_10
obi = (bid_depth_10 - ask_depth_10) / total_depth if total_depth > 0 else 0.0
w = bid_depth_10 / total_depth if total_depth > 0 else 0.5
microprice = w * best_bid + (1 - w) * best_ask
spread_bps = (best_ask - best_bid) / mid * 10000 if mid > 0 else 0
rows.append((
msg.get("exchange_ts", 0) or 0,
msg.get("local_ts", 0.0),
coin.upper(),
best_bid,
best_ask,
mid,
microprice,
obi,
spread_bps,
bid_depth_10,
ask_depth_10,
len(bid_prices),
len(ask_prices),
))
if rows:
import duckdb
rel = duckdb.from_sequence(rows)
self.conn.execute(
"INSERT OR IGNORE INTO l2_snapshots SELECT * FROM rel"
)
logger.info("Loaded %d L2 snapshots for %s", len(rows), coin)
return len(rows)
def load_trades(
self,
data_dir: str,
coin: str,
start_date: str,
end_date: str,
) -> int:
"""Load trade data from Parquet into DuckDB. Returns row count."""
from data.store import read_range
msgs = read_range(data_dir, "trades", coin.upper(), start_date, end_date)
if not msgs:
return 0
rows = []
for msg in 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", "?"))
aggressor = "buy" if side.upper() in ("B", "BUY") else "sell"
rows.append((
msg.get("exchange_ts", 0) or 0,
msg.get("local_ts", 0.0),
coin.upper(),
px,
sz,
side,
aggressor,
))
if rows:
import duckdb
rel = duckdb.from_sequence(rows)
self.conn.execute(
"INSERT INTO trades SELECT * FROM rel"
)
logger.info("Loaded %d trades for %s", len(rows), coin)
return len(rows)
def load_funding(
self,
data_dir: str,
coin: str,
start_date: str,
end_date: str,
) -> int:
"""Load funding rate data from Parquet into DuckDB."""
from data.store import read_range
msgs = read_range(data_dir, "funding", coin.upper(), start_date, end_date)
if not msgs:
return 0
rows = []
for msg in msgs:
payload = msg.get("payload", {})
rate = float(payload.get("funding", 0))
mark = float(payload.get("mark_px", 0))
annual = rate * 1095 if rate else 0
rows.append((
msg.get("exchange_ts", 0) or 0,
msg.get("local_ts", 0.0),
coin.upper(),
rate,
mark,
annual,
))
if rows:
import duckdb
rel = duckdb.from_sequence(rows)
self.conn.execute(
"INSERT OR IGNORE INTO funding SELECT * FROM rel"
)
logger.info("Loaded %d funding observations for %s", len(rows), coin)
return len(rows)
def create_rollups(self):
"""Create pre-computed 1-second and 1-minute aggregation views."""
self.conn.execute("""
CREATE VIEW IF NOT EXISTS l2_rollup_1s AS
SELECT
(exchange_ts_ms / 1000)::BIGINT * 1000 AS ts_1s,
coin,
AVG(mid_price) AS mid_price,
AVG(microprice) AS microprice,
AVG(obi) AS obi,
AVG(spread_bps) AS spread_bps,
AVG(bid_depth_10) AS bid_depth_10,
AVG(ask_depth_10) AS ask_depth_10,
COUNT(*) AS n_snapshots
FROM l2_snapshots
GROUP BY ts_1s, coin
""")
self.conn.execute("""
CREATE VIEW IF NOT EXISTS ofi_rollup_1s AS
SELECT
(exchange_ts_ms / 1000)::BIGINT * 1000 AS ts_1s,
coin,
SUM(CASE WHEN aggressor = 'buy' THEN size ELSE 0 END) AS buy_volume,
SUM(CASE WHEN aggressor = 'sell' THEN size ELSE 0 END) AS sell_volume,
COUNT(*) AS trade_count
FROM trades
GROUP BY ts_1s, coin
""")
self.conn.execute("""
CREATE VIEW IF NOT EXISTS micro_rollup_1s AS
SELECT
b.ts_1s,
b.coin,
b.mid_price,
b.microprice,
b.spread_bps,
b.n_snapshots,
COALESCE(o.buy_volume, 0) AS buy_volume,
COALESCE(o.sell_volume, 0) AS sell_volume,
COALESCE(o.trade_count, 0) AS trade_count,
CASE
WHEN COALESCE(o.buy_volume + o.sell_volume, 0) > 0
THEN (o.buy_volume - o.sell_volume)::DOUBLE / (o.buy_volume + o.sell_volume)
ELSE 0.0
END AS trade_imbalance
FROM l2_rollup_1s b
LEFT JOIN ofi_rollup_1s o
ON b.ts_1s = o.ts_1s AND b.coin = o.coin
""")
logger.info("Rollup views created")
def load_all(
self,
data_dir: str,
coins: list[str],
start_date: str,
end_date: str,
) -> dict:
"""Load all data for given coins and date range."""
self.init_schema()
totals = {"l2": 0, "trades": 0, "funding": 0}
for coin in coins:
totals["l2"] += self.load_l2(data_dir, coin, start_date, end_date)
totals["trades"] += self.load_trades(data_dir, coin, start_date, end_date)
totals["funding"] += self.load_funding(data_dir, coin, start_date, end_date)
self.create_rollups()
self.conn.execute(
"INSERT OR REPLACE INTO load_state (coin, channel, last_loaded_ts, loaded_at) "
"VALUES ('ALL', 'all', ?::BIGINT, now())",
[int(time.time() * 1000)],
)
return totals
def query_markouts(
self,
coin: str,
start_date: str,
end_date: str,
horizons_ms: list[int] | None = None,
) -> dict:
"""Compute trade markouts directly in DuckDB."""
if horizons_ms is None:
horizons_ms = [100, 500, 1000, 5000, 10000, 30000, 60000]
start_ts = int(datetime.fromisoformat(start_date).timestamp() * 1000)
end_ts = int(datetime.fromisoformat(end_date).timestamp() * 1000)
query = """
WITH trade_mids AS (
SELECT
t.exchange_ts_ms,
t.price AS trade_px,
t.size AS trade_sz,
t.aggressor,
t.coin,
s.mid_price AS mid_at_trade
FROM trades t
LEFT JOIN l2_snapshots s
ON s.coin = t.coin
AND s.exchange_ts_ms <= t.exchange_ts_ms
AND s.exchange_ts_ms >= t.exchange_ts_ms - 2000
WHERE t.coin = ?::VARCHAR
AND t.exchange_ts_ms >= ?::BIGINT
AND t.exchange_ts_ms < ?::BIGINT
QUALIFY ROW_NUMBER() OVER (
PARTITION BY t.exchange_ts_ms, t.price, t.size
ORDER BY ABS(s.exchange_ts_ms - t.exchange_ts_ms)
) = 1
)
SELECT aggressor, COUNT(*) AS n, AVG(markout_bps) AS mean_bps,
STDDEV(markout_bps) AS std_bps
FROM (
SELECT
aggressor,
(future_mid - mid_at_trade) / mid_at_trade * 10000 AS markout_bps
FROM trade_mids tm
LEFT JOIN LATERAL (
SELECT mid_price AS future_mid
FROM l2_snapshots
WHERE coin = tm.coin
AND exchange_ts_ms >= tm.exchange_ts_ms + 100
ORDER BY exchange_ts_ms
LIMIT 1
) ON TRUE
WHERE mid_at_trade > 0
)
GROUP BY aggressor
"""
result = self.conn.execute(query, [coin.upper(), start_ts, end_ts]).fetchall()
return {
row[0]: {"count": row[1], "mean_bps": row[2], "std_bps": row[3]}
for row in result
}
def stats(self) -> dict:
"""Get current database statistics."""
return {
"l2_snapshots": self.conn.execute("SELECT COUNT(*) FROM l2_snapshots").fetchone()[0],
"trades": self.conn.execute("SELECT COUNT(*) FROM trades").fetchone()[0],
"funding": self.conn.execute("SELECT COUNT(*) FROM funding").fetchone()[0],
"coins": [r[0] for r in self.conn.execute(
"SELECT DISTINCT coin FROM l2_snapshots"
).fetchall()],
"date_range": self.conn.execute(
"SELECT MIN(exchange_ts_ms), MAX(exchange_ts_ms) FROM l2_snapshots"
).fetchone(),
}
def close(self):
if self._conn:
self._conn.close()
self._conn = None
def main():
p = argparse.ArgumentParser(description="DuckDB Tick Data Loader")
p.add_argument("--data-dir", default="data/raw")
p.add_argument("--db", default="data/normalized/ftdt_tick.db")
p.add_argument("--coins", nargs="+", default=["BTC", "ETH"])
p.add_argument("--start-date", default="2026-01-01")
p.add_argument("--end-date", default=date.today().isoformat())
p.add_argument("--stats", action="store_true", help="Print DB stats and exit")
args = p.parse_args()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s", datefmt="%H:%M:%S")
loader = DuckDBLoader(args.db)
if args.stats:
s = loader.stats()
print(f"DuckDB: {args.db}")
print(f" L2 snapshots: {s['l2_snapshots']:,}")
print(f" Trades: {s['trades']:,}")
print(f" Funding: {s['funding']:,}")
print(f" Coins: {s['coins']}")
print(f" Date range: {s['date_range']}")
loader.close()
return
totals = loader.load_all(args.data_dir, args.coins, args.start_date, args.end_date)
print(f"Loaded: {totals['l2']} L2 snapshots, {totals['trades']} trades, "
f"{totals['funding']} funding observations")
loader.close()
if __name__ == "__main__":
main()
+377
View File
@@ -0,0 +1,377 @@
"""
Tests for VBT validator, visualizer, report generator, and CLI integration.
"""
import numpy as np
import pandas as pd
class TestValidationReport:
def test_empty_report_passes(self):
from backtests.vbt_validator import ValidationReport
r = ValidationReport(strategy="test", interval="1h")
assert r.passes
assert r.all_checks_pass
def test_errors_cause_failure(self):
from backtests.vbt_validator import ValidationReport
r = ValidationReport(strategy="test", interval="1h")
r.errors.append("lookahead detected")
assert not r.passes
def test_warnings_dont_cause_failure(self):
from backtests.vbt_validator import ValidationReport
r = ValidationReport(strategy="test", interval="1h")
r.warnings.append("low trade count")
assert r.passes
def test_checks_tracking(self):
from backtests.vbt_validator import ValidationReport
r = ValidationReport(strategy="test", interval="1h")
r.checks["no_lookahead"] = True
r.checks["min_trades"] = False
assert not r.all_checks_pass
class TestVBTValidator:
def make_data(self, n=500):
dates = pd.date_range("2026-01-01", periods=n, freq="1h")
close = pd.Series(100 + np.cumsum(np.random.RandomState(42).randn(n) * 0.1), index=dates)
entries = pd.Series(False, index=dates)
exits = pd.Series(False, index=dates)
entries.iloc[[50, 100, 150, 200, 250, 300, 350, 400, 420, 440, 460]] = True
exits.iloc[[60, 110, 160, 210, 260, 310, 360, 410, 430, 450, 470]] = True
return entries, exits, close
def test_clean_data_passes(self):
from backtests.vbt_validator import VBTValidator
entries, exits, close = self.make_data()
v = VBTValidator()
report = v.validate(entries=entries, exits=exits, close=close,
strategy="test", interval="1h")
assert report.passes
def test_lookahead_detected(self):
from backtests.vbt_validator import VBTValidator
entries, exits, close = self.make_data()
entries.iloc[0] = True
v = VBTValidator()
report = v.validate(entries=entries, exits=exits, close=close,
strategy="test", interval="1h")
assert not report.checks["no_lookahead"]
def test_coincident_signals_detected(self):
from backtests.vbt_validator import VBTValidator
entries, exits, close = self.make_data()
entries.iloc[100] = True
exits.iloc[100] = True
v = VBTValidator()
report = v.validate(entries=entries, exits=exits, close=close,
strategy="test", interval="1h")
assert not report.checks["no_coincident_entry_exit"]
def test_nan_detected(self):
from backtests.vbt_validator import VBTValidator
entries, exits, close = self.make_data()
close.iloc[50] = np.nan
v = VBTValidator()
report = v.validate(entries=entries, exits=exits, close=close,
strategy="test", interval="1h")
assert not report.checks["no_nan_close"]
def test_duplicate_timestamps_detected(self):
from backtests.vbt_validator import VBTValidator
dates = pd.date_range("2026-01-01", periods=500, freq="1h")
dates = dates.insert(3, dates[2])
close = pd.Series(100 + np.random.randn(501) * 0.1, index=dates)
entries = pd.Series(False, index=dates)
exits = pd.Series(False, index=dates)
entries.iloc[[50, 100]] = True
exits.iloc[[60, 110]] = True
v = VBTValidator()
report = v.validate(entries=entries, exits=exits, close=close,
strategy="test", interval="1h")
assert not report.checks["no_duplicate_timestamps"]
def test_low_trade_count_warns(self):
from backtests.vbt_validator import VBTValidator
dates = pd.date_range("2026-01-01", periods=100, freq="1h")
close = pd.Series(100 + np.random.randn(100) * 0.1, index=dates)
entries = pd.Series(False, index=dates)
exits = pd.Series(False, index=dates)
entries.iloc[20] = True
exits.iloc[30] = True
entries.iloc[40] = True
exits.iloc[50] = True
v = VBTValidator(min_trades=10)
report = v.validate(entries=entries, exits=exits, close=close,
strategy="test", interval="1h")
assert not report.checks["min_trade_count"]
def test_benchmark_accepts_series(self):
from backtests.vbt_validator import VBTValidator
entries, exits, close = self.make_data()
bm = close * 1.01
v = VBTValidator()
report = v.validate(entries=entries, exits=exits, close=close,
benchmark_close=bm, strategy="test", interval="1h")
assert report.checks["benchmark_available"]
assert "benchmark_return_pct" in report.details
class TestVBTVisualizer:
def test_visualizer_creates_output_dir(self):
from backtests.vbt_viz import VBTVisualizer
viz = VBTVisualizer(output_dir="/tmp/vbt_test_viz")
assert viz._output_dir.exists()
def test_equity_curve_with_data(self):
import vectorbt as vbt
from backtests.vbt_viz import VBTVisualizer
dates = pd.date_range("2026-01-01", periods=100, freq="1h")
close = pd.Series(100 + np.cumsum(np.random.RandomState(42).randn(100) * 0.1), index=dates)
entries = pd.Series(False, index=dates)
exits = pd.Series(False, index=dates)
entries.iloc[[10, 30, 50, 70]] = True
exits.iloc[[20, 40, 60, 80]] = True
pf = vbt.Portfolio.from_signals(close=close, entries=entries, exits=exits,
fees=0.001, init_cash=10000.0)
viz = VBTVisualizer()
fig = viz.equity_curve(pf)
assert fig is not None
def test_drawdown_returns_figure(self):
import vectorbt as vbt
from backtests.vbt_viz import VBTVisualizer
dates = pd.date_range("2026-01-01", periods=100, freq="1h")
close = pd.Series(100 + np.cumsum(np.random.RandomState(42).randn(100) * 0.2), index=dates)
entries = pd.Series(False, index=dates)
exits = pd.Series(False, index=dates)
entries.iloc[[10, 50]] = True
exits.iloc[[30, 70]] = True
pf = vbt.Portfolio.from_signals(close=close, entries=entries, exits=exits,
fees=0.001, init_cash=10000.0)
viz = VBTVisualizer()
fig = viz.drawdown(pf)
assert fig is not None
def test_returns_distribution_empty_safe(self):
from backtests.vbt_viz import VBTVisualizer
import plotly.graph_objects as go
viz = VBTVisualizer()
fig = viz.returns_distribution(None)
assert isinstance(fig, go.Figure)
def test_monthly_heatmap_empty_safe(self):
from backtests.vbt_viz import VBTVisualizer
import plotly.graph_objects as go
viz = VBTVisualizer()
fig = viz.monthly_heatmap(None)
assert isinstance(fig, go.Figure)
def test_param_heatmap_empty_safe(self):
from backtests.vbt_viz import VBTVisualizer
import plotly.graph_objects as go
viz = VBTVisualizer()
fig = viz.param_heatmap(None, "w", "t", "sharpe")
assert isinstance(fig, go.Figure)
def test_dashboard_returns_list(self):
import vectorbt as vbt
from backtests.vbt_viz import VBTVisualizer
dates = pd.date_range("2026-01-01", periods=200, freq="1h")
close = pd.Series(100 + np.cumsum(np.random.RandomState(42).randn(200) * 0.1), index=dates)
entries = pd.Series(False, index=dates)
exits = pd.Series(False, index=dates)
entries.iloc[[20, 50, 80, 110, 140]] = True
exits.iloc[[35, 65, 95, 125, 155]] = True
pf = vbt.Portfolio.from_signals(close=close, entries=entries, exits=exits,
fees=0.001, init_cash=10000.0)
viz = VBTVisualizer()
figs = viz.dashboard(pf, close, entries, exits, strategy="test", interval="1h")
assert len(figs) >= 5
def test_holding_periods_empty_safe(self):
from backtests.vbt_viz import VBTVisualizer
import plotly.graph_objects as go
viz = VBTVisualizer()
fig = viz.holding_periods(None)
assert isinstance(fig, go.Figure)
def test_gross_vs_net_empty_safe(self):
from backtests.vbt_viz import VBTVisualizer
import plotly.graph_objects as go
viz = VBTVisualizer()
fig = viz.gross_vs_net(None)
assert isinstance(fig, go.Figure)
class TestVBTReport:
def test_generates_markdown(self):
from backtests.vbt_report import generate_markdown_report
result = {
"strategy": "pairs", "interval": "1h",
"pnl": 10.0, "total_return_pct": 0.1, "sharpe": 0.5,
"sortino": 0.6, "max_drawdown_pct": 2.0, "win_rate": 0.55,
"profit_factor": 1.2, "expectancy": 0.1, "n_bars": 1000,
"trades": [], "total_trades": 20, "params": {"z_entry": 1.5},
"fee_info": {"effective_rate_pct": 0.045, "tier_name": "Tier 0"},
}
md = generate_markdown_report(result)
assert "pairs" in md
assert "1h" in md
assert "Sharpe" in md
assert "Limitations" in md
def test_generates_html(self):
from backtests.vbt_report import generate_html_report
result = {
"strategy": "test", "interval": "1h",
"pnl": 0.0, "total_return_pct": 0.0, "sharpe": 0.0,
"sortino": 0.0, "max_drawdown_pct": 0.0, "win_rate": 0.0,
"profit_factor": 0.0, "expectancy": 0.0, "n_bars": 0,
"trades": [], "total_trades": 0, "params": {},
"fee_info": {},
}
html = generate_html_report(result)
assert "<html" in html
def test_saves_report(self):
from backtests.vbt_report import save_report
result = {
"strategy": "test_save", "interval": "1h",
"pnl": 0.0, "total_return_pct": 0.0, "sharpe": 0.0,
"sortino": 0.0, "max_drawdown_pct": 0.0, "win_rate": 0.0,
"profit_factor": 0.0, "expectancy": 0.0, "n_bars": 0,
"trades": [], "total_trades": 0, "params": {},
"fee_info": {},
}
p = save_report(result, output_dir="/tmp/vbt_test_reports")
assert p.endswith(".md")
import os
assert os.path.exists(p)
os.unlink(p)
class TestCLIIntegration:
def test_report_command_registered(self):
import cli
assert hasattr(cli, "cmd_report")
def test_validate_command_registered(self):
import cli
assert hasattr(cli, "cmd_validate")
def test_hft_viz_command_registered(self):
import cli
assert hasattr(cli, "cmd_hft_viz")
def test_cli_help_runs(self):
import subprocess, sys
result = subprocess.run(
[sys.executable, "-m", "cli", "--help"],
capture_output=True, text=True, cwd="/home/satoshi/ftdt-quant-lab",
)
assert result.returncode == 0
assert "report" in result.stdout
assert "validate" in result.stdout
assert "hft" in result.stdout
assert "tick" in result.stdout
class TestTickViz:
def test_empty_data_safe(self):
from backtests.tick_viz import tick_dashboard
l2_df = pd.DataFrame()
trade_df = pd.DataFrame()
figs = tick_dashboard(l2_df, trade_df)
assert len(figs) == 7 # 7 without tick_result, 8 with it
def test_price_plot_with_data(self):
from backtests.tick_viz import plot_price_with_trades
l2_df = pd.DataFrame({
"ts": [0.0, 1.0, 2.0], "mid": [100.0, 101.0, 100.5],
})
trade_df = pd.DataFrame({
"ts": [0.5, 1.5], "price": [100.2, 100.8],
"aggressor": ["buy", "sell"],
})
fig = plot_price_with_trades(l2_df, trade_df)
assert fig is not None
def test_spread_dynamics(self):
from backtests.tick_viz import plot_spread_dynamics
l2_df = pd.DataFrame({
"ts": [0.0, 1.0, 2.0], "spread_bps": [1.0, 1.5, 1.2],
"mid": [100.0, 101.0, 100.5],
})
fig = plot_spread_dynamics(l2_df)
assert fig is not None
def test_markout_empty_safe(self):
from backtests.tick_viz import plot_markout_curves
fig = plot_markout_curves(pd.DataFrame(), pd.DataFrame())
assert fig is not None
class TestDuckDBLoader:
def test_loader_creation(self):
from data.duckdb_load import DuckDBLoader
loader = DuckDBLoader(db_path="/tmp/test_ftdt.db")
try:
import duckdb
loader.init_schema()
loader.create_rollups()
s = loader.stats()
assert s["l2_snapshots"] == 0
assert s["trades"] == 0
except ImportError:
pass
finally:
loader.close()
import os
try:
os.unlink("/tmp/test_ftdt.db")
except Exception:
pass
def test_stats_on_empty_db(self):
from data.duckdb_load import DuckDBLoader
loader = DuckDBLoader(db_path="/tmp/test_ftdt2.db")
try:
import duckdb
loader.init_schema()
s = loader.stats()
assert isinstance(s, dict)
except ImportError:
pass
finally:
loader.close()
import os
try:
os.unlink("/tmp/test_ftdt2.db")
except Exception:
pass
def test_query_markouts_on_empty(self):
from data.duckdb_load import DuckDBLoader
loader = DuckDBLoader(db_path="/tmp/test_ftdt3.db")
try:
import duckdb
loader.init_schema()
result = loader.query_markouts("BTC", "2026-01-01", "2026-01-02")
assert isinstance(result, dict)
except ImportError:
pass
finally:
loader.close()
import os
try:
os.unlink("/tmp/test_ftdt3.db")
except Exception:
pass