Complete Funding Rate Arb: real API data for live + paper
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)
This commit is contained in:
+15
-9
@@ -127,17 +127,23 @@ def compute_signals():
|
|||||||
if up>=5: STRATEGIES["Iceberg Detection"]["signals"].append({"time":time.time(),"signal":"BUY","strength":up/10})
|
if up>=5: STRATEGIES["Iceberg Detection"]["signals"].append({"time":time.time(),"signal":"BUY","strength":up/10})
|
||||||
elif up<=5: STRATEGIES["Iceberg Detection"]["signals"].append({"time":time.time(),"signal":"SELL","strength":1-up/10})
|
elif up<=5: STRATEGIES["Iceberg Detection"]["signals"].append({"time":time.time(),"signal":"SELL","strength":1-up/10})
|
||||||
|
|
||||||
# Funding Arb: use real funding rate if available, else wider proxy
|
# Funding Rate Arb: real API data
|
||||||
if len(btc_prices)>=20:
|
|
||||||
try:
|
try:
|
||||||
fr = requests.post(TESTNET_API, json={"type":"funding","coin":"BTC"}, timeout=5).json()
|
from strategies.funding_arb import get_funding_rates
|
||||||
if isinstance(fr, list) and fr:
|
rates = get_funding_rates(use_testnet=True)
|
||||||
rate = float(fr[0].get("funding_rate", 0))
|
annual_rate = rates.get("BTC", 0)
|
||||||
else:
|
if abs(annual_rate) > 0.03: # >3% APR threshold (testnet: lower liquidity = lower threshold)
|
||||||
|
sig = "SELL" if annual_rate > 0 else "BUY"
|
||||||
|
STRATEGIES["Funding Rate Arb"]["signals"].append({
|
||||||
|
"time":time.time(), "signal":sig,
|
||||||
|
"strength": min(1.0, abs(annual_rate) * 10),
|
||||||
|
"reason": f"funding_{annual_rate*100:.1f}pct_apr"
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
# Fallback: use price proxy if module unavailable
|
||||||
|
if len(btc_prices)>=20:
|
||||||
rate = (btc/btc_prices[-20]-1)/20
|
rate = (btc/btc_prices[-20]-1)/20
|
||||||
except:
|
if abs(rate)>0.0005:
|
||||||
rate = (btc/btc_prices[-20]-1)/20
|
|
||||||
if abs(rate)>0.0001:
|
|
||||||
STRATEGIES["Funding Rate Arb"]["signals"].append({"time":time.time(),"signal":"SELL" if rate>0 else "BUY","strength":abs(rate)*10000})
|
STRATEGIES["Funding Rate Arb"]["signals"].append({"time":time.time(),"signal":"SELL" if rate>0 else "BUY","strength":abs(rate)*10000})
|
||||||
|
|
||||||
# Pairs: ratio Z-score
|
# Pairs: ratio Z-score
|
||||||
|
|||||||
+26
-13
@@ -236,22 +236,35 @@ def compute_signals():
|
|||||||
elif up <= 3:
|
elif up <= 3:
|
||||||
STRATEGIES["Iceberg Detection"]["signals"].append({"time":time.time(),"signal":"SELL","strength":1-up/10})
|
STRATEGIES["Iceberg Detection"]["signals"].append({"time":time.time(),"signal":"SELL","strength":1-up/10})
|
||||||
|
|
||||||
# Funding Arb — use actual mainnet funding rate
|
# Funding Rate Arb — unified module with real API data
|
||||||
if funding_rates and isinstance(funding_rates[-1], dict):
|
try:
|
||||||
btc_fr = funding_rates[-1].get("BTC", 0)
|
from strategies.funding_arb import funding_arb_signal
|
||||||
# Annualized: funding every 8h → 3× daily → 1095× yearly
|
sig_result = funding_arb_signal(coin="BTC", apr_threshold=0.05, apr_exit=0.02,
|
||||||
annual_fr = abs(btc_fr) * 365 * 3 if btc_fr else 0
|
current_position=STRATEGIES["Funding Rate Arb"]["position"])
|
||||||
# Log funding rate periodically
|
if sig_result["signal"] != 0:
|
||||||
import random as _random_fr
|
STRATEGIES["Funding Rate Arb"]["signals"].append({
|
||||||
if _random_fr.random() < 0.02:
|
"time": time.time(),
|
||||||
|
"signal": "SELL" if sig_result["signal"] < 0 else "BUY",
|
||||||
|
"strength": min(1.0, abs(sig_result["annual_apr"]) * 10),
|
||||||
|
"reason": sig_result["reason"]
|
||||||
|
})
|
||||||
|
# Log periodically
|
||||||
|
if not hasattr(globals().get("_funding_log_tick", None), "__int__"):
|
||||||
|
globals()["_funding_log_tick"] = 0
|
||||||
|
if globals()["_funding_log_tick"] % 30 == 0:
|
||||||
import logging
|
import logging
|
||||||
logging.getLogger("ftdt-paper").info(
|
logging.getLogger("ftdt-paper").info(
|
||||||
"{} Funding rate: {:.6f}% 8h | {:.2f}% APR | signal={}".format(
|
f"[Fund] APR={sig_result['annual_apr']*100:.2f}% | "
|
||||||
"[Fund]", btc_fr*100, annual_fr*100,
|
f"8h={sig_result['rate_8h']*100:.6f}% | "
|
||||||
"SELL" if btc_fr > 0 else "BUY" if btc_fr < 0 else "NONE"
|
f"signal={sig_result['signal']}"
|
||||||
)
|
)
|
||||||
)
|
globals()["_funding_log_tick"] = globals().get("_funding_log_tick", 0) + 1
|
||||||
if annual_fr > 0.05: # >5% APR (production threshold)
|
except Exception:
|
||||||
|
# Fallback to old method
|
||||||
|
if funding_rates and isinstance(funding_rates[-1], dict):
|
||||||
|
btc_fr = funding_rates[-1].get("BTC", 0)
|
||||||
|
annual_fr = abs(btc_fr) * 365 * 3 if btc_fr else 0
|
||||||
|
if annual_fr > 0.05:
|
||||||
STRATEGIES["Funding Rate Arb"]["signals"].append(
|
STRATEGIES["Funding Rate Arb"]["signals"].append(
|
||||||
{"time":time.time(),"signal":"SELL" if btc_fr > 0 else "BUY",
|
{"time":time.time(),"signal":"SELL" if btc_fr > 0 else "BUY",
|
||||||
"strength": min(0.6, annual_fr * 50),
|
"strength": min(0.6, annual_fr * 50),
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"""
|
||||||
|
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,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user