20ee340cef
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).
751 lines
29 KiB
Python
751 lines
29 KiB
Python
"""
|
|
VectorBT backtest runner — fast vectorized backtesting on Hyperliquid candle data.
|
|
|
|
Fetches real candles from Hyperliquid, converts to signals, and runs
|
|
through VectorBT's Portfolio simulator for instant results.
|
|
|
|
Supports parameter sweeps, walk-forward optimization, and full metrics.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
import vectorbt as vbt
|
|
|
|
sys_path = str(Path(__file__).resolve().parent.parent)
|
|
if sys_path not in __import__("sys").path:
|
|
__import__("sys").path.insert(0, sys_path)
|
|
|
|
from framework.data import HyperliquidDataProvider, INTERVAL_MAP
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
RESULTS_DIR = Path(__file__).resolve().parent / "results"
|
|
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# Strategy signal generators
|
|
# ═══════════════════════════════════════════════════════════════
|
|
|
|
def _generate_signals(strategy: str, data: dict[str, pd.DataFrame],
|
|
params: dict | None = None) -> tuple[pd.Series, pd.Series]:
|
|
"""Generate entry/exit signals for a strategy from candle data.
|
|
|
|
Returns (entries, exits) as boolean pandas Series.
|
|
Each strategy uses the primary coin's close prices.
|
|
|
|
Params:
|
|
grid_mm: grid_levels, spacing_bps, rebalance_every
|
|
as_mm: gamma, k, tau, min_hold, max_hold, profit_target, stop_loss
|
|
obi: lookback, entry_threshold, exit_threshold
|
|
pairs: z_entry, z_exit, lookback
|
|
"""
|
|
if params is None:
|
|
params = {}
|
|
main_coin = {"pairs": "ETH", "hurst_vpin": "BTC", "as_mm": "BTC",
|
|
"obi": "BTC", "grid_mm": "BTC", "composite_mm": "BTC",
|
|
"iceberg": "BTC", "funding_arb": "BTC", "momentum": "BTC",
|
|
"mean_rev": "BTC"}.get(strategy, "BTC")
|
|
df = data.get(main_coin)
|
|
if df is None or df.empty:
|
|
return pd.Series(dtype=bool), pd.Series(dtype=bool)
|
|
|
|
close = df["close"]
|
|
entries = pd.Series(False, index=close.index)
|
|
exits = pd.Series(False, index=close.index)
|
|
|
|
if strategy == "pairs":
|
|
btc_df = data.get("BTC")
|
|
if btc_df is not None and not btc_df.empty:
|
|
ratio = btc_df["close"] / close
|
|
mu = ratio.rolling(20).mean()
|
|
std = ratio.rolling(20).std()
|
|
z = (ratio - mu) / std
|
|
entries = z < -1.5
|
|
exits = z.shift(1) >= -0.5
|
|
|
|
elif strategy == "hurst_vpin":
|
|
# Hurst exponent on returns
|
|
returns = close.pct_change().dropna()
|
|
hurst = returns.rolling(64).apply(_hurst_rs_series, raw=False)
|
|
|
|
# VPIN proxy from candle volumes: buy_vol if close > open, sell_vol if close < open
|
|
buy_vol = df["volume"].where(df["close"] > df["open"], 0.0)
|
|
sell_vol = df["volume"].where(df["close"] < df["open"], 0.0)
|
|
flat_mask = df["close"] == df["open"]
|
|
buy_vol = buy_vol + df["volume"].where(flat_mask, 0.0) * 0.5
|
|
sell_vol = sell_vol + df["volume"].where(flat_mask, 0.0) * 0.5
|
|
|
|
vpin_window = 50
|
|
buy_rolling = buy_vol.rolling(vpin_window).sum()
|
|
sell_rolling = sell_vol.rolling(vpin_window).sum()
|
|
total_rolling = buy_rolling + sell_rolling
|
|
vpin = abs(buy_rolling - sell_rolling) / total_rolling.replace(0, 1)
|
|
direction = (buy_rolling - sell_rolling) / total_rolling.replace(0, 1)
|
|
|
|
# Entry: trending + high VPIN + directional
|
|
entries = (hurst > 0.55) & (vpin > 0.25) & (direction.abs() > 0.05)
|
|
# Exit: Hurst fades or direction flips
|
|
exits = (hurst.shift(1) < 0.45) | ((direction.shift(1) > 0.3) & (direction < -0.1)) | ((direction.shift(1) < -0.3) & (direction > 0.1))
|
|
|
|
elif strategy == "as_mm":
|
|
# A-S simulation: virtual orderbook from candles with inventory tracking
|
|
mid = close
|
|
sigma = close.pct_change().rolling(20).std() * np.sqrt(365 * 24)
|
|
gamma = 0.1
|
|
tau_sess = 1.0 / 24 # 1 hour as fraction of session
|
|
|
|
inventory = 0.0
|
|
entries = pd.Series(False, index=close.index)
|
|
exits = pd.Series(False, index=close.index)
|
|
in_trade = False
|
|
bars_held = 0
|
|
entry_px = 0.0
|
|
min_hold = 3 # Hold at least 4 bars
|
|
entry_zones = 0 # Count of bars where reservation was favorable
|
|
|
|
for i in range(20, len(close)):
|
|
s = sigma.iloc[i]
|
|
sigma_sq = s * s if s > 0 else 0.0001
|
|
reservation = mid.iloc[i] - inventory * gamma * sigma_sq * tau_sess
|
|
bid_px = df["low"].iloc[i]
|
|
ask_px = df["high"].iloc[i]
|
|
|
|
if not in_trade:
|
|
if reservation > bid_px:
|
|
entry_zones += 1
|
|
elif reservation < ask_px:
|
|
entry_zones += 1
|
|
else:
|
|
entry_zones = max(0, entry_zones - 1)
|
|
|
|
# Enter after 2 consecutive favorable zones
|
|
if entry_zones >= 3:
|
|
entries.iloc[i] = True
|
|
in_trade = True
|
|
entry_px = mid.iloc[i]
|
|
inventory += 0.001 if reservation > bid_px else -0.001
|
|
bars_held = 0
|
|
entry_zones = 0
|
|
else:
|
|
bars_held += 1
|
|
pnl_pct = (mid.iloc[i] - entry_px) / entry_px if entry_px > 0 else 0
|
|
if inventory > 0:
|
|
pnl_pct = pnl_pct
|
|
else:
|
|
pnl_pct = -pnl_pct
|
|
|
|
# Exit: held max bars or profit captured or stop-loss
|
|
if bars_held >= 5 or pnl_pct > 0.002 or pnl_pct < -0.01:
|
|
exits.iloc[i] = True
|
|
in_trade = False
|
|
inventory = 0.0
|
|
|
|
elif strategy == "grid_mm":
|
|
# Grid MM: simulate grid fills from candle high/low ranges
|
|
grid_levels = params.get("grid_levels", 10)
|
|
grid_spacing_pct = params.get("spacing_bps", 10) / 10000 # bps → decimal
|
|
rebalance = params.get("rebalance_every", 20)
|
|
|
|
entries = pd.Series(False, index=close.index)
|
|
exits = pd.Series(False, index=close.index)
|
|
fills_accumulated = 0
|
|
|
|
for i in range(1, len(close)):
|
|
mid = close.iloc[i]
|
|
high = df["high"].iloc[i]
|
|
low = df["low"].iloc[i]
|
|
fills_this_bar = 0
|
|
for level in range(1, grid_levels + 1):
|
|
buy_px = mid * (1 - level * grid_spacing_pct)
|
|
sell_px = mid * (1 + level * grid_spacing_pct)
|
|
if low <= buy_px:
|
|
fills_this_bar += 1
|
|
if high >= sell_px:
|
|
fills_this_bar += 1
|
|
if fills_this_bar > 0:
|
|
fills_accumulated += fills_this_bar
|
|
entries.iloc[i] = True
|
|
# Exit after rebalance period
|
|
if i + rebalance < len(close):
|
|
exits.iloc[i + rebalance] = True
|
|
|
|
elif strategy == "composite_mm":
|
|
# Composite: weighted ensemble of OBI + Hurst
|
|
buy_vol = df["volume"].where(df["close"] > df["open"], 0.0)
|
|
sell_vol = df["volume"].where(df["close"] < df["open"], 0.0)
|
|
flat_mask = df["close"] == df["open"]
|
|
buy_vol = buy_vol + df["volume"].where(flat_mask, 0.0) * 0.5
|
|
sell_vol = sell_vol + df["volume"].where(flat_mask, 0.0) * 0.5
|
|
|
|
lookback = 20
|
|
buy_rolling = buy_vol.rolling(lookback).sum()
|
|
sell_rolling = sell_vol.rolling(lookback).sum()
|
|
total_rolling = buy_rolling + sell_rolling
|
|
obi_score = (buy_rolling - sell_rolling) / total_rolling.replace(0, 1)
|
|
|
|
returns = close.pct_change().dropna()
|
|
hurst = returns.rolling(64).apply(_hurst_rs_series, raw=False)
|
|
hurst_score = hurst.fillna(0.5) - 0.5
|
|
|
|
score = 0.3 * obi_score.fillna(0) + 0.3 * (hurst_score.fillna(0) / 0.3) + 0.4 * (-close.pct_change().rolling(10).sum().fillna(0) / 0.05)
|
|
|
|
entries = score.abs() > 0.5
|
|
exits = score.abs() < 0.3
|
|
|
|
elif strategy == "momentum":
|
|
sma = close.rolling(20).mean()
|
|
std = close.rolling(20).std()
|
|
upper = sma + 2 * std
|
|
lower = sma - 2 * std
|
|
entries = (close > upper) | (close < lower)
|
|
exits = (close.shift(1) > sma.shift(1)) & (close < sma)
|
|
|
|
elif strategy in ("mean_rev",):
|
|
sma = close.rolling(20).mean()
|
|
std = close.rolling(20).std()
|
|
entries = (close < sma - 1.0 * std) | (close > sma + 1.0 * std)
|
|
exits = abs((close - sma) / std) < 0.3
|
|
|
|
elif strategy == "obi":
|
|
# Volume-based order book imbalance proxy
|
|
# Buy volume = volume where close > open, sell vol = volume where close < open
|
|
buy_vol = df["volume"].where(df["close"] > df["open"], 0.0)
|
|
sell_vol = df["volume"].where(df["close"] < df["open"], 0.0)
|
|
# Flat bars: split volume evenly
|
|
flat_mask = df["close"] == df["open"]
|
|
buy_vol_adj = buy_vol + df["volume"].where(flat_mask, 0.0) * 0.5
|
|
sell_vol_adj = sell_vol + df["volume"].where(flat_mask, 0.0) * 0.5
|
|
|
|
lookback = 20
|
|
entry_threshold = 0.35
|
|
exit_threshold = 0.10
|
|
|
|
buy_rolling = buy_vol_adj.rolling(lookback).sum()
|
|
sell_rolling = sell_vol_adj.rolling(lookback).sum()
|
|
total_rolling = buy_rolling + sell_rolling
|
|
|
|
imbalance = (buy_rolling - sell_rolling) / total_rolling.replace(0, 1)
|
|
imbalance = imbalance.fillna(0)
|
|
|
|
entries = (imbalance > entry_threshold) | (imbalance < -entry_threshold)
|
|
# Exit when imbalance crosses back toward zero
|
|
exits = ((imbalance.shift(1) > exit_threshold) & (imbalance < exit_threshold)) | \
|
|
((imbalance.shift(1) < -exit_threshold) & (imbalance > -exit_threshold))
|
|
exits = exits.fillna(False)
|
|
|
|
elif strategy == "iceberg":
|
|
# Volume spike detection: large-volume bars signal whale activity
|
|
avg_vol = df["volume"].rolling(20).mean()
|
|
vol_spike = df["volume"] > avg_vol * 1.15
|
|
|
|
# Require at least 2 consecutive same-direction spikes
|
|
buy_spike = vol_spike & (df["close"] > df["open"])
|
|
sell_spike = vol_spike & (df["close"] < df["open"])
|
|
|
|
buy_consec = buy_spike.rolling(3).sum() >= 2
|
|
sell_consec = sell_spike.rolling(3).sum() >= 2
|
|
|
|
entries = (buy_consec | sell_consec).astype(bool)
|
|
# Exit when volume spike subsides (not fixed 5-bar hold)
|
|
exits = entries.shift(3).fillna(False).astype(bool) & ~entries
|
|
|
|
elif strategy == "funding_arb":
|
|
entries[:] = False
|
|
exits[:] = False
|
|
|
|
entries.fillna(False, inplace=True)
|
|
exits.fillna(False, inplace=True)
|
|
return entries, exits
|
|
|
|
|
|
def _hurst_rs_series(returns_series: pd.Series) -> float:
|
|
"""Hurst exponent via R/S on a window of log returns."""
|
|
rets = returns_series.dropna().values
|
|
if len(rets) < 32:
|
|
return 0.5
|
|
n = len(rets)
|
|
max_lag = min(n // 2, 64)
|
|
lags = []
|
|
rs_vals = []
|
|
for lag in range(4, max_lag):
|
|
segs = n // lag
|
|
if segs < 2:
|
|
continue
|
|
vals = []
|
|
for s in range(segs):
|
|
seg = rets[s * lag:(s + 1) * lag]
|
|
mean = np.mean(seg)
|
|
dev = np.cumsum(seg - mean)
|
|
r = float(np.max(dev) - np.min(dev))
|
|
sd = float(np.std(seg, ddof=1))
|
|
if sd > 1e-12:
|
|
vals.append(r / sd)
|
|
if vals:
|
|
lags.append(np.log(lag))
|
|
rs_vals.append(np.log(np.mean(vals)))
|
|
if len(lags) < 4:
|
|
return 0.5
|
|
slope = float(np.polyfit(lags, rs_vals, 1)[0])
|
|
return max(0.2, min(0.8, slope))
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# VBT Backtest Runner
|
|
# ═══════════════════════════════════════════════════════════════
|
|
|
|
class VBTBacktestRunner:
|
|
"""VectorBT-powered backtesting on Hyperliquid candle data."""
|
|
|
|
def __init__(self, fee_rate: float | None = None,
|
|
vip_tier: int = 0, staking_tier: str = "none", maker_rebate_tier: int = 0):
|
|
from config.fee_tiers import get_perp_fees, get_strategy_fee_model
|
|
self._provider = HyperliquidDataProvider()
|
|
self._vip_tier = vip_tier
|
|
self._staking_tier = staking_tier
|
|
self._maker_rebate_tier = maker_rebate_tier
|
|
self._fee_rate = fee_rate if fee_rate is not None else get_perp_fees(vip_tier, staking_tier, "taker", maker_rebate_tier)
|
|
self._maker_rate = get_perp_fees(vip_tier, staking_tier, "maker", maker_rebate_tier)
|
|
|
|
def run_strategy(
|
|
self,
|
|
strategy: str = "pairs",
|
|
interval: str = "1h",
|
|
testnet: bool = False,
|
|
limit: int = 5000,
|
|
start_ms: int | None = None,
|
|
end_ms: int | None = None,
|
|
params: dict | None = None,
|
|
) -> dict[str, Any] | None:
|
|
"""Fetch candles, generate signals, run VBT backtest, return metrics."""
|
|
coins = self._get_coins(strategy)
|
|
provider = HyperliquidDataProvider(testnet=testnet)
|
|
|
|
data = {}
|
|
for coin in coins:
|
|
try:
|
|
df = provider.fetch_candles(coin, interval=interval, limit=limit, start_ms=start_ms, end_ms=end_ms)
|
|
if not df.empty:
|
|
data[coin] = df
|
|
except Exception as e:
|
|
logger.warning("Failed to fetch %s: %s", coin, e)
|
|
|
|
if not data:
|
|
logger.error("No candle data fetched for strategy: %s", strategy)
|
|
return None
|
|
|
|
entries, exits = _generate_signals(strategy, data, params)
|
|
|
|
primary = list(data.values())[0]
|
|
close = primary["close"]
|
|
|
|
# Align indices
|
|
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)
|
|
|
|
if entries.sum() == 0:
|
|
logger.warning("No signals generated for %s", strategy)
|
|
return self._empty_result(strategy, interval)
|
|
|
|
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 as e:
|
|
logger.error("VBT portfolio error: %s", e)
|
|
return self._empty_result(strategy, interval)
|
|
|
|
stats = pf.stats()
|
|
result = self._extract_metrics(pf, stats, strategy, interval, len(close), params)
|
|
|
|
# Save equity curve
|
|
eq_curve = pf.value().dropna()
|
|
result["equity_curve"] = [
|
|
{"t": idx.isoformat(), "v": round(float(v), 2)}
|
|
for idx, v in eq_curve.to_dict().items()
|
|
]
|
|
result["total_trades"] = int(pf.trades.count())
|
|
result["generated_at"] = datetime.now(timezone.utc).isoformat()
|
|
|
|
return result
|
|
|
|
def param_sweep(
|
|
self,
|
|
strategy: str = "pairs",
|
|
param_grid: dict[str, list] | None = None,
|
|
) -> pd.DataFrame | None:
|
|
"""Grid search over parameters using VBT."""
|
|
|
|
coins = self._get_coins(strategy)
|
|
data = {}
|
|
for coin in coins:
|
|
df = self._provider.fetch_candles(coin, interval="1h", limit=2000)
|
|
if not df.empty:
|
|
data[coin] = df
|
|
|
|
if not data:
|
|
return None
|
|
|
|
primary = list(data.values())[0]
|
|
close = primary["close"]
|
|
|
|
if param_grid is None:
|
|
param_grid = {
|
|
"window": [10, 20, 30, 50],
|
|
"threshold": [1.0, 1.5, 2.0, 2.5],
|
|
}
|
|
|
|
results_rows = []
|
|
for window in param_grid.get("window", [20]):
|
|
for threshold in param_grid.get("threshold", [1.5]):
|
|
entries, exits = _generate_signals_sweep(strategy, data, window, threshold)
|
|
try:
|
|
pf = vbt.Portfolio.from_signals(
|
|
close=close,
|
|
entries=entries,
|
|
exits=exits,
|
|
fees=self._fee_rate,
|
|
init_cash=10000.0,
|
|
)
|
|
stats = pf.stats()
|
|
results_rows.append({
|
|
"window": window,
|
|
"threshold": threshold,
|
|
"sharpe": stats.get("Sharpe Ratio", 0),
|
|
"total_return": stats.get("Total Return [%]", 0),
|
|
"max_drawdown": stats.get("Max Drawdown [%]", 0),
|
|
"win_rate": stats.get("Win Rate [%]", 0),
|
|
"trades": int(pf.trades.count()),
|
|
})
|
|
except Exception:
|
|
pass
|
|
|
|
return pd.DataFrame(results_rows) if results_rows else None
|
|
|
|
def run_benchmark(
|
|
self,
|
|
coin: str = "BTC",
|
|
interval: str = "1h",
|
|
testnet: bool = False,
|
|
limit: int = 5000,
|
|
start_ms: int | None = None,
|
|
end_ms: int | None = None,
|
|
) -> dict[str, Any] | None:
|
|
"""Run a simple buy-and-hold benchmark using VBT."""
|
|
provider = HyperliquidDataProvider(testnet=testnet)
|
|
df = provider.fetch_candles(coin, interval=interval, limit=limit,
|
|
start_ms=start_ms, end_ms=end_ms)
|
|
if df.empty:
|
|
return None
|
|
|
|
close = df["close"]
|
|
if len(close) < 2:
|
|
return None
|
|
|
|
entries = pd.Series(False, index=close.index)
|
|
entries.iloc[0] = True
|
|
exits = pd.Series(False, index=close.index)
|
|
exits.iloc[-1] = True
|
|
|
|
try:
|
|
pf = vbt.Portfolio.from_signals(
|
|
close=close,
|
|
entries=entries,
|
|
exits=exits,
|
|
fees=self._fee_rate,
|
|
slippage=0.001,
|
|
freq=INTERVAL_MAP.get(interval, "1h"),
|
|
init_cash=10000.0,
|
|
)
|
|
except Exception:
|
|
return None
|
|
|
|
total_return = float(pf.stats().get("Total Return [%]", 0))
|
|
bm_sharpe = float(pf.stats().get("Sharpe Ratio", 0))
|
|
|
|
return {
|
|
"strategy": "buy_and_hold",
|
|
"coin": coin.upper(),
|
|
"interval": interval,
|
|
"n_bars": len(close),
|
|
"start_equity": 10000.0,
|
|
"end_equity": round(float(pf.value().iloc[-1]), 2),
|
|
"total_return_pct": round(total_return, 2),
|
|
"sharpe": round(bm_sharpe, 3),
|
|
"close": close,
|
|
"pf": pf,
|
|
}
|
|
|
|
def validate(
|
|
self,
|
|
result: dict,
|
|
pf,
|
|
entries: pd.Series,
|
|
exits: pd.Series,
|
|
close: pd.Series,
|
|
):
|
|
"""Run validation checks on a backtest result."""
|
|
from backtests.vbt_validator import VBTValidator
|
|
|
|
validator = VBTValidator(min_trades=10)
|
|
report = validator.validate(
|
|
entries=entries,
|
|
exits=exits,
|
|
close=close,
|
|
pf=pf,
|
|
trades=result.get("trades", []),
|
|
strategy=result.get("strategy", "unknown"),
|
|
interval=result.get("interval", "unknown"),
|
|
)
|
|
return report
|
|
|
|
def run_with_report(
|
|
self,
|
|
strategy: str = "pairs",
|
|
interval: str = "1h",
|
|
testnet: bool = False,
|
|
limit: int = 5000,
|
|
params: dict | None = None,
|
|
output_dir: str = "backtests/reports",
|
|
) -> dict | None:
|
|
"""End-to-end: fetch, backtest, validate, visualize, save report."""
|
|
result = self.run_strategy(
|
|
strategy=strategy, interval=interval, testnet=testnet,
|
|
limit=limit, params=params,
|
|
)
|
|
if result is None:
|
|
return None
|
|
|
|
data = {}
|
|
coins = self._get_coins(strategy)
|
|
provider = HyperliquidDataProvider(testnet=testnet)
|
|
for coin in coins:
|
|
df = provider.fetch_candles(coin, interval=interval, limit=limit)
|
|
if not df.empty:
|
|
data[coin] = df
|
|
|
|
entries, exits = _generate_signals(strategy, data, params)
|
|
primary = list(data.values())[0]
|
|
close = primary["close"]
|
|
common_idx = entries.index.intersection(close.index)
|
|
entries = entries.reindex(common_idx).fillna(False)
|
|
exits = exits.reindex(common_idx).fillna(False)
|
|
close = close.reindex(common_idx)
|
|
|
|
try:
|
|
from config.fee_tiers import get_strategy_fee_model
|
|
fee_model = get_strategy_fee_model(strategy)
|
|
effective_fee = self._maker_rate if fee_model == "maker" else self._fee_rate
|
|
pf = vbt.Portfolio.from_signals(
|
|
close=close, entries=entries, exits=exits,
|
|
fees=effective_fee, slippage=0.001,
|
|
freq=INTERVAL_MAP.get(interval, "1h"),
|
|
init_cash=10000.0,
|
|
)
|
|
except Exception:
|
|
pf = None
|
|
|
|
bm_result = self.run_benchmark(coin=self._get_coins(strategy)[0],
|
|
interval=interval, testnet=testnet, limit=limit)
|
|
benchmark_close = bm_result.get("close") if bm_result else None
|
|
|
|
validation_report = None
|
|
if pf is not None:
|
|
validation_report = self.validate(result, pf, entries, exits, close)
|
|
|
|
from backtests.vbt_viz import VBTVisualizer
|
|
viz = VBTVisualizer(output_dir=output_dir)
|
|
viz.save_dashboard(
|
|
pf=pf, close=close, entries=entries, exits=exits,
|
|
benchmark_close=benchmark_close,
|
|
strategy=strategy, interval=interval,
|
|
)
|
|
|
|
result["validation"] = validation_report.summary() if validation_report else "N/A"
|
|
|
|
if validation_report:
|
|
result["validation_checks"] = validation_report.checks
|
|
result["validation_errors"] = validation_report.errors
|
|
result["validation_warnings"] = validation_report.warnings
|
|
|
|
logger.info("Report generated for %s (%s) — saved to %s",
|
|
strategy, interval, output_dir)
|
|
|
|
return result
|
|
|
|
# ── Helpers ─────────────────────────────────────────────────
|
|
|
|
def _get_coins(self, strategy: str) -> list[str]:
|
|
coin_map = {
|
|
"pairs": ["BTC", "ETH"],
|
|
"hurst_vpin": ["BTC"],
|
|
"as_mm": ["BTC"],
|
|
"obi": ["BTC"],
|
|
"grid_mm": ["BTC"],
|
|
"composite_mm": ["BTC"],
|
|
"iceberg": ["BTC"],
|
|
"funding_arb": ["BTC"],
|
|
"momentum": ["BTC"],
|
|
"mean_rev": ["BTC"],
|
|
}
|
|
return coin_map.get(strategy, ["BTC"])
|
|
|
|
def _extract_metrics(self, pf, stats, strategy, interval, n_bars,
|
|
runtime_params=None) -> dict:
|
|
from config.fee_tiers import compute_trade_fees, get_strategy_fee_model
|
|
|
|
main_coin = self._get_coins(strategy)[0]
|
|
asset = main_coin if main_coin else "BTC"
|
|
fee_model = get_strategy_fee_model(strategy)
|
|
vip = self._vip_tier
|
|
staking = self._staking_tier
|
|
rebate = self._maker_rebate_tier
|
|
|
|
# Summary fee info from compute_trade_fees at nominal size
|
|
fee_info = compute_trade_fees("BUY", 0.001, 100000.0, 100000.0,
|
|
vip_tier=vip, staking_tier=staking,
|
|
fee_model=fee_model, maker_rebate_tier=rebate)
|
|
|
|
trades = []
|
|
try:
|
|
trade_records = pf.trades.records_readable
|
|
for _, t in trade_records.iterrows():
|
|
side = "BUY" if str(t.get("Direction", "")) == "Long" else "SELL"
|
|
entry_px = round(float(t.get("Avg Entry Price", 0)), 2)
|
|
exit_px = round(float(t.get("Avg Exit Price", 0)), 2)
|
|
size = round(float(t.get("Size", 0)), 6)
|
|
|
|
# Compute actual per-trade fees using HL schedule
|
|
ft = compute_trade_fees(
|
|
side=side,
|
|
size=size,
|
|
entry_px=entry_px,
|
|
exit_px=exit_px,
|
|
vip_tier=vip,
|
|
staking_tier=staking,
|
|
fee_model=fee_model,
|
|
maker_rebate_tier=rebate,
|
|
)
|
|
|
|
pnl_gross_raw = float(t.get("PnL", 0))
|
|
pnl_net = round(pnl_gross_raw - ft["total_fee"], 4)
|
|
|
|
trades.append({
|
|
"time": str(t.get("Exit Timestamp", t.get("Entry Timestamp", "")))[:19],
|
|
"side": side,
|
|
"asset": asset,
|
|
"size": size,
|
|
"entry_px": entry_px,
|
|
"exit_px": exit_px,
|
|
"pnl_gross": round(pnl_gross_raw, 4),
|
|
"pnl_net": pnl_net,
|
|
"fee": ft["total_fee"],
|
|
"fee_rate_pct": fee_info["effective_rate_pct"],
|
|
"return_pct": round(float(t.get("Return", 0)) * 100, 3),
|
|
"duration": str(t.get("Duration", "")),
|
|
})
|
|
except Exception:
|
|
pass
|
|
|
|
return {
|
|
"strategy": strategy,
|
|
"interval": interval,
|
|
"n_bars": n_bars,
|
|
"start_equity": 10000.0,
|
|
"end_equity": round(float(pf.value().iloc[-1]), 2),
|
|
"total_return_pct": round(float(stats.get("Total Return [%]", 0)), 2),
|
|
"pnl": round(float(pf.value().iloc[-1]) - 10000, 2),
|
|
"sharpe": round(float(stats.get("Sharpe Ratio", 0)), 3),
|
|
"sortino": round(float(stats.get("Sortino Ratio", 0)), 3),
|
|
"max_drawdown_pct": round(float(stats.get("Max Drawdown [%]", 0)), 2),
|
|
"win_rate": round(float(stats.get("Win Rate [%]", 0)) / 100, 3),
|
|
"profit_factor": round(float(stats.get("Profit Factor", 0)), 3),
|
|
"expectancy": round(float(stats.get("Expectancy", 0)), 3),
|
|
"trades": trades,
|
|
"params": _strategy_params(strategy, runtime_params),
|
|
"fee_info": fee_info,
|
|
}
|
|
|
|
def _empty_result(self, strategy: str, interval: str) -> dict:
|
|
return {
|
|
"strategy": strategy,
|
|
"interval": interval,
|
|
"n_bars": 0,
|
|
"start_equity": 10000.0,
|
|
"end_equity": 10000.0,
|
|
"total_return_pct": 0.0,
|
|
"pnl": 0.0,
|
|
"sharpe": 0.0,
|
|
"sortino": 0.0,
|
|
"max_drawdown_pct": 0.0,
|
|
"win_rate": 0.0,
|
|
"total_trades": 0,
|
|
"trades": [],
|
|
"params": _strategy_params(strategy),
|
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
|
|
|
|
def _strategy_params(strategy: str, runtime_params: dict | None = None) -> dict:
|
|
"""Return the key parameters/coefficients for a strategy."""
|
|
base = {
|
|
"pairs": {"z_entry": 1.5, "z_exit": 0.5, "lookback": 20, "type": "Stat Arb"},
|
|
"hurst_vpin": {"hurst_entry": 0.55, "hurst_exit": 0.45, "vpin_threshold": 0.25, "vpin_window": 50, "hurst_window": 64, "type": "Directional"},
|
|
"as_mm": {"gamma": 0.1, "sigma_dynamic": True, "inventory_skew": True, "type": "Market Making"},
|
|
"obi": {"obi_lookback": 20, "obi_entry": 0.35, "obi_exit": 0.10, "type": "Reversal"},
|
|
"grid_mm": {"grid_levels": 10, "spacing_bps": 10, "rebalance_every": 20, "type": "Market Making"},
|
|
"composite_mm": {"obi_weight": 0.30, "as_weight": 0.40, "hurst_weight": 0.30, "entry_score": 0.50, "type": "Ensemble"},
|
|
"iceberg": {"vol_mult": 1.8, "min_consec": 3, "max_hold": 8, "type": "Momentum"},
|
|
"momentum": {"bollinger_window": 20, "bollinger_std": 2.0, "type": "Momentum"},
|
|
"mean_rev": {"vwap_window": 20, "deviation": 1.0, "type": "Reversal"},
|
|
}
|
|
result = base.get(strategy, {"type": "Unknown"})
|
|
if runtime_params:
|
|
result.update({k: v for k, v in runtime_params.items() if k in result})
|
|
return result
|
|
|
|
|
|
def _generate_signals_sweep(
|
|
strategy: str,
|
|
data: dict[str, pd.DataFrame],
|
|
window: int,
|
|
threshold: float,
|
|
) -> tuple[pd.Series, pd.Series]:
|
|
"""Variant of signal generator for parameter sweeps with configurable params."""
|
|
main_coin = {"pairs": "ETH", "hurst_vpin": "BTC", "as_mm": "BTC"}.get(strategy, "BTC")
|
|
df = data.get(main_coin)
|
|
if df is None or df.empty:
|
|
return pd.Series(dtype=bool), pd.Series(dtype=bool)
|
|
|
|
close = df["close"]
|
|
entries = pd.Series(False, index=close.index)
|
|
exits = pd.Series(False, index=close.index)
|
|
|
|
sma = close.rolling(window).mean()
|
|
std = close.rolling(window).std()
|
|
entries = (close < sma - threshold * std) | (close > sma + threshold * std)
|
|
exits = abs((close - sma) / (std + 1e-10)) < 0.3 * threshold
|
|
|
|
entries.fillna(False, inplace=True)
|
|
exits.fillna(False, inplace=True)
|
|
return entries, exits
|