9cf871be46
A-S was quoting at best bid/ask (0% win) — orders filled but 0.04% round-trip maker fee exceeded spread capture. Now: bid+1 / ask-1 = captures spread minus 1 tick each side. Signal-driven strategies: same 1-tick pricing instead of 0.03% offset that crossed the book or sat too far away. Added fcntl file lock to prevent duplicate live nodes. Added IOC fallback (market-crossing) when post-only rejected. A-S win rate: 0% → 25% (first 8 trades with new pricing)
601 lines
33 KiB
Python
601 lines
33 KiB
Python
"""
|
||
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.
|
||
|
||
8 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.000200,"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.000210,"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.000220,"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.012,"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.000230,"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":"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":"momentum","description":"Bollinger Band (1.2σ) breakout on ETH — enters when price breaks bands."},
|
||
"Mean Reversion": {"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":"reversal","description":"VWAP deviation on ETH — buys below VWAP, sells above. Higher vol = more reversion."},
|
||
"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.010,"fee_paid":0.0,"signals":[],"type":"stat_arb","description":"Kalman-filter adaptive hedge ratio — tracks evolving BTC/ETH beta with every tick."},
|
||
"Hurst VPIN": {"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.000240,"fee_paid":0.0,"signals":[],"type":"momentum","description":"Hurst exponent regime filter + VPIN informed flow — enters when both align trending + high flow imbalance."}
|
||
}
|
||
|
||
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
|
||
|
||
# Module-level cache for open orders/positions (avoid 429 rate limit)
|
||
_cached_orders = []
|
||
_cached_positions = []
|
||
_last_metrics_fetch = 0.0
|
||
|
||
def write_metrics(addr):
|
||
global _cached_orders, _cached_positions, _last_metrics_fetch
|
||
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"]
|
||
# Get real open orders and positions from Hyperliquid (cached 5s to avoid 429)
|
||
if time.time() - _last_metrics_fetch > 5:
|
||
try:
|
||
_cached_orders = requests.post(TESTNET_API, json={"type":"openOrders","user":addr}, timeout=5).json() or []
|
||
_cached_positions = []
|
||
ch = requests.post(TESTNET_API, json={"type":"clearinghouseState","user":addr}, timeout=5).json()
|
||
if ch and "assetPositions" in ch:
|
||
for a in ch["assetPositions"]:
|
||
pos = a.get("position", {})
|
||
if pos and float(pos.get("szi", 0)) != 0:
|
||
_cached_positions.append({
|
||
"coin": pos.get("coin", "?"),
|
||
"size": float(pos.get("szi", 0)),
|
||
"entry_px": float(pos.get("entryPx", 0)),
|
||
"pnl": float(pos.get("unrealizedPnl", 0)),
|
||
})
|
||
_last_metrics_fetch = time.time()
|
||
except Exception:
|
||
pass
|
||
live_orders = _cached_orders
|
||
live_positions = _cached_positions
|
||
|
||
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":live_positions,"open_orders":live_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>=7: STRATEGIES["Iceberg Detection"]["signals"].append({"time":time.time(),"signal":"BUY","strength":up/10})
|
||
elif up<=3: 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.2: STRATEGIES["Pairs Trading"]["signals"].append({"time":time.time(),"signal":"SELL_ETH","strength":z})
|
||
elif z<-1.2: 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 on ETH
|
||
if len(eth_prices)>=20:
|
||
w = list(eth_prices)[-20:]; eth_cur = eth_prices[-1]; sma = sum(w)/len(w)
|
||
variance = sum((p-sma)**2 for p in w)/len(w); std = math.sqrt(variance)
|
||
if std>0:
|
||
if eth_cur > sma+1.2*std: STRATEGIES["Momentum Breakout"]["signals"].append({"time":time.time(),"signal":"BUY","strength":(eth_cur-sma-1.2*std)/std})
|
||
elif eth_cur < sma-1.2*std: STRATEGIES["Momentum Breakout"]["signals"].append({"time":time.time(),"signal":"SELL","strength":(sma-1.2*std-eth_cur)/std})
|
||
|
||
# Mean Reversion: VWAP on ETH (exclude current price from VWAP)
|
||
if len(eth_prices)>=20:
|
||
w = list(eth_prices)[-20:]; eth_mr = eth_prices[-1]
|
||
# VWAP on prior 19 prices, equal volume weights
|
||
prior = w[:-1]
|
||
sma = sum(prior)/len(prior)
|
||
vstd = math.sqrt(sum((p-sma)**2 for p in prior)/len(prior))
|
||
dev = (eth_mr-sma)/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)})
|
||
|
||
# Hurst/VPIN: feed BTC price into dollar bars
|
||
if len(btc_prices)>=3:
|
||
try:
|
||
from strategies.hurst_vpin_live import HurstVPINLive
|
||
if "_hv_live" not in dir():
|
||
globals()["_hv_live"] = HurstVPINLive()
|
||
hv_signal = globals()["_hv_live"].feed_price(btc)
|
||
if hv_signal:
|
||
STRATEGIES["Hurst VPIN"]["signals"].append({
|
||
"time":time.time(),
|
||
"signal": hv_signal["signal"],
|
||
"strength": hv_signal["hurst"],
|
||
"reason": f"H={hv_signal['hurst']:.2f}_V={hv_signal['vpin']:.2f}"
|
||
})
|
||
except: pass
|
||
|
||
# Trim signals
|
||
for s in STRATEGIES.values(): s["signals"] = s["signals"][-20:]
|
||
|
||
# ═══════════════════════ Process Guard ═══════════════════════
|
||
|
||
import fcntl
|
||
_lock_fd = open("/tmp/ftdt-live.lock", "w")
|
||
try:
|
||
fcntl.flock(_lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||
except IOError:
|
||
print("Another live node is already running. Exiting.", flush=True)
|
||
sys.exit(0)
|
||
|
||
# ═══════════════════════ 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)
|
||
if meta_r.status_code != 200 or not meta_r.json():
|
||
# Testnet meta returns null — try mainnet
|
||
log.info("Testnet meta unavailable, trying mainnet...")
|
||
meta_r = requests.post("https://api.hyperliquid.xyz/info", 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" {len(STRATEGIES)} 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() or []
|
||
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) or []
|
||
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:
|
||
try:
|
||
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
|
||
# Track position for AS model
|
||
if side == "B":
|
||
STRATEGIES[strat]["position"] = STRATEGIES[strat].get("position", 0.0) + sz
|
||
else:
|
||
STRATEGIES[strat]["position"] = STRATEGIES[strat].get("position", 0.0) - sz
|
||
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"]})
|
||
if len(strategy_equity[strat]) > 1000:
|
||
strategy_equity[strat][:] = strategy_equity[strat][-600:]
|
||
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:
|
||
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 Exception:
|
||
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 Exception:
|
||
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 Exception:
|
||
pass
|
||
del active_cloids[name]
|
||
has_position = False
|
||
|
||
if has_position:
|
||
continue # Don't replace existing orders
|
||
|
||
# Avellaneda-Stoikov: side selection via reservation price
|
||
if name == "Avellaneda-Stoikov":
|
||
try:
|
||
from strategies.as_quoter import ASMarketMaker
|
||
if "_as_mm" not in dir():
|
||
globals()["_as_mm"] = ASMarketMaker(gamma=0.1, tau=1.0, max_inventory=cfg["size"] * 10)
|
||
asmm = globals()["_as_mm"]
|
||
asmm.observe(mid)
|
||
|
||
# Get A-S inventory from position tracking
|
||
as_inv = STRATEGIES[name].get("position", 0.0)
|
||
elapsed = (tick * 1.0) % (asmm.tau * 3600) / 3600.0
|
||
|
||
selection = asmm.should_quote(mid, bid, ask, as_inv, elapsed)
|
||
quote_bid = selection["quote_bid"]
|
||
quote_ask = selection["quote_ask"]
|
||
r_price = selection.get("reservation", mid)
|
||
|
||
# Quote at best bid/ask with 1-tick advantage to capture spread
|
||
# BUY at best bid + 1 tick = maker that likely fills
|
||
# SELL at best ask - 1 tick = maker that likely fills
|
||
# Spread captured per round-trip: spread - 2 ticks - 0.04% fees
|
||
if quote_bid:
|
||
bid_px = int(bid) + 1 # 1 tick above best bid
|
||
cid_bid = 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(bid_px)), time_in_force=TimeInForce.GTC, post_only=True)
|
||
active_cloids[name + "_bid"] = str(cid_bid)
|
||
active_cloids_times[name + "_bid"] = tick
|
||
active_cloids_px[name + "_bid"] = bid_px
|
||
except Exception as e:
|
||
if "cross" in str(e).lower() or "matched" in str(e):
|
||
# Fallback: aggressive market-crossing IOC
|
||
try:
|
||
client.submit_order(instrument_id=perp.id, client_order_id=ClientOrderId(str(UUID4())), order_side=OrderSide.BUY, order_type=OrderType.LIMIT, quantity=Quantity.from_str(str(cfg["size"])), price=Price.from_str(str(int(ask))), time_in_force=TimeInForce.IOC)
|
||
except Exception:
|
||
pass
|
||
if quote_ask:
|
||
ask_px = int(ask) - 1 # 1 tick below best ask
|
||
cid_ask = ClientOrderId(str(UUID4()))
|
||
try:
|
||
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(ask_px)), time_in_force=TimeInForce.GTC, post_only=True)
|
||
active_cloids[name + "_ask"] = str(cid_ask)
|
||
active_cloids_times[name + "_ask"] = tick
|
||
active_cloids_px[name + "_ask"] = ask_px
|
||
except Exception as e:
|
||
if "cross" in str(e).lower() or "matched" in str(e):
|
||
try:
|
||
client.submit_order(instrument_id=perp.id, client_order_id=ClientOrderId(str(UUID4())), order_side=OrderSide.SELL, order_type=OrderType.LIMIT, quantity=Quantity.from_str(str(cfg["size"])), price=Price.from_str(str(int(bid))), time_in_force=TimeInForce.IOC)
|
||
except Exception:
|
||
pass
|
||
|
||
if tick % 60 == 0 and (quote_bid or quote_ask):
|
||
sides = ("BID" if quote_bid else "") + ("|" if quote_bid and quote_ask else "") + ("ASK" if quote_ask else "")
|
||
log.info(f"[AS] r={r_price:.1f} σ={selection.get('sigma',0)*100:.2f}% q={as_inv:.6f} {sides}")
|
||
except Exception:
|
||
# Fallback: best bid/ask both sides
|
||
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)
|
||
active_cloids[name] = str(cid_bid)
|
||
active_cloids_times[name] = tick
|
||
active_cloids_px[name] = bid
|
||
except Exception:
|
||
pass
|
||
continue
|
||
|
||
# For signal-driven strategies: quote at best bid/ask with 1-tick edge
|
||
if signal:
|
||
side = OrderSide.SELL if "SELL" in str(signal).upper() else OrderSide.BUY
|
||
# BUY at best bid + 1 tick (maker), SELL at best ask - 1 tick (maker)
|
||
px_level = (int(bid) + 1) if side == OrderSide.BUY else (int(ask) - 1)
|
||
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(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']} @ ${px_level:,} (best bid {int(bid)} ask {int(ask)})")
|
||
active_cloids[name] = str(cid)
|
||
active_cloids_times[name] = tick
|
||
active_cloids_px[name] = px_level
|
||
except Exception as e:
|
||
if "cross" in str(e).lower() or "matched" in str(e):
|
||
# Fallback: aggressive IOC at market-crossing price for guaranteed fill
|
||
market_px = int(ask) if side == OrderSide.BUY else int(bid)
|
||
try:
|
||
client.submit_order(instrument_id=perp.id, client_order_id=ClientOrderId(str(UUID4())), order_side=side, order_type=OrderType.LIMIT, quantity=Quantity.from_str(str(cfg["size"])), price=Price.from_str(str(market_px)), time_in_force=TimeInForce.IOC)
|
||
except Exception:
|
||
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})
|
||
if len(equity_history) > 1000:
|
||
equity_history[:] = equity_history[-600:]
|
||
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 Exception as loop_err:
|
||
log.error(f"Loop error (tick {tick}): {loop_err}")
|
||
await asyncio.sleep(5) # back off and retry
|
||
except KeyboardInterrupt:
|
||
log.info("Stopping...")
|
||
|
||
# Cancel all
|
||
open_ords = requests.post(TESTNET_API, json={"type":"openOrders","user":addr}, timeout=10).json() or []
|
||
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())
|