From c1da0cbe6577735bdbd822bf0e7ba61479dd6e59 Mon Sep 17 00:00:00 2001 From: ramseshk Date: Mon, 3 Aug 2026 11:37:47 +0000 Subject: [PATCH] Wire up real Hyperliquid integration and funding rate API Replaced the placeholder live node with a proper NautilusTrader TradingNode that connects to Hyperliquid Testnet using the official adapter. Added: - common/hyperliquid_api.py: direct REST calls to Hyperliquid's info endpoint for funding rates, predicted fundings, and asset contexts - backtests/run_backtest.py: CLI runner for strategy backtests - Updated funding_rate_arb.py to fetch real funding rates instead of using a hardcoded placeholder - Added requests to requirements.txt --- backtests/run_backtest.py | 90 ++++++++++++++++++++ common/hyperliquid_api.py | 75 +++++++++++++++++ live/node.py | 150 +++++++++++++++++++++++++++++++-- requirements.txt | 5 +- strategies/funding_rate_arb.py | 71 ++++++++++++---- 5 files changed, 366 insertions(+), 25 deletions(-) create mode 100644 backtests/run_backtest.py create mode 100644 common/hyperliquid_api.py diff --git a/backtests/run_backtest.py b/backtests/run_backtest.py new file mode 100644 index 0000000..72a7371 --- /dev/null +++ b/backtests/run_backtest.py @@ -0,0 +1,90 @@ +""" +Quick backtest runner for strategy validation. + +Runs any strategy against historical bar data to check basic +logic before deploying live. Uses NautilusTrader's BacktestEngine. + +Usage: + python backtests/run_backtest.py --strategy ofi --bars data/BTC-1h.parquet +""" +import argparse +import asyncio +from pathlib import Path + +from nautilus_trader.config import BacktestEngineConfig +from nautilus_trader.config import BacktestDataConfig +from nautilus_trader.config import BacktestVenueConfig +from nautilus_trader.model.data import BarType +from nautilus_trader.model.identifiers import InstrumentId, Venue +from nautilus_trader.backtest.node import BacktestNode + + +STRATEGIES = { + "ofi": "strategies.orderbook_imbalance:OrderBookImbalanceConfig", + "iceberg": "strategies.iceberg_detection:IcebergDetectorConfig", + "funding_arb": "strategies.funding_rate_arb:FundingRateArbConfig", + "pairs": "strategies.pairs_trading:PairsTradingConfig", + "avellaneda": "strategies.avellaneda_stoikov:AvellanedaStoikovConfig", +} + + +async def run_backtest(strategy_name: str, data_path: str) -> None: + """Run a single strategy backtest.""" + if strategy_name not in STRATEGIES: + print(f"Unknown strategy: {strategy_name}") + print(f"Options: {list(STRATEGIES.keys())}") + return + + config_path = STRATEGIES[strategy_name] + + # Basic backtest config — swap these for real data + engine_config = BacktestEngineConfig() + + venue_config = BacktestVenueConfig( + name="HYPERLIQUID", + oms_type="NETTING", + account_type="MARGIN", + starting_balances=["100000 USDC"], + ) + + data_config = BacktestDataConfig( + catalog_path=str(Path(data_path).parent), + data_cls="nautilus_trader.model.data.Bar", + catalog_fs_protocol="file", + bar_type=BarType.from_str("BTC-USD-PERP-1-HOUR-LAST-INTERNAL"), + instrument_id=InstrumentId.from_str("BTC-USD-PERP.HYPERLIQUID"), + start_time=None, + end_time=None, + ) + + node = BacktestNode( + config=engine_config, + venue_configs=[venue_config], + data_configs=[data_config], + ) + + node.add_strategy(config_path=config_path) + await node.run() + node.dispose() + + +def main(): + parser = argparse.ArgumentParser(description="FTDT Quant Lab - Backtest Runner") + parser.add_argument( + "--strategy", "-s", + choices=list(STRATEGIES.keys()), + required=True, + help="Strategy to backtest", + ) + parser.add_argument( + "--data", "-d", + default="data/BTC-1h.parquet", + help="Path to bar data (parquet format)", + ) + args = parser.parse_args() + + asyncio.run(run_backtest(args.strategy, args.data)) + + +if __name__ == "__main__": + main() diff --git a/common/hyperliquid_api.py b/common/hyperliquid_api.py new file mode 100644 index 0000000..50895b7 --- /dev/null +++ b/common/hyperliquid_api.py @@ -0,0 +1,75 @@ +""" +Hyperliquid API utilities. + +Direct REST calls to Hyperliquid info endpoint for data +not yet covered by the NautilusTrader adapter (funding rates, +predicted fundings, asset contexts). +""" +import requests +from typing import Any + + +TESTNET_API = "https://api.hyperliquid-testnet.xyz/info" +MAINNET_API = "https://api.hyperliquid.xyz/info" + + +def _post(api_url: str, payload: dict) -> Any: + resp = requests.post(api_url, json=payload, timeout=10) + resp.raise_for_status() + return resp.json() + + +def get_asset_contexts(testnet: bool = True) -> list[dict]: + """ + Fetch asset contexts including current funding rates. + Returns list of per-asset dicts with keys: + funding, openInterest, markPx, oraclePx, premium, dayNtlVlm, etc. + """ + api = TESTNET_API if testnet else MAINNET_API + data = _post(api, {"type": "metaAndAssetCtxs"}) + # data[0] = universe, data[1] = asset contexts + if isinstance(data, list) and len(data) >= 2: + return data[1] + return [] + + +def get_funding_rate(asset_name: str, testnet: bool = True) -> float | None: + """ + Get the current funding rate for a specific asset. + Funding is paid every 8 hours. Positive = longs pay shorts. + """ + ctxs = get_asset_contexts(testnet=testnet) + for ctx in ctxs: + if isinstance(ctx, dict) and ctx.get("name") == asset_name.upper(): + funding_str = ctx.get("funding", "0") + return float(funding_str) + return None + + +def get_all_funding_rates(testnet: bool = True) -> dict[str, float]: + """Get funding rates for all assets on Hyperliquid.""" + ctxs = get_asset_contexts(testnet=testnet) + rates = {} + for ctx in ctxs: + if isinstance(ctx, dict): + name = ctx.get("name", "") + funding_str = ctx.get("funding", "0") + if name: + rates[name] = float(funding_str) + return rates + + +def get_predicted_funding(asset_name: str, testnet: bool = True) -> float | None: + """ + Get the predicted funding rate for the next interval. + Uses the predictedFundings endpoint. + """ + api = TESTNET_API if testnet else MAINNET_API + data = _post(api, {"type": "predictedFundings"}) + if isinstance(data, list): + for item in data: + if isinstance(item, dict) and item.get("name") == asset_name.upper(): + # Return the Hyperliquid-specific prediction + predicted = item.get("funding", "0") + return float(predicted) + return None diff --git a/live/node.py b/live/node.py index 56682b4..6c79033 100644 --- a/live/node.py +++ b/live/node.py @@ -1,17 +1,145 @@ """ Live trading node for Hyperliquid Testnet. -Runs all five strategies concurrently with shared risk management. +Runs all five quant strategies against the Hyperliquid testnet +using NautilusTrader's event-driven architecture. Strategies share +a risk manager and portfolio tracker. + +Usage: + export HYPERLIQUID_TESTNET_PK=0x... + python live/node.py """ -import asyncio import os import sys +import asyncio + +from nautilus_trader.config import TradingNodeConfig +from nautilus_trader.config import LiveDataEngineConfig +from nautilus_trader.config import LiveRiskEngineConfig +from nautilus_trader.config import LiveExecEngineConfig +from nautilus_trader.model.identifiers import TraderId +from nautilus_trader.common.enums import Environment +from nautilus_trader.live.node import TradingNode + +from nautilus_trader.adapters.hyperliquid.config import ( + HyperliquidDataClientConfig, + HyperliquidExecClientConfig, +) +from nautilus_trader.adapters.hyperliquid.factories import ( + HyperliquidLiveDataClientFactory, + HyperliquidLiveExecClientFactory, +) + + +def build_node(private_key: str) -> TradingNode: + """Build and configure the trading node with all strategies.""" + + data_config = HyperliquidDataClientConfig( + environment="testnet", + http_timeout_secs=30, + ) + exec_config = HyperliquidExecClientConfig( + private_key=private_key, + environment="testnet", + normalize_prices=True, + http_timeout_secs=30, + ) + + node_config = TradingNodeConfig( + trader_id=TraderId("FTDT-QUANT-001"), + environment=Environment.LIVE, + data_engine=LiveDataEngineConfig(), + risk_engine=LiveRiskEngineConfig(), + exec_engine=LiveExecEngineConfig(), + data_clients={ + "HYPERLIQUID": data_config, + }, + exec_clients={ + "HYPERLIQUID": exec_config, + }, + timeout_connection=30.0, + timeout_reconciliation=15.0, + timeout_portfolio=15.0, + timeout_disconnection=15.0, + timeout_post_stop=5.0, + ) + + node = TradingNode(config=node_config) + + # Register the Hyperliquid client factories + node.add_data_client_factory("HYPERLIQUID", HyperliquidLiveDataClientFactory) + node.add_exec_client_factory("HYPERLIQUID", HyperliquidLiveExecClientFactory) + + return node + + +def register_strategies(node: TradingNode) -> None: + """Register all five strategies with the trading node.""" + + # Import strategies here to avoid circular imports + from strategies.orderbook_imbalance import ( + OrderBookImbalance, OrderBookImbalanceConfig, + ) + from strategies.iceberg_detection import ( + IcebergDetector, IcebergDetectorConfig, + ) + from strategies.funding_rate_arb import ( + FundingRateArb, FundingRateArbConfig, + ) + from strategies.pairs_trading import ( + PairsTrading, PairsTradingConfig, + ) + from strategies.avellaneda_stoikov import ( + AvellanedaStoikov, AvellanedaStoikovConfig, + ) + + # 1. Order Book Imbalance + node.add_strategy( + OrderBookImbalance, + OrderBookImbalanceConfig( + instrument_id="BTC-USD-PERP", + ), + ) + + # 2. Iceberg / TWAP Detection + node.add_strategy( + IcebergDetector, + IcebergDetectorConfig( + instrument_id="BTC-USD-PERP", + ), + ) + + # 3. Funding Rate Arbitrage + node.add_strategy( + FundingRateArb, + FundingRateArbConfig( + spot_instrument="BTC-SPOT", + perp_instrument="BTC-USD-PERP", + ), + ) + + # 4. Pairs Trading (BTC/ETH) + node.add_strategy( + PairsTrading, + PairsTradingConfig( + pair=("BTC-USD-PERP", "ETH-USD-PERP"), + ), + ) + + # 5. Avellaneda-Stoikov Market Making + node.add_strategy( + AvellanedaStoikov, + AvellanedaStoikovConfig( + instrument_id="BTC-USD-PERP", + ), + ) async def main(): private_key = os.getenv("HYPERLIQUID_TESTNET_PK") if not private_key: - print("Set HYPERLIQUID_TESTNET_PK environment variable") + print("ERROR: Set HYPERLIQUID_TESTNET_PK environment variable") + print(" export HYPERLIQUID_TESTNET_PK=0x...") sys.exit(1) print("=" * 55) @@ -26,9 +154,21 @@ async def main(): print(" 4. Pairs Trading (BTC/ETH)") print(" 5. Avellaneda-Stoikov Market Making") print() + + node = build_node(private_key) + register_strategies(node) + print("Connecting to Hyperliquid Testnet...") - # TODO: Full Nautilus TradingNode integration - print("Ready.") + try: + await node.start() + print("Node started. Running strategies...") + print("Press Ctrl+C to stop.") + await node.run_until_stopped() + except KeyboardInterrupt: + print("\nShutting down...") + finally: + await node.stop() + print("Node stopped. Goodbye.") if __name__ == "__main__": diff --git a/requirements.txt b/requirements.txt index 6edf1b5..3e5856d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,9 @@ -# Nautilus Trader +# Core nautilus-trader>=1.210.0 - -# Data & Math numpy>=1.24.0 pandas>=2.0.0 pyyaml>=6.0 +requests>=2.28.0 # Visualization matplotlib>=3.7.0 diff --git a/strategies/funding_rate_arb.py b/strategies/funding_rate_arb.py index 1d74fdd..c440d65 100644 --- a/strategies/funding_rate_arb.py +++ b/strategies/funding_rate_arb.py @@ -8,59 +8,95 @@ longs pay shorts. This strategy: 2. Goes SHORT perp (collects funding) 3. Maintains delta neutrality -The profit comes from funding, not price direction. +The profit comes from funding, not price direction. The strategy +fetches real funding rates from Hyperliquid's API every bar +and enters/exits based on the rate crossing configurable thresholds. """ from nautilus_trader.trading.strategy import Strategy from nautilus_trader.config import StrategyConfig +from common.hyperliquid_api import get_funding_rate, get_predicted_funding + class FundingRateArbConfig(StrategyConfig, frozen=True): spot_instrument: str perp_instrument: str - min_funding_rate: float = 0.0001 - rebalance_threshold: float = 0.05 - position_size: float = 0.01 + min_funding_rate: float = 0.0001 # 0.01% annualized ~ 10.95% APR + rebalance_threshold: float = 0.05 # 5% PnL deviation triggers rebalance + position_size: float = 0.01 # BTC + use_predicted: bool = True # Use predicted funding rate + testnet: bool = True class FundingRateArb(Strategy): """ Delta-neutral funding rate carry trade. - Key idea: funding rate IS the edge. Stay neutral, collect - the payments. + Key concept: the funding rate IS the edge. + Direction doesn't matter — neutrality does. + + Entry: when funding rate > min_funding_rate AND no position + Exit: when funding rate drops below half the entry threshold """ def __init__(self, config: FundingRateArbConfig) -> None: super().__init__(config) self.config = config self.position_open = False + self.bars_elapsed = 0 def on_start(self) -> None: bar_type = f"{self.config.perp_instrument}-1-MINUTE-LAST-INTERNAL" self.subscribe_bars(bar_type) self.log.info( - f"Funding arb: {self.config.spot_instrument} / {self.config.perp_instrument}" + f"Funding arb started: " + f"{self.config.spot_instrument} / {self.config.perp_instrument} " + f"(min_rate={self.config.min_funding_rate:.4%}, " + f"size={self.config.position_size})" ) def on_bar(self, bar) -> None: - funding_rate = self._get_funding_rate() - if funding_rate is None: + # Check funding every 5 bars to avoid hammering the API + self.bars_elapsed += 1 + if self.bars_elapsed % 5 != 0: return - spot_pos = self.portfolio.net_position(self.config.spot_instrument) + # Fetch real funding rate from Hyperliquid + asset = self._extract_asset(self.config.perp_instrument) + if self.config.use_predicted: + funding_rate = get_predicted_funding(asset, testnet=self.config.testnet) + else: + funding_rate = get_funding_rate(asset, testnet=self.config.testnet) + if funding_rate is None: + return # API call failed, skip this bar + + spot_pos = float(self.portfolio.net_position(self.config.spot_instrument)) + + # Entry condition: funding rate is attractive and we have no position if funding_rate > self.config.min_funding_rate and spot_pos == 0: - self._open() + self.log.info( + f"Entering funding arb: rate={funding_rate:.6f} " + f"(>{self.config.min_funding_rate:.6f})" + ) + self._open_arb() self.position_open = True + + # Exit condition: funding rate no longer worth the risk elif funding_rate < self.config.min_funding_rate / 2 and self.position_open: - self._close() + self.log.info( + f"Closing funding arb: rate={funding_rate:.6f} " + f"(<{self.config.min_funding_rate / 2:.6f})" + ) + self._close_arb() self.position_open = False - def _get_funding_rate(self) -> float | None: - # TODO: fetch from Hyperliquid API - return 0.0001 + def _extract_asset(self, instrument: str) -> str: + """Extract asset name from instrument ID (e.g. BTC-USD-PERP -> BTC).""" + return instrument.split("-")[0] - def _open(self) -> None: + def _open_arb(self) -> None: + """Long spot, short perp — delta neutral.""" self.submit_order(self.order_factory.market( instrument_id=self.config.spot_instrument, order_side="BUY", @@ -72,6 +108,7 @@ class FundingRateArb(Strategy): quantity=self.config.position_size, )) - def _close(self) -> None: + def _close_arb(self) -> None: + """Close both legs.""" self.close_all_positions(self.config.spot_instrument) self.close_all_positions(self.config.perp_instrument)