feat: queue-aware paper fills, kill switch, systemd services, WQI+FundingArb in paper trader
- live/paper_trader.py: replaced random 5% fill probability in simulate_avellaneda() with QueueAwareFillModel — fills only when aggressor volume exceeds depth ahead, regime-adaptive quote placement (tight in LOW_VOL, wide in HIGH_VOL). Integrated WQI Predictor and Funding Rate Arb as new strategies with signal generation. Dashboard metrics now include WQI summaries, funding arb status, and fill model throughput stats (fill rate, fills vs skips). 14 strategies total. - scripts/kill_switch.py: emergency kill switch — flattens all positions, cancels all open orders, verifies account is flat. Supports --dry-run, --mainnet, retry logic, L1 action signing. Reads private key from HL_PRIVATE_KEY env or ~/.hl/key. - infrastructure/systemd/: three service unit files for production deployment: ftdt-collector (data collection), ftdt-paper (trading node v2), ftdt-dashboard (FastAPI backend). Includes memory/cpu limits, auto-restart, log rotation. 321 tests passing.
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
# FTDT Quant Lab — Data Collector
|
||||
# Install: sudo cp ftdt-collector.service /etc/systemd/system/
|
||||
# sudo systemctl enable ftdt-collector
|
||||
# sudo systemctl start ftdt-collector
|
||||
|
||||
[Unit]
|
||||
Description=FTDT Quant Lab — Hyperliquid Data Collector
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=satoshi
|
||||
WorkingDirectory=/home/satoshi/ftdt-quant-lab
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Environment=PATH=/home/satoshi/ftdt-quant-lab/.venv/bin:/usr/local/bin:/usr/bin:/bin
|
||||
ExecStart=/home/satoshi/ftdt-quant-lab/.venv/bin/python -m cli collect --mainnet --coins BTC,ETH,SOL,HYPE --data-dir data/raw --poll-interval 60 --flush-interval 5
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StandardOutput=append:/home/satoshi/ftdt-quant-lab/logs/collector.log
|
||||
StandardError=append:/home/satoshi/ftdt-quant-lab/logs/collector.log
|
||||
MemoryMax=512M
|
||||
CPUQuota=50%
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,26 @@
|
||||
# FTDT Quant Lab — Dashboard Server
|
||||
# Install: sudo cp ftdt-dashboard.service /etc/systemd/system/
|
||||
# sudo systemctl enable ftdt-dashboard
|
||||
# sudo systemctl start ftdt-dashboard
|
||||
|
||||
[Unit]
|
||||
Description=FTDT Quant Lab — FastAPI Dashboard Backend
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=satoshi
|
||||
WorkingDirectory=/home/satoshi/ftdt-quant-lab
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Environment=PATH=/home/satoshi/ftdt-quant-lab/.venv/bin:/usr/local/bin:/usr/bin:/bin
|
||||
ExecStart=/home/satoshi/ftdt-quant-lab/.venv/bin/python dashboard/server.py --port 9175
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=append:/home/satoshi/ftdt-quant-lab/logs/dashboard.log
|
||||
StandardError=append:/home/satoshi/ftdt-quant-lab/logs/dashboard.log
|
||||
MemoryMax=256M
|
||||
CPUQuota=25%
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,27 @@
|
||||
# FTDT Quant Lab — Paper Trading Node
|
||||
# Install: sudo cp ftdt-paper.service /etc/systemd/system/
|
||||
# sudo systemctl enable ftdt-paper
|
||||
# sudo systemctl start ftdt-paper
|
||||
|
||||
[Unit]
|
||||
Description=FTDT Quant Lab — Paper Trading Node (v2)
|
||||
After=network-online.target ftdt-collector.service
|
||||
Wants=network-online.target
|
||||
Requires=ftdt-collector.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=satoshi
|
||||
WorkingDirectory=/home/satoshi/ftdt-quant-lab
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Environment=PATH=/home/satoshi/ftdt-quant-lab/.venv/bin:/usr/local/bin:/usr/bin:/bin
|
||||
ExecStart=/home/satoshi/ftdt-quant-lab/.venv/bin/python -m live.node_v2 --testnet --coins BTC,ETH --mode paper --equity 100000 --metrics-file /tmp/ftdt-metrics-v2.json
|
||||
Restart=always
|
||||
RestartSec=15
|
||||
StandardOutput=append:/home/satoshi/ftdt-quant-lab/logs/paper-node.log
|
||||
StandardError=append:/home/satoshi/ftdt-quant-lab/logs/paper-node.log
|
||||
MemoryMax=512M
|
||||
CPUQuota=50%
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
+126
-54
@@ -20,6 +20,9 @@ from strategies.deep_lob import DeepLOB
|
||||
from strategies.cartea_jaimungal import CarteaJaimungal
|
||||
from strategies.queue_imbalance import QueueImbalance
|
||||
from strategies.gueant import GueantMM
|
||||
from strategies.wqi_predictor import WQIPredictor
|
||||
from strategies.funding_arb_strategy import FundingArb
|
||||
from sim.fills import QueueAwareFillModel
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [paper] %(message)s", datefmt="%H:%M:%S")
|
||||
log = logging.getLogger("ftdt-paper")
|
||||
@@ -129,6 +132,20 @@ STRATEGIES = {
|
||||
"signals": [], "type": "gueant", "size": 0.001, "fee_model": "maker",
|
||||
"description": "Closed-form market making — Guéant-Lehalle asymptotic solution. Handles asymmetric information with adverse-selection-adjusted spreads. Computationally efficient closed form.",
|
||||
},
|
||||
"WQI Predictor": {
|
||||
"allocation": 10000.0, "instrument": "BTC", "pnl": 0.0,
|
||||
"trades_today": 0, "wins": 0, "win_rate": 0.0, "status": "idle",
|
||||
"position": 0.0, "entry_price": 0.0, "fee_paid": 0.0,
|
||||
"signals": [], "type": "wqi", "size": 0.002, "fee_model": "taker",
|
||||
"description": "Weighted Queue Imbalance directional predictor — enters on extreme WQI z-score with adverse selection gating. Exits on timeout, reversal, or stop-loss.",
|
||||
},
|
||||
"Funding Rate Arb": {
|
||||
"allocation": 10000.0, "instrument": "BTC", "pnl": 0.0,
|
||||
"trades_today": 0, "wins": 0, "win_rate": 0.0, "status": "idle",
|
||||
"position": 0.0, "entry_price": 0.0, "fee_paid": 0.0,
|
||||
"signals": [], "type": "funding_arb", "size": 0.005, "fee_model": "taker",
|
||||
"description": "Delta-neutral funding rate carry — shorts perp when funding APR is extreme, collects hourly payments. Exits when rate fades, flips, or max hold reached.",
|
||||
},
|
||||
}
|
||||
|
||||
trades_log: list[dict] = []
|
||||
@@ -226,6 +243,13 @@ queue_imb = QueueImbalance(depth_levels=10)
|
||||
gueant = GueantMM(gamma=0.1, sigma=0.015, k=1.5, T=3600, max_pos=0.005)
|
||||
prev_bids = None
|
||||
prev_asks = None
|
||||
fill_model = QueueAwareFillModel()
|
||||
wqi_predictors = {coin: WQIPredictor(z_entry=2.0, max_hold_seconds=30,
|
||||
stop_loss_bps=5.0, take_profit_bps=10.0,
|
||||
size=0.001, fee_model="taker")
|
||||
for coin in ("BTC", "ETH")}
|
||||
funding_arb = FundingArb(apr_threshold=0.30, apr_exit=0.10, size=0.001,
|
||||
max_hold_hours=48.0, taker_fee_pct=TAKER_FEE)
|
||||
|
||||
# ═══════════════════════ Signal Engine ═══════════════════════
|
||||
|
||||
@@ -433,13 +457,10 @@ def simulate_fill(name: str, side: str, coin: str, price: float, reason: str = "
|
||||
|
||||
# ═══════════════════════ A-S Spread Capture ═══════════════════════
|
||||
|
||||
def simulate_avellaneda(btc_bid, btc_ask):
|
||||
"""Avellaneda-Stoikov: regime-adaptive spread capture.
|
||||
def simulate_avellaneda(btc_bid, btc_ask, bid_depth=2.0, ask_depth=2.0):
|
||||
"""Avellaneda-Stoikov: queue-aware spread capture with regime gating.
|
||||
|
||||
Regime-dependent behavior:
|
||||
LOW_VOL → fill_prob=25%, tight margins (capture small spreads frequently)
|
||||
NORMAL → fill_prob=15%, baseline
|
||||
HIGH_VOL → fill_prob=8%, skip if spread too wide (adverse selection risk)
|
||||
Uses QueueAwareFillModel instead of random probabilities.
|
||||
"""
|
||||
cfg = STRATEGIES["Avellaneda-Stoikov"]
|
||||
if btc_bid <= 0 or btc_ask <= 0:
|
||||
@@ -448,57 +469,68 @@ def simulate_avellaneda(btc_bid, btc_ask):
|
||||
regime = current_regime
|
||||
spread = btc_ask - btc_bid
|
||||
|
||||
# Regime-dependent fill probability
|
||||
if regime == "HIGH_VOL" and spread > 30:
|
||||
return
|
||||
|
||||
mid = (btc_bid + btc_ask) / 2
|
||||
sz = cfg["size"]
|
||||
quote_bid = btc_bid
|
||||
quote_ask = btc_ask
|
||||
|
||||
if regime == "LOW_VOL":
|
||||
fill_prob = 0.25
|
||||
quote_bid = btc_bid + spread * 0.05
|
||||
quote_ask = btc_ask - spread * 0.05
|
||||
elif regime == "HIGH_VOL":
|
||||
fill_prob = 0.08
|
||||
# During high vol with wide spreads, avoid getting picked off
|
||||
if spread > 30: # >$30 spread = dangerous
|
||||
return
|
||||
else:
|
||||
fill_prob = 0.15
|
||||
quote_bid = btc_bid - spread * 0.1
|
||||
quote_ask = btc_ask + spread * 0.1
|
||||
|
||||
if random.random() < fill_prob:
|
||||
if cfg["position"] <= 0:
|
||||
bid_fill_price = btc_bid
|
||||
else:
|
||||
bid_fill_price = btc_ask
|
||||
|
||||
side = "BUY" if cfg["position"] <= 0 else "SELL"
|
||||
sz = cfg["size"]
|
||||
notional = sz * bid_fill_price
|
||||
fee = notional * MAKER_FEE # A-S is a MAKER strategy — pay maker fee, not taker
|
||||
spread_profit = sz * (btc_ask - btc_bid)/2 if side == "BUY" else 0
|
||||
|
||||
if side == "BUY":
|
||||
if cfg["position"] < 0:
|
||||
close_pnl = abs(cfg["position"]) * (cfg["entry_price"] - bid_fill_price)
|
||||
cfg["pnl"] += close_pnl
|
||||
if close_pnl > 0: cfg["wins"] += 1
|
||||
cfg["entry_price"] = bid_fill_price
|
||||
cfg["position"] = sz
|
||||
cfg["pnl"] += spread_profit - fee
|
||||
else:
|
||||
if cfg["position"] > 0:
|
||||
close_pnl = cfg["position"] * (bid_fill_price - cfg["entry_price"])
|
||||
cfg["pnl"] += close_pnl
|
||||
if close_pnl > 0: cfg["wins"] += 1
|
||||
trades_log.append({
|
||||
"time": datetime.now().strftime("%H:%M:%S"),
|
||||
"strategy": "Avellaneda-Stoikov",
|
||||
"side": "SELL", "size": sz,
|
||||
"price": bid_fill_price,
|
||||
"pnl": round(close_pnl - fee, 4),
|
||||
"fee": round(fee, 4),
|
||||
})
|
||||
cfg["position"] = 0
|
||||
cfg["entry_price"] = 0
|
||||
depth = max(bid_depth, ask_depth, 1.0)
|
||||
depth_ahead = depth * 0.5
|
||||
|
||||
bid_fill = fill_model.check_fill(
|
||||
aggressor_side="sell", agg_size=depth * 0.3, agg_price=max(quote_bid, 1),
|
||||
our_price=quote_bid, our_size=sz, depth_ahead=depth_ahead,
|
||||
)
|
||||
if bid_fill["filled"] and cfg["position"] <= 0:
|
||||
fee = sz * bid_fill["fill_size"] * quote_bid * MAKER_FEE
|
||||
cfg["fee_paid"] += fee
|
||||
spread_profit = bid_fill["fill_size"] * (btc_ask - quote_bid) / 2
|
||||
if cfg["position"] < 0:
|
||||
close_pnl = abs(cfg["position"]) * (cfg["entry_price"] - quote_bid)
|
||||
cfg["pnl"] += close_pnl
|
||||
if close_pnl > 0:
|
||||
cfg["wins"] += 1
|
||||
cfg["entry_price"] = quote_bid
|
||||
cfg["position"] = sz
|
||||
cfg["pnl"] += spread_profit - fee
|
||||
cfg["trades_today"] += 1
|
||||
cfg["pnl_pct"] = cfg["pnl"] / cfg["allocation"] * 100
|
||||
strategy_equity["Avellaneda-Stoikov"].append({"t": time.time(), "v": cfg["allocation"] + cfg["pnl"]})
|
||||
strategy_equity["Avellaneda-Stoikov"].append(
|
||||
{"t": time.time(), "v": cfg["allocation"] + cfg["pnl"]}
|
||||
)
|
||||
return
|
||||
|
||||
ask_fill = fill_model.check_fill(
|
||||
aggressor_side="buy", agg_size=depth * 0.3, agg_price=min(quote_ask, mid * 2),
|
||||
our_price=quote_ask, our_size=sz, depth_ahead=depth_ahead,
|
||||
)
|
||||
if ask_fill["filled"] and cfg["position"] >= 0:
|
||||
fee = sz * ask_fill["fill_size"] * quote_ask * MAKER_FEE
|
||||
cfg["fee_paid"] += fee
|
||||
spread_profit = ask_fill["fill_size"] * (quote_ask - btc_bid) / 2
|
||||
if cfg["position"] > 0:
|
||||
close_pnl = cfg["position"] * (quote_ask - cfg["entry_price"])
|
||||
cfg["pnl"] += close_pnl
|
||||
if close_pnl > 0:
|
||||
cfg["wins"] += 1
|
||||
cfg["entry_price"] = quote_ask
|
||||
cfg["position"] = -sz
|
||||
cfg["pnl"] += spread_profit - fee
|
||||
cfg["trades_today"] += 1
|
||||
cfg["pnl_pct"] = cfg["pnl"] / cfg["allocation"] * 100
|
||||
strategy_equity["Avellaneda-Stoikov"].append(
|
||||
{"t": time.time(), "v": cfg["allocation"] + cfg["pnl"]}
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════ Metrics ═══════════════════════
|
||||
@@ -522,6 +554,13 @@ def write_metrics():
|
||||
"strategy_equity": {k: list(v)[-300:] for k, v in strategy_equity.items()},
|
||||
"strategies": STRATEGIES,
|
||||
"trades": trades_log[-200:],
|
||||
"wqi": {c: wqi_predictors[c].summary() for c in wqi_predictors},
|
||||
"funding_arb": funding_arb.summary(),
|
||||
"fill_model": {
|
||||
"fill_rate": round(fill_model.fill_rate(), 4),
|
||||
"fills": fill_model.fill_count,
|
||||
"skips": fill_model.skip_count,
|
||||
},
|
||||
"status": "running",
|
||||
"btc_price": btc_prices[-1] if btc_prices else 0,
|
||||
"eth_price": eth_prices[-1] if eth_prices else 0,
|
||||
@@ -590,8 +629,10 @@ async def main():
|
||||
btc_bid, btc_ask = get_mainnet_orderbook("BTC")
|
||||
bids, asks = get_deep_orderbook("BTC")
|
||||
|
||||
# Avellaneda-Stoikov: simulate spread capture
|
||||
simulate_avellaneda(btc_bid, btc_ask)
|
||||
# Avellaneda-Stoikov: simulate spread capture with queue-aware fills
|
||||
bid_depth = sum(sz for _, sz in bids[:10]) if bids else 2.0
|
||||
ask_depth = sum(sz for _, sz in asks[:10]) if asks else 2.0
|
||||
simulate_avellaneda(btc_bid, btc_ask, bid_depth, ask_depth)
|
||||
|
||||
# Hawkes OFI: feed simulated trade to model
|
||||
hawkes_btc.update("B" if tick % 2 == 0 else "S", 0.001, btc)
|
||||
@@ -660,7 +701,6 @@ async def main():
|
||||
g_quotes = gueant.optimal_quotes(
|
||||
btc, gueant_inv, tick % 3600,
|
||||
adverse_prob=queue_imb.wqi_history[-1] if queue_imb.wqi_history else 0)
|
||||
# Simulate fill: if our quote is at/near best, track a signal
|
||||
if btc_bid > 0 and g_quotes["bid"] >= btc_bid * 0.999:
|
||||
STRATEGIES["Guéant Market Making"]["signals"].append({
|
||||
"time": time.time(), "signal": "BUY",
|
||||
@@ -672,7 +712,39 @@ async def main():
|
||||
"strength": 0.5,
|
||||
})
|
||||
|
||||
# Process next strategy's signals (round-robin 9 strategies)
|
||||
# WQI Predictor: directional signal from queue imbalance
|
||||
if bids and asks and btc > 0:
|
||||
try:
|
||||
wqi_signal = wqi_predictors.get("BTC")
|
||||
if wqi_signal:
|
||||
sig = wqi_signal.feed_signal(bids, asks, btc,
|
||||
prev_bids, prev_asks,
|
||||
btc_prices[-2] if len(btc_prices) >= 2 else 0)
|
||||
if sig["action"] in ("BUY", "SELL"):
|
||||
STRATEGIES["WQI Predictor"]["signals"].append({
|
||||
"time": time.time(),
|
||||
"signal": sig["action"],
|
||||
"strength": abs(sig["z_score"]) / 3.0,
|
||||
"reason": f"z={sig['z_score']:.2f}_wqi={sig['wqi']:.3f}",
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Funding Rate Arb: check funding signal
|
||||
if funding_rates and isinstance(funding_rates[-1], dict):
|
||||
fr = funding_rates[-1].get("BTC", 0)
|
||||
if fr != 0:
|
||||
annual_apr = abs(fr) * 1095
|
||||
arb_signal = funding_arb.signal(annual_apr, btc)
|
||||
if arb_signal["action"] != "HOLD":
|
||||
STRATEGIES["Funding Rate Arb"]["signals"].append({
|
||||
"time": time.time(),
|
||||
"signal": arb_signal["action"],
|
||||
"strength": min(1.0, annual_apr),
|
||||
"reason": arb_signal.get("reason", ""),
|
||||
})
|
||||
|
||||
# Process next strategy's signals (round-robin all strategies)
|
||||
total_strats = len(strategy_names)
|
||||
name = strategy_names[idx % total_strats]
|
||||
idx += 1
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
FTDT Quant Lab — Emergency Kill Switch.
|
||||
|
||||
Flattens all active positions and cancels all open orders.
|
||||
Runs against either testnet or mainnet Hyperliquid.
|
||||
|
||||
Usage:
|
||||
python scripts/kill_switch.py --mainnet # Production emergency stop
|
||||
python scripts/kill_switch.py --testnet # Testnet safety test
|
||||
python scripts/kill_switch.py --testnet --dry-run # Print what would happen
|
||||
|
||||
The script:
|
||||
1. Loads private key from environment (HL_PRIVATE_KEY) or keyfile
|
||||
2. Fetches current positions from HL API
|
||||
3. Issues market/close orders to flatten each position
|
||||
4. Cancels all open orders
|
||||
5. Verifies positions are zero
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
TESTNET_API = "https://api.hyperliquid-testnet.xyz/info"
|
||||
MAINNET_API = "https://api.hyperliquid.xyz/info"
|
||||
TESTNET_EXCHANGE = "https://api.hyperliquid-testnet.xyz/exchange"
|
||||
MAINNET_EXCHANGE = "https://api.hyperliquid.xyz/exchange"
|
||||
|
||||
|
||||
def get_private_key() -> str:
|
||||
pk = os.environ.get("HL_PRIVATE_KEY", "")
|
||||
if pk:
|
||||
return pk
|
||||
|
||||
keyfile = Path.home() / ".hl" / "key"
|
||||
if keyfile.exists():
|
||||
return keyfile.read_text().strip()
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def sign_l1_action(action: dict, private_key: str) -> dict:
|
||||
try:
|
||||
from eth_account import Account
|
||||
import eth_account.messages
|
||||
except ImportError:
|
||||
print("eth_account not installed. Install with: pip install eth-account")
|
||||
sys.exit(1)
|
||||
|
||||
account = Account.from_key(private_key)
|
||||
from datetime import datetime, timezone
|
||||
|
||||
typed_data = {
|
||||
"domain": {"chainId": 1337},
|
||||
"types": {
|
||||
"HyperliquidTransaction": [
|
||||
{"name": "txType", "type": "string"},
|
||||
{"name": "nonce", "type": "uint64"},
|
||||
],
|
||||
},
|
||||
"primaryType": "HyperliquidTransaction",
|
||||
"message": {
|
||||
"txType": action.get("type", "order"),
|
||||
"nonce": int(time.time() * 1000),
|
||||
},
|
||||
}
|
||||
|
||||
signable = eth_account.messages.encode_typed_data(
|
||||
typed_data["domain"],
|
||||
typed_data["types"],
|
||||
typed_data["message"],
|
||||
)
|
||||
sig = account.sign_message(signable)
|
||||
action["signature"] = sig.signature.hex()
|
||||
return action
|
||||
|
||||
|
||||
def fetch_positions(api_url: str) -> list[dict]:
|
||||
import requests
|
||||
pk = get_private_key()
|
||||
if not pk:
|
||||
return []
|
||||
|
||||
try:
|
||||
from eth_account import Account
|
||||
account = Account.from_key(pk)
|
||||
resp = requests.post(
|
||||
api_url,
|
||||
json={"type": "clearinghouseState", "user": account.address},
|
||||
timeout=10,
|
||||
)
|
||||
data = resp.json()
|
||||
positions = []
|
||||
if isinstance(data, dict):
|
||||
asset_positions = data.get("assetPositions", [])
|
||||
for pos in asset_positions:
|
||||
p = pos.get("position", {})
|
||||
coin = p.get("coin", "?")
|
||||
szi = float(p.get("szi", 0))
|
||||
if szi != 0:
|
||||
positions.append({
|
||||
"coin": coin,
|
||||
"szi": szi,
|
||||
"entry_px": float(p.get("entryPx", 0)),
|
||||
"leverage": p.get("leverage", {}),
|
||||
})
|
||||
return positions
|
||||
except Exception as e:
|
||||
print(f"Failed to fetch positions: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def close_position(exchange_url: str, coin: str, current_szi: float, dry_run: bool) -> bool:
|
||||
import requests
|
||||
|
||||
side = "A" if current_szi < 0 else "B"
|
||||
close_size = abs(current_szi)
|
||||
|
||||
action = {
|
||||
"type": "order",
|
||||
"orders": [{
|
||||
"a": 0,
|
||||
"b": open_price(exchange_url.replace("exchange", "info"), coin, side),
|
||||
"p": open_price(exchange_url.replace("exchange", "info"), coin, side),
|
||||
"s": round(close_size, 6),
|
||||
"r": False,
|
||||
"t": {"limit": {"tif": "Ioc"}},
|
||||
}],
|
||||
"grouping": "na",
|
||||
}
|
||||
|
||||
if not dry_run:
|
||||
pk = get_private_key()
|
||||
if pk:
|
||||
action = sign_l1_action(action, pk)
|
||||
try:
|
||||
resp = requests.post(exchange_url, json=action, timeout=10)
|
||||
result = resp.json()
|
||||
if resp.status_code == 200:
|
||||
print(f" [{coin}] Closed {close_size:.4f} ({side}) — OK")
|
||||
return True
|
||||
else:
|
||||
print(f" [{coin}] Close failed: {result}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f" [{coin}] Close error: {e}")
|
||||
return False
|
||||
else:
|
||||
print(f" [{coin}] [DRY RUN] Would close {close_size:.4f} ({side})")
|
||||
return True
|
||||
|
||||
|
||||
def open_price(api_url: str, coin: str, side: str) -> float:
|
||||
import requests
|
||||
try:
|
||||
resp = requests.post(api_url, json={"type": "l2Book", "coin": coin}, timeout=5)
|
||||
data = resp.json()
|
||||
levels = data.get("levels", [])
|
||||
if levels and len(levels) >= 2:
|
||||
bids = levels[0]
|
||||
asks = levels[1]
|
||||
if side == "A":
|
||||
px = float(bids[0]["px"]) if bids else 0
|
||||
return round(px * 0.99, 1)
|
||||
else:
|
||||
px = float(asks[0]["px"]) if asks else 0
|
||||
return round(px * 1.01, 1)
|
||||
except Exception:
|
||||
pass
|
||||
return 0.0
|
||||
|
||||
|
||||
def cancel_all_orders(exchange_url: str, api_url: str, dry_run: bool):
|
||||
import requests
|
||||
pk = get_private_key()
|
||||
if not pk:
|
||||
print(" No private key — cannot cancel orders")
|
||||
return
|
||||
|
||||
from eth_account import Account
|
||||
account = Account.from_key(pk)
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
api_url,
|
||||
json={"type": "openOrders", "user": account.address},
|
||||
timeout=10,
|
||||
)
|
||||
orders = resp.json()
|
||||
if not isinstance(orders, list) or len(orders) == 0:
|
||||
print(" No open orders found")
|
||||
return
|
||||
|
||||
print(f" Found {len(orders)} open orders")
|
||||
|
||||
if not dry_run:
|
||||
cancels = []
|
||||
for order in orders:
|
||||
cancels.append({
|
||||
"a": order.get("oid", 0),
|
||||
"b": order.get("coin", "BTC"),
|
||||
})
|
||||
|
||||
action = {
|
||||
"type": "cancel",
|
||||
"cancels": cancels,
|
||||
}
|
||||
action = sign_l1_action(action, pk)
|
||||
|
||||
try:
|
||||
resp = requests.post(exchange_url, json=action, timeout=10)
|
||||
if resp.status_code == 200:
|
||||
print(f" Cancelled {len(cancels)} orders — OK")
|
||||
else:
|
||||
print(f" Cancel failed: {resp.json()}")
|
||||
except Exception as e:
|
||||
print(f" Cancel error: {e}")
|
||||
else:
|
||||
print(f" [DRY RUN] Would cancel {len(orders)} orders")
|
||||
except Exception as e:
|
||||
print(f" Failed to fetch orders: {e}")
|
||||
|
||||
|
||||
def verify_flat(api_url: str) -> bool:
|
||||
positions = fetch_positions(api_url)
|
||||
if not positions:
|
||||
print(" No positions — flat")
|
||||
return True
|
||||
|
||||
total = sum(abs(p["szi"]) for p in positions)
|
||||
if total < 1e-6:
|
||||
print(" No positions — flat")
|
||||
return True
|
||||
|
||||
print(f" WARNING: {len(positions)} positions remain (total exposure: {total:.4f})")
|
||||
for p in positions:
|
||||
print(f" {p['coin']}: {p['szi']:.4f} @ ${p['entry_px']:.1f}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description="FTDT Quant Lab — Emergency Kill Switch")
|
||||
p.add_argument("--testnet", action="store_true", default=True)
|
||||
p.add_argument("--mainnet", dest="testnet", action="store_false")
|
||||
p.add_argument("--dry-run", action="store_true", help="Print actions without executing")
|
||||
p.add_argument("--skip-cancel", action="store_true", help="Skip order cancellation")
|
||||
p.add_argument("--retry", type=int, default=3, help="Retry count for position closes")
|
||||
args = p.parse_args()
|
||||
|
||||
api_url = MAINNET_API if not args.testnet else TESTNET_API
|
||||
exchange_url = MAINNET_EXCHANGE if not args.testnet else TESTNET_EXCHANGE
|
||||
env_name = "MAINNET" if not args.testnet else "TESTNET"
|
||||
|
||||
print("=" * 60)
|
||||
print(f" FTDT Quant Lab — KILL SWITCH [{env_name}]")
|
||||
print("=" * 60)
|
||||
if args.dry_run:
|
||||
print(" *** DRY RUN — no orders will be placed ***")
|
||||
print()
|
||||
|
||||
pk = get_private_key()
|
||||
if not pk:
|
||||
print(" ERROR: No private key found.")
|
||||
print(" Set HL_PRIVATE_KEY environment variable or create ~/.hl/key")
|
||||
if not args.dry_run:
|
||||
sys.exit(1)
|
||||
|
||||
print(f" [1] Fetching positions...")
|
||||
positions = fetch_positions(api_url)
|
||||
if not positions:
|
||||
print(" No positions to close")
|
||||
else:
|
||||
total_size = sum(abs(p["szi"]) for p in positions)
|
||||
print(f" Found {len(positions)} position(s) (total size: {total_size:.4f})")
|
||||
for p in positions:
|
||||
direction = "LONG" if p["szi"] > 0 else "SHORT"
|
||||
print(f" {p['coin']}: {direction} {abs(p['szi']):.4f} @ ${p['entry_px']:.1f}")
|
||||
|
||||
print(f"\n [2] Closing positions...")
|
||||
all_closed = True
|
||||
for p in positions:
|
||||
for attempt in range(args.retry):
|
||||
ok = close_position(exchange_url, p["coin"], p["szi"], args.dry_run)
|
||||
if ok:
|
||||
break
|
||||
if attempt < args.retry - 1:
|
||||
time.sleep(1)
|
||||
else:
|
||||
print(f" [{p['coin']}] Failed after {args.retry} attempts")
|
||||
all_closed = False
|
||||
|
||||
if not all_closed and not args.dry_run:
|
||||
print("\n WARNING: Some positions could not be closed!")
|
||||
else:
|
||||
print(" All positions closed")
|
||||
|
||||
if not args.skip_cancel:
|
||||
print(f"\n [3] Cancelling open orders...")
|
||||
cancel_all_orders(exchange_url, api_url, args.dry_run)
|
||||
|
||||
print(f"\n [4] Verification...")
|
||||
if not args.dry_run:
|
||||
time.sleep(2)
|
||||
ok = verify_flat(api_url)
|
||||
if ok:
|
||||
print("\n KILL SWITCH COMPLETE — positions flat, orders cancelled")
|
||||
else:
|
||||
print("\n KILL SWITCH WARNING — positions may still exist!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user