diff --git a/live/node.py b/live/node.py index 48f6439..22424ac 100644 --- a/live/node.py +++ b/live/node.py @@ -127,18 +127,24 @@ def compute_signals(): 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}) - # Funding Arb: use real funding rate if available, else wider proxy - if len(btc_prices)>=20: - try: - fr = requests.post(TESTNET_API, json={"type":"funding","coin":"BTC"}, timeout=5).json() - if isinstance(fr, list) and fr: - rate = float(fr[0].get("funding_rate", 0)) - else: - rate = (btc/btc_prices[-20]-1)/20 - except: + # Funding Rate Arb: real API data + try: + from strategies.funding_arb import get_funding_rates + rates = get_funding_rates(use_testnet=True) + annual_rate = rates.get("BTC", 0) + 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 - 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}) + if abs(rate)>0.0005: + STRATEGIES["Funding Rate Arb"]["signals"].append({"time":time.time(),"signal":"SELL" if rate>0 else "BUY","strength":abs(rate)*10000}) # Pairs: ratio Z-score if len(btc_prices)>=20 and len(eth_prices)>=20: diff --git a/live/paper_trader.py b/live/paper_trader.py index 8e88c51..8564efc 100644 --- a/live/paper_trader.py +++ b/live/paper_trader.py @@ -236,27 +236,40 @@ def compute_signals(): elif up <= 3: STRATEGIES["Iceberg Detection"]["signals"].append({"time":time.time(),"signal":"SELL","strength":1-up/10}) - # Funding Arb — use actual mainnet funding rate - if funding_rates and isinstance(funding_rates[-1], dict): - btc_fr = funding_rates[-1].get("BTC", 0) - # Annualized: funding every 8h → 3× daily → 1095× yearly - annual_fr = abs(btc_fr) * 365 * 3 if btc_fr else 0 - # Log funding rate periodically - import random as _random_fr - if _random_fr.random() < 0.02: + # Funding Rate Arb — unified module with real API data + try: + from strategies.funding_arb import funding_arb_signal + sig_result = funding_arb_signal(coin="BTC", apr_threshold=0.05, apr_exit=0.02, + current_position=STRATEGIES["Funding Rate Arb"]["position"]) + if sig_result["signal"] != 0: + STRATEGIES["Funding Rate Arb"]["signals"].append({ + "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 logging.getLogger("ftdt-paper").info( - "{} Funding rate: {:.6f}% 8h | {:.2f}% APR | signal={}".format( - "[Fund]", btc_fr*100, annual_fr*100, - "SELL" if btc_fr > 0 else "BUY" if btc_fr < 0 else "NONE" + f"[Fund] APR={sig_result['annual_apr']*100:.2f}% | " + f"8h={sig_result['rate_8h']*100:.6f}% | " + f"signal={sig_result['signal']}" + ) + globals()["_funding_log_tick"] = globals().get("_funding_log_tick", 0) + 1 + 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( + {"time":time.time(),"signal":"SELL" if btc_fr > 0 else "BUY", + "strength": min(0.6, annual_fr * 50), + "reason": "funding_{:.1f}pct_apr".format(annual_fr*100)} ) - ) - if annual_fr > 0.05: # >5% APR (production threshold) - STRATEGIES["Funding Rate Arb"]["signals"].append( - {"time":time.time(),"signal":"SELL" if btc_fr > 0 else "BUY", - "strength": min(0.6, annual_fr * 50), - "reason": "funding_{:.1f}pct_apr".format(annual_fr*100)} - ) # Pairs: BTC/ETH ratio Z-score if len(btc_prices) >= 20 and len(eth_prices) >= 20: diff --git a/strategies/funding_arb.py b/strategies/funding_arb.py new file mode 100644 index 0000000..518dd87 --- /dev/null +++ b/strategies/funding_arb.py @@ -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, + }