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:
ramseshk
2026-08-11 10:43:51 +08:00
parent cdc7a01986
commit 50d63e1ecc
6 changed files with 1517 additions and 3 deletions
+342
View File
@@ -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,
},
}