Files
ftdt-quant-lab/backtests/vbt_runner.py
T
ramseshk 3606e7f92e feat: proper Grid MM, Composite MM, Hurst/VPIN, Iceberg, A-S strategies
New strategies (strategies/nt/):
- GridMMNT: symmetric limit order grid around mid-price, captures spread from
  oscillation. Simulates fills from candle high/low. Rebuilds grid every 20 bars.
- CompositeMMNT: weighted ensemble of OBI (30%) + A-S inventory skew (40%) +
  Hurst/VPIN (30%). Votes: +1 long, -1 short, 0 neutral. Entry |score| > 0.5.
- IcebergNT: volume spike detection for whale accumulation. Dual-mode:
  candle proxy (volume > avg*2.5, >= 3 consecutive same-direction) and
  L2 wall detection (single level > avg*3). Exit on stop-loss/time/spike-fade.

Fixed strategies:
- Hurst/VPIN VBT: added proper VPIN proxy from candle volume (buy_vol when close
  > open, sell_vol when close < open). 50-bar rolling VPIN window. Signal:
  H>0.55 AND VPIN>0.25 AND |direction|>0.05. Exit: H<0.45 or direction flips.
- Hurst/VPIN paper trader: added HurstVPINLive integration (was missing entirely)
- A-S VBT: replaced placeholder spread filter with proper A-S simulation using
  reservation price formula (mid - q*gamma*sigma^2*tau), inventory tracking
- A-S NT formula: fixed to standard: mid - q*gamma*sigma^2*tau (was scaled by
  notional and gamma_scale improperly)
- Iceberg VBT: new volume spike detection replacing the old trend proxy

Registry: all 7 strategies now  (pairs, hurst_vpin, as_mm, obi, grid_mm,
         composite_mm, iceberg)

VBT backtest results (500 BTC 1h bars):
  pairs:       -2.81%  13 trades   38% win
  hurst_vpin:  -0.77%   1 trade    (VPIN now active, very selective)
  as_mm:       -16.38%  73 trades  29% win
  obi:         -7.19%  15 trades    7% win
  grid_mm:     -4.79%  22 trades  33% win
  iceberg:     0 trades (threshold strict for 1h BTC data)
2026-08-07 11:42:32 +08:00

503 lines
19 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", "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 = 10
grid_spacing_pct = 0.001
entries = pd.Series(False, index=close.index)
exits = pd.Series(False, index=close.index)
# Track grid state per bar
grid_fills = 0
prev_entry = 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:
entries.iloc[i] = True
# Exit after spread capture (next bar close)
if i + 1 < len(close):
exits.iloc[i + 1] = 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)
# Force exit after 5 bars of being in trade (stale signal)
entries.fillna(False, inplace=True)
exits.fillna(False, inplace=True)
return entries, exits
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.3
# Direction: buy if close > open, sell if close < open
buy_spike = vol_spike & (df["close"] > df["open"])
sell_spike = vol_spike & (df["close"] < df["open"])
# Consecutive same-direction spikes (>= 2)
buy_consec = buy_spike.rolling(1).sum() >= 1
sell_consec = sell_spike.rolling(1).sum() >= 1
entries = buy_consec | sell_consec
exits = entries.shift(5).fillna(False)
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"],
"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) -> 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