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