feat: HFT infrastructure — tick backtest runner, VPIN-gated A-S maker, WQI predictor, queue-aware fills
- backtests/tick_runner.py: TickBacktestRunner replays stored Parquet L2/trade events through sim/engine.py with queue position modeling, producing PnL breakdowns, equity curves, VPIN curves, and QuantVerdict significance reports - VPINGatedASMaker: VPIN-toxicity-gated A-S market maker with inventory skew and dynamic spread widening; blocks quoting when VPIN >= alarm threshold - sim/engine.py: Added SimConfig.from_fee_tier() factory — constructs sim config from Hyperliquid fee tier (VIP + staking) - sim/fills.py: Added QueueAwareFillModel — realistic queue-priority fill simulation replacing random fills in paper trading - strategies/wqi_predictor.py: WQI z-score directional strategy with adverse selection gating, timeout exit, stop-loss, and take-profit - cli.py: Added 'tick', 'markout' analysis, and 'discover' signal-discovery commands for end-to-end tick-level HFT research pipeline 301 tests passing (23 new).
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
"""
|
||||
WQI Predictor — Queue Imbalance Directional Strategy.
|
||||
|
||||
Uses the Weighted Queue Imbalance (WQI) from `strategies/queue_imbalance.py`
|
||||
to predict short-term price direction. The strategy enters when WQI z-score
|
||||
is extreme and exits on timeout, reversal, or stop-loss.
|
||||
|
||||
Based on the Stoikov-Sağlam and Cont frameworks for order book dynamics.
|
||||
|
||||
Entry logic:
|
||||
- WQI z-score > z_entry (default 2.0) AND WQI > wqi_threshold (0.15) → BUY
|
||||
- WQI z-score < -z_entry AND WQI < -wqi_threshold → SELL
|
||||
- Gate: adverse selection score < max_adverse (0.3)
|
||||
|
||||
Exit logic:
|
||||
- WQI crosses zero (microstructure reversal)
|
||||
- Hold time exceeds max_hold_seconds
|
||||
- Stop-loss: price moves > stop_loss_bps against position
|
||||
- Profit target: price moves > take_profit_bps in favor
|
||||
|
||||
Usage (tick-level backtest):
|
||||
predictor = WQIPredictor(z_entry=2.0, max_hold_seconds=30)
|
||||
for l2_snapshot, trade in replay:
|
||||
signal = predictor.update(bids, asks, mid_price, prev_bids, prev_asks, prev_mid)
|
||||
if signal["action"] in ("BUY", "SELL"):
|
||||
execute_trade(signal)
|
||||
|
||||
Usage (live paper trading):
|
||||
predictor = WQIPredictor(adverse_trades=True)
|
||||
signal = predictor.feed_signal(bids, asks, mid_price, trade_side, trade_size)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import Optional
|
||||
|
||||
from strategies.queue_imbalance import QueueImbalance
|
||||
|
||||
|
||||
class WQIPredictor:
|
||||
"""Queue-imbalance-based directional trading strategy.
|
||||
|
||||
The predictive signal: when depth at the top of the book systematically
|
||||
shifts to one side, the mid-price tends to move in that direction.
|
||||
This strategy captures that by entering when the deviation is extreme
|
||||
relative to recent history and exiting when it normalizes.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
z_entry: float = 2.0,
|
||||
z_exit: float = 0.5,
|
||||
wqi_threshold: float = 0.15,
|
||||
max_adverse: float = 0.3,
|
||||
max_hold_seconds: float = 30.0,
|
||||
stop_loss_bps: float = 5.0,
|
||||
take_profit_bps: float = 10.0,
|
||||
size: float = 0.001,
|
||||
depth_levels: int = 10,
|
||||
fee_model: str = "taker",
|
||||
):
|
||||
self._z_entry = z_entry
|
||||
self._z_exit = z_exit
|
||||
self._wqi_threshold = wqi_threshold
|
||||
self._max_adverse = max_adverse
|
||||
self._max_hold_seconds = max_hold_seconds
|
||||
self._stop_loss_bps = stop_loss_bps
|
||||
self._take_profit_bps = take_profit_bps
|
||||
self._size = size
|
||||
self._fee_model = fee_model
|
||||
|
||||
self._qi = QueueImbalance(depth_levels=depth_levels)
|
||||
self._mid_prices: deque[float] = deque(maxlen=200)
|
||||
self._signals: deque[dict] = deque(maxlen=100)
|
||||
|
||||
self._position: int = 0
|
||||
self._entry_price: float = 0.0
|
||||
self._entry_time: float = 0.0
|
||||
self._entry_wqi: float = 0.0
|
||||
|
||||
self._trades: list[dict] = []
|
||||
|
||||
def feed_signal(
|
||||
self,
|
||||
bids: list,
|
||||
asks: list,
|
||||
mid_price: float,
|
||||
prev_bids: Optional[list] = None,
|
||||
prev_asks: Optional[list] = None,
|
||||
prev_mid: float = 0.0,
|
||||
timestamp: Optional[float] = None,
|
||||
) -> dict:
|
||||
if timestamp is None:
|
||||
timestamp = time.time()
|
||||
|
||||
analysis = self._qi.analyze(bids, asks, mid_price, prev_bids, prev_asks, prev_mid)
|
||||
self._mid_prices.append(mid_price)
|
||||
|
||||
wqi = analysis["wqi"]
|
||||
z_score = analysis["z_score"]
|
||||
adverse = analysis["adverse_selection"]
|
||||
net_flow = analysis["net_flow"]
|
||||
|
||||
action = "HOLD"
|
||||
reason = ""
|
||||
|
||||
if self._position == 0:
|
||||
if z_score > self._z_entry and wqi > self._wqi_threshold and adverse < self._max_adverse:
|
||||
action = "BUY"
|
||||
reason = f"wqi_z={z_score:.2f}_wqi={wqi:.3f}_adv={adverse:.3f}"
|
||||
elif z_score < -self._z_entry and wqi < -self._wqi_threshold and adverse < self._max_adverse:
|
||||
action = "SELL"
|
||||
reason = f"wqi_z={z_score:.2f}_wqi={wqi:.3f}_adv={adverse:.3f}"
|
||||
else:
|
||||
hold_time = timestamp - self._entry_time
|
||||
pnl_bps = (mid_price / self._entry_price - 1) * 10000
|
||||
if self._position == -1:
|
||||
pnl_bps = -pnl_bps
|
||||
|
||||
if abs(z_score) < self._z_exit:
|
||||
action = "EXIT"
|
||||
reason = f"z_cross={z_score:.2f}"
|
||||
elif hold_time >= self._max_hold_seconds:
|
||||
action = "EXIT"
|
||||
reason = f"timeout_{hold_time:.0f}s"
|
||||
elif pnl_bps <= -self._stop_loss_bps:
|
||||
action = "EXIT"
|
||||
reason = f"stop_loss_{pnl_bps:.1f}bps"
|
||||
elif pnl_bps >= self._take_profit_bps:
|
||||
action = "EXIT"
|
||||
reason = f"take_profit_{pnl_bps:.1f}bps"
|
||||
|
||||
if action in ("BUY", "SELL"):
|
||||
self._position = 1 if action == "BUY" else -1
|
||||
self._entry_price = mid_price
|
||||
self._entry_time = timestamp
|
||||
self._entry_wqi = wqi
|
||||
elif action == "EXIT":
|
||||
pnl_bps = (mid_price / self._entry_price - 1) * 10000
|
||||
if self._position == 1:
|
||||
gross_pnl = self._size * (mid_price - self._entry_price)
|
||||
else:
|
||||
gross_pnl = self._size * (self._entry_price - mid_price)
|
||||
fee = self._size * mid_price * (0.00045 if self._fee_model == "taker" else 0.00015)
|
||||
net_pnl = gross_pnl - fee
|
||||
|
||||
self._trades.append({
|
||||
"entry_price": round(self._entry_price, 2),
|
||||
"exit_price": round(mid_price, 2),
|
||||
"side": "BUY" if self._position == 1 else "SELL",
|
||||
"size": self._size,
|
||||
"pnl_bps": round(pnl_bps, 2),
|
||||
"gross_pnl": round(gross_pnl, 4),
|
||||
"fee": round(fee, 4),
|
||||
"net_pnl": round(net_pnl, 4),
|
||||
"hold_seconds": round(timestamp - self._entry_time, 2),
|
||||
"entry_wqi": round(self._entry_wqi, 4),
|
||||
"exit_wqi": round(wqi, 4),
|
||||
"reason": reason,
|
||||
})
|
||||
self._position = 0
|
||||
self._entry_price = 0.0
|
||||
self._entry_time = 0.0
|
||||
self._entry_wqi = 0.0
|
||||
|
||||
signal = {
|
||||
"action": action,
|
||||
"wqi": wqi,
|
||||
"z_score": z_score,
|
||||
"adverse": adverse,
|
||||
"net_flow": net_flow,
|
||||
"mid_price": mid_price,
|
||||
"position": self._position,
|
||||
"reason": reason,
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
self._signals.append(signal)
|
||||
return signal
|
||||
|
||||
@property
|
||||
def position(self) -> int:
|
||||
return self._position
|
||||
|
||||
@property
|
||||
def trades(self) -> list[dict]:
|
||||
return self._trades
|
||||
|
||||
@property
|
||||
def recent_signals(self) -> list[dict]:
|
||||
return list(self._signals)
|
||||
|
||||
def summary(self) -> dict:
|
||||
if not self._trades:
|
||||
return {
|
||||
"total_trades": 0,
|
||||
"win_rate": 0.0,
|
||||
"total_net_pnl": 0.0,
|
||||
"avg_net_pnl": 0.0,
|
||||
"avg_hold_seconds": 0.0,
|
||||
"max_profit_bps": 0.0,
|
||||
"max_loss_bps": 0.0,
|
||||
}
|
||||
|
||||
wins = sum(1 for t in self._trades if t["net_pnl"] > 0)
|
||||
total_net = sum(t["net_pnl"] for t in self._trades)
|
||||
gross_pnls = [t["pnl_bps"] for t in self._trades]
|
||||
hold_times = [t["hold_seconds"] for t in self._trades]
|
||||
|
||||
return {
|
||||
"total_trades": len(self._trades),
|
||||
"win_rate": round(wins / len(self._trades), 3),
|
||||
"total_net_pnl": round(total_net, 4),
|
||||
"avg_net_pnl": round(total_net / len(self._trades), 4),
|
||||
"avg_pnl_bps": round(sum(gross_pnls) / len(gross_pnls), 2),
|
||||
"avg_hold_seconds": round(sum(hold_times) / len(hold_times), 2),
|
||||
"max_profit_bps": round(max(gross_pnls), 2),
|
||||
"max_loss_bps": round(min(gross_pnls), 2),
|
||||
}
|
||||
|
||||
def reset(self):
|
||||
self._position = 0
|
||||
self._entry_price = 0.0
|
||||
self._entry_time = 0.0
|
||||
self._entry_wqi = 0.0
|
||||
self._trades.clear()
|
||||
self._signals.clear()
|
||||
self._mid_prices.clear()
|
||||
self._qi = QueueImbalance(depth_levels=self._qi.depth_levels)
|
||||
|
||||
|
||||
def run_wqi_backtest(
|
||||
tick_data_path: str,
|
||||
coin: str = "BTC",
|
||||
z_entry: float = 2.0,
|
||||
size: float = 0.001,
|
||||
max_hold: float = 30.0,
|
||||
stop_loss: float = 5.0,
|
||||
take_profit: float = 10.0,
|
||||
taker_fee_pct: float = 0.05,
|
||||
) -> dict:
|
||||
"""Run WQI predictor backtest on stored tick data.
|
||||
|
||||
Args:
|
||||
tick_data_path: path to parquet data directory
|
||||
coin: instrument
|
||||
z_entry: WQI z-score entry threshold
|
||||
size: trade size in BTC
|
||||
max_hold: max hold time in seconds
|
||||
stop_loss: stop loss in bps
|
||||
take_profit: take profit in bps
|
||||
taker_fee_pct: taker fee in % (0.05 = 5bps)
|
||||
|
||||
Returns dict with trades, PnL, and metrics.
|
||||
"""
|
||||
import json
|
||||
from data.store import read_range
|
||||
from microstructure.book import mid_price
|
||||
|
||||
predictor = WQIPredictor(
|
||||
z_entry=z_entry,
|
||||
max_hold_seconds=max_hold,
|
||||
stop_loss_bps=stop_loss,
|
||||
take_profit_bps=take_profit,
|
||||
size=size,
|
||||
fee_model="taker",
|
||||
)
|
||||
|
||||
trade_msgs = read_range(tick_data_path, channel="l2book", coin=coin,
|
||||
start_date="2026-01-01", end_date="2030-01-01")
|
||||
|
||||
prev_bids = None
|
||||
prev_asks = None
|
||||
prev_mid = 0.0
|
||||
|
||||
for msg in trade_msgs:
|
||||
payload = msg.get("payload", {})
|
||||
levels = payload.get("levels", [])
|
||||
if not isinstance(levels, list) or len(levels) < 2:
|
||||
continue
|
||||
|
||||
bids = [(float(l["px"]), float(l["sz"])) for l in levels[0] if float(l.get("sz", 0)) > 0]
|
||||
asks = [(float(l["px"]), float(l["sz"])) for l in levels[1] if float(l.get("sz", 0)) > 0]
|
||||
if not bids or not asks:
|
||||
continue
|
||||
|
||||
current_mid = mid_price(dict(bids), dict(asks))
|
||||
if current_mid <= 0:
|
||||
continue
|
||||
|
||||
ts = msg.get("local_ts", time.time())
|
||||
|
||||
signal = predictor.feed_signal(
|
||||
bids, asks, current_mid,
|
||||
prev_bids, prev_asks, prev_mid,
|
||||
timestamp=ts,
|
||||
)
|
||||
|
||||
prev_bids = bids
|
||||
prev_asks = asks
|
||||
prev_mid = current_mid
|
||||
|
||||
return predictor.summary()
|
||||
|
||||
|
||||
def run_wqi_live_signal(bids, asks, mid_market, state: dict) -> dict:
|
||||
"""Stateless WQI signal for live trading integration.
|
||||
|
||||
Args:
|
||||
bids: list of [price, size]
|
||||
asks: list of [price, size]
|
||||
mid_market: current mid price
|
||||
state: dict with 'prev_bids', 'prev_asks', 'prev_mid', 'position',
|
||||
'entry_price', 'entry_time'
|
||||
|
||||
Returns signal dict with action and state updates.
|
||||
"""
|
||||
predictor = WQIPredictor()
|
||||
predictor._position = state.get("position", 0)
|
||||
predictor._entry_price = state.get("entry_price", 0.0)
|
||||
predictor._entry_time = state.get("entry_time", time.time())
|
||||
|
||||
signal = predictor.feed_signal(
|
||||
bids, asks, mid_market,
|
||||
prev_bids=state.get("prev_bids"),
|
||||
prev_asks=state.get("prev_asks"),
|
||||
prev_mid=state.get("prev_mid", 0.0),
|
||||
)
|
||||
|
||||
return {
|
||||
**signal,
|
||||
"state_update": {
|
||||
"prev_bids": bids,
|
||||
"prev_asks": asks,
|
||||
"prev_mid": mid_market,
|
||||
"position": predictor.position,
|
||||
"entry_price": predictor._entry_price,
|
||||
"entry_time": predictor._entry_time,
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user