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:
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
)
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user