""" 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