feat: NautilusTrader + VectorBT unified framework for Hyperliquid

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.
This commit is contained in:
ramseshk
2026-08-06 17:23:49 +08:00
parent 08a95e8fe2
commit f5ffe4baee
16 changed files with 22419 additions and 20 deletions
+10
View File
@@ -0,0 +1,10 @@
"""
NautilusTrader strategy implementations.
Ported from existing strategies for unified backtest → paper → live pipeline.
"""
from strategies.nt.pairs_trading_nt import PairsTradingNT
from strategies.nt.hurst_vpin_nt import HurstVPINNT
from strategies.nt.as_mm_nt import ASMarketMakingNT
__all__ = ["PairsTradingNT", "HurstVPINNT", "ASMarketMakingNT"]
+184
View File
@@ -0,0 +1,184 @@
"""
Avellaneda-Stoikov Market Making NautilusTrader strategy.
Inventory-aware dual-sided quoting with stochastic control.
Adapted from the production ASMarketMaker (strategies/as_quoter.py).
Key insight: AS tells you WHEN to quote each side, not what price.
We always quote at best bid/ask — the AS formula controls which sides
are active based on inventory risk and reservation price.
When long → reservation price drops → stop quoting bid side
When short → reservation price rises → stop quoting ask side
When flat → quote both sides
For backtesting: simulate maker fills when price reaches our levels.
For live: submit POST-ONLY limit orders at best bid/ask.
"""
from __future__ import annotations
import logging
import math
from collections import deque
import numpy as np
from nautilus_trader.model.data import Bar
from nautilus_trader.model.enums import OrderSide
from framework.base_strategy import BaseHlStrategy
from framework.config import StrategyConfig
logger = logging.getLogger(__name__)
class ASMarketMakingNT(BaseHlStrategy):
"""A-S stochastic control market making — side selection, not price selection."""
def __init__(self, config: StrategyConfig):
super().__init__(config)
# A-S parameters
self._gamma = config.params.get("gamma", 0.1)
self._tau = config.params.get("tau", 1.0) # Session length (hours)
self._max_inventory = config.params.get("max_inventory", config.order_size * 10)
self._gamma_scale = config.params.get("gamma_scale", 500000)
self._vol_window = config.params.get("vol_window", 300)
# Vol estimation
self._sigma_prices: deque[float] = deque(maxlen=self._vol_window)
self._sigma: float = 0.01
# Inventory tracking
self._inventory: float = 0.0
self._last_mid: float = 0.0
self._tick_count: int = 0
# Fill simulation (backtest mode)
self._fills: list[dict] = []
self._cumulative_pnl: float = 0.0
def on_bar(self, bar: Bar):
mid = float(bar.close)
self._sigma_prices.append(mid)
self._update_vol()
self._tick_count += 1
# Simulate bid/ask from candle high/low
bid = float(bar.low)
ask = float(bar.high)
# T elapsed for this bar (approximate)
t = (self._tick_count * 1.0) / (self._tau * 3600) # Simplified
selection = self._should_quote(mid, bid, ask, t)
if not selection.get("quote_bid") and not selection.get("quote_ask"):
return # No quoting — circuit breaker active
# Simulate fill: if we quoted bid and price went down past our level
if selection.get("quote_bid"):
# Check if candle low dipped below our bid level
if float(bar.low) <= bid:
self._simulate_fill(OrderSide.BUY, bid)
if selection.get("quote_ask"):
if float(bar.high) >= ask:
self._simulate_fill(OrderSide.SELL, ask)
def _update_vol(self):
if len(self._sigma_prices) >= 10:
prices = list(self._sigma_prices)
returns = [(prices[i] - prices[i - 1]) / prices[i - 1] for i in range(1, len(prices))]
mu = np.mean(returns)
var = np.mean([(r - mu) ** 2 for r in returns])
self._sigma = max(math.sqrt(var) if var > 0 else 0.01, 0.001)
def _should_quote(self, mid: float, best_bid: float, best_ask: float, t: float) -> dict:
# Hard inventory bounds
if abs(self._inventory) >= self._max_inventory:
if self._inventory > 0:
return {"quote_bid": False, "quote_ask": True, "reservation": mid, "sigma": self._sigma}
else:
return {"quote_bid": True, "quote_ask": False, "reservation": mid, "sigma": self._sigma}
# Circuit breaker: skip if vol is extremely high (> 3x normal)
if len(self._sigma_prices) >= 5:
recent = list(self._sigma_prices)[-5:]
move_pct = abs(recent[-1] - recent[0]) / (recent[0] + 1e-8)
if move_pct > 3 * self._sigma * math.sqrt(5):
return {"quote_bid": False, "quote_ask": False, "reservation": mid, "sigma": self._sigma}
# Reservation price from A-S formula
q_notional = self._inventory * mid
gamma_eff = self._gamma * self._gamma_scale
tau_rem = max(self._tau - t, 0.01)
sigma_sq = max(self._sigma ** 2, 0.000001)
reservation = mid - q_notional * gamma_eff * sigma_sq * tau_rem
# Quote sides based on reservation vs market
quote_bid = reservation >= best_bid or abs(self._inventory) < self._max_inventory * 0.1
quote_ask = reservation <= best_ask or abs(self._inventory) < self._max_inventory * 0.1
return {
"quote_bid": quote_bid,
"quote_ask": quote_ask,
"reservation": reservation,
"sigma": self._sigma,
}
def _simulate_fill(self, side: OrderSide, price: float):
"""Simulate a fill in backtest mode."""
size = self._cfg.order_size
fee_rate = self._cfg.maker_fee if self._cfg.fee_model == "maker" else self._cfg.taker_fee
fee = size * price * fee_rate
# Update inventory + PnL
if side == OrderSide.BUY:
self._inventory += size
# PnL from spread capture
self._cumulative_pnl -= fee
else:
self._inventory -= size
self._cumulative_pnl -= fee
# Assume we close immediately at same price (simplification for backtest)
# In production, fills are tracked by the real exchange
self._fills.append({
"side": "BUY" if side == OrderSide.BUY else "SELL",
"size": size,
"price": price,
"fee": round(fee, 6),
"inventory": round(self._inventory, 8),
"cumulative_pnl": round(self._cumulative_pnl, 4),
})
def compute_signal(self, price: float | None = None) -> dict | None:
"""External signal compute for paper trade orchestrator."""
if price is None or price <= 0:
return None
self._sigma_prices.append(price)
self._update_vol()
# Return quoting decision as a signal
selection = self._should_quote(price, price * 0.999, price * 1.001, 0.5)
if selection.get("quote_bid") and selection.get("quote_ask"):
return {"signal": "DUAL", "strength": 1.0, "reservation": selection.get("reservation", price)}
elif selection.get("quote_bid"):
return {"signal": "BID_ONLY", "strength": 1.0, "reservation": selection.get("reservation", price)}
elif selection.get("quote_ask"):
return {"signal": "ASK_ONLY", "strength": 1.0, "reservation": selection.get("reservation", price)}
return None
def handle_signal(self, signal: dict):
sig = signal.get("signal", "")
if "DUAL" in sig:
self._submit_order(OrderSide.BUY)
self._submit_order(OrderSide.SELL)
elif "BID" in sig:
self._submit_order(OrderSide.BUY)
elif "ASK" in sig:
self._submit_order(OrderSide.SELL)
+247
View File
@@ -0,0 +1,247 @@
"""
Hurst/VPIN NautilusTrader strategy.
Hurst exponent regime detection combined with VPIN (Volume-synchronized
Probability of INformed trading) for directional flow imbalance.
Hurst > 0.55 → trending regime
High VPIN → informed flow present
Entry: trending + high VPIN + directional alignment
Exit: Hurst drops below 0.45 (mean-reverting regime) or VPIN normalizes
Based on the existing HurstVPINLive signal generator used in the production node.
Backtest shows 96% win rate on synthetic data — this port enables testing on
real Hyperliquid candles.
"""
from __future__ import annotations
import logging
import math
from collections import deque
import numpy as np
from nautilus_trader.model.data import Bar
from nautilus_trader.model.enums import OrderSide
from framework.base_strategy import BaseHlStrategy
from framework.config import StrategyConfig
logger = logging.getLogger(__name__)
def _hurst_rs(returns: list[float]) -> float:
n = len(returns)
if n < 32:
return 0.50
max_lag = min(n // 2, 64)
lags = []
rs = []
for lag in range(4, max_lag):
segs = n // lag
if segs < 2:
continue
vals = []
for s in range(segs):
seg = returns[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.append(np.log(np.mean(vals)))
if len(lags) < 4:
return 0.50
slope = float(np.polyfit(lags, rs, 1)[0])
return max(0.20, min(0.80, slope))
class HurstVPINNT(BaseHlStrategy):
"""Hurst exponent + VPIN directional signal on real candle data."""
def __init__(self, config: StrategyConfig):
super().__init__(config)
# Hurst
self._hurst_window = config.params.get("hurst_window", 64)
self._hurst_entry = config.params.get("hurst_entry", 0.55)
self._hurst_exit = config.params.get("hurst_exit", 0.45)
# VPIN
self._vpin_window = config.params.get("vpin_window", 50)
self._vpin_threshold = config.params.get("vpin_threshold", 0.25)
self._dollar_threshold = config.params.get("dollar_threshold", 100000.0)
# State
self._close_history: deque[float] = deque(maxlen=self._hurst_window)
self._vpin_values: deque[float] = deque(maxlen=self._vpin_window)
self._vpin_directions: deque[float] = deque(maxlen=self._vpin_window)
# Dollar bar accumulator
self._bar_volume = 0.0
self._bar_buy_vol = 0.0
self._bar_sell_vol = 0.0
self._bar_close = 0.0
self._bar_open = 0.0
# Rolling state
self._returns: deque[float] = deque(maxlen=self._hurst_window)
self._last_emit_close = 0.0
self._in_trade = False
self._trade_direction: str | None = None
def on_bar(self, bar: Bar):
price = float(bar.close)
self._close_history.append(price)
# Accumulate notional for dollar bars
notional = price * float(bar.volume) if hasattr(bar, 'volume') else price * 100
is_buy = float(bar.close) > float(bar.open)
self._bar_volume += notional
if is_buy:
self._bar_buy_vol += notional
else:
self._bar_sell_vol += notional
self._bar_close = price
if self._bar_open == 0:
self._bar_open = float(bar.open)
if self._bar_volume < self._dollar_threshold:
return
# Dollar bar complete — emit
self._emit_dollar_bar()
signal = self._compute_hurst_vpin_signal()
if signal:
self._last_signal = signal
self.handle_signal(signal)
def _emit_dollar_bar(self):
total = self._bar_buy_vol + self._bar_sell_vol
vpin = abs(self._bar_buy_vol - self._bar_sell_vol) / total if total > 1 else 0.0
direction = (self._bar_buy_vol - self._bar_sell_vol) / total if total > 1 else 0.0
self._vpin_values.append(vpin)
self._vpin_directions.append(direction)
if self._last_emit_close > 0 and self._bar_close > 0:
self._returns.append(math.log(self._bar_close / self._last_emit_close))
self._last_emit_close = self._bar_close
# Reset accumulator
self._bar_volume = 0.0
self._bar_buy_vol = 0.0
self._bar_sell_vol = 0.0
self._bar_open = self._bar_close
def _compute_hurst_vpin_signal(self) -> dict | None:
if len(self._returns) < 32 or len(self._vpin_values) < 10:
return None
hurst = _hurst_rs(list(self._returns))
vpin = float(np.mean(self._vpin_values))
direction = float(np.mean(self._vpin_directions))
trending = hurst >= self._hurst_entry
high_vpin = vpin >= self._vpin_threshold
# Exit logic
if self._in_trade:
if hurst < self._hurst_exit:
self._in_trade = False
self._trade_direction = None
return {
"signal": "SELL" if self._trade_direction == "long" else "BUY",
"strength": 1.0,
"hurst": round(hurst, 3),
"vpin": round(vpin, 3),
"reason": "exit_hurst_fade",
}
# Exit on direction flip with high certainty
if self._trade_direction == "long" and direction < -0.5 and high_vpin:
self._in_trade = False
self._trade_direction = None
return {
"signal": "SELL",
"strength": abs(direction),
"hurst": round(hurst, 3),
"vpin": round(vpin, 3),
"reason": "exit_direction_flip",
}
elif self._trade_direction == "short" and direction > 0.5 and high_vpin:
self._in_trade = False
self._trade_direction = None
return {
"signal": "BUY",
"strength": abs(direction),
"hurst": round(hurst, 3),
"vpin": round(vpin, 3),
"reason": "exit_direction_flip",
}
return None
# Entry: trending + informed flow + directional alignment
if trending and high_vpin:
if direction > 0.05:
self._in_trade = True
self._trade_direction = "long"
return {
"signal": "BUY",
"strength": max(0.15, direction),
"hurst": round(hurst, 3),
"vpin": round(vpin, 3),
"direction": round(direction, 3),
"reason": "entry_trending_vpin",
}
elif direction < -0.05:
self._in_trade = True
self._trade_direction = "short"
return {
"signal": "SELL",
"strength": max(0.15, abs(direction)),
"hurst": round(hurst, 3),
"vpin": round(vpin, 3),
"direction": round(direction, 3),
"reason": "entry_trending_vpin",
}
return None
def compute_signal(self, price: float | None = None) -> dict | None:
"""External signal compute for paper trader / deploy orchestrator."""
if price is None:
return None
self._close_history.append(price)
# Simplified: just use price-based dollar bar
if len(self._close_history) < 2:
return None
last = self._close_history[-2]
cur = self._close_history[-1]
notional = cur * abs(cur - last) * 100
is_buy = cur > last
self._bar_volume += notional
if is_buy:
self._bar_buy_vol += notional
else:
self._bar_sell_vol += notional
self._bar_close = cur
if self._bar_volume < self._dollar_threshold:
return None
self._emit_dollar_bar()
return self._compute_hurst_vpin_signal()
def handle_signal(self, signal: dict):
side_str = signal["signal"]
if "BUY" in side_str:
self._submit_order(OrderSide.BUY, size=self._cfg.order_size)
elif "SELL" in side_str:
self._submit_order(OrderSide.SELL, size=self._cfg.order_size)
+135
View File
@@ -0,0 +1,135 @@
"""
Pairs Trading NautilusTrader strategy.
BTC/ETH ratio Z-score mean reversion. Computes the rolling ratio spread
between BTC and ETH prices and enters when Z-score exceeds threshold.
Entry: Z-score < -1.5 (buy ETH relative to BTC) or Z-score > 1.5 (sell ETH)
Exit: Z-score reverts to 0 or crossing signal in opposite direction
This is the #1 performing live strategy (67% win rate, +$0.74).
"""
from __future__ import annotations
import logging
import math
from collections import deque
import numpy as np
from nautilus_trader.model.data import Bar
from nautilus_trader.model.enums import OrderSide
from framework.base_strategy import BaseHlStrategy
from framework.config import StrategyConfig
logger = logging.getLogger(__name__)
class PairsTradingNT(BaseHlStrategy):
"""BTC/ETH pairs trading with Z-score entry/exit rules."""
def __init__(self, config: StrategyConfig):
super().__init__(config)
# Ratio tracking
self._btc_prices: deque[float] = deque(maxlen=100)
self._eth_prices: deque[float] = deque(maxlen=100)
self._ratios: deque[float] = deque(maxlen=100)
# Configurable params
self._z_entry = config.params.get("z_entry", 1.5)
self._z_exit = config.params.get("z_exit", 0.5)
self._lookback = config.params.get("lookback", 20)
# State
self._in_trade = False
self._trade_direction: str | None = None # "long_eth" or "short_eth"
def on_bar(self, bar: Bar):
"""Track both BTC and ETH prices. Signal on ETH bars."""
symbol = str(bar.bar_type.instrument_id.symbol) if hasattr(bar, 'bar_type') else ""
price = float(bar.close)
if "BTC" in symbol.upper():
self._btc_prices.append(price)
elif "ETH" in symbol.upper():
self._eth_prices.append(price)
self._check_signal()
def compute_signal(self, price: float | None = None) -> dict | None:
"""Alternative: compute signal from price feed (for paper trading)."""
if price is not None:
self._eth_prices.append(price)
# Use last known BTC price from cached data
if not self._btc_prices:
return None
return self._check_signal()
def _check_signal(self) -> dict | None:
if len(self._btc_prices) < self._lookback or len(self._eth_prices) < self._lookback:
return None
btc_list = list(self._btc_prices)
eth_list = list(self._eth_prices)
# Align BTC/ETH on common window
ratios = []
for i in range(-min(len(btc_list), len(eth_list)), 0):
if eth_list[i] > 0:
ratios.append(btc_list[i] / eth_list[i])
if len(ratios) < self._lookback:
return None
self._ratios.append(ratios[-1])
recent = ratios[-self._lookback:]
mu = np.mean(recent)
std = np.std(recent, ddof=1)
if std <= 0:
return None
z = (ratios[-1] - mu) / std
# Exit logic
if self._in_trade:
# Exit when Z-score reverts toward zero
if abs(z) < self._z_exit:
self._in_trade = False
sig = "BUY_ETH" if self._trade_direction == "short_eth" else "SELL_ETH"
self._trade_direction = None
return {"signal": sig, "strength": abs(z), "reason": "exit_reversion"}
# Exit on crossing
if self._trade_direction == "long_eth" and z > self._z_entry:
self._in_trade = False
self._trade_direction = None
return {"signal": "SELL_ETH", "strength": abs(z), "reason": "exit_crossing"}
elif self._trade_direction == "short_eth" and z < -self._z_entry:
self._in_trade = False
self._trade_direction = None
return {"signal": "BUY_ETH", "strength": abs(z), "reason": "exit_crossing"}
return None
# Entry logic
if z < -self._z_entry:
# BTC/ETH ratio is low → ETH is relatively expensive → buy ETH vs BTC
self._in_trade = True
self._trade_direction = "long_eth"
return {"signal": "BUY_ETH", "strength": abs(z) / self._z_entry, "z_score": round(z, 3), "reason": "entry_zscore"}
if z > self._z_entry:
# BTC/ETH ratio is high → ETH is relatively cheap → sell ETH vs BTC
self._in_trade = True
self._trade_direction = "short_eth"
return {"signal": "SELL_ETH", "strength": abs(z) / self._z_entry, "z_score": round(z, 3), "reason": "entry_zscore"}
return None
def handle_signal(self, signal: dict):
side_str = signal["signal"]
if "BUY" in side_str:
self._submit_order(OrderSide.BUY)
elif "SELL" in side_str:
self._submit_order(OrderSide.SELL)