diff --git a/backtests/tick_viz.py b/backtests/tick_viz.py new file mode 100644 index 0000000..cce7195 --- /dev/null +++ b/backtests/tick_viz.py @@ -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}
Toxic: {toxic}
Cancels: {cancels}
" + f"Net PnL: ${net_pnl:.4f}", + showarrow=False, bgcolor="white", bordercolor="#ccc", + xanchor="right", yanchor="top", + ) + + fig.update_layout( + title=title, + xaxis_title="Time", + yaxis_title="Equity ($)", + template="plotly_white", + hovermode="x unified", + margin=dict(l=60, r=30, t=50, b=60), + ) + return fig + + +def tick_dashboard( + l2_df: pd.DataFrame, + trade_df: pd.DataFrame, + tick_result: Optional[dict] = None, + coin: str = "BTC", + vpin_threshold: float = 0.30, + vpin_alarm: float = 0.50, +): + """Full 9-panel HFT dashboard.""" + figs = [] + + figs.append(plot_price_with_trades(l2_df, trade_df, + title=f"{coin} — Price & Trade Markers")) + figs.append(plot_spread_dynamics(l2_df, + title=f"{coin} — Spread Dynamics")) + figs.append(plot_depth_panel(l2_df, + title=f"{coin} — Top-of-Book Depth")) + figs.append(plot_microprice_vs_mid(l2_df, + title=f"{coin} — Microprice vs Mid")) + figs.append(plot_obi_panel(l2_df, + title=f"{coin} — Order Flow Imbalance")) + figs.append(plot_vpin_panel(l2_df, vpin_threshold, vpin_alarm, + title=f"{coin} — VPIN Toxicity")) + + if tick_result: + figs.append(plot_event_timeline(tick_result, + title=f"{coin} — PnL Timeline")) + + figs.append(plot_markout_curves(l2_df, trade_df, + title=f"{coin} — Markout Analysis")) + + return figs + + +def save_tick_dashboard( + l2_df: pd.DataFrame, + trade_df: pd.DataFrame, + tick_result: Optional[dict] = None, + coin: str = "BTC", + output_dir: str = "", +) -> str: + """Save the full tick dashboard as a standalone HTML file.""" + import plotly.io as pio + from datetime import datetime, timezone + + figs = tick_dashboard(l2_df, trade_df, tick_result, coin) + ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + fname = f"tick_dashboard_{coin}_{ts}.html" + out = Path(output_dir) if output_dir else REPORT_DIR + out.mkdir(parents=True, exist_ok=True) + fpath = out / fname + + html_parts = ["", + f"HFT Dashboard — {coin}", + "", + "", + f"

HFT Microstructure Dashboard — {coin}

", + f"

{ts}

"] + for i, fig in enumerate(figs): + html_parts.append(f'
') + html_parts.append(pio.to_html(fig, include_plotlyjs="cdn", full_html=False)) + html_parts.append("
") + html_parts.append("") + + with open(fpath, "w") as f: + f.write("\n".join(html_parts)) + + logger.info("Tick dashboard saved to %s", fpath) + return str(fpath) + + +def cmd_tick_viz(args): + """CLI entry: load data and generate dashboard.""" + l2_df, trade_df = load_tick_dataframe( + args.data_dir, args.coin, args.start_date, args.end_date, + ) + + if l2_df.empty: + print("No L2 data available. Run 'python -m cli collect --mainnet' first.") + return + + tick_result = None + if args.tick_result: + import json + try: + with open(args.tick_result) as f: + tick_result = json.load(f) + except Exception: + pass + + fpath = save_tick_dashboard( + l2_df, trade_df, tick_result, args.coin, + output_dir=args.output_dir, + ) + print(f"Dashboard: {fpath}") + + +if __name__ == "__main__": + p = argparse.ArgumentParser(description="HFT Tick Visualization") + p.add_argument("--data-dir", default="data/raw") + p.add_argument("--coin", default="BTC") + p.add_argument("--start-date", default="2026-08-01") + p.add_argument("--end-date", default="2026-08-02") + p.add_argument("--output-dir", default="") + p.add_argument("--tick-result", help="Path to tick_runner JSON result for PnL panel") + args = p.parse_args() + + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s", datefmt="%H:%M:%S") + cmd_tick_viz(args) diff --git a/backtests/vbt_report.py b/backtests/vbt_report.py new file mode 100644 index 0000000..bfe794b --- /dev/null +++ b/backtests/vbt_report.py @@ -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 = "
" + md_body.replace("<", "<") + "
" + + html = f""" + + + +VBT Backtest Report + + + +{body} + +""" + + 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})") diff --git a/backtests/vbt_runner.py b/backtests/vbt_runner.py index 0af57b3..b3119b2 100644 --- a/backtests/vbt_runner.py +++ b/backtests/vbt_runner.py @@ -441,6 +441,157 @@ class VBTBacktestRunner: return pd.DataFrame(results_rows) if results_rows else None + def run_benchmark( + self, + coin: str = "BTC", + interval: str = "1h", + testnet: bool = False, + limit: int = 5000, + start_ms: int | None = None, + end_ms: int | None = None, + ) -> dict[str, Any] | None: + """Run a simple buy-and-hold benchmark using VBT.""" + provider = HyperliquidDataProvider(testnet=testnet) + df = provider.fetch_candles(coin, interval=interval, limit=limit, + start_ms=start_ms, end_ms=end_ms) + if df.empty: + return None + + close = df["close"] + if len(close) < 2: + return None + + entries = pd.Series(False, index=close.index) + entries.iloc[0] = True + exits = pd.Series(False, index=close.index) + exits.iloc[-1] = True + + try: + pf = vbt.Portfolio.from_signals( + close=close, + entries=entries, + exits=exits, + fees=self._fee_rate, + slippage=0.001, + freq=INTERVAL_MAP.get(interval, "1h"), + init_cash=10000.0, + ) + except Exception: + return None + + total_return = float(pf.stats().get("Total Return [%]", 0)) + bm_sharpe = float(pf.stats().get("Sharpe Ratio", 0)) + + return { + "strategy": "buy_and_hold", + "coin": coin.upper(), + "interval": interval, + "n_bars": len(close), + "start_equity": 10000.0, + "end_equity": round(float(pf.value().iloc[-1]), 2), + "total_return_pct": round(total_return, 2), + "sharpe": round(bm_sharpe, 3), + "close": close, + "pf": pf, + } + + def validate( + self, + result: dict, + pf, + entries: pd.Series, + exits: pd.Series, + close: pd.Series, + ): + """Run validation checks on a backtest result.""" + from backtests.vbt_validator import VBTValidator + + validator = VBTValidator(min_trades=10) + report = validator.validate( + entries=entries, + exits=exits, + close=close, + pf=pf, + trades=result.get("trades", []), + strategy=result.get("strategy", "unknown"), + interval=result.get("interval", "unknown"), + ) + return report + + def run_with_report( + self, + strategy: str = "pairs", + interval: str = "1h", + testnet: bool = False, + limit: int = 5000, + params: dict | None = None, + output_dir: str = "backtests/reports", + ) -> dict | None: + """End-to-end: fetch, backtest, validate, visualize, save report.""" + result = self.run_strategy( + strategy=strategy, interval=interval, testnet=testnet, + limit=limit, params=params, + ) + if result is None: + return None + + data = {} + coins = self._get_coins(strategy) + provider = HyperliquidDataProvider(testnet=testnet) + for coin in coins: + df = provider.fetch_candles(coin, interval=interval, limit=limit) + if not df.empty: + data[coin] = df + + entries, exits = _generate_signals(strategy, data, params) + primary = list(data.values())[0] + close = primary["close"] + common_idx = entries.index.intersection(close.index) + entries = entries.reindex(common_idx).fillna(False) + exits = exits.reindex(common_idx).fillna(False) + close = close.reindex(common_idx) + + try: + from config.fee_tiers import get_strategy_fee_model + fee_model = get_strategy_fee_model(strategy) + effective_fee = self._maker_rate if fee_model == "maker" else self._fee_rate + pf = vbt.Portfolio.from_signals( + close=close, entries=entries, exits=exits, + fees=effective_fee, slippage=0.001, + freq=INTERVAL_MAP.get(interval, "1h"), + init_cash=10000.0, + ) + except Exception: + pf = None + + bm_result = self.run_benchmark(coin=self._get_coins(strategy)[0], + interval=interval, testnet=testnet, limit=limit) + benchmark_close = bm_result.get("close") if bm_result else None + + validation_report = None + if pf is not None: + validation_report = self.validate(result, pf, entries, exits, close) + + from backtests.vbt_viz import VBTVisualizer + viz = VBTVisualizer(output_dir=output_dir) + viz.save_dashboard( + pf=pf, close=close, entries=entries, exits=exits, + benchmark_close=benchmark_close, + strategy=strategy, interval=interval, + ) + + result["validation"] = validation_report.summary() if validation_report else "N/A" + + if validation_report: + result["validation_checks"] = validation_report.checks + result["validation_errors"] = validation_report.errors + result["validation_warnings"] = validation_report.warnings + + logger.info("Report generated for %s (%s) — saved to %s", + strategy, interval, output_dir) + + return result + # ── Helpers ───────────────────────────────────────────────── def _get_coins(self, strategy: str) -> list[str]: diff --git a/backtests/vbt_validator.py b/backtests/vbt_validator.py new file mode 100644 index 0000000..3317b33 --- /dev/null +++ b/backtests/vbt_validator.py @@ -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 diff --git a/backtests/vbt_viz.py b/backtests/vbt_viz.py new file mode 100644 index 0000000..10052c5 --- /dev/null +++ b/backtests/vbt_viz.py @@ -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}
Excess Kurt: {kurtosis:.3f}
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
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 = ["", + "VBT Backtest Report", + "", + "", + f"

VBT Backtest — {strategy} ({interval})

", + f"

Generated: {ts}

"] + for i, fig in enumerate(figs): + chart_id = f"chart_{i}" + html_parts.append(f'
') + html_parts.append(pio.to_html(fig, include_plotlyjs="cdn", full_html=False)) + html_parts.append("
") + html_parts.append("") + + with open(fpath, "w") as f: + f.write("\n".join(html_parts)) + + logger.info("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 = ["", + f"Parameter Sweep — {strategy}", + "", + "", + f"

Parameter Sweep — {strategy}

"] + for i, fig in enumerate(figs): + html_parts.append(f'
') + html_parts.append(pio.to_html(fig, include_plotlyjs="cdn", full_html=False)) + html_parts.append("
") + html_parts.append("") + + with open(fpath, "w") as f: + f.write("\n".join(html_parts)) + + logger.info("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 diff --git a/cli.py b/cli.py index ba703fb..e1cda5e 100644 --- a/cli.py +++ b/cli.py @@ -545,6 +545,98 @@ def cmd_funding(args): 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 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(): import argparse 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("--vpin-threshold", type=float, default=0.30) 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("--taker-fee", type=float, default=0.05, help="Taker 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 (e.g. 0.05 = 5bps)") pt.add_argument("--adverse-prob", type=float, default=0.15) pt.add_argument("--cancel-after-ms", type=float, default=5000.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("--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() import json as _json @@ -653,6 +767,12 @@ def main(): cmd_discover(args) elif args.command == "funding": 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__": diff --git a/data/duckdb_load.py b/data/duckdb_load.py new file mode 100644 index 0000000..1016cbb --- /dev/null +++ b/data/duckdb_load.py @@ -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() diff --git a/tests/test_vbt_viz.py b/tests/test_vbt_viz.py new file mode 100644 index 0000000..9b51ffc --- /dev/null +++ b/tests/test_vbt_viz.py @@ -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 "