70d43fefe0
New module: strategies/funding_arb.py
- get_funding_rates(): fetches predicted funding from Hyperliquid
Uses metaAndAssetCtxs (primary) + predictedFundings (fallback)
- funding_arb_signal(): generates entry/exit signals
Entry: |annual_rate| > threshold (3% testnet, 5% mainnet)
Exit: rate drops below 2% or flips sign
- 30s cache to avoid rate-limiting
Live node:
- Replaced proxy-based funding (20-period return) with real API
- Calls get_funding_rates(use_testnet=True) every compute_signals()
- Lowered threshold to 3% APR for testnet (lower liquidity)
Paper trader:
- Replaced manual funding calc with unified funding_arb_signal()
- Proper entry/exit logic with position tracking
- 5% APR threshold for mainnet data
Current rates: BTC +0.87% APR, ETH -0.82% APR
(Arb fires when rates exceed threshold during volatility)
144 lines
5.0 KiB
Python
144 lines
5.0 KiB
Python
"""
|
||
Funding Rate Arb — Complete Implementation.
|
||
|
||
Strategy:
|
||
Funding rates on perpetual futures represent the cost of leverage.
|
||
When funding is positive (longs pay shorts), short the perp and collect.
|
||
When funding is negative (shorts pay longs), go long the perp and collect.
|
||
|
||
The Hyperliquid API provides predicted funding rates via:
|
||
- predictedFundings: current predicted rate for each interval
|
||
- metaAndAssetCtxs: asset context including current funding
|
||
|
||
Entry: |annualized_funding_rate| > threshold (5-10% APR)
|
||
Exit: |annualized_funding_rate| < threshold/2 or after N hours
|
||
Size: scales with rate — higher rate = larger size
|
||
"""
|
||
|
||
import requests
|
||
import time
|
||
import math
|
||
from typing import Optional
|
||
|
||
MAINNET_API = "https://api.hyperliquid.xyz/info"
|
||
TESTNET_API = "https://api.hyperliquid-testnet.xyz/info"
|
||
|
||
# Cache funding rates to avoid hitting API every tick
|
||
_funding_cache: dict = {}
|
||
_last_funding_fetch: float = 0
|
||
FUNDING_CACHE_TTL = 30 # seconds
|
||
|
||
|
||
def get_funding_rates(use_testnet: bool = False) -> dict[str, float]:
|
||
"""
|
||
Fetch current predicted funding rates for supported coins.
|
||
|
||
Uses Hyperliquid's predictedFundings endpoint which returns
|
||
the current projected funding rate for each perpetual.
|
||
|
||
Returns: {coin: funding_rate_annualized}
|
||
"""
|
||
global _funding_cache, _last_funding_fetch
|
||
|
||
now = time.time()
|
||
if now - _last_funding_fetch < FUNDING_CACHE_TTL and _funding_cache:
|
||
return _funding_cache
|
||
|
||
api = TESTNET_API if use_testnet else MAINNET_API
|
||
rates: dict[str, float] = {}
|
||
|
||
# Method 1: Try metaAndAssetCtxs (most reliable)
|
||
try:
|
||
r = requests.post(MAINNET_API, json={"type": "metaAndAssetCtxs"}, timeout=10)
|
||
data = r.json()
|
||
if isinstance(data, list) and len(data) >= 2:
|
||
universe = data[0].get("universe", [])
|
||
ctxs = data[1]
|
||
for i, u in enumerate(universe):
|
||
name = u.get("name", "")
|
||
if name in ("BTC", "ETH", "HYPE", "VVV", "SOL"):
|
||
try:
|
||
funding = float(ctxs[i].get("funding", 0))
|
||
# funding is the 8h rate; annualize: × 365 × (24/8) = × 1095
|
||
annual = funding * 1095
|
||
rates[name] = annual
|
||
except (IndexError, ValueError, TypeError):
|
||
pass
|
||
except Exception:
|
||
pass
|
||
|
||
# Method 2: Fallback to predictedFundings
|
||
if not rates:
|
||
try:
|
||
r = requests.post(MAINNET_API, json={"type": "predictedFundings"}, timeout=10)
|
||
data = r.json()
|
||
if isinstance(data, list):
|
||
for coin_entry in data:
|
||
coin = coin_entry[0]
|
||
if coin not in ("BTC", "ETH", "HYPE", "VVV", "SOL"):
|
||
continue
|
||
for venue_entry in coin_entry[1]:
|
||
venue = venue_entry[0]
|
||
info = venue_entry[1]
|
||
rate_str = info.get("fundingRate", "0")
|
||
try:
|
||
rate = float(rate_str)
|
||
except (ValueError, TypeError):
|
||
rate = 0.0
|
||
interval_hours = info.get("fundingIntervalHours", 8)
|
||
annual = rate * (365 * 24 / interval_hours)
|
||
if coin not in rates or "HlPerp" in venue:
|
||
rates[coin] = annual
|
||
except Exception:
|
||
pass
|
||
|
||
_funding_cache = rates
|
||
_last_funding_fetch = now
|
||
return rates
|
||
|
||
|
||
def funding_arb_signal(
|
||
coin: str = "BTC",
|
||
apr_threshold: float = 0.05, # 5% APR minimum
|
||
apr_exit: float = 0.02, # 2% APR to exit
|
||
current_position: int = 0,
|
||
) -> dict:
|
||
"""
|
||
Generate funding rate arbitrage signal.
|
||
|
||
Args:
|
||
coin: Ticker to check.
|
||
apr_threshold: Minimum annualized funding rate to enter (>0.05 = 5%).
|
||
apr_exit: Rate below which to exit position.
|
||
current_position: -1 (short), 0 (none), +1 (long).
|
||
|
||
Returns:
|
||
dict with signal, rate, annual_apr, reason.
|
||
"""
|
||
rates = get_funding_rates()
|
||
annual = rates.get(coin, 0)
|
||
rate_8h = annual / 1095 # de-annualize
|
||
|
||
signal = 0
|
||
reason = ""
|
||
|
||
if abs(annual) > apr_threshold and current_position == 0:
|
||
signal = -1 if annual > 0 else +1 # short if funding positive, long if negative
|
||
reason = f"funding_{annual*100:.1f}pct_apr"
|
||
elif current_position != 0:
|
||
# Exit condition: rate has dropped below exit threshold
|
||
if abs(annual) < apr_exit:
|
||
signal = -current_position
|
||
reason = f"exit_funding_{annual*100:.2f}pct_apr"
|
||
# Also exit if funding flips sign (we'd be paying instead of collecting)
|
||
elif (current_position == -1 and annual < 0) or (current_position == 1 and annual > 0):
|
||
signal = -current_position
|
||
reason = f"exit_funding_flipped_{annual*100:.2f}pct_apr"
|
||
|
||
return {
|
||
"signal": signal,
|
||
"rate_8h": rate_8h,
|
||
"annual_apr": annual,
|
||
"reason": reason,
|
||
}
|