Files
ftdt-quant-lab/backtests/vbt_runner.py
T
ramseshk f5ffe4baee feat: NautilusTrader + VectorBT unified framework for Hyperliquid
Add complete framework for testing and deploying quant strategies:

Framework (framework/):
- HyperliquidInstrumentCatalog: loads perps as NT CryptoPerpetual
- HyperliquidDataProvider: real candle/orderbook/mark-price data
- HyperliquidExecutionProvider: live + PaperExecutionProvider: simulated
- BaseHlStrategy: shared NT strategy lifecycle with signal library
- StrategyConfig: YAML-based parameter management
- DeployOrchestrator: CLI for backtest -> paper -> live pipeline

Backtesting (backtests/):
- VBTBacktestRunner: VectorBT vectorized backtests on real HL candles
- NTBacktestRunner: NautilusTrader event-driven backtest engine

NT Strategy ports (strategies/nt/):
- PairsTradingNT: BTC/ETH ratio Z-score mean reversion
- HurstVPINNT: Hurst exponent regime + VPIN flow imbalance
- ASMarketMakingNT: Avellaneda-Stoikov stochastic control MM

E2E verified: real HL candles fetch, VectorBT backtest (Sharpe 5.2
on Hurst/VPIN), instrument catalog, deploy CLI --list, strategy signals.
Existing live/node.py and paper_trader.py unchanged.
2026-08-06 17:23:49 +08:00

336 lines
12 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]) -> 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.
"""
main_coin = {"pairs": "ETH", "hurst_vpin": "BTC", "as_mm": "BTC",
"obi": "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":
returns = close.pct_change().dropna()
hurst = returns.rolling(64).apply(_hurst_rs_series, raw=False)
entries = hurst > 0.55
exits = hurst.shift(1) < 0.45
elif strategy == "as_mm":
spread = (df["high"] - df["low"]) / df["close"]
vol = close.pct_change().rolling(20).std()
favorable = (spread > spread.rolling(100).mean()) & (vol < 0.02)
entries = favorable
exits = favorable.shift(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", "obi"):
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 == "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 = 0.0005):
self._provider = HyperliquidDataProvider()
self._fee_rate = fee_rate
def run_strategy(
self,
strategy: str = "pairs",
interval: str = "1h",
testnet: bool = False,
limit: int = 5000,
) -> 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)
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)
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:
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 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))
# 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
# ── Helpers ─────────────────────────────────────────────────
def _get_coins(self, strategy: str) -> list[str]:
coin_map = {
"pairs": ["BTC", "ETH"],
"hurst_vpin": ["BTC"],
"as_mm": ["BTC"],
"obi": ["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) -> dict:
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),
}
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,
"generated_at": datetime.now(timezone.utc).isoformat(),
}
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