f5ffe4baee
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.
223 lines
8.2 KiB
Python
223 lines
8.2 KiB
Python
"""
|
|
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)
|