""" 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, }, }