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.
232 lines
7.6 KiB
Python
232 lines
7.6 KiB
Python
"""
|
|
NautilusTrader event-driven backtest engine for Hyperliquid strategies.
|
|
|
|
Sets up a BacktestEngine with Hyperliquid venue, instruments, historical
|
|
bar data, and registered strategies. Runs event-driven simulation with
|
|
realistic fill emulation (maker/taker, slippage).
|
|
|
|
Slower but more realistic than VectorBT — intended for final validation
|
|
before paper/live deployment.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from nautilus_trader.backtest.engine import BacktestEngine, BacktestEngineConfig
|
|
from nautilus_trader.model.data import Bar, BarSpecification, BarType
|
|
from nautilus_trader.model.enums import BarAggregation, PriceType
|
|
from nautilus_trader.model.identifiers import InstrumentId, Venue
|
|
from nautilus_trader.model.instruments import CryptoPerpetual
|
|
from nautilus_trader.model.objects import Price, Quantity
|
|
|
|
from framework.data import HyperliquidDataProvider, INTERVAL_TO_SECONDS
|
|
from framework.instruments import HL_VENUE
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
INTERVAL_TO_AGG = {
|
|
"1m": (1, BarAggregation.MINUTE),
|
|
"5m": (5, BarAggregation.MINUTE),
|
|
"15m": (15, BarAggregation.MINUTE),
|
|
"30m": (30, BarAggregation.MINUTE),
|
|
"1h": (1, BarAggregation.HOUR),
|
|
"4h": (4, BarAggregation.HOUR),
|
|
"8h": (8, BarAggregation.HOUR),
|
|
"1d": (1, BarAggregation.DAY),
|
|
}
|
|
|
|
|
|
class NTBacktestRunner:
|
|
"""NautilusTrader backtest engine wrapper for Hyperliquid."""
|
|
|
|
def __init__(self):
|
|
pass
|
|
|
|
def run_backtest(
|
|
self,
|
|
strategy: str = "pairs",
|
|
interval: str = "1h",
|
|
instruments: dict[str, CryptoPerpetual] | None = None,
|
|
testnet: bool = False,
|
|
limit: int = 5000,
|
|
) -> dict[str, Any] | None:
|
|
"""Run event-driven backtest with NautilusTrader.
|
|
|
|
1. Set up BacktestEngine
|
|
2. Register Hyperliquid venue + instruments
|
|
3. Load historical bars from Hyperliquid
|
|
4. Add strategy and run
|
|
5. Return metrics
|
|
"""
|
|
|
|
step, agg = INTERVAL_TO_AGG.get(interval, (1, BarAggregation.HOUR))
|
|
|
|
config = BacktestEngineConfig()
|
|
engine = BacktestEngine(config=config)
|
|
engine.add_venue(HL_VENUE)
|
|
|
|
# Add instruments
|
|
if instruments:
|
|
for inst in instruments.values():
|
|
engine.add_instrument(inst)
|
|
|
|
coin = self._get_coin(strategy)
|
|
|
|
# Fetch real candles
|
|
provider = HyperliquidDataProvider(testnet=testnet)
|
|
df = provider.fetch_candles(coin, interval=interval, limit=limit)
|
|
if df.empty:
|
|
logger.error("No candles for %s", coin)
|
|
return None
|
|
|
|
# Build bars
|
|
inst_id = InstrumentId.from_str(f"{coin.upper()}-USD-PERP.HYPERLIQUID")
|
|
bars = self._df_to_bars(df, inst_id, step, agg)
|
|
|
|
# Add bars
|
|
engine.add_data(bars)
|
|
|
|
# Add strategy
|
|
strategy_class = self._resolve_strategy_class(strategy)
|
|
if strategy_class is None:
|
|
logger.error("No NT strategy class for %s", strategy)
|
|
return None
|
|
|
|
from framework.config import StrategyConfig
|
|
cfg = StrategyConfig(
|
|
name=strategy,
|
|
instrument=f"{coin}-USD-PERP",
|
|
asset=coin,
|
|
allocation=10000.0,
|
|
order_size=0.001,
|
|
)
|
|
nt_strategy = strategy_class(cfg)
|
|
engine.add_strategy(nt_strategy)
|
|
|
|
# Run
|
|
try:
|
|
result = engine.run()
|
|
except Exception as e:
|
|
logger.error("Backtest engine error: %s", e)
|
|
import traceback
|
|
traceback.print_exc()
|
|
return None
|
|
|
|
# Extract metrics
|
|
return self._extract_result(result, engine, strategy, interval, len(bars))
|
|
|
|
# ── Helpers ─────────────────────────────────────────────────
|
|
|
|
def _get_coin(self, strategy: str) -> str:
|
|
return {"pairs": "ETH", "hurst_vpin": "BTC", "as_mm": "BTC",
|
|
"obi": "BTC", "funding_arb": "BTC"}.get(strategy, "BTC")
|
|
|
|
def _df_to_bars(
|
|
self,
|
|
df: pd.DataFrame,
|
|
instrument_id: InstrumentId,
|
|
step: int,
|
|
aggregation: BarAggregation,
|
|
) -> list[Bar]:
|
|
spec = BarSpecification(step, aggregation, PriceType.LAST)
|
|
bar_type = BarType(instrument_id, spec)
|
|
bars = []
|
|
for idx, row in df.iterrows():
|
|
ts = int(idx.timestamp() * 1e9)
|
|
bar = Bar(
|
|
bar_type=bar_type,
|
|
open=Price.from_str(str(row["open"])),
|
|
high=Price.from_str(str(row["high"])),
|
|
low=Price.from_str(str(row["low"])),
|
|
close=Price.from_str(str(row["close"])),
|
|
volume=Quantity.from_str(str(row["volume"])),
|
|
ts_event=ts,
|
|
ts_init=ts,
|
|
)
|
|
bars.append(bar)
|
|
return bars
|
|
|
|
def _resolve_strategy_class(self, strategy: str):
|
|
import importlib
|
|
registry = {
|
|
"pairs": "strategies.nt.pairs_trading_nt.PairsTradingNT",
|
|
"hurst_vpin": "strategies.nt.hurst_vpin_nt.HurstVPINNT",
|
|
"as_mm": "strategies.nt.as_mm_nt.ASMarketMakingNT",
|
|
}
|
|
path = registry.get(strategy)
|
|
if not path:
|
|
return None
|
|
module_path, class_name = path.rsplit(".", 1)
|
|
mod = importlib.import_module(module_path)
|
|
return getattr(mod, class_name)
|
|
|
|
def _extract_result(
|
|
self,
|
|
result,
|
|
engine,
|
|
strategy: str,
|
|
interval: str,
|
|
n_bars: int,
|
|
) -> dict:
|
|
try:
|
|
pnl = float(sum(
|
|
a.pnl() for a in result.accounts if hasattr(a, 'pnl')
|
|
)) if hasattr(result, 'accounts') else 0.0
|
|
except Exception:
|
|
pnl = 0.0
|
|
|
|
try:
|
|
equity = result.equity_curve if hasattr(result, 'equity_curve') else None
|
|
except Exception:
|
|
equity = None
|
|
|
|
equity_vals = []
|
|
if equity is not None and hasattr(equity, '__iter__'):
|
|
equity_vals = [float(v) for v in equity] if equity is not None else []
|
|
|
|
total_return = (equity_vals[-1] / 10000.0 - 1) * 100 if equity_vals else 0.0
|
|
|
|
return {
|
|
"strategy": strategy,
|
|
"engine": "nautilus_trader",
|
|
"interval": interval,
|
|
"n_bars": n_bars,
|
|
"start_equity": 10000.0,
|
|
"end_equity": equity_vals[-1] if equity_vals else 10000.0,
|
|
"total_return_pct": round(total_return, 2),
|
|
"pnl": round(pnl, 2),
|
|
"sharpe": self._compute_sharpe(equity_vals),
|
|
"max_drawdown_pct": round(self._compute_max_dd(equity_vals) * 100, 2),
|
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
|
|
def _compute_sharpe(self, equity: list[float]) -> float:
|
|
if len(equity) < 2:
|
|
return 0.0
|
|
returns = [(equity[i] - equity[i - 1]) / equity[i - 1] for i in range(1, len(equity))]
|
|
mean_ret = np.mean(returns) if returns else 0.0
|
|
std_ret = np.std(returns, ddof=1) if returns else 0.0
|
|
return (mean_ret / std_ret) * np.sqrt(365 * 24) if std_ret > 0 else 0.0
|
|
|
|
def _compute_max_dd(self, equity: list[float]) -> float:
|
|
if not equity:
|
|
return 0.0
|
|
peak = equity[0]
|
|
worst = 0.0
|
|
for v in equity:
|
|
if v > peak:
|
|
peak = v
|
|
dd = (peak - v) / peak if peak > 0 else 0.0
|
|
worst = max(worst, dd)
|
|
return worst
|