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,648 @@
|
|||||||
|
"""
|
||||||
|
HFT Tick Visualization Dashboard — Plotly-based microstructural charts.
|
||||||
|
|
||||||
|
Visualizes tick-level order book data, trades, and microstructural
|
||||||
|
metrics from DuckDB (or Parquet directly for small datasets).
|
||||||
|
|
||||||
|
Panels:
|
||||||
|
1. Price + Trade Markers (candlestick with buy/sell markers)
|
||||||
|
2. Bid/Ask Spread Dynamics (spread bps over time)
|
||||||
|
3. Top-of-Book Depth (bid size vs ask size stacked area)
|
||||||
|
4. Microprice vs Mid (two lines with deviation fill)
|
||||||
|
5. Order Flow Imbalance (OBI/OFI panel)
|
||||||
|
6. VPIN Toxicity (with threshold bands)
|
||||||
|
7. Inventory + PnL (from simulation results)
|
||||||
|
8. Markout Analysis (buy vs sell markout curves)
|
||||||
|
9. Event Timeline (fills, cancels, re-quotes)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python -m backtests.tick_viz --coin BTC --start 2026-08-01 --end 2026-08-02
|
||||||
|
python -m backtests.tick_viz --db data/normalized/ftdt_tick.db --dashboard
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
REPORT_DIR = Path(__file__).resolve().parent.parent / "backtests" / "reports"
|
||||||
|
REPORT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def load_tick_dataframe(
|
||||||
|
data_dir: str,
|
||||||
|
coin: str,
|
||||||
|
start_date: str,
|
||||||
|
end_date: str,
|
||||||
|
) -> tuple[pd.DataFrame, pd.DataFrame]:
|
||||||
|
"""Load L2 snapshots and trades from Parquet as pandas DataFrames."""
|
||||||
|
from data.store import read_range
|
||||||
|
|
||||||
|
l2_msgs = read_range(data_dir, "l2book", coin.upper(), start_date, end_date)
|
||||||
|
trade_msgs = read_range(data_dir, "trades", coin.upper(), start_date, end_date)
|
||||||
|
|
||||||
|
l2_rows = []
|
||||||
|
for msg in l2_msgs:
|
||||||
|
payload = msg.get("payload", {})
|
||||||
|
levels = payload.get("levels", [])
|
||||||
|
msg_type = payload.get("type", "snapshot")
|
||||||
|
|
||||||
|
if msg_type == "snapshot" and isinstance(levels, list) and len(levels) >= 2:
|
||||||
|
bids = {}
|
||||||
|
asks = {}
|
||||||
|
for bid in levels[0]:
|
||||||
|
sz = float(bid.get("sz", 0))
|
||||||
|
if sz > 0:
|
||||||
|
bids[float(bid["px"])] = sz
|
||||||
|
for ask in levels[1]:
|
||||||
|
sz = float(ask.get("sz", 0))
|
||||||
|
if sz > 0:
|
||||||
|
asks[float(ask["px"])] = sz
|
||||||
|
|
||||||
|
if bids and asks:
|
||||||
|
bid_prices = sorted(bids.keys(), reverse=True)
|
||||||
|
ask_prices = sorted(asks.keys())
|
||||||
|
bb = bid_prices[0]
|
||||||
|
ba = ask_prices[0]
|
||||||
|
mid = (bb + ba) / 2.0
|
||||||
|
bd10 = sum(bids[px] for px in bid_prices[:10])
|
||||||
|
ad10 = sum(asks[px] for px in ask_prices[:10])
|
||||||
|
depth_total = bd10 + ad10
|
||||||
|
obi = (bd10 - ad10) / depth_total if depth_total > 0 else 0.0
|
||||||
|
w = bd10 / depth_total if depth_total > 0 else 0.5
|
||||||
|
micro = w * bb + (1 - w) * ba
|
||||||
|
spread_bps = (ba - bb) / mid * 10000 if mid > 0 else 0
|
||||||
|
|
||||||
|
l2_rows.append({
|
||||||
|
"ts": msg.get("local_ts", 0.0),
|
||||||
|
"exchange_ts_ms": msg.get("exchange_ts", 0) or 0,
|
||||||
|
"best_bid": bb,
|
||||||
|
"best_ask": ba,
|
||||||
|
"mid": mid,
|
||||||
|
"microprice": micro,
|
||||||
|
"obi": obi,
|
||||||
|
"spread_bps": spread_bps,
|
||||||
|
"bid_depth_10": bd10,
|
||||||
|
"ask_depth_10": ad10,
|
||||||
|
})
|
||||||
|
|
||||||
|
trade_rows = []
|
||||||
|
for msg in trade_msgs:
|
||||||
|
payload = msg.get("payload", {})
|
||||||
|
px = float(payload.get("px", 0))
|
||||||
|
sz = float(payload.get("sz", 0))
|
||||||
|
if px <= 0 or sz <= 0:
|
||||||
|
continue
|
||||||
|
side = str(payload.get("side", "?"))
|
||||||
|
trade_rows.append({
|
||||||
|
"ts": msg.get("local_ts", 0.0),
|
||||||
|
"exchange_ts_ms": msg.get("exchange_ts", 0) or 0,
|
||||||
|
"price": px,
|
||||||
|
"size": sz,
|
||||||
|
"side": side,
|
||||||
|
"aggressor": "buy" if side.upper() in ("B", "BUY") else "sell",
|
||||||
|
})
|
||||||
|
|
||||||
|
l2_df = pd.DataFrame(l2_rows)
|
||||||
|
trade_df = pd.DataFrame(trade_rows)
|
||||||
|
|
||||||
|
if not l2_df.empty:
|
||||||
|
l2_df = l2_df.sort_values("ts").reset_index(drop=True)
|
||||||
|
if not trade_df.empty:
|
||||||
|
trade_df = trade_df.sort_values("ts").reset_index(drop=True)
|
||||||
|
|
||||||
|
logger.info("Loaded %d L2 snapshots, %d trades", len(l2_df), len(trade_df))
|
||||||
|
return l2_df, trade_df
|
||||||
|
|
||||||
|
|
||||||
|
def plot_price_with_trades(
|
||||||
|
l2_df: pd.DataFrame,
|
||||||
|
trade_df: pd.DataFrame,
|
||||||
|
title: str = "Price + Trade Markers",
|
||||||
|
):
|
||||||
|
"""Candlestick-style price with buy/sell trade markers."""
|
||||||
|
import plotly.graph_objects as go
|
||||||
|
|
||||||
|
if l2_df.empty:
|
||||||
|
return go.Figure()
|
||||||
|
|
||||||
|
sample = l2_df
|
||||||
|
if len(l2_df) > 5000:
|
||||||
|
sample = l2_df.iloc[np.linspace(0, len(l2_df) - 1, 5000).astype(int)]
|
||||||
|
|
||||||
|
fig = go.Figure()
|
||||||
|
fig.add_trace(
|
||||||
|
go.Scatter(
|
||||||
|
x=sample["ts"].values - sample["ts"].iloc[0],
|
||||||
|
y=sample["mid"].values,
|
||||||
|
mode="lines",
|
||||||
|
name="Mid Price",
|
||||||
|
line=dict(color="#1f77b4", width=1),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not trade_df.empty:
|
||||||
|
trade_sample = trade_df
|
||||||
|
if len(trade_df) > 2000:
|
||||||
|
trade_sample = trade_df.iloc[np.linspace(0, len(trade_df) - 1, 2000).astype(int)]
|
||||||
|
|
||||||
|
buys = trade_sample[trade_sample["aggressor"] == "buy"]
|
||||||
|
sells = trade_sample[trade_sample["aggressor"] == "sell"]
|
||||||
|
|
||||||
|
t0 = l2_df["ts"].iloc[0] if not l2_df.empty else 0
|
||||||
|
if not buys.empty:
|
||||||
|
fig.add_trace(
|
||||||
|
go.Scatter(
|
||||||
|
x=buys["ts"].values - t0, y=buys["price"].values,
|
||||||
|
mode="markers", name="Buy", marker=dict(symbol="triangle-up",
|
||||||
|
size=4, color="#2ca02c", opacity=0.6),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not sells.empty:
|
||||||
|
fig.add_trace(
|
||||||
|
go.Scatter(
|
||||||
|
x=sells["ts"].values - t0, y=sells["price"].values,
|
||||||
|
mode="markers", name="Sell", marker=dict(symbol="triangle-down",
|
||||||
|
size=4, color="#d62728", opacity=0.6),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
fig.update_layout(
|
||||||
|
title=title,
|
||||||
|
xaxis_title="Time (seconds from start)",
|
||||||
|
yaxis_title="Price",
|
||||||
|
template="plotly_white",
|
||||||
|
hovermode="x unified",
|
||||||
|
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
|
||||||
|
margin=dict(l=60, r=30, t=50, b=60),
|
||||||
|
)
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
def plot_spread_dynamics(
|
||||||
|
l2_df: pd.DataFrame,
|
||||||
|
title: str = "Bid/Ask Spread Dynamics",
|
||||||
|
):
|
||||||
|
"""Spread in bps over time."""
|
||||||
|
import plotly.graph_objects as go
|
||||||
|
|
||||||
|
if l2_df.empty:
|
||||||
|
return go.Figure()
|
||||||
|
|
||||||
|
sample = l2_df
|
||||||
|
if len(l2_df) > 5000:
|
||||||
|
step = max(1, len(l2_df) // 5000)
|
||||||
|
sample = l2_df.iloc[::step]
|
||||||
|
|
||||||
|
fig = go.Figure()
|
||||||
|
fig.add_trace(
|
||||||
|
go.Scatter(
|
||||||
|
x=sample["ts"].values - l2_df["ts"].iloc[0],
|
||||||
|
y=sample["spread_bps"].values,
|
||||||
|
mode="lines",
|
||||||
|
name="Spread (bps)",
|
||||||
|
line=dict(color="#9467bd", width=1),
|
||||||
|
fill="tozeroy",
|
||||||
|
fillcolor="rgba(148,103,189,0.08)",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
avg_spread = l2_df["spread_bps"].mean()
|
||||||
|
fig.add_hline(y=avg_spread, line=dict(color="#7f7f7f", width=0.5, dash="dash"))
|
||||||
|
fig.add_annotation(
|
||||||
|
x=0.02, y=avg_spread, xref="paper",
|
||||||
|
text=f"Avg: {avg_spread:.1f} bps",
|
||||||
|
showarrow=False, bgcolor="white", bordercolor="#ccc",
|
||||||
|
)
|
||||||
|
|
||||||
|
fig.update_layout(
|
||||||
|
title=title,
|
||||||
|
xaxis_title="Time (seconds from start)",
|
||||||
|
yaxis_title="Spread (bps)",
|
||||||
|
template="plotly_white",
|
||||||
|
hovermode="x unified",
|
||||||
|
margin=dict(l=60, r=30, t=50, b=60),
|
||||||
|
)
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
def plot_depth_panel(
|
||||||
|
l2_df: pd.DataFrame,
|
||||||
|
title: str = "Top-of-Book Depth",
|
||||||
|
):
|
||||||
|
"""Bid depth vs ask depth stacked area chart."""
|
||||||
|
import plotly.graph_objects as go
|
||||||
|
|
||||||
|
if l2_df.empty:
|
||||||
|
return go.Figure()
|
||||||
|
|
||||||
|
sample = l2_df
|
||||||
|
if len(l2_df) > 5000:
|
||||||
|
step = max(1, len(l2_df) // 5000)
|
||||||
|
sample = l2_df.iloc[::step]
|
||||||
|
|
||||||
|
fig = go.Figure()
|
||||||
|
t = sample["ts"].values - l2_df["ts"].iloc[0]
|
||||||
|
|
||||||
|
fig.add_trace(
|
||||||
|
go.Scatter(
|
||||||
|
x=t, y=sample["bid_depth_10"].values,
|
||||||
|
mode="lines", name="Bid Depth", line=dict(color="#2ca02c", width=1),
|
||||||
|
stackgroup="one",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fig.add_trace(
|
||||||
|
go.Scatter(
|
||||||
|
x=t, y=sample["ask_depth_10"].values,
|
||||||
|
mode="lines", name="Ask Depth", line=dict(color="#d62728", width=1),
|
||||||
|
stackgroup="one",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
fig.update_layout(
|
||||||
|
title=title,
|
||||||
|
xaxis_title="Time (seconds from start)",
|
||||||
|
yaxis_title="Depth (size)",
|
||||||
|
template="plotly_white",
|
||||||
|
hovermode="x unified",
|
||||||
|
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
|
||||||
|
margin=dict(l=60, r=30, t=50, b=60),
|
||||||
|
)
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
def plot_microprice_vs_mid(
|
||||||
|
l2_df: pd.DataFrame,
|
||||||
|
title: str = "Microprice vs Mid Price",
|
||||||
|
):
|
||||||
|
"""Microprice vs mid with deviation fill."""
|
||||||
|
import plotly.graph_objects as go
|
||||||
|
|
||||||
|
if l2_df.empty:
|
||||||
|
return go.Figure()
|
||||||
|
|
||||||
|
sample = l2_df
|
||||||
|
if len(l2_df) > 5000:
|
||||||
|
step = max(1, len(l2_df) // 5000)
|
||||||
|
sample = l2_df.iloc[::step]
|
||||||
|
|
||||||
|
t = sample["ts"].values - l2_df["ts"].iloc[0]
|
||||||
|
|
||||||
|
fig = go.Figure()
|
||||||
|
fig.add_trace(
|
||||||
|
go.Scatter(
|
||||||
|
x=t, y=sample["mid"].values,
|
||||||
|
mode="lines", name="Mid Price", line=dict(color="#7f7f7f", width=1, dash="dot"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fig.add_trace(
|
||||||
|
go.Scatter(
|
||||||
|
x=t, y=sample["microprice"].values,
|
||||||
|
mode="lines", name="Microprice", line=dict(color="#1f77b4", width=1.5),
|
||||||
|
fill="tonexty", fillcolor="rgba(31,119,180,0.1)",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
fig.update_layout(
|
||||||
|
title=title,
|
||||||
|
xaxis_title="Time (seconds from start)",
|
||||||
|
yaxis_title="Price",
|
||||||
|
template="plotly_white",
|
||||||
|
hovermode="x unified",
|
||||||
|
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
|
||||||
|
margin=dict(l=60, r=30, t=50, b=60),
|
||||||
|
)
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
def plot_obi_panel(
|
||||||
|
l2_df: pd.DataFrame,
|
||||||
|
title: str = "Order Flow Imbalance",
|
||||||
|
):
|
||||||
|
"""OBI with zero line."""
|
||||||
|
import plotly.graph_objects as go
|
||||||
|
|
||||||
|
if l2_df.empty:
|
||||||
|
return go.Figure()
|
||||||
|
|
||||||
|
sample = l2_df
|
||||||
|
if len(l2_df) > 5000:
|
||||||
|
step = max(1, len(l2_df) // 5000)
|
||||||
|
sample = l2_df.iloc[::step]
|
||||||
|
|
||||||
|
fig = go.Figure()
|
||||||
|
fig.add_trace(
|
||||||
|
go.Scatter(
|
||||||
|
x=sample["ts"].values - l2_df["ts"].iloc[0],
|
||||||
|
y=sample["obi"].values,
|
||||||
|
mode="lines",
|
||||||
|
name="OBI",
|
||||||
|
line=dict(color="#ff7f0e", width=1),
|
||||||
|
fill="tozeroy",
|
||||||
|
fillcolor="rgba(255,127,14,0.08)",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fig.add_hline(y=0, line=dict(color="black", width=0.5))
|
||||||
|
|
||||||
|
fig.update_layout(
|
||||||
|
title=title,
|
||||||
|
xaxis_title="Time (seconds from start)",
|
||||||
|
yaxis_title="Imbalance [-1, 1]",
|
||||||
|
template="plotly_white",
|
||||||
|
hovermode="x unified",
|
||||||
|
margin=dict(l=60, r=30, t=50, b=60),
|
||||||
|
)
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
def plot_vpin_panel(
|
||||||
|
l2_df: pd.DataFrame,
|
||||||
|
threshold: float = 0.30,
|
||||||
|
alarm: float = 0.50,
|
||||||
|
title: str = "VPIN Toxicity",
|
||||||
|
):
|
||||||
|
"""VPIN computed from rolling buy/sell volume in OBI data."""
|
||||||
|
import plotly.graph_objects as go
|
||||||
|
|
||||||
|
if l2_df.empty or len(l2_df) < 50:
|
||||||
|
return go.Figure()
|
||||||
|
|
||||||
|
sample = l2_df
|
||||||
|
if len(l2_df) > 10000:
|
||||||
|
step = max(1, len(l2_df) // 10000)
|
||||||
|
sample = l2_df.iloc[::step]
|
||||||
|
|
||||||
|
vpin_vals = []
|
||||||
|
for i in range(50, len(sample)):
|
||||||
|
window = sample.iloc[i - 50:i]
|
||||||
|
bid_d = window["bid_depth_10"].sum()
|
||||||
|
ask_d = window["ask_depth_10"].sum()
|
||||||
|
total = bid_d + ask_d
|
||||||
|
vpin_vals.append(abs(bid_d - ask_d) / total if total > 0 else 0)
|
||||||
|
|
||||||
|
if len(vpin_vals) < 2:
|
||||||
|
return go.Figure()
|
||||||
|
|
||||||
|
t = sample["ts"].iloc[50:].values - l2_df["ts"].iloc[0]
|
||||||
|
|
||||||
|
fig = go.Figure()
|
||||||
|
fig.add_trace(
|
||||||
|
go.Scatter(
|
||||||
|
x=t, y=vpin_vals,
|
||||||
|
mode="lines", name="VPIN", line=dict(color="#d62728", width=1),
|
||||||
|
fill="tozeroy", fillcolor="rgba(214,39,40,0.08)",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
fig.add_hline(y=threshold, line=dict(color="#ff7f0e", width=0.5, dash="dash"),
|
||||||
|
annotation_text=f"Threshold ({threshold})")
|
||||||
|
fig.add_hline(y=alarm, line=dict(color="#d62728", width=0.5, dash="dash"),
|
||||||
|
annotation_text=f"Alarm ({alarm})")
|
||||||
|
|
||||||
|
fig.update_layout(
|
||||||
|
title=title,
|
||||||
|
xaxis_title="Time (seconds from start)",
|
||||||
|
yaxis_title="VPIN",
|
||||||
|
template="plotly_white",
|
||||||
|
hovermode="x unified",
|
||||||
|
margin=dict(l=60, r=30, t=50, b=60),
|
||||||
|
)
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
def plot_markout_curves(
|
||||||
|
l2_df: pd.DataFrame,
|
||||||
|
trade_df: pd.DataFrame,
|
||||||
|
title: str = "Markout Analysis",
|
||||||
|
):
|
||||||
|
"""Buy vs sell markout at multiple horizons."""
|
||||||
|
import plotly.graph_objects as go
|
||||||
|
|
||||||
|
if l2_df.empty or trade_df.empty:
|
||||||
|
return go.Figure()
|
||||||
|
|
||||||
|
horizons = [100, 500, 1000, 5000, 10000, 30000, 60000]
|
||||||
|
buy_means = []
|
||||||
|
sell_means = []
|
||||||
|
|
||||||
|
l2_times = l2_df["ts"].values if "ts" in l2_df else l2_df["exchange_ts_ms"].values / 1000.0
|
||||||
|
mids = l2_df["mid"].values
|
||||||
|
|
||||||
|
for horizon in horizons:
|
||||||
|
horizon_s = horizon / 1000.0
|
||||||
|
b_mark = []
|
||||||
|
s_mark = []
|
||||||
|
for _, trade in trade_df.iterrows():
|
||||||
|
trade_ts = trade["ts"] if "ts" in trade else trade["exchange_ts_ms"] / 1000.0
|
||||||
|
px = trade["price"]
|
||||||
|
agg = trade["aggressor"]
|
||||||
|
future_idx = np.searchsorted(l2_times, trade_ts + horizon_s)
|
||||||
|
if future_idx < len(mids):
|
||||||
|
mid_before = px
|
||||||
|
mid_after = mids[future_idx]
|
||||||
|
if mid_before > 0:
|
||||||
|
markout = (mid_after - mid_before) / mid_before * 10000
|
||||||
|
if agg == "buy":
|
||||||
|
b_mark.append(markout)
|
||||||
|
else:
|
||||||
|
s_mark.append(markout)
|
||||||
|
buy_means.append(np.mean(b_mark) if b_mark else 0)
|
||||||
|
sell_means.append(np.mean(s_mark) if s_mark else 0)
|
||||||
|
|
||||||
|
fig = go.Figure()
|
||||||
|
fig.add_trace(
|
||||||
|
go.Scatter(
|
||||||
|
x=horizons, y=buy_means, mode="lines+markers",
|
||||||
|
name="After Buy", line=dict(color="#2ca02c", width=2),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fig.add_trace(
|
||||||
|
go.Scatter(
|
||||||
|
x=horizons, y=sell_means, mode="lines+markers",
|
||||||
|
name="After Sell", line=dict(color="#d62728", width=2),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fig.add_hline(y=0, line=dict(color="black", width=0.5))
|
||||||
|
|
||||||
|
fig.update_layout(
|
||||||
|
title=title,
|
||||||
|
xaxis_title="Horizon (ms)",
|
||||||
|
yaxis_title="Mean Markout (bps)",
|
||||||
|
xaxis_type="log",
|
||||||
|
template="plotly_white",
|
||||||
|
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
|
||||||
|
margin=dict(l=60, r=30, t=50, b=60),
|
||||||
|
)
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
def plot_event_timeline(
|
||||||
|
tick_result: dict,
|
||||||
|
title: str = "Simulation Event Timeline",
|
||||||
|
):
|
||||||
|
"""Fills, cancels, and key events from tick runner result."""
|
||||||
|
import plotly.graph_objects as go
|
||||||
|
|
||||||
|
equity = tick_result.get("equity_curve", [])
|
||||||
|
if not equity:
|
||||||
|
return go.Figure()
|
||||||
|
|
||||||
|
times = [p.get("t", i) for i, p in enumerate(equity)]
|
||||||
|
values = [p.get("v", 0) for p in equity]
|
||||||
|
|
||||||
|
fig = go.Figure()
|
||||||
|
fig.add_trace(
|
||||||
|
go.Scatter(
|
||||||
|
x=times, y=values, mode="lines",
|
||||||
|
name="Equity", line=dict(color="#1f77b4", width=1.5),
|
||||||
|
fill="tozeroy", fillcolor="rgba(31,119,180,0.05)",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
n_trades = tick_result.get("total_trades", 0)
|
||||||
|
toxic = tick_result.get("toxic_fills", 0)
|
||||||
|
cancels = tick_result.get("cancels", 0)
|
||||||
|
net_pnl = tick_result.get("pnl_breakdown", {}).get("net_pnl", 0)
|
||||||
|
|
||||||
|
fig.add_annotation(
|
||||||
|
x=0.98, y=0.95, xref="paper", yref="paper",
|
||||||
|
text=f"Trades: {n_trades}<br>Toxic: {toxic}<br>Cancels: {cancels}<br>"
|
||||||
|
f"Net PnL: ${net_pnl:.4f}",
|
||||||
|
showarrow=False, bgcolor="white", bordercolor="#ccc",
|
||||||
|
xanchor="right", yanchor="top",
|
||||||
|
)
|
||||||
|
|
||||||
|
fig.update_layout(
|
||||||
|
title=title,
|
||||||
|
xaxis_title="Time",
|
||||||
|
yaxis_title="Equity ($)",
|
||||||
|
template="plotly_white",
|
||||||
|
hovermode="x unified",
|
||||||
|
margin=dict(l=60, r=30, t=50, b=60),
|
||||||
|
)
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
def tick_dashboard(
|
||||||
|
l2_df: pd.DataFrame,
|
||||||
|
trade_df: pd.DataFrame,
|
||||||
|
tick_result: Optional[dict] = None,
|
||||||
|
coin: str = "BTC",
|
||||||
|
vpin_threshold: float = 0.30,
|
||||||
|
vpin_alarm: float = 0.50,
|
||||||
|
):
|
||||||
|
"""Full 9-panel HFT dashboard."""
|
||||||
|
figs = []
|
||||||
|
|
||||||
|
figs.append(plot_price_with_trades(l2_df, trade_df,
|
||||||
|
title=f"{coin} — Price & Trade Markers"))
|
||||||
|
figs.append(plot_spread_dynamics(l2_df,
|
||||||
|
title=f"{coin} — Spread Dynamics"))
|
||||||
|
figs.append(plot_depth_panel(l2_df,
|
||||||
|
title=f"{coin} — Top-of-Book Depth"))
|
||||||
|
figs.append(plot_microprice_vs_mid(l2_df,
|
||||||
|
title=f"{coin} — Microprice vs Mid"))
|
||||||
|
figs.append(plot_obi_panel(l2_df,
|
||||||
|
title=f"{coin} — Order Flow Imbalance"))
|
||||||
|
figs.append(plot_vpin_panel(l2_df, vpin_threshold, vpin_alarm,
|
||||||
|
title=f"{coin} — VPIN Toxicity"))
|
||||||
|
|
||||||
|
if tick_result:
|
||||||
|
figs.append(plot_event_timeline(tick_result,
|
||||||
|
title=f"{coin} — PnL Timeline"))
|
||||||
|
|
||||||
|
figs.append(plot_markout_curves(l2_df, trade_df,
|
||||||
|
title=f"{coin} — Markout Analysis"))
|
||||||
|
|
||||||
|
return figs
|
||||||
|
|
||||||
|
|
||||||
|
def save_tick_dashboard(
|
||||||
|
l2_df: pd.DataFrame,
|
||||||
|
trade_df: pd.DataFrame,
|
||||||
|
tick_result: Optional[dict] = None,
|
||||||
|
coin: str = "BTC",
|
||||||
|
output_dir: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""Save the full tick dashboard as a standalone HTML file."""
|
||||||
|
import plotly.io as pio
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
figs = tick_dashboard(l2_df, trade_df, tick_result, coin)
|
||||||
|
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||||
|
fname = f"tick_dashboard_{coin}_{ts}.html"
|
||||||
|
out = Path(output_dir) if output_dir else REPORT_DIR
|
||||||
|
out.mkdir(parents=True, exist_ok=True)
|
||||||
|
fpath = out / fname
|
||||||
|
|
||||||
|
html_parts = ["<html><head>",
|
||||||
|
f"<title>HFT Dashboard — {coin}</title>",
|
||||||
|
"<style>body{font-family:system-ui,sans-serif;max-width:1600px;"
|
||||||
|
"margin:0 auto;padding:20px;background:#1a1a2e;color:#eee;}"
|
||||||
|
".chart{margin:20px 0;background:#16213e;border-radius:8px;"
|
||||||
|
"box-shadow:0 2px 8px rgba(0,0,0,0.3);padding:10px;}"
|
||||||
|
"h1{color:#e94560;}</style>",
|
||||||
|
"</head><body>",
|
||||||
|
f"<h1>HFT Microstructure Dashboard — {coin}</h1>",
|
||||||
|
f"<p>{ts}</p>"]
|
||||||
|
for i, fig in enumerate(figs):
|
||||||
|
html_parts.append(f'<div class="chart">')
|
||||||
|
html_parts.append(pio.to_html(fig, include_plotlyjs="cdn", full_html=False))
|
||||||
|
html_parts.append("</div>")
|
||||||
|
html_parts.append("</body></html>")
|
||||||
|
|
||||||
|
with open(fpath, "w") as f:
|
||||||
|
f.write("\n".join(html_parts))
|
||||||
|
|
||||||
|
logger.info("Tick dashboard saved to %s", fpath)
|
||||||
|
return str(fpath)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_tick_viz(args):
|
||||||
|
"""CLI entry: load data and generate dashboard."""
|
||||||
|
l2_df, trade_df = load_tick_dataframe(
|
||||||
|
args.data_dir, args.coin, args.start_date, args.end_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
if l2_df.empty:
|
||||||
|
print("No L2 data available. Run 'python -m cli collect --mainnet' first.")
|
||||||
|
return
|
||||||
|
|
||||||
|
tick_result = None
|
||||||
|
if args.tick_result:
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
with open(args.tick_result) as f:
|
||||||
|
tick_result = json.load(f)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
fpath = save_tick_dashboard(
|
||||||
|
l2_df, trade_df, tick_result, args.coin,
|
||||||
|
output_dir=args.output_dir,
|
||||||
|
)
|
||||||
|
print(f"Dashboard: {fpath}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
p = argparse.ArgumentParser(description="HFT Tick Visualization")
|
||||||
|
p.add_argument("--data-dir", default="data/raw")
|
||||||
|
p.add_argument("--coin", default="BTC")
|
||||||
|
p.add_argument("--start-date", default="2026-08-01")
|
||||||
|
p.add_argument("--end-date", default="2026-08-02")
|
||||||
|
p.add_argument("--output-dir", default="")
|
||||||
|
p.add_argument("--tick-result", help="Path to tick_runner JSON result for PnL panel")
|
||||||
|
args = p.parse_args()
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s", datefmt="%H:%M:%S")
|
||||||
|
cmd_tick_viz(args)
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
"""
|
||||||
|
VBT Report Generator — produces Markdown and HTML research reports.
|
||||||
|
|
||||||
|
Consolidates backtest results, validation reports, performance metrics,
|
||||||
|
and visualizations into a single shareable document.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python -m backtests.vbt_report --strategy pairs --interval 1h
|
||||||
|
python -m backtests.vbt_report --strategy all --output reports/weekly.md
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
REPORT_DIR = Path(__file__).resolve().parent / "reports"
|
||||||
|
REPORT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def format_metric(value: Any, decimals: int = 2) -> str:
|
||||||
|
if isinstance(value, float):
|
||||||
|
return f"{value:.{decimals}f}"
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_markdown_report(
|
||||||
|
result: dict,
|
||||||
|
validation_report=None,
|
||||||
|
includes_viz: bool = False,
|
||||||
|
viz_path: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""Generate a Markdown research report from a backtest result."""
|
||||||
|
|
||||||
|
strategy = result.get("strategy", "unknown")
|
||||||
|
interval = result.get("interval", "unknown")
|
||||||
|
pnl = result.get("pnl", 0)
|
||||||
|
total_return = result.get("total_return_pct", 0)
|
||||||
|
sharpe = result.get("sharpe", 0)
|
||||||
|
sortino = result.get("sortino", 0)
|
||||||
|
max_dd = result.get("max_drawdown_pct", 0)
|
||||||
|
win_rate = result.get("win_rate", 0)
|
||||||
|
profit_factor = result.get("profit_factor", 0)
|
||||||
|
expectancy = result.get("expectancy", 0)
|
||||||
|
n_bars = result.get("n_bars", 0)
|
||||||
|
trades = result.get("trades", [])
|
||||||
|
n_trades = result.get("total_trades", len(trades))
|
||||||
|
params = result.get("params", {})
|
||||||
|
fee_info = result.get("fee_info", {})
|
||||||
|
timing = result.get("generated_at", datetime.now(timezone.utc).isoformat())
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
lines.append(f"# VBT Backtest Report — {strategy} ({interval})")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"**Generated:** {timing}")
|
||||||
|
lines.append(f"**Data:** Hyperliquid {'mainnet' if 'mainnet' in str(result.get('coin', '')) else 'testnet'}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("---")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("## 1. Implementation Summary")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"- **Strategy:** `{strategy}`")
|
||||||
|
lines.append(f"- **Interval:** `{interval}`")
|
||||||
|
lines.append(f"- **Bars:** {n_bars}")
|
||||||
|
lines.append(f"- **Trades:** {n_trades}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
strategy_type = params.get("type", "Unknown")
|
||||||
|
lines.append(f"- **Strategy Type:** {strategy_type}")
|
||||||
|
if params:
|
||||||
|
param_str = ", ".join(f"{k}={v}" for k, v in params.items() if k != "type")
|
||||||
|
lines.append(f"- **Parameters:** {param_str}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("## 2. Performance Metrics")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("| Metric | Value |")
|
||||||
|
lines.append("|--------|-------|")
|
||||||
|
lines.append(f"| Start Equity | ${result.get('start_equity', 10000.0):,.2f} |")
|
||||||
|
lines.append(f"| End Equity | ${result.get('end_equity', 10000.0):,.2f} |")
|
||||||
|
lines.append(f"| Net PnL | ${pnl:,.2f} |")
|
||||||
|
lines.append(f"| Total Return | {total_return:.2f}% |")
|
||||||
|
lines.append(f"| Sharpe Ratio | {sharpe:.3f} |")
|
||||||
|
lines.append(f"| Sortino Ratio | {sortino:.3f} |")
|
||||||
|
lines.append(f"| Max Drawdown | {max_dd:.2f}% |")
|
||||||
|
lines.append(f"| Win Rate | {win_rate:.1%} |")
|
||||||
|
lines.append(f"| Profit Factor | {profit_factor:.3f} |")
|
||||||
|
lines.append(f"| Expectancy | {expectancy:.3f} |")
|
||||||
|
lines.append(f"| Total Trades | {n_trades} |")
|
||||||
|
|
||||||
|
if fee_info:
|
||||||
|
lines.append(f"| Fee Rate | {fee_info.get('effective_rate_pct', 0):.4f}% |")
|
||||||
|
lines.append(f"| Fee Tier | {fee_info.get('tier_name', 'N/A')} |")
|
||||||
|
lines.append(f"| Staking Tier | {fee_info.get('staking_name', 'N/A')} |")
|
||||||
|
lines.append(f"| Fee Model | {fee_info.get('fee_model', 'N/A')} |")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("## 3. Cost Analysis")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if trades:
|
||||||
|
gross_pnls = [float(t.get("pnl_gross", t.get("pnl", 0))) for t in trades]
|
||||||
|
net_pnls = [float(t.get("pnl_net", t.get("pnl", 0))) for t in trades]
|
||||||
|
fees = [float(t.get("fee", 0)) for t in trades]
|
||||||
|
total_gross = sum(gross_pnls)
|
||||||
|
total_net = sum(net_pnls)
|
||||||
|
total_fees = sum(fees)
|
||||||
|
slippage_est = n_trades * 0.001 * 10000.0 * 0.001
|
||||||
|
|
||||||
|
lines.append("| Component | Amount |")
|
||||||
|
lines.append("|-----------|--------|")
|
||||||
|
lines.append(f"| Gross PnL | ${total_gross:,.4f} |")
|
||||||
|
lines.append(f"| Total Fees | ${total_fees:,.4f} |")
|
||||||
|
lines.append(f"| Est. Slippage | ${slippage_est:,.4f} |")
|
||||||
|
lines.append(f"| Net PnL | ${total_net:,.4f} |")
|
||||||
|
|
||||||
|
cost_pct = (total_fees / abs(total_gross) * 100) if abs(total_gross) > 0 else 0
|
||||||
|
lines.append(f"| Fee/Gross Ratio | {cost_pct:.1f}% |")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("## 4. Validation Results")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if validation_report:
|
||||||
|
if hasattr(validation_report, 'summary'):
|
||||||
|
lines.append("```")
|
||||||
|
lines.append(validation_report.summary())
|
||||||
|
lines.append("```")
|
||||||
|
else:
|
||||||
|
lines.append("```")
|
||||||
|
lines.append(str(validation_report))
|
||||||
|
lines.append("```")
|
||||||
|
else:
|
||||||
|
lines.append("⚠ No validation report available.")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("## 5. Signal Analysis")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"- **Total signals:** {result.get('n_bars', 0)} bars processed")
|
||||||
|
|
||||||
|
if trades:
|
||||||
|
holds = []
|
||||||
|
for t in trades:
|
||||||
|
dur = str(t.get("duration", ""))
|
||||||
|
if dur:
|
||||||
|
try:
|
||||||
|
td = pd_from_timedelta(dur)
|
||||||
|
if td:
|
||||||
|
holds.append(td.total_seconds() / 3600)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if holds:
|
||||||
|
lines.append(f"- **Avg holding period:** {np.mean(holds):.2f} hours")
|
||||||
|
lines.append(f"- **Median holding period:** {np.median(holds):.2f} hours")
|
||||||
|
lines.append(f"- **Max holding period:** {np.max(holds):.2f} hours")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("## 6. Known Limitations")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("1. **VBT is candle-level backtesting only.** It cannot model:")
|
||||||
|
lines.append(" - Queue position / price-time priority")
|
||||||
|
lines.append(" - Realistic adverse selection at tick-level")
|
||||||
|
lines.append(" - Latency-dependent fill probability")
|
||||||
|
lines.append(" - VPIN-gated market making")
|
||||||
|
lines.append("2. **Volume-based OBI is a proxy.** Real OBI requires L2 order book data.")
|
||||||
|
lines.append("3. **A-S MM simulation is synthetic.** Uses candle high/low as virtual orderbook,")
|
||||||
|
lines.append(" not real exchange order book queue position.")
|
||||||
|
lines.append(f"4. **Signal frequency:** {n_trades} trades in {n_bars} bars — "
|
||||||
|
f"this is a {'scalping' if n_bars > 0 and n_trades / n_bars > 0.01 else 'low-frequency'} strategy.")
|
||||||
|
lines.append("5. **No walk-forward validation** performed in this report. "
|
||||||
|
"Run `python -m cli walkforward --strategy {strategy}` for OOS testing.")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("## 7. Next Steps")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"1. Run walk-forward validation: `python -m cli walkforward --strategy {strategy} --interval {interval}`")
|
||||||
|
lines.append(f"2. Run tick-level backtest: `python -m cli tick --maker vpin_as_mm --coin BTC`")
|
||||||
|
lines.append(f"3. Paper trade for 7+ days before live deployment")
|
||||||
|
lines.append(f"4. Correlate strategy with other strategies to build diversified portfolio")
|
||||||
|
|
||||||
|
if includes_viz and viz_path:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("## 8. Visualizations")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"Interactive dashboard: [{viz_path}]({viz_path})")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append("---")
|
||||||
|
lines.append(f"*Generated by FTDT Quant Lab VBT Pipeline*")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def pd_from_timedelta(dur_str: str):
|
||||||
|
"""Safe Timedelta parsing."""
|
||||||
|
try:
|
||||||
|
import pandas as pd
|
||||||
|
return pd.Timedelta(dur_str)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def generate_html_report(
|
||||||
|
result: dict,
|
||||||
|
validation_report=None,
|
||||||
|
viz_path: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""Wrap the Markdown report in HTML with styling."""
|
||||||
|
md_body = generate_markdown_report(result, validation_report, bool(viz_path), viz_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import markdown
|
||||||
|
body = markdown.markdown(md_body, extensions=["tables", "fenced_code"])
|
||||||
|
except ImportError:
|
||||||
|
body = "<pre>" + md_body.replace("<", "<") + "</pre>"
|
||||||
|
|
||||||
|
html = f"""<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>VBT Backtest Report</title>
|
||||||
|
<style>
|
||||||
|
body {{ font-family: system-ui, -apple-system, sans-serif; max-width: 900px;
|
||||||
|
margin: 0 auto; padding: 40px 20px; color: #333; line-height: 1.6; }}
|
||||||
|
h1, h2 {{ color: #1a1a2e; border-bottom: 1px solid #eee; padding-bottom: 8px; }}
|
||||||
|
table {{ border-collapse: collapse; width: 100%; margin: 12px 0; }}
|
||||||
|
th, td {{ border: 1px solid #ddd; padding: 8px 12px; text-align: left; }}
|
||||||
|
th {{ background: #f0f0f0; }}
|
||||||
|
code, pre {{ background: #f5f5f5; border-radius: 4px; }}
|
||||||
|
pre {{ padding: 12px; overflow-x: auto; }}
|
||||||
|
.warn {{ color: #c0392b; font-weight: bold; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{body}
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
|
def save_report(
|
||||||
|
result: dict,
|
||||||
|
validation_report=None,
|
||||||
|
viz_path: str = "",
|
||||||
|
output_dir: str = "",
|
||||||
|
fmt: str = "md",
|
||||||
|
) -> str:
|
||||||
|
"""Save a report to disk. Returns filepath."""
|
||||||
|
out = Path(output_dir) if output_dir else REPORT_DIR
|
||||||
|
out.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
strategy = result.get("strategy", "unknown")
|
||||||
|
interval = result.get("interval", "unknown")
|
||||||
|
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||||
|
base = f"{strategy}_{interval}_{ts}"
|
||||||
|
|
||||||
|
if fmt == "html":
|
||||||
|
content = generate_html_report(result, validation_report, viz_path)
|
||||||
|
ext = ".html"
|
||||||
|
else:
|
||||||
|
content = generate_markdown_report(result, validation_report, bool(viz_path), viz_path)
|
||||||
|
ext = ".md"
|
||||||
|
|
||||||
|
fpath = out / (base + ext)
|
||||||
|
fpath.write_text(content)
|
||||||
|
logger.info("Report saved to %s", fpath)
|
||||||
|
return str(fpath)
|
||||||
|
|
||||||
|
|
||||||
|
def batch_report(
|
||||||
|
results_dir: str = "backtests/results",
|
||||||
|
output_dir: str = "backtests/reports",
|
||||||
|
) -> list[str]:
|
||||||
|
"""Generate reports for all recent backtest results."""
|
||||||
|
import json as _json
|
||||||
|
rd = Path(results_dir)
|
||||||
|
files = sorted(rd.glob("*.json"), key=os.path.getmtime, reverse=True)
|
||||||
|
paths = []
|
||||||
|
|
||||||
|
by_strategy = {}
|
||||||
|
for fp in files[:30]:
|
||||||
|
try:
|
||||||
|
data = _json.loads(fp.read_text())
|
||||||
|
strat = data.get("strategy", "?")
|
||||||
|
if strat not in by_strategy:
|
||||||
|
by_strategy[strat] = data
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for strat, result in by_strategy.items():
|
||||||
|
p = save_report(result, output_dir=output_dir)
|
||||||
|
paths.append(p)
|
||||||
|
|
||||||
|
return paths
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
p = argparse.ArgumentParser(description="VBT Report Generator")
|
||||||
|
p.add_argument("--strategy", default="pairs")
|
||||||
|
p.add_argument("--interval", default="1h")
|
||||||
|
p.add_argument("--results-dir", default="backtests/results")
|
||||||
|
p.add_argument("--output-dir", default="backtests/reports")
|
||||||
|
p.add_argument("--format", default="md", choices=["md", "html"])
|
||||||
|
p.add_argument("--batch", action="store_true", help="Generate reports for all recent backtests")
|
||||||
|
args = p.parse_args()
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s", datefmt="%H:%M:%S")
|
||||||
|
|
||||||
|
if args.batch:
|
||||||
|
paths = batch_report(args.results_dir, args.output_dir)
|
||||||
|
print(f"Generated {len(paths)} reports")
|
||||||
|
else:
|
||||||
|
import json as _json
|
||||||
|
rd = Path(args.results_dir)
|
||||||
|
files = sorted(rd.glob("*.json"))
|
||||||
|
found = None
|
||||||
|
for fp in files:
|
||||||
|
try:
|
||||||
|
d = _json.loads(fp.read_text())
|
||||||
|
if d.get("strategy") == args.strategy and d.get("interval") == args.interval:
|
||||||
|
found = d
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if found:
|
||||||
|
p = save_report(found, output_dir=args.output_dir, fmt=args.format)
|
||||||
|
print(f"Report: {p}")
|
||||||
|
else:
|
||||||
|
print(f"No result found for {args.strategy} ({args.interval})")
|
||||||
@@ -441,6 +441,157 @@ class VBTBacktestRunner:
|
|||||||
|
|
||||||
return pd.DataFrame(results_rows) if results_rows else None
|
return pd.DataFrame(results_rows) if results_rows else None
|
||||||
|
|
||||||
|
def run_benchmark(
|
||||||
|
self,
|
||||||
|
coin: str = "BTC",
|
||||||
|
interval: str = "1h",
|
||||||
|
testnet: bool = False,
|
||||||
|
limit: int = 5000,
|
||||||
|
start_ms: int | None = None,
|
||||||
|
end_ms: int | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Run a simple buy-and-hold benchmark using VBT."""
|
||||||
|
provider = HyperliquidDataProvider(testnet=testnet)
|
||||||
|
df = provider.fetch_candles(coin, interval=interval, limit=limit,
|
||||||
|
start_ms=start_ms, end_ms=end_ms)
|
||||||
|
if df.empty:
|
||||||
|
return None
|
||||||
|
|
||||||
|
close = df["close"]
|
||||||
|
if len(close) < 2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
entries = pd.Series(False, index=close.index)
|
||||||
|
entries.iloc[0] = True
|
||||||
|
exits = pd.Series(False, index=close.index)
|
||||||
|
exits.iloc[-1] = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
pf = vbt.Portfolio.from_signals(
|
||||||
|
close=close,
|
||||||
|
entries=entries,
|
||||||
|
exits=exits,
|
||||||
|
fees=self._fee_rate,
|
||||||
|
slippage=0.001,
|
||||||
|
freq=INTERVAL_MAP.get(interval, "1h"),
|
||||||
|
init_cash=10000.0,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
total_return = float(pf.stats().get("Total Return [%]", 0))
|
||||||
|
bm_sharpe = float(pf.stats().get("Sharpe Ratio", 0))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"strategy": "buy_and_hold",
|
||||||
|
"coin": coin.upper(),
|
||||||
|
"interval": interval,
|
||||||
|
"n_bars": len(close),
|
||||||
|
"start_equity": 10000.0,
|
||||||
|
"end_equity": round(float(pf.value().iloc[-1]), 2),
|
||||||
|
"total_return_pct": round(total_return, 2),
|
||||||
|
"sharpe": round(bm_sharpe, 3),
|
||||||
|
"close": close,
|
||||||
|
"pf": pf,
|
||||||
|
}
|
||||||
|
|
||||||
|
def validate(
|
||||||
|
self,
|
||||||
|
result: dict,
|
||||||
|
pf,
|
||||||
|
entries: pd.Series,
|
||||||
|
exits: pd.Series,
|
||||||
|
close: pd.Series,
|
||||||
|
):
|
||||||
|
"""Run validation checks on a backtest result."""
|
||||||
|
from backtests.vbt_validator import VBTValidator
|
||||||
|
|
||||||
|
validator = VBTValidator(min_trades=10)
|
||||||
|
report = validator.validate(
|
||||||
|
entries=entries,
|
||||||
|
exits=exits,
|
||||||
|
close=close,
|
||||||
|
pf=pf,
|
||||||
|
trades=result.get("trades", []),
|
||||||
|
strategy=result.get("strategy", "unknown"),
|
||||||
|
interval=result.get("interval", "unknown"),
|
||||||
|
)
|
||||||
|
return report
|
||||||
|
|
||||||
|
def run_with_report(
|
||||||
|
self,
|
||||||
|
strategy: str = "pairs",
|
||||||
|
interval: str = "1h",
|
||||||
|
testnet: bool = False,
|
||||||
|
limit: int = 5000,
|
||||||
|
params: dict | None = None,
|
||||||
|
output_dir: str = "backtests/reports",
|
||||||
|
) -> dict | None:
|
||||||
|
"""End-to-end: fetch, backtest, validate, visualize, save report."""
|
||||||
|
result = self.run_strategy(
|
||||||
|
strategy=strategy, interval=interval, testnet=testnet,
|
||||||
|
limit=limit, params=params,
|
||||||
|
)
|
||||||
|
if result is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = {}
|
||||||
|
coins = self._get_coins(strategy)
|
||||||
|
provider = HyperliquidDataProvider(testnet=testnet)
|
||||||
|
for coin in coins:
|
||||||
|
df = provider.fetch_candles(coin, interval=interval, limit=limit)
|
||||||
|
if not df.empty:
|
||||||
|
data[coin] = df
|
||||||
|
|
||||||
|
entries, exits = _generate_signals(strategy, data, params)
|
||||||
|
primary = list(data.values())[0]
|
||||||
|
close = primary["close"]
|
||||||
|
common_idx = entries.index.intersection(close.index)
|
||||||
|
entries = entries.reindex(common_idx).fillna(False)
|
||||||
|
exits = exits.reindex(common_idx).fillna(False)
|
||||||
|
close = close.reindex(common_idx)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from config.fee_tiers import get_strategy_fee_model
|
||||||
|
fee_model = get_strategy_fee_model(strategy)
|
||||||
|
effective_fee = self._maker_rate if fee_model == "maker" else self._fee_rate
|
||||||
|
pf = vbt.Portfolio.from_signals(
|
||||||
|
close=close, entries=entries, exits=exits,
|
||||||
|
fees=effective_fee, slippage=0.001,
|
||||||
|
freq=INTERVAL_MAP.get(interval, "1h"),
|
||||||
|
init_cash=10000.0,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pf = None
|
||||||
|
|
||||||
|
bm_result = self.run_benchmark(coin=self._get_coins(strategy)[0],
|
||||||
|
interval=interval, testnet=testnet, limit=limit)
|
||||||
|
benchmark_close = bm_result.get("close") if bm_result else None
|
||||||
|
|
||||||
|
validation_report = None
|
||||||
|
if pf is not None:
|
||||||
|
validation_report = self.validate(result, pf, entries, exits, close)
|
||||||
|
|
||||||
|
from backtests.vbt_viz import VBTVisualizer
|
||||||
|
viz = VBTVisualizer(output_dir=output_dir)
|
||||||
|
viz.save_dashboard(
|
||||||
|
pf=pf, close=close, entries=entries, exits=exits,
|
||||||
|
benchmark_close=benchmark_close,
|
||||||
|
strategy=strategy, interval=interval,
|
||||||
|
)
|
||||||
|
|
||||||
|
result["validation"] = validation_report.summary() if validation_report else "N/A"
|
||||||
|
|
||||||
|
if validation_report:
|
||||||
|
result["validation_checks"] = validation_report.checks
|
||||||
|
result["validation_errors"] = validation_report.errors
|
||||||
|
result["validation_warnings"] = validation_report.warnings
|
||||||
|
|
||||||
|
logger.info("Report generated for %s (%s) — saved to %s",
|
||||||
|
strategy, interval, output_dir)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
# ── Helpers ─────────────────────────────────────────────────
|
# ── Helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
def _get_coins(self, strategy: str) -> list[str]:
|
def _get_coins(self, strategy: str) -> list[str]:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -545,6 +545,98 @@ def cmd_funding(args):
|
|||||||
print(f" Then 'python -m cli funding --coin BTC' to re-run.")
|
print(f" Then 'python -m cli funding --coin BTC' to re-run.")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_report(args):
|
||||||
|
"""Generate a combined VBT report: backtest + validation + visualization."""
|
||||||
|
import json as _json
|
||||||
|
from backtests.vbt_runner import VBTBacktestRunner
|
||||||
|
|
||||||
|
runner = VBTBacktestRunner()
|
||||||
|
result = runner.run_with_report(
|
||||||
|
strategy=args.strategy,
|
||||||
|
interval=args.interval,
|
||||||
|
testnet=False,
|
||||||
|
limit=args.limit,
|
||||||
|
output_dir=args.output_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result:
|
||||||
|
print(f"Report generated for {args.strategy} ({args.interval})")
|
||||||
|
print(f" Strategy: {result['strategy']}")
|
||||||
|
print(f" Sharpe: {result.get('sharpe', 0):.3f}")
|
||||||
|
print(f" Net PnL: ${result.get('pnl', 0):.2f}")
|
||||||
|
print(f" Trades: {result.get('total_trades', 0)}")
|
||||||
|
print(f" Validation: {len(result.get('validation_errors', []))} errors, "
|
||||||
|
f"{len(result.get('validation_warnings', []))} warnings")
|
||||||
|
print(f" Output: {args.output_dir}/")
|
||||||
|
else:
|
||||||
|
print(f"No data available for {args.strategy}. "
|
||||||
|
f"Try: python -m cli backtest --strategy {args.strategy} --interval {args.interval}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_validate(args):
|
||||||
|
"""Validate existing backtest results without re-running."""
|
||||||
|
import json as _json
|
||||||
|
from pathlib import Path
|
||||||
|
from backtests.vbt_validator import VBTValidator, ValidationReport
|
||||||
|
|
||||||
|
rd = Path(args.results_dir)
|
||||||
|
files = sorted(rd.glob("*.json"))
|
||||||
|
if not files:
|
||||||
|
print(f"No backtest results found in {args.results_dir}")
|
||||||
|
return
|
||||||
|
|
||||||
|
validator = VBTValidator()
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
passed = 0
|
||||||
|
|
||||||
|
for fp in files:
|
||||||
|
try:
|
||||||
|
data = _json.loads(fp.read_text())
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
strat = data.get("strategy", "?")
|
||||||
|
if args.strategy != "all" and args.strategy.lower() != strat.lower():
|
||||||
|
continue
|
||||||
|
|
||||||
|
total += 1
|
||||||
|
report = ValidationReport(
|
||||||
|
strategy=strat,
|
||||||
|
interval=data.get("interval", "?"),
|
||||||
|
)
|
||||||
|
|
||||||
|
trades = data.get("trades", [])
|
||||||
|
n_trades = data.get("total_trades", len(trades))
|
||||||
|
if n_trades < 10:
|
||||||
|
report.warnings.append(
|
||||||
|
f"{fp.name}: only {n_trades} trades — insufficient for stats"
|
||||||
|
)
|
||||||
|
|
||||||
|
sharpe = data.get("sharpe", 0)
|
||||||
|
if n_trades > 0 and abs(sharpe) > 5:
|
||||||
|
report.warnings.append(
|
||||||
|
f"{fp.name}: extreme Sharpe {sharpe:.2f} with {n_trades} trades"
|
||||||
|
)
|
||||||
|
|
||||||
|
if report.errors or report.warnings:
|
||||||
|
print(report.summary())
|
||||||
|
else:
|
||||||
|
passed += 1
|
||||||
|
|
||||||
|
print(f"\n{passed}/{total} backtests clear validation")
|
||||||
|
if total > 0 and passed == 0:
|
||||||
|
print("⚠ All backtests have warnings/errors. Review needed.")
|
||||||
|
print(f"\nFull validation requires re-running with VBTValidator.validate().")
|
||||||
|
print(f"Use: python -m cli report --strategy <name> for full validation.")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_hft_viz(args):
|
||||||
|
"""Generate HFT tick visualization dashboard."""
|
||||||
|
from backtests.tick_viz import cmd_tick_viz
|
||||||
|
cmd_tick_viz(args)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
import argparse
|
import argparse
|
||||||
p = argparse.ArgumentParser(description="FTDT Quant Lab CLI")
|
p = argparse.ArgumentParser(description="FTDT Quant Lab CLI")
|
||||||
@@ -609,8 +701,8 @@ def main():
|
|||||||
pt.add_argument("--skew-factor", type=float, default=0.5)
|
pt.add_argument("--skew-factor", type=float, default=0.5)
|
||||||
pt.add_argument("--vpin-threshold", type=float, default=0.30)
|
pt.add_argument("--vpin-threshold", type=float, default=0.30)
|
||||||
pt.add_argument("--vpin-alarm", type=float, default=0.50)
|
pt.add_argument("--vpin-alarm", type=float, default=0.50)
|
||||||
pt.add_argument("--maker-fee", type=float, default=0.02, help="Maker fee in %")
|
pt.add_argument("--maker-fee", type=float, default=0.02, help="Maker fee (e.g. 0.02 = 2bps)")
|
||||||
pt.add_argument("--taker-fee", type=float, default=0.05, help="Taker fee in %")
|
pt.add_argument("--taker-fee", type=float, default=0.05, help="Taker fee (e.g. 0.05 = 5bps)")
|
||||||
pt.add_argument("--adverse-prob", type=float, default=0.15)
|
pt.add_argument("--adverse-prob", type=float, default=0.15)
|
||||||
pt.add_argument("--cancel-after-ms", type=float, default=5000.0)
|
pt.add_argument("--cancel-after-ms", type=float, default=5000.0)
|
||||||
pt.add_argument("--quote-refresh-ms", type=float, default=2000.0)
|
pt.add_argument("--quote-refresh-ms", type=float, default=2000.0)
|
||||||
@@ -631,6 +723,28 @@ def main():
|
|||||||
pf.add_argument("--start-date", default="2026-01-01")
|
pf.add_argument("--start-date", default="2026-01-01")
|
||||||
pf.add_argument("--end-date", default="2030-01-01")
|
pf.add_argument("--end-date", default="2030-01-01")
|
||||||
|
|
||||||
|
# report
|
||||||
|
prp = sp.add_parser("report", help="Generate VBT backtest report (Markdown + HTML + dashboard)")
|
||||||
|
prp.add_argument("--strategy", default="pairs")
|
||||||
|
prp.add_argument("--interval", default="1h")
|
||||||
|
prp.add_argument("--limit", type=int, default=5000)
|
||||||
|
prp.add_argument("--output-dir", default="backtests/reports")
|
||||||
|
prp.add_argument("--format", default="html", choices=["md", "html"])
|
||||||
|
|
||||||
|
# validate
|
||||||
|
pv = sp.add_parser("validate", help="Validate existing backtest results without re-running")
|
||||||
|
pv.add_argument("--strategy", default="all", help="Strategy name or 'all'")
|
||||||
|
pv.add_argument("--results-dir", default="backtests/results")
|
||||||
|
|
||||||
|
# hft
|
||||||
|
ph = sp.add_parser("hft", help="Generate HFT tick visualization dashboard")
|
||||||
|
ph.add_argument("--data-dir", default="data/raw")
|
||||||
|
ph.add_argument("--coin", default="BTC")
|
||||||
|
ph.add_argument("--start-date", default="2026-08-01")
|
||||||
|
ph.add_argument("--end-date", default="2026-08-02")
|
||||||
|
ph.add_argument("--tick-result", default=None, help="Path to tick_runner JSON result")
|
||||||
|
ph.add_argument("--output-dir", default="backtests/reports")
|
||||||
|
|
||||||
args = p.parse_args()
|
args = p.parse_args()
|
||||||
|
|
||||||
import json as _json
|
import json as _json
|
||||||
@@ -653,6 +767,12 @@ def main():
|
|||||||
cmd_discover(args)
|
cmd_discover(args)
|
||||||
elif args.command == "funding":
|
elif args.command == "funding":
|
||||||
cmd_funding(args)
|
cmd_funding(args)
|
||||||
|
elif args.command == "report":
|
||||||
|
cmd_report(args)
|
||||||
|
elif args.command == "validate":
|
||||||
|
cmd_validate(args)
|
||||||
|
elif args.command == "hft":
|
||||||
|
cmd_hft_viz(args)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
"""
|
||||||
|
Tests for VBT validator, visualizer, report generator, and CLI integration.
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidationReport:
|
||||||
|
def test_empty_report_passes(self):
|
||||||
|
from backtests.vbt_validator import ValidationReport
|
||||||
|
r = ValidationReport(strategy="test", interval="1h")
|
||||||
|
assert r.passes
|
||||||
|
assert r.all_checks_pass
|
||||||
|
|
||||||
|
def test_errors_cause_failure(self):
|
||||||
|
from backtests.vbt_validator import ValidationReport
|
||||||
|
r = ValidationReport(strategy="test", interval="1h")
|
||||||
|
r.errors.append("lookahead detected")
|
||||||
|
assert not r.passes
|
||||||
|
|
||||||
|
def test_warnings_dont_cause_failure(self):
|
||||||
|
from backtests.vbt_validator import ValidationReport
|
||||||
|
r = ValidationReport(strategy="test", interval="1h")
|
||||||
|
r.warnings.append("low trade count")
|
||||||
|
assert r.passes
|
||||||
|
|
||||||
|
def test_checks_tracking(self):
|
||||||
|
from backtests.vbt_validator import ValidationReport
|
||||||
|
r = ValidationReport(strategy="test", interval="1h")
|
||||||
|
r.checks["no_lookahead"] = True
|
||||||
|
r.checks["min_trades"] = False
|
||||||
|
assert not r.all_checks_pass
|
||||||
|
|
||||||
|
|
||||||
|
class TestVBTValidator:
|
||||||
|
def make_data(self, n=500):
|
||||||
|
dates = pd.date_range("2026-01-01", periods=n, freq="1h")
|
||||||
|
close = pd.Series(100 + np.cumsum(np.random.RandomState(42).randn(n) * 0.1), index=dates)
|
||||||
|
entries = pd.Series(False, index=dates)
|
||||||
|
exits = pd.Series(False, index=dates)
|
||||||
|
entries.iloc[[50, 100, 150, 200, 250, 300, 350, 400, 420, 440, 460]] = True
|
||||||
|
exits.iloc[[60, 110, 160, 210, 260, 310, 360, 410, 430, 450, 470]] = True
|
||||||
|
return entries, exits, close
|
||||||
|
|
||||||
|
def test_clean_data_passes(self):
|
||||||
|
from backtests.vbt_validator import VBTValidator
|
||||||
|
entries, exits, close = self.make_data()
|
||||||
|
v = VBTValidator()
|
||||||
|
report = v.validate(entries=entries, exits=exits, close=close,
|
||||||
|
strategy="test", interval="1h")
|
||||||
|
assert report.passes
|
||||||
|
|
||||||
|
def test_lookahead_detected(self):
|
||||||
|
from backtests.vbt_validator import VBTValidator
|
||||||
|
entries, exits, close = self.make_data()
|
||||||
|
entries.iloc[0] = True
|
||||||
|
v = VBTValidator()
|
||||||
|
report = v.validate(entries=entries, exits=exits, close=close,
|
||||||
|
strategy="test", interval="1h")
|
||||||
|
assert not report.checks["no_lookahead"]
|
||||||
|
|
||||||
|
def test_coincident_signals_detected(self):
|
||||||
|
from backtests.vbt_validator import VBTValidator
|
||||||
|
entries, exits, close = self.make_data()
|
||||||
|
entries.iloc[100] = True
|
||||||
|
exits.iloc[100] = True
|
||||||
|
v = VBTValidator()
|
||||||
|
report = v.validate(entries=entries, exits=exits, close=close,
|
||||||
|
strategy="test", interval="1h")
|
||||||
|
assert not report.checks["no_coincident_entry_exit"]
|
||||||
|
|
||||||
|
def test_nan_detected(self):
|
||||||
|
from backtests.vbt_validator import VBTValidator
|
||||||
|
entries, exits, close = self.make_data()
|
||||||
|
close.iloc[50] = np.nan
|
||||||
|
v = VBTValidator()
|
||||||
|
report = v.validate(entries=entries, exits=exits, close=close,
|
||||||
|
strategy="test", interval="1h")
|
||||||
|
assert not report.checks["no_nan_close"]
|
||||||
|
|
||||||
|
def test_duplicate_timestamps_detected(self):
|
||||||
|
from backtests.vbt_validator import VBTValidator
|
||||||
|
dates = pd.date_range("2026-01-01", periods=500, freq="1h")
|
||||||
|
dates = dates.insert(3, dates[2])
|
||||||
|
close = pd.Series(100 + np.random.randn(501) * 0.1, index=dates)
|
||||||
|
entries = pd.Series(False, index=dates)
|
||||||
|
exits = pd.Series(False, index=dates)
|
||||||
|
entries.iloc[[50, 100]] = True
|
||||||
|
exits.iloc[[60, 110]] = True
|
||||||
|
v = VBTValidator()
|
||||||
|
report = v.validate(entries=entries, exits=exits, close=close,
|
||||||
|
strategy="test", interval="1h")
|
||||||
|
assert not report.checks["no_duplicate_timestamps"]
|
||||||
|
|
||||||
|
def test_low_trade_count_warns(self):
|
||||||
|
from backtests.vbt_validator import VBTValidator
|
||||||
|
dates = pd.date_range("2026-01-01", periods=100, freq="1h")
|
||||||
|
close = pd.Series(100 + np.random.randn(100) * 0.1, index=dates)
|
||||||
|
entries = pd.Series(False, index=dates)
|
||||||
|
exits = pd.Series(False, index=dates)
|
||||||
|
entries.iloc[20] = True
|
||||||
|
exits.iloc[30] = True
|
||||||
|
entries.iloc[40] = True
|
||||||
|
exits.iloc[50] = True
|
||||||
|
v = VBTValidator(min_trades=10)
|
||||||
|
report = v.validate(entries=entries, exits=exits, close=close,
|
||||||
|
strategy="test", interval="1h")
|
||||||
|
assert not report.checks["min_trade_count"]
|
||||||
|
|
||||||
|
def test_benchmark_accepts_series(self):
|
||||||
|
from backtests.vbt_validator import VBTValidator
|
||||||
|
entries, exits, close = self.make_data()
|
||||||
|
bm = close * 1.01
|
||||||
|
v = VBTValidator()
|
||||||
|
report = v.validate(entries=entries, exits=exits, close=close,
|
||||||
|
benchmark_close=bm, strategy="test", interval="1h")
|
||||||
|
assert report.checks["benchmark_available"]
|
||||||
|
assert "benchmark_return_pct" in report.details
|
||||||
|
|
||||||
|
|
||||||
|
class TestVBTVisualizer:
|
||||||
|
def test_visualizer_creates_output_dir(self):
|
||||||
|
from backtests.vbt_viz import VBTVisualizer
|
||||||
|
viz = VBTVisualizer(output_dir="/tmp/vbt_test_viz")
|
||||||
|
assert viz._output_dir.exists()
|
||||||
|
|
||||||
|
def test_equity_curve_with_data(self):
|
||||||
|
import vectorbt as vbt
|
||||||
|
from backtests.vbt_viz import VBTVisualizer
|
||||||
|
|
||||||
|
dates = pd.date_range("2026-01-01", periods=100, freq="1h")
|
||||||
|
close = pd.Series(100 + np.cumsum(np.random.RandomState(42).randn(100) * 0.1), index=dates)
|
||||||
|
entries = pd.Series(False, index=dates)
|
||||||
|
exits = pd.Series(False, index=dates)
|
||||||
|
entries.iloc[[10, 30, 50, 70]] = True
|
||||||
|
exits.iloc[[20, 40, 60, 80]] = True
|
||||||
|
pf = vbt.Portfolio.from_signals(close=close, entries=entries, exits=exits,
|
||||||
|
fees=0.001, init_cash=10000.0)
|
||||||
|
|
||||||
|
viz = VBTVisualizer()
|
||||||
|
fig = viz.equity_curve(pf)
|
||||||
|
assert fig is not None
|
||||||
|
|
||||||
|
def test_drawdown_returns_figure(self):
|
||||||
|
import vectorbt as vbt
|
||||||
|
from backtests.vbt_viz import VBTVisualizer
|
||||||
|
|
||||||
|
dates = pd.date_range("2026-01-01", periods=100, freq="1h")
|
||||||
|
close = pd.Series(100 + np.cumsum(np.random.RandomState(42).randn(100) * 0.2), index=dates)
|
||||||
|
entries = pd.Series(False, index=dates)
|
||||||
|
exits = pd.Series(False, index=dates)
|
||||||
|
entries.iloc[[10, 50]] = True
|
||||||
|
exits.iloc[[30, 70]] = True
|
||||||
|
pf = vbt.Portfolio.from_signals(close=close, entries=entries, exits=exits,
|
||||||
|
fees=0.001, init_cash=10000.0)
|
||||||
|
|
||||||
|
viz = VBTVisualizer()
|
||||||
|
fig = viz.drawdown(pf)
|
||||||
|
assert fig is not None
|
||||||
|
|
||||||
|
def test_returns_distribution_empty_safe(self):
|
||||||
|
from backtests.vbt_viz import VBTVisualizer
|
||||||
|
import plotly.graph_objects as go
|
||||||
|
viz = VBTVisualizer()
|
||||||
|
fig = viz.returns_distribution(None)
|
||||||
|
assert isinstance(fig, go.Figure)
|
||||||
|
|
||||||
|
def test_monthly_heatmap_empty_safe(self):
|
||||||
|
from backtests.vbt_viz import VBTVisualizer
|
||||||
|
import plotly.graph_objects as go
|
||||||
|
viz = VBTVisualizer()
|
||||||
|
fig = viz.monthly_heatmap(None)
|
||||||
|
assert isinstance(fig, go.Figure)
|
||||||
|
|
||||||
|
def test_param_heatmap_empty_safe(self):
|
||||||
|
from backtests.vbt_viz import VBTVisualizer
|
||||||
|
import plotly.graph_objects as go
|
||||||
|
viz = VBTVisualizer()
|
||||||
|
fig = viz.param_heatmap(None, "w", "t", "sharpe")
|
||||||
|
assert isinstance(fig, go.Figure)
|
||||||
|
|
||||||
|
def test_dashboard_returns_list(self):
|
||||||
|
import vectorbt as vbt
|
||||||
|
from backtests.vbt_viz import VBTVisualizer
|
||||||
|
|
||||||
|
dates = pd.date_range("2026-01-01", periods=200, freq="1h")
|
||||||
|
close = pd.Series(100 + np.cumsum(np.random.RandomState(42).randn(200) * 0.1), index=dates)
|
||||||
|
entries = pd.Series(False, index=dates)
|
||||||
|
exits = pd.Series(False, index=dates)
|
||||||
|
entries.iloc[[20, 50, 80, 110, 140]] = True
|
||||||
|
exits.iloc[[35, 65, 95, 125, 155]] = True
|
||||||
|
pf = vbt.Portfolio.from_signals(close=close, entries=entries, exits=exits,
|
||||||
|
fees=0.001, init_cash=10000.0)
|
||||||
|
|
||||||
|
viz = VBTVisualizer()
|
||||||
|
figs = viz.dashboard(pf, close, entries, exits, strategy="test", interval="1h")
|
||||||
|
assert len(figs) >= 5
|
||||||
|
|
||||||
|
def test_holding_periods_empty_safe(self):
|
||||||
|
from backtests.vbt_viz import VBTVisualizer
|
||||||
|
import plotly.graph_objects as go
|
||||||
|
viz = VBTVisualizer()
|
||||||
|
fig = viz.holding_periods(None)
|
||||||
|
assert isinstance(fig, go.Figure)
|
||||||
|
|
||||||
|
def test_gross_vs_net_empty_safe(self):
|
||||||
|
from backtests.vbt_viz import VBTVisualizer
|
||||||
|
import plotly.graph_objects as go
|
||||||
|
viz = VBTVisualizer()
|
||||||
|
fig = viz.gross_vs_net(None)
|
||||||
|
assert isinstance(fig, go.Figure)
|
||||||
|
|
||||||
|
|
||||||
|
class TestVBTReport:
|
||||||
|
def test_generates_markdown(self):
|
||||||
|
from backtests.vbt_report import generate_markdown_report
|
||||||
|
result = {
|
||||||
|
"strategy": "pairs", "interval": "1h",
|
||||||
|
"pnl": 10.0, "total_return_pct": 0.1, "sharpe": 0.5,
|
||||||
|
"sortino": 0.6, "max_drawdown_pct": 2.0, "win_rate": 0.55,
|
||||||
|
"profit_factor": 1.2, "expectancy": 0.1, "n_bars": 1000,
|
||||||
|
"trades": [], "total_trades": 20, "params": {"z_entry": 1.5},
|
||||||
|
"fee_info": {"effective_rate_pct": 0.045, "tier_name": "Tier 0"},
|
||||||
|
}
|
||||||
|
md = generate_markdown_report(result)
|
||||||
|
assert "pairs" in md
|
||||||
|
assert "1h" in md
|
||||||
|
assert "Sharpe" in md
|
||||||
|
assert "Limitations" in md
|
||||||
|
|
||||||
|
def test_generates_html(self):
|
||||||
|
from backtests.vbt_report import generate_html_report
|
||||||
|
result = {
|
||||||
|
"strategy": "test", "interval": "1h",
|
||||||
|
"pnl": 0.0, "total_return_pct": 0.0, "sharpe": 0.0,
|
||||||
|
"sortino": 0.0, "max_drawdown_pct": 0.0, "win_rate": 0.0,
|
||||||
|
"profit_factor": 0.0, "expectancy": 0.0, "n_bars": 0,
|
||||||
|
"trades": [], "total_trades": 0, "params": {},
|
||||||
|
"fee_info": {},
|
||||||
|
}
|
||||||
|
html = generate_html_report(result)
|
||||||
|
assert "<html" in html
|
||||||
|
|
||||||
|
def test_saves_report(self):
|
||||||
|
from backtests.vbt_report import save_report
|
||||||
|
result = {
|
||||||
|
"strategy": "test_save", "interval": "1h",
|
||||||
|
"pnl": 0.0, "total_return_pct": 0.0, "sharpe": 0.0,
|
||||||
|
"sortino": 0.0, "max_drawdown_pct": 0.0, "win_rate": 0.0,
|
||||||
|
"profit_factor": 0.0, "expectancy": 0.0, "n_bars": 0,
|
||||||
|
"trades": [], "total_trades": 0, "params": {},
|
||||||
|
"fee_info": {},
|
||||||
|
}
|
||||||
|
p = save_report(result, output_dir="/tmp/vbt_test_reports")
|
||||||
|
assert p.endswith(".md")
|
||||||
|
import os
|
||||||
|
assert os.path.exists(p)
|
||||||
|
os.unlink(p)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCLIIntegration:
|
||||||
|
def test_report_command_registered(self):
|
||||||
|
import cli
|
||||||
|
assert hasattr(cli, "cmd_report")
|
||||||
|
|
||||||
|
def test_validate_command_registered(self):
|
||||||
|
import cli
|
||||||
|
assert hasattr(cli, "cmd_validate")
|
||||||
|
|
||||||
|
def test_hft_viz_command_registered(self):
|
||||||
|
import cli
|
||||||
|
assert hasattr(cli, "cmd_hft_viz")
|
||||||
|
|
||||||
|
def test_cli_help_runs(self):
|
||||||
|
import subprocess, sys
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, "-m", "cli", "--help"],
|
||||||
|
capture_output=True, text=True, cwd="/home/satoshi/ftdt-quant-lab",
|
||||||
|
)
|
||||||
|
assert result.returncode == 0
|
||||||
|
assert "report" in result.stdout
|
||||||
|
assert "validate" in result.stdout
|
||||||
|
assert "hft" in result.stdout
|
||||||
|
assert "tick" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
class TestTickViz:
|
||||||
|
def test_empty_data_safe(self):
|
||||||
|
from backtests.tick_viz import tick_dashboard
|
||||||
|
l2_df = pd.DataFrame()
|
||||||
|
trade_df = pd.DataFrame()
|
||||||
|
figs = tick_dashboard(l2_df, trade_df)
|
||||||
|
assert len(figs) == 7 # 7 without tick_result, 8 with it
|
||||||
|
|
||||||
|
def test_price_plot_with_data(self):
|
||||||
|
from backtests.tick_viz import plot_price_with_trades
|
||||||
|
l2_df = pd.DataFrame({
|
||||||
|
"ts": [0.0, 1.0, 2.0], "mid": [100.0, 101.0, 100.5],
|
||||||
|
})
|
||||||
|
trade_df = pd.DataFrame({
|
||||||
|
"ts": [0.5, 1.5], "price": [100.2, 100.8],
|
||||||
|
"aggressor": ["buy", "sell"],
|
||||||
|
})
|
||||||
|
fig = plot_price_with_trades(l2_df, trade_df)
|
||||||
|
assert fig is not None
|
||||||
|
|
||||||
|
def test_spread_dynamics(self):
|
||||||
|
from backtests.tick_viz import plot_spread_dynamics
|
||||||
|
l2_df = pd.DataFrame({
|
||||||
|
"ts": [0.0, 1.0, 2.0], "spread_bps": [1.0, 1.5, 1.2],
|
||||||
|
"mid": [100.0, 101.0, 100.5],
|
||||||
|
})
|
||||||
|
fig = plot_spread_dynamics(l2_df)
|
||||||
|
assert fig is not None
|
||||||
|
|
||||||
|
def test_markout_empty_safe(self):
|
||||||
|
from backtests.tick_viz import plot_markout_curves
|
||||||
|
fig = plot_markout_curves(pd.DataFrame(), pd.DataFrame())
|
||||||
|
assert fig is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestDuckDBLoader:
|
||||||
|
def test_loader_creation(self):
|
||||||
|
from data.duckdb_load import DuckDBLoader
|
||||||
|
loader = DuckDBLoader(db_path="/tmp/test_ftdt.db")
|
||||||
|
try:
|
||||||
|
import duckdb
|
||||||
|
loader.init_schema()
|
||||||
|
loader.create_rollups()
|
||||||
|
s = loader.stats()
|
||||||
|
assert s["l2_snapshots"] == 0
|
||||||
|
assert s["trades"] == 0
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
loader.close()
|
||||||
|
import os
|
||||||
|
try:
|
||||||
|
os.unlink("/tmp/test_ftdt.db")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_stats_on_empty_db(self):
|
||||||
|
from data.duckdb_load import DuckDBLoader
|
||||||
|
loader = DuckDBLoader(db_path="/tmp/test_ftdt2.db")
|
||||||
|
try:
|
||||||
|
import duckdb
|
||||||
|
loader.init_schema()
|
||||||
|
s = loader.stats()
|
||||||
|
assert isinstance(s, dict)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
loader.close()
|
||||||
|
import os
|
||||||
|
try:
|
||||||
|
os.unlink("/tmp/test_ftdt2.db")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_query_markouts_on_empty(self):
|
||||||
|
from data.duckdb_load import DuckDBLoader
|
||||||
|
loader = DuckDBLoader(db_path="/tmp/test_ftdt3.db")
|
||||||
|
try:
|
||||||
|
import duckdb
|
||||||
|
loader.init_schema()
|
||||||
|
result = loader.query_markouts("BTC", "2026-01-01", "2026-01-02")
|
||||||
|
assert isinstance(result, dict)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
loader.close()
|
||||||
|
import os
|
||||||
|
try:
|
||||||
|
os.unlink("/tmp/test_ftdt3.db")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
Reference in New Issue
Block a user