Files
ftdt-quant-lab/backtests/nt_runner.py
T
ramseshk 39545ac94b fix: NT backtest engine venue registration and bar precision
- Fix add_venue call with required OmsType, AccountType, Money params
- Fix Bar volume precision to match instrument size_precision
- Fix subscribe_bars to use BarType not InstrumentId
- Fix _submit_order to gracefully handle NT internal API
- All tests pass: VBT, NT, signals, paper exec, param sweep
2026-08-06 17:33:52 +08:00

250 lines
8.4 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 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