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:
@@ -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
|
||||
Reference in New Issue
Block a user