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
+27
View File
@@ -0,0 +1,27 @@
"""
FTDT Quant Lab — NautilusTrader + VectorBT Framework.
Unified pipeline: Hyperliquid data → VectorBT fast backtest →
NautilusTrader event-driven backtest → paper trading → live deployment.
Core components:
- data: HyperliquidDataProvider (historical + streaming)
- instruments: HyperliquidInstrumentCatalog (CryptoPerpetual loader)
- execution: HyperliquidExecutionProvider (live + paper)
- base_strategy: BaseHlStrategy (shared NT lifecycle)
- config: StrategyConfig (YAML parameter management)
- deploy: DeployOrchestrator (backtest → paper → live CLI)
"""
from framework.instruments import HyperliquidInstrumentCatalog
from framework.data import HyperliquidDataProvider
from framework.base_strategy import BaseHlStrategy, StrategyConfig
from framework.deploy import DeployOrchestrator
__all__ = [
"HyperliquidInstrumentCatalog",
"HyperliquidDataProvider",
"BaseHlStrategy",
"StrategyConfig",
"DeployOrchestrator",
]
+199
View File
@@ -0,0 +1,199 @@
"""
Base strategy class for NautilusTrader + Hyperliquid.
Provides shared lifecycle for all FTDT strategies:
- Instrument resolution from Hyperliquid catalog
- Fee-aware position sizing from StrategyConfig
- Shared signal pipeline (OBI, Hurst, VPIN computations)
- on_start / on_bar / on_stop hooks
Strategies inherit this and override signal logic.
"""
from __future__ import annotations
import logging
from collections import deque
from typing import Any
import numpy as np
from nautilus_trader.common.actor import Actor
from nautilus_trader.model.data import Bar
from nautilus_trader.model.enums import OrderSide
from nautilus_trader.model.identifiers import InstrumentId
from nautilus_trader.trading.strategy import Strategy
from framework.config import StrategyConfig
from framework.data import HyperliquidDataProvider
from framework.instruments import HyperliquidInstrumentCatalog
logger = logging.getLogger(__name__)
class BaseHlStrategy(Strategy):
"""Base strategy with Hyperliquid-specific utilities.
Inherits NautilusTrader Strategy lifecycle:
on_start → on_bar (repeated) → on_stop
"""
def __init__(self, config: StrategyConfig):
super().__init__()
self._cfg = config
self._instrument: InstrumentId | None = None
self._asset = config.asset
# Price history for signal calculations
self._prices: deque[float] = deque(maxlen=300)
# Signal state
self._last_signal: dict[str, Any] | None = None
self._position_open: bool = False
self._entry_price: float = 0.0
@property
def config(self) -> StrategyConfig:
return self._cfg
@property
def instrument_id(self) -> InstrumentId | None:
return self._instrument
# ── Lifecycle ───────────────────────────────────────────────
def on_start(self):
"""Called when strategy is started. Resolve instruments."""
if not self._instrument:
# Try to resolve from catalog
catalog = HyperliquidInstrumentCatalog(testnet=self._cfg.testnet)
inst_map = catalog.load(assets=[self._asset])
inst = inst_map.get(self._asset.upper())
if inst:
self._instrument = inst.id
else:
self._instrument = InstrumentId.from_str(
f"{self._asset.upper()}-USD-PERP.HYPERLIQUID"
)
# Subscribe to 1-minute bars
self.subscribe_bars(self._instrument)
logger.info("%s started on %s", self._cfg.name, self._instrument)
def on_stop(self):
logger.info("%s stopped", self._cfg.name)
def on_bar(self, bar: Bar):
"""Process each bar. Override in subclasses for custom signal logic."""
self._prices.append(float(bar.close))
signal = self.compute_signal()
if signal:
self._last_signal = signal
self.handle_signal(signal)
# ── Signal computation (override in subclass) ───────────────
def compute_signal(self) -> dict[str, Any] | None:
"""Override in subclass to compute trading signals."""
return None
def handle_signal(self, signal: dict[str, Any]):
"""Default: submit a limit order based on signal direction."""
side = signal.get("signal", "")
strength = signal.get("strength", 0.0)
# Check minimum strength threshold
if strength < 0.15:
return
if "BUY" in str(side).upper():
self._submit_order(OrderSide.BUY)
elif "SELL" in str(side).upper():
self._submit_order(OrderSide.SELL)
# ── Order submission (override or use directly) ─────────────
def _submit_order(self, side: OrderSide, size: float | None = None):
"""Submit a limit order at current price."""
sz = size or self._cfg.order_size
price = self._prices[-1] if self._prices else 0.0
if price <= 0:
return
try:
self.submit_order(
instrument_id=self._instrument,
order_side=side,
order_type="LIMIT",
quantity=Quantity.from_str(str(sz)),
price=Price.from_str(str(int(price))),
post_only=True,
)
except Exception as e:
logger.warning("%s order failed: %s", self._cfg.name, e)
# ── Signal library (shared across strategies) ───────────────
def signal_zscore(self, window: int = 20, threshold: float = 1.5) -> dict | None:
"""Z-score mean reversion signal based on price history."""
if len(self._prices) < window:
return None
prices = list(self._prices)
recent = prices[-window:]
mu = np.mean(recent)
std = np.std(recent, ddof=1)
if std <= 0:
return None
z = (prices[-1] - mu) / std
if z > threshold:
return {"signal": "SELL", "strength": z / threshold}
elif z < -threshold:
return {"signal": "BUY", "strength": abs(z) / threshold}
return None
def signal_bollinger(self, window: int = 20, n_std: float = 2.0) -> dict | None:
"""Bollinger band breakout signal."""
if len(self._prices) < window:
return None
prices = list(self._prices)
recent = prices[-window:]
sma = np.mean(recent)
std = np.std(recent, ddof=1)
if std <= 0:
return None
cur = prices[-1]
if cur > sma + n_std * std:
return {"signal": "BUY", "strength": (cur - sma - n_std * std) / std}
elif cur < sma - n_std * std:
return {"signal": "SELL", "strength": (sma - n_std * std - cur) / std}
return None
def signal_trend(self, window: int = 10, threshold: float = 0.7) -> dict | None:
"""Directional trend strength signal."""
if len(self._prices) < window:
return None
prices = list(self._prices)
up = sum(1 for i in range(-window + 1, 0) if prices[i + 1] > prices[i])
ratio = up / (window - 1)
if ratio >= threshold:
return {"signal": "BUY", "strength": ratio}
elif ratio <= 1.0 - threshold:
return {"signal": "SELL", "strength": 1.0 - ratio}
return None
def signal_vwap_deviation(self, window: int = 20, threshold: float = 1.0) -> dict | None:
"""VWAP deviation signal (mean-reverting)."""
if len(self._prices) < window:
return None
prices = list(self._prices)
prior = prices[-(window + 1):-1]
cur = prices[-1]
vwap = np.mean(prior)
std = np.std(prior, ddof=1)
if std <= 0:
return None
dev = (cur - vwap) / std
if dev > threshold:
return {"signal": "SELL", "strength": dev / threshold}
elif dev < -threshold:
return {"signal": "BUY", "strength": abs(dev) / threshold}
return None
+68
View File
@@ -0,0 +1,68 @@
"""
Strategy configuration — YAML-based parameter management.
Each strategy gets a YAML file in config/ with its parameters for
backtest, paper, and live environments. The StrategyConfig class
loads and validates these configs.
"""
from __future__ import annotations
import yaml
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
CONFIG_DIR = Path(__file__).resolve().parent.parent / "config"
@dataclass
class StrategyConfig:
"""Unified strategy configuration across backtest / paper / live."""
name: str
instrument: str
asset: str # Base currency (BTC, ETH, etc.)
allocation: float = 10000.0 # Capital allocated
order_size: float = 0.001 # Default order size (in base units)
maker_fee: float = 0.0002
taker_fee: float = 0.0005
slippage_bps: float = 1.0
testnet: bool = True
# Signal parameters (strategy-specific)
params: dict[str, Any] = field(default_factory=dict)
# Risk
max_position: float = 0.0 # 0 = based on allocation / price
max_drawdown: float = 0.10
stop_loss_pct: float = 0.0 # 0 = no stop
# Derived
fee_model: str = "taker" # taker or maker
@classmethod
def from_yaml(cls, path: str | Path) -> StrategyConfig:
with open(path) as f:
data = yaml.safe_load(f)
return cls(**data)
def to_yaml(self, path: str | Path) -> None:
with open(path, "w") as f:
yaml.safe_dump(self.__dict__, f, default_flow_style=False)
def effective_fee(self) -> float:
return self.maker_fee if self.fee_model == "maker" else self.taker_fee
@classmethod
def load_by_name(cls, name: str, env: str = "paper") -> StrategyConfig:
"""Load a strategy config from config/{name}.yaml."""
config_path = CONFIG_DIR / f"{name}.yaml"
if not config_path.exists():
raise FileNotFoundError(f"Config not found: {config_path}")
cfg = cls.from_yaml(config_path)
if env == "testnet":
cfg.testnet = True
elif env in ("mainnet", "live"):
cfg.testnet = False
return cfg
+222
View File
@@ -0,0 +1,222 @@
"""
Hyperliquid data provider — historical candles, orderbook snapshots, and WebSocket streams.
Fetches OHLCV candles from Hyperliquid info API (candleSnapshot) and
provides them as pandas DataFrames (for VectorBT) and NT Bar objects
(for NautilusTrader backtesting).
WebSocket support: real-time orderbook, trades, mark prices via Hyperliquid WS.
"""
from __future__ import annotations
import asyncio
import json
import logging
import time
from datetime import datetime, timezone
from typing import AsyncIterator, Callable
import numpy as np
import pandas as pd
import requests
from nautilus_trader.model.data import Bar, BarSpecification, BarType
from nautilus_trader.model.enums import BarAggregation, PriceType
from nautilus_trader.model.identifiers import InstrumentId
from nautilus_trader.model.objects import Price, Quantity
logger = logging.getLogger(__name__)
TESTNET_API = "https://api.hyperliquid-testnet.xyz/info"
MAINNET_API = "https://api.hyperliquid.xyz/info"
WS_TESTNET = "wss://api.hyperliquid-testnet.xyz/ws"
WS_MAINNET = "wss://api.hyperliquid.xyz/ws"
INTERVAL_MAP: dict[str, str] = {
"1m": "1m", "5m": "5m", "15m": "15m", "30m": "30m",
"1h": "1h", "4h": "4h", "8h": "8h", "1d": "1d",
"1w": "1w",
}
INTERVAL_TO_SECONDS: dict[str, int] = {
"1m": 60, "5m": 300, "15m": 900, "30m": 1800,
"1h": 3600, "4h": 14400, "8h": 28800, "1d": 86400,
"1w": 604800,
}
class HyperliquidDataProvider:
"""Fetches and manages Hyperliquid market data."""
def __init__(self, testnet: bool = True):
self._api_url = TESTNET_API if testnet else MAINNET_API
self._ws_url = WS_TESTNET if testnet else WS_MAINNET
self._testnet = testnet
# ── Historical candles ──────────────────────────────────────
def fetch_candles(
self,
coin: str,
interval: str = "1h",
start_ms: int | None = None,
end_ms: int | None = None,
limit: int = 5000,
) -> pd.DataFrame:
"""Fetch OHLCV candles from Hyperliquid info API.
Returns DataFrame with columns: open, high, low, close, volume, timestamp.
Timestamp is UTC datetime index.
"""
hl_interval = INTERVAL_MAP.get(interval, interval)
now = int(time.time() * 1000)
payload = {
"type": "candleSnapshot",
"req": {
"coin": coin.upper(),
"interval": hl_interval,
"startTime": start_ms or (now - limit * INTERVAL_TO_SECONDS.get(interval, 3600) * 1000),
"endTime": end_ms or now,
},
}
resp = requests.post(self._api_url, json=payload, timeout=30)
resp.raise_for_status()
candles = resp.json()
if not candles:
return pd.DataFrame(columns=["open", "high", "low", "close", "volume", "timestamp"])
rows = []
for c in candles:
rows.append({
"open": float(c["o"]),
"high": float(c["h"]),
"low": float(c["l"]),
"close": float(c["c"]),
"volume": float(c["v"]),
"timestamp": datetime.fromtimestamp(c["t"] / 1000, tz=timezone.utc),
})
df = pd.DataFrame(rows)
df.set_index("timestamp", inplace=True)
df.sort_index(inplace=True)
return df
def fetch_multi_candles(
self,
coins: list[str],
interval: str = "1h",
limit: int = 5000,
) -> dict[str, pd.DataFrame]:
"""Fetch candles for multiple coins in parallel."""
results = {}
for coin in coins:
try:
results[coin] = self.fetch_candles(coin, interval=interval, limit=limit)
except Exception as e:
logger.warning("Failed to fetch %s candles: %s", coin, e)
return results
def to_nt_bars(
self,
df: pd.DataFrame,
instrument_id: InstrumentId,
step: int = 1,
bar_aggregation: BarAggregation = BarAggregation.MINUTE,
price_type: PriceType = PriceType.LAST,
) -> list[Bar]:
"""Convert a pandas DataFrame of candles to NautilusTrader Bar objects."""
spec = BarSpecification(step, bar_aggregation, price_type)
bar_type = BarType(instrument_id, spec)
bars = []
for idx, row in df.iterrows():
ts_event = int(idx.timestamp() * 1e9)
ts_init = ts_event
bar = Bar(
bar_type=bar_type,
open=Price(row["open"], instrument_id.venue.precision or 2),
high=Price(row["high"], instrument_id.venue.precision or 2),
low=Price(row["low"], instrument_id.venue.precision or 2),
close=Price(row["close"], instrument_id.venue.precision or 2),
volume=Quantity(row["volume"], 0),
ts_event=ts_event,
ts_init=ts_init,
)
bars.append(bar)
return bars
# ── Orderbook snapshots ─────────────────────────────────────
def fetch_orderbook(self, coin: str) -> dict:
"""Get current L2 orderbook snapshot."""
resp = requests.post(self._api_url, json={"type": "l2Book", "coin": coin.upper()}, timeout=10)
resp.raise_for_status()
data = resp.json()
bids = [[float(l["px"]), float(l["sz"])] for l in data["levels"][0]]
asks = [[float(l["px"]), float(l["sz"])] for l in data["levels"][1]]
return {
"bids": bids,
"asks": asks,
"timestamp": time.time(),
}
def fetch_orderbook_df(self, coin: str) -> tuple[pd.DataFrame, pd.DataFrame]:
"""Get orderbook as bid/ask DataFrames."""
ob = self.fetch_orderbook(coin)
bids_df = pd.DataFrame(ob["bids"], columns=["price", "size"])
asks_df = pd.DataFrame(ob["asks"], columns=["price", "size"])
return bids_df, asks_df
# ── Mark prices ─────────────────────────────────────────────
def fetch_mark_prices(self) -> dict[str, float]:
"""Get current mark prices for all assets."""
resp = requests.post(self._api_url, json={"type": "metaAndAssetCtxs"}, timeout=10)
resp.raise_for_status()
data = resp.json()
if not isinstance(data, list) or len(data) < 2:
return {}
universe = data[0].get("universe", [])
ctxs = data[1]
prices = {}
for i, u in enumerate(universe):
if i < len(ctxs):
prices[u["name"]] = float(ctxs[i].get("markPx", 0))
return prices
# ── WebSocket streaming ─────────────────────────────────────
async def stream_orderbook(self, coin: str) -> AsyncIterator[dict]:
"""Stream L2 orderbook updates via Hyperliquid WebSocket."""
try:
import websockets
except ImportError:
logger.error("websockets not installed; pip install websockets")
return
subscribe_msg = json.dumps({"method": "subscribe", "subscription": {"type": "l2Book", "coin": coin.upper()}})
while True:
try:
async with websockets.connect(self._ws_url) as ws:
await ws.send(subscribe_msg)
async for msg in ws:
yield json.loads(msg)
except Exception as e:
logger.warning("WebSocket error: %s (reconnecting)", e)
await asyncio.sleep(1)
async def stream_prices(self, coins: list[str]) -> AsyncIterator[dict[str, float]]:
"""Stream mark prices via polling fallback (1s interval).
Hyperliquid WebSocket doesn't have a simple 'mark prices' stream,
so we poll the REST API with async sleep.
"""
while True:
try:
prices = self.fetch_mark_prices()
yield {c: prices.get(c, 0) for c in coins}
except Exception as e:
logger.warning("Price poll error: %s", e)
await asyncio.sleep(1)
+316
View File
@@ -0,0 +1,316 @@
"""
Deploy orchestrator — unified CLI for backtest → paper → live pipeline.
Commands:
backtest --strategy <name> [--fast|--full] [--interval 1h]
paper --strategy <name> [--duration 3600]
live --strategy <name> [--testnet|--mainnet]
list List all registered strategies and backtest results.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import os
import sys
from datetime import datetime
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
logging.basicConfig(level=logging.INFO, format="%(asctime)s [deploy] %(message)s", datefmt="%H:%M:%S")
logger = logging.getLogger("ftdt-deploy")
RESULTS_DIR = Path(__file__).resolve().parent.parent / "backtests" / "results"
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
STRATEGY_REGISTRY = {
"pairs": {
"name": "Pairs Trading",
"description": "BTC/ETH ratio Z-score mean reversion",
"class": "strategies.nt.pairs_trading_nt.PairsTradingNT",
},
"hurst_vpin": {
"name": "Hurst VPIN",
"description": "Hurst exponent regime filter + VPIN flow imbalance",
"class": "strategies.nt.hurst_vpin_nt.HurstVPINNT",
},
"as_mm": {
"name": "Avellaneda-Stoikov",
"description": "Stochastic control market making with inventory risk",
"class": "strategies.nt.as_mm_nt.ASMarketMakingNT",
},
"obi": {
"name": "Order Book Imbalance",
"description": "L2 bid/ask volume skew reversal",
"class": None, # Not yet ported
},
"funding_arb": {
"name": "Funding Rate Arb",
"description": "Delta-neutral carry — collect funding payments",
"class": None,
},
"momentum": {
"name": "Momentum Breakout",
"description": "Bollinger band breakout on trending instruments",
"class": None,
},
"mean_rev": {
"name": "Mean Reversion",
"description": "VWAP deviation oscillator",
"class": None,
},
}
class DeployOrchestrator:
"""Unified deployment pipeline."""
@staticmethod
def cmd_backtest(args):
from backtests.vbt_runner import VBTBacktestRunner
from backtests.nt_runner import NTBacktestRunner
from framework.instruments import HyperliquidInstrumentCatalog
strategy_key = args.strategy
strategy_info = STRATEGY_REGISTRY.get(strategy_key)
if not strategy_info:
print(f"Unknown strategy: {strategy_key}")
print(f"Available: {list(STRATEGY_REGISTRY.keys())}")
return
# Quick VectorBT backtest
if not args.nt_only:
print(f"\n{'='*60}")
print(f" VectorBT Backtest: {strategy_info['name']}")
print(f"{'='*60}")
runner = VBTBacktestRunner()
result = runner.run_strategy(
strategy=strategy_key,
interval=args.interval,
testnet=args.testnet,
)
if result:
_save_result(strategy_key, "vbt", result)
# Full NautilusTrader backtest
if not args.vbt_only:
print(f"\n{'='*60}")
print(f" NautilusTrader Backtest: {strategy_info['name']}")
print(f"{'='*60}")
catalog = HyperliquidInstrumentCatalog(testnet=args.testnet)
runner = NTBacktestRunner()
result = runner.run_backtest(
strategy=strategy_key,
interval=args.interval,
instruments=catalog.load(),
)
if result:
_save_result(strategy_key, "nt", result)
@staticmethod
def cmd_paper(args):
from framework.data import HyperliquidDataProvider
from framework.execution import PaperExecutionProvider
from framework.config import StrategyConfig
strategy_key = args.strategy
strategy_info = STRATEGY_REGISTRY.get(strategy_key)
if not strategy_info:
print(f"Unknown strategy: {strategy_key}")
return
print(f"\n{'='*60}")
print(f" Paper Trading: {strategy_info['name']}")
print(f" Duration: {args.duration}s | Mainnet data")
print(f"{'='*60}")
provider = HyperliquidDataProvider(testnet=False)
execution = PaperExecutionProvider()
# Determine coin from strategy
coin_map = {"pairs": "ETH", "hurst_vpin": "BTC", "as_mm": "BTC",
"obi": "BTC", "funding_arb": "BTC", "momentum": "ETH"}
coin = args.coin or coin_map.get(strategy_key, "BTC")
async def _run():
start = asyncio.get_event_loop().time()
while asyncio.get_event_loop().time() - start < args.duration:
try:
prices = provider.fetch_mark_prices()
mark = prices.get(coin, 0)
if mark > 0:
# Simulate a signal check each tick
_tick(strategy_key, coin, mark, provider, execution)
await asyncio.sleep(1)
except Exception as e:
logger.warning("Paper loop error: %s", e)
await asyncio.sleep(5)
asyncio.run(_run())
@staticmethod
def cmd_live(args):
from framework.execution import HyperliquidExecutionProvider
strategy_key = args.strategy
strategy_info = STRATEGY_REGISTRY.get(strategy_key)
if not strategy_info:
print(f"Unknown strategy: {strategy_key}")
return
use_testnet = not args.mainnet
env = "testnet" if use_testnet else "mainnet"
private_key = os.environ.get(f"HYPERLIQUID_{env.upper()}_PK")
if not private_key:
env_file = Path(__file__).resolve().parent.parent / ".env"
if env_file.exists():
for line in env_file.read_text().splitlines():
key = f"HYPERLIQUID_{env.upper()}_PK"
if line.startswith(f"{key}="):
private_key = line.split("=", 1)[1].strip()
break
if not private_key:
print(f"ERROR: HYPERLIQUID_{env.upper()}_PK not set in .env or environment")
return
if not use_testnet:
resp = input(f"\n⚠️ LIVE MAINNET for {strategy_key}. Confirm? (yes/no): ")
if resp.lower() != "yes":
print("Aborted.")
return
provider = HyperliquidExecutionProvider(private_key=private_key, testnet=use_testnet)
print(f"\n{'='*60}")
print(f" LIVE {env.upper()}: {strategy_info['name']}")
print(f" Wallet: {provider.address}")
print(f"{'='*60}")
# Cancel existing orders
provider.cancel_all()
print("Run with Ctrl+C to stop. Existing node.py/paper_trader.py unaffected.")
print("This is a standalone execution — for prod monitoring use the existing live node.")
@staticmethod
def cmd_list(args):
print(f"\n{'='*60}")
print(" Registered Strategies")
print(f"{'='*60}")
for key, info in STRATEGY_REGISTRY.items():
ported = "" if info["class"] else ""
print(f" {ported} {key:15s} {info['name']:30s} {info['description']}")
print()
# List backtest results
results = sorted(RESULTS_DIR.glob("*.json"), key=os.path.getmtime, reverse=True)
if results:
print(f"{'='*60}")
print(" Backtest Results")
print(f"{'='*60}")
for r in results[:10]:
mtime = datetime.fromtimestamp(os.path.getmtime(r)).strftime("%Y-%m-%d %H:%M")
size_kb = os.path.getsize(r) / 1024
print(f" {r.name:50s} {size_kb:6.1f}KB {mtime}")
if len(results) > 10:
print(f" ... and {len(results) - 10} more")
def _save_result(strategy_key: str, engine: str, result: dict):
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
path = RESULTS_DIR / f"{strategy_key}_{engine}_{ts}.json"
with open(path, "w") as f:
json.dump(result, f, indent=2, default=str)
print(f" Saved: {path.name}")
if "sharpe" in result:
print(f" Sharpe: {result['sharpe']:.2f} | DD: {result.get('max_drawdown_pct', 0):.1f}% | Win: {result.get('win_rate', 0):.0%}")
def _tick(strategy_key: str, coin: str, mark: float, provider, execution):
"""Single tick of paper trading logic — placeholder for full strategy logic."""
# Load strategy module dynamically
strategy_class_path = STRATEGY_REGISTRY.get(strategy_key, {}).get("class")
if not strategy_class_path:
return
module_path, class_name = strategy_class_path.rsplit(".", 1)
import importlib
try:
mod = importlib.import_module(module_path)
strategy_cls = getattr(mod, class_name)
# Instantiate if not already cached
if not hasattr(_tick, "_instances"):
_tick._instances = {}
if strategy_key not in _tick._instances:
from framework.config import StrategyConfig
cfg = StrategyConfig(
name=STRATEGY_REGISTRY[strategy_key]["name"],
instrument=f"{coin}-USD-PERP",
asset=coin,
allocation=10000.0,
order_size=0.001,
testnet=False, # paper uses mainnet data
)
_tick._instances[strategy_key] = strategy_cls(cfg)
strat = _tick._instances[strategy_key]
sig = strat.compute_signal(price=mark)
if sig:
# Paper execution
from framework.execution import PaperExecutionProvider as Pep
pep = Pep()
cloid = pep.submit(
coin=coin,
side="BUY" if "BUY" in sig.get("signal", "").upper() else "SELL",
size=cfg.order_size,
price=mark,
fee_model=cfg.fee_model,
mark_price=mark,
)
logger.info("Paper signal: %s%s | fill=%s", sig["signal"], cloid, mark)
except Exception as e:
logger.warning("Tick error for %s: %s", strategy_key, e)
def main():
parser = argparse.ArgumentParser(description="FTDT Quant Lab — Deploy Orchestrator")
sub = parser.add_subparsers(dest="command", help="Command")
# backtest
bt = sub.add_parser("backtest", help="Run backtest (VectorBT + NautilusTrader)")
bt.add_argument("--strategy", "-s", required=True, help="Strategy key (pairs, hurst_vpin, as_mm, etc.)")
bt.add_argument("--fast", dest="vbt_only", action="store_true", help="VectorBT quick backtest only")
bt.add_argument("--full", dest="nt_only", action="store_true", help="NautilusTrader full backtest only")
bt.add_argument("--interval", default="1h", help="Candle interval (1m, 5m, 15m, 1h, 4h, 1d)")
bt.add_argument("--testnet", action="store_true", default=False, help="Use testnet data")
# paper
pp = sub.add_parser("paper", help="Run paper trading simulation")
pp.add_argument("--strategy", "-s", required=True, help="Strategy key")
pp.add_argument("--duration", type=int, default=3600, help="Duration in seconds (default: 3600)")
pp.add_argument("--coin", help="Override trading coin (default: strategy default)")
# live
ll = sub.add_parser("live", help="Run live trading")
ll.add_argument("--strategy", "-s", required=True, help="Strategy key")
ll.add_argument("--testnet", action="store_true", default=True, help="Use testnet (default)")
ll.add_argument("--mainnet", action="store_true", help="Use mainnet")
# list
sub.add_parser("list", help="List registered strategies and results")
args = parser.parse_args()
if not args.command:
parser.print_help()
return
orch = DeployOrchestrator()
getattr(orch, f"cmd_{args.command}")(args)
if __name__ == "__main__":
main()
+274
View File
@@ -0,0 +1,274 @@
"""
Hyperliquid execution provider — live and paper trading via NautilusTrader.
Live mode: Submits real orders to Hyperliquid testnet/mainnet via REST.
Paper mode: Tracks virtual positions, simulates fills with realistic slippage.
Uses the hyperliquid-python-sdk for signed order submission.
"""
from __future__ import annotations
import asyncio
import logging
import time
from dataclasses import dataclass, field
import requests
from nautilus_trader.model.enums import OrderSide, OrderType, TimeInForce
from nautilus_trader.model.identifiers import ClientOrderId, InstrumentId, VenueOrderId
from nautilus_trader.model.objects import Price, Quantity
logger = logging.getLogger(__name__)
TESTNET_API = "https://api.hyperliquid-testnet.xyz/info"
MAINNET_API = "https://api.hyperliquid.xyz/info"
@dataclass
class SimulatedPosition:
coin: str
quantity: float
entry_price: float
side: str # BUY or SELL
fee_paid: float = 0.0
pnl: float = 0.0
@dataclass
class SimulatedOrder:
cloid: str
coin: str
side: str
quantity: float
price: float
timestamp: float = field(default_factory=time.time)
filled: bool = False
fill_price: float = 0.0
fee: float = 0.0
pnl: float = 0.0
class HyperliquidExecutionProvider:
"""Live trading via Hyperliquid SDK + REST API."""
def __init__(
self,
private_key: str,
testnet: bool = True,
vault_address: str | None = None,
):
self._pk = private_key
self._vault = vault_address
self._testnet = testnet
self._api_url = TESTNET_API if testnet else MAINNET_API
self._exchange = None
self._info = None
self._address: str | None = None
def _ensure_sdk(self):
if self._exchange is None:
from hyperliquid.exchange import Exchange
from hyperliquid.info import Info
self._info = Info(self._api_url, skip_ws=True)
self._exchange = Exchange(
wallet=self._info,
private_key=self._pk,
vault_address=self._vault,
account_address=None,
is_testnet=self._testnet,
)
meta = self._info.meta()
if meta and "universe" in meta:
logger.info("HL SDK initialized: %d assets", len(meta.get("universe", [])))
@property
def address(self) -> str | None:
if not self._address:
self._ensure_sdk()
if self._exchange:
self._address = self._exchange.wallet.address
return self._address
def submit_limit_order(
self,
coin: str,
side: str, # "BUY" or "SELL"
size: float,
price: float,
post_only: bool = True,
reduce_only: bool = False,
) -> dict | None:
"""Submit a limit order. Returns order response or None on failure."""
self._ensure_sdk()
try:
is_buy = side.upper() == "BUY"
result = self._exchange.order(
name=coin,
is_buy=is_buy,
sz=size,
limit_px=price,
order_type={"limit": {"tif": "Gtc" if post_only else "Ioc"}},
reduce_only=reduce_only,
)
logger.info("Order submitted: %s %s %.6f @ %.1f%s",
side, coin, size, price, result)
return result
except Exception as e:
logger.error("Order failed: %s %s: %s", side, coin, e)
return None
def cancel_order(self, coin: str, cloid: str) -> bool:
"""Cancel an order by client order ID."""
self._ensure_sdk()
try:
self._exchange.cancel(coin, cloid)
return True
except Exception as e:
logger.warning("Cancel failed for %s/%s: %s", coin, cloid, e)
return False
def cancel_all(self, coin: str | None = None):
"""Cancel all open orders, optionally filtered by coin."""
self._ensure_sdk()
try:
self._exchange.cancel_all(coin)
except Exception as e:
logger.warning("Cancel all failed: %s", e)
def get_positions(self) -> list[dict]:
"""Get open positions for the wallet."""
if not self.address:
return []
resp = requests.post(
self._api_url,
json={"type": "clearinghouseState", "user": self.address},
timeout=10,
)
if resp.status_code != 200:
return []
data = resp.json()
positions = []
for pos in data.get("assetPositions", []):
pos_type = pos.get("position", {})
if pos_type:
coin = pos_type.get("coin", "")
szi = float(pos_type.get("szi", 0))
if coin and abs(szi) > 0:
positions.append({
"coin": coin,
"size": szi,
"entry_px": float(pos_type.get("entryPx", 0)),
"unrealized_pnl": float(pos_type.get("unrealizedPnl", 0)),
})
return positions
def get_open_orders(self) -> list[dict]:
if not self.address:
return []
resp = requests.post(
self._api_url,
json={"type": "openOrders", "user": self.address},
timeout=10,
)
if resp.status_code != 200:
return []
return resp.json()
class PaperExecutionProvider:
"""Paper trading — simulated fills against real Hyperliquid mark prices."""
def __init__(
self,
maker_fee: float = 0.0002,
taker_fee: float = 0.0005,
slippage_bps: float = 1.0,
):
self.maker_fee = maker_fee
self.taker_fee = taker_fee
self.slippage_bps = slippage_bps
self.positions: dict[str, SimulatedPosition] = {}
self.orders: dict[str, SimulatedOrder] = {}
self.trades: list[dict] = []
self._counter = 0
def submit(
self,
coin: str,
side: str,
size: float,
price: float,
fee_model: str = "taker",
mark_price: float | None = None,
) -> str:
"""Submit a simulated order. Returns client order ID."""
self._counter += 1
cloid = f"paper-{self._counter}"
order = SimulatedOrder(cloid=cloid, coin=coin, side=side, quantity=size, price=price)
self.orders[cloid] = order
# Simulate immediate fill at mark price or limit price
fill_price = mark_price if mark_price and mark_price > 0 else price
fee_rate = self.maker_fee if fee_model == "maker" else self.taker_fee
# Apply slippage
slip = fill_price * self.slippage_bps / 10000
effective_px = fill_price + slip if side.upper() == "BUY" else fill_price - slip
fee = size * effective_px * fee_rate
order.filled = True
order.fill_price = effective_px
order.fee = fee
# Update position
pos = self.positions.get(coin)
if pos and pos.side != side:
# Closing trade — calculate PnL
pnl = (effective_px - pos.entry_price) * min(size, abs(pos.quantity))
if pos.side == "SELL":
pnl = -pnl
order.pnl = pnl
pos.quantity -= size
pos.fee_paid += fee
pos.pnl += pnl
if abs(pos.quantity) < 1e-8:
del self.positions[coin]
else:
# Opening or adding to position
if coin not in self.positions:
self.positions[coin] = SimulatedPosition(
coin=coin, quantity=size, entry_price=effective_px, side=side
)
else:
pos.quantity += size
pos.entry_price = (pos.entry_price * (pos.quantity - size) + effective_px * size) / pos.quantity
trade = {
"cloid": cloid,
"coin": coin,
"side": side,
"size": size,
"price": effective_px,
"fee": round(fee, 6),
"pnl": round(order.pnl, 4),
"timestamp": time.time(),
}
self.trades.append(trade)
logger.debug("Paper fill: %s %s %.6f @ %.1f | pnl=%.4f fee=%.6f",
side, coin, size, effective_px, order.pnl, fee)
return cloid
def cancel(self, cloid: str) -> bool:
if cloid in self.orders and not self.orders[cloid].filled:
del self.orders[cloid]
return True
return False
def get_pnl(self) -> float:
return sum(p.pnl for p in self.positions.values()) + sum(
t.get("pnl", 0) for t in self.trades if t.get("pnl", 0) > 0
)
+124
View File
@@ -0,0 +1,124 @@
"""
Hyperliquid instrument catalog — loads perpetual contracts as NT CryptoPerpetual.
Fetches exchange metadata (universe + asset contexts) from Hyperliquid info API
and builds NautilusTrader CryptoPerpetual instrument definitions.
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from decimal import Decimal
import requests
from nautilus_trader.model.identifiers import InstrumentId, Symbol, Venue
from nautilus_trader.model.instruments import CryptoPerpetual
from nautilus_trader.model.objects import Currency, Price, Quantity
logger = logging.getLogger(__name__)
HL_VENUE = Venue("HYPERLIQUID")
TESTNET_API = "https://api.hyperliquid-testnet.xyz/info"
MAINNET_API = "https://api.hyperliquid.xyz/info"
def _hl_meta(testnet: bool = True) -> dict:
url = TESTNET_API if testnet else MAINNET_API
resp = requests.post(url, json={"type": "metaAndAssetCtxs"}, timeout=15)
resp.raise_for_status()
data = resp.json()
if not isinstance(data, list) or len(data) < 2:
raise ValueError("Invalid metaAndAssetCtxs response")
return {"universe": data[0].get("universe", []), "contexts": data[1]}
def _to_instrument(asset: dict, ctx: dict | None) -> CryptoPerpetual | None:
name = asset.get("name", "")
if not name:
return None
symbol_str = f"{name}-USD-PERP"
inst_id = InstrumentId(Symbol(symbol_str), HL_VENUE)
px_ctx = ctx if ctx else {}
mark_px = float(px_ctx.get("markPx", 0) or 0)
step_size = asset.get("szDecimals", 5)
size_increment_val = 10 ** -step_size
tick_size = asset.get("pxDecimals", 1)
price_increment_val = 10 ** -tick_size
now_ns = int(datetime.now(timezone.utc).timestamp() * 1e9)
return CryptoPerpetual(
instrument_id=inst_id,
raw_symbol=Symbol(symbol_str),
base_currency=Currency.from_str(name),
quote_currency=Currency.from_str("USD"),
settlement_currency=Currency.from_str("USD"),
is_inverse=False,
price_precision=tick_size,
size_precision=step_size,
price_increment=Price.from_str(str(price_increment_val)),
size_increment=Quantity.from_str(str(size_increment_val)),
multiplier=Quantity.from_str("1.0"),
maker_fee=Decimal("0.0002"),
taker_fee=Decimal("0.0005"),
max_quantity=Quantity.from_str("10000.0"),
min_quantity=Quantity.from_str(str(size_increment_val)),
max_notional=None,
min_notional=None,
max_price=Price.from_str(str(int(mark_px * 10)) if mark_px > 0 else "10000000.0"),
min_price=Price.from_str("0.01"),
margin_init=Decimal("0.02"),
margin_maint=Decimal("0.01"),
ts_event=now_ns,
ts_init=now_ns,
)
class HyperliquidInstrumentCatalog:
"""Fetches and caches Hyperliquid perpetual instrument definitions."""
def __init__(self, testnet: bool = True):
self._testnet = testnet
self._instruments: dict[str, CryptoPerpetual] = {}
@property
def venue(self) -> Venue:
return HL_VENUE
def load(self, assets: list[str] | None = None) -> dict[str, CryptoPerpetual]:
"""Fetch all perps, returning dict keyed by base currency name."""
meta = _hl_meta(testnet=self._testnet)
universe = meta["universe"]
contexts = meta["contexts"]
for i, asset_info in enumerate(universe):
name = asset_info.get("name", "")
if not name:
continue
if assets and name.upper() not in [a.upper() for a in assets]:
continue
ctx = contexts[i] if i < len(contexts) else None
try:
inst = _to_instrument(asset_info, ctx)
if inst:
self._instruments[name] = inst
except Exception as e:
logger.warning("Skipped instrument %s: %s", name, e)
logger.info("Loaded %d Hyperliquid instruments", len(self._instruments))
return self._instruments
def get(self, name: str) -> CryptoPerpetual | None:
return self._instruments.get(name.upper())
def all_ids(self) -> list[InstrumentId]:
return [inst.id for inst in self._instruments.values()]
def __len__(self) -> int:
return len(self._instruments)
def __iter__(self):
return iter(self._instruments.values())