""" 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 AccountType, BarAggregation, OmsType, PriceType from nautilus_trader.model.identifiers import InstrumentId, Venue from nautilus_trader.model.instruments import CryptoPerpetual from nautilus_trader.model.objects import Currency, Money, 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( venue=HL_VENUE, oms_type=OmsType.NETTING, account_type=AccountType.MARGIN, starting_balances=[Money(10_000.0, Currency.from_str("USD"))], ) # Add instruments coin = self._get_coin(strategy) inst_for_coin = None if instruments: for name, inst in instruments.items(): engine.add_instrument(inst) if name.upper() == coin.upper(): inst_for_coin = inst if not inst_for_coin and instruments: # Try to find any instrument matching for inst in instruments.values(): instr_name = str(inst.id.symbol) if coin.upper() in instr_name.upper(): inst_for_coin = inst break sz_prec = inst_for_coin.size_precision if inst_for_coin else 5 # 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, size_precision=sz_prec) # 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, size_precision: int = 5, ) -> 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(f'{row["volume"]:.{size_precision}f}'), 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