f5ffe4baee
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.
336 lines
12 KiB
Python
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
|