c1da0cbe65
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
115 lines
4.2 KiB
Python
115 lines
4.2 KiB
Python
"""
|
|
Funding Rate Arbitrage strategy.
|
|
|
|
Hyperliquid pays funding every 8 hours. When the rate is positive,
|
|
longs pay shorts. This strategy:
|
|
|
|
1. Goes LONG spot (no funding payments)
|
|
2. Goes SHORT perp (collects funding)
|
|
3. Maintains delta neutrality
|
|
|
|
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 # 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 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 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:
|
|
# Check funding every 5 bars to avoid hammering the API
|
|
self.bars_elapsed += 1
|
|
if self.bars_elapsed % 5 != 0:
|
|
return
|
|
|
|
# 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.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.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 _extract_asset(self, instrument: str) -> str:
|
|
"""Extract asset name from instrument ID (e.g. BTC-USD-PERP -> BTC)."""
|
|
return instrument.split("-")[0]
|
|
|
|
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",
|
|
quantity=self.config.position_size,
|
|
))
|
|
self.submit_order(self.order_factory.market(
|
|
instrument_id=self.config.perp_instrument,
|
|
order_side="SELL",
|
|
quantity=self.config.position_size,
|
|
))
|
|
|
|
def _close_arb(self) -> None:
|
|
"""Close both legs."""
|
|
self.close_all_positions(self.config.spot_instrument)
|
|
self.close_all_positions(self.config.perp_instrument)
|