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:
ramseshk
2026-08-11 11:28:01 +08:00
parent 3073415d33
commit 09cb0d42b5
5 changed files with 526 additions and 55 deletions
@@ -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
+27
View File
@@ -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
+127 -55
View File
@@ -20,6 +20,9 @@ from strategies.deep_lob import DeepLOB
from strategies.cartea_jaimungal import CarteaJaimungal from strategies.cartea_jaimungal import CarteaJaimungal
from strategies.queue_imbalance import QueueImbalance from strategies.queue_imbalance import QueueImbalance
from strategies.gueant import GueantMM 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") logging.basicConfig(level=logging.INFO, format="%(asctime)s [paper] %(message)s", datefmt="%H:%M:%S")
log = logging.getLogger("ftdt-paper") log = logging.getLogger("ftdt-paper")
@@ -129,6 +132,20 @@ STRATEGIES = {
"signals": [], "type": "gueant", "size": 0.001, "fee_model": "maker", "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.", "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] = [] 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) gueant = GueantMM(gamma=0.1, sigma=0.015, k=1.5, T=3600, max_pos=0.005)
prev_bids = None prev_bids = None
prev_asks = 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 ═══════════════════════ # ═══════════════════════ Signal Engine ═══════════════════════
@@ -433,13 +457,10 @@ def simulate_fill(name: str, side: str, coin: str, price: float, reason: str = "
# ═══════════════════════ A-S Spread Capture ═══════════════════════ # ═══════════════════════ A-S Spread Capture ═══════════════════════
def simulate_avellaneda(btc_bid, btc_ask): def simulate_avellaneda(btc_bid, btc_ask, bid_depth=2.0, ask_depth=2.0):
"""Avellaneda-Stoikov: regime-adaptive spread capture. """Avellaneda-Stoikov: queue-aware spread capture with regime gating.
Regime-dependent behavior: Uses QueueAwareFillModel instead of random probabilities.
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)
""" """
cfg = STRATEGIES["Avellaneda-Stoikov"] cfg = STRATEGIES["Avellaneda-Stoikov"]
if btc_bid <= 0 or btc_ask <= 0: if btc_bid <= 0 or btc_ask <= 0:
@@ -448,57 +469,68 @@ def simulate_avellaneda(btc_bid, btc_ask):
regime = current_regime regime = current_regime
spread = btc_ask - btc_bid 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": 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": elif regime == "HIGH_VOL":
fill_prob = 0.08 quote_bid = btc_bid - spread * 0.1
# During high vol with wide spreads, avoid getting picked off quote_ask = btc_ask + spread * 0.1
if spread > 30: # >$30 spread = dangerous
return
else:
fill_prob = 0.15
if random.random() < fill_prob: depth = max(bid_depth, ask_depth, 1.0)
if cfg["position"] <= 0: depth_ahead = depth * 0.5
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
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 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["trades_today"] += 1
cfg["pnl_pct"] = cfg["pnl"] / cfg["allocation"] * 100 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 ═══════════════════════ # ═══════════════════════ Metrics ═══════════════════════
@@ -522,6 +554,13 @@ def write_metrics():
"strategy_equity": {k: list(v)[-300:] for k, v in strategy_equity.items()}, "strategy_equity": {k: list(v)[-300:] for k, v in strategy_equity.items()},
"strategies": STRATEGIES, "strategies": STRATEGIES,
"trades": trades_log[-200:], "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", "status": "running",
"btc_price": btc_prices[-1] if btc_prices else 0, "btc_price": btc_prices[-1] if btc_prices else 0,
"eth_price": eth_prices[-1] if eth_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") btc_bid, btc_ask = get_mainnet_orderbook("BTC")
bids, asks = get_deep_orderbook("BTC") bids, asks = get_deep_orderbook("BTC")
# Avellaneda-Stoikov: simulate spread capture # Avellaneda-Stoikov: simulate spread capture with queue-aware fills
simulate_avellaneda(btc_bid, btc_ask) 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 OFI: feed simulated trade to model
hawkes_btc.update("B" if tick % 2 == 0 else "S", 0.001, btc) 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( g_quotes = gueant.optimal_quotes(
btc, gueant_inv, tick % 3600, btc, gueant_inv, tick % 3600,
adverse_prob=queue_imb.wqi_history[-1] if queue_imb.wqi_history else 0) 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: if btc_bid > 0 and g_quotes["bid"] >= btc_bid * 0.999:
STRATEGIES["Guéant Market Making"]["signals"].append({ STRATEGIES["Guéant Market Making"]["signals"].append({
"time": time.time(), "signal": "BUY", "time": time.time(), "signal": "BUY",
@@ -672,7 +712,39 @@ async def main():
"strength": 0.5, "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) total_strats = len(strategy_names)
name = strategy_names[idx % total_strats] name = strategy_names[idx % total_strats]
idx += 1 idx += 1
+320
View File
@@ -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()