""" 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())