Files
ftdt-quant-lab/sim/fills.py
T
ramseshk 50d63e1ecc 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).
2026-08-11 10:43:51 +08:00

298 lines
9.2 KiB
Python

"""
Fill simulation: partial fills, cancel latency, adverse selection.
Models realistic fill behavior for maker orders:
- Partial fills (not all-or-nothing)
- Cancel latency (cancel arrives after fill)
- Adverse selection (getting filled right before adverse price move)
"""
from __future__ import annotations
import math
import random
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class FillEvent:
"""A fill (or partial fill) of a maker order."""
order_id: str
side: str
price: float
size: float
fee: float
pnl_immediate: float # PnL if closed instantly at mid
is_toxic: bool # was this fill followed by adverse price move?
timestamp: float
aggressor_side: str = ""
@dataclass
class CancelEvent:
order_id: str
requested_time: float
executed_time: float
filled_before_cancel: float
latency_ms: float
@dataclass
class FillModelConfig:
"""Parameters for fill simulation."""
partial_fill_prob: float = 0.3 # probability of partial (not full) fill
min_fill_ratio: float = 0.25 # min fraction of order filled
cancel_latency_ms: float = 50.0 # typical cancel RTT
cancel_latency_std_ms: float = 20.0
adverse_selection_prob: float = 0.15 # prob a fill is adverse
adverse_move_bps: float = 3.0 # bps adverse move after toxic fill
queue_priority_decay: float = 0.02 # per-event reduction in fill prob if not front
class FillSimulator:
"""Simulates fill behavior for maker orders in the queue model."""
def __init__(self, config: FillModelConfig | None = None, seed: int | None = None):
self._cfg = config or FillModelConfig()
self._rng = random.Random(seed)
self._events: list[FillEvent | CancelEvent] = []
def simulate_fill(
self,
order_id: str,
side: str,
price: float,
size: float,
queue_position: int,
mid_price: float,
aggressor_size: float,
timestamp: float,
fee_rate: float = 0.0002,
) -> Optional[FillEvent]:
"""Simulate whether this trade event fills our order.
Returns FillEvent if filled, None if our order survives.
"""
if queue_position > 0:
# Not at front: low probability of fill from agg size
prob = min(aggressor_size / (size * 2), 0.1)
if self._rng.random() > prob:
return None
# At front or lucky: may get partial fill
is_partial = self._rng.random() < self._cfg.partial_fill_prob
fill_ratio = self._rng.uniform(self._cfg.min_fill_ratio, 1.0) if is_partial else 1.0
fill_size = round(size * fill_ratio, 8)
# Fee
fee = fill_size * price * fee_rate
# Immediate PnL estimate
aggressive = "buy" if side == "ask" else "sell"
if side == "bid":
pnl_immediate = fill_size * (mid_price - price) - fee
else:
pnl_immediate = fill_size * (price - mid_price) - fee
# Adverse selection check
is_toxic = self._rng.random() < self._cfg.adverse_selection_prob
event = FillEvent(
order_id=order_id,
side=side,
price=price,
size=fill_size,
fee=round(fee, 6),
pnl_immediate=round(pnl_immediate, 6),
is_toxic=is_toxic,
timestamp=timestamp,
aggressor_side=aggressive,
)
self._events.append(event)
return event
def simulate_cancel(
self,
order_id: str,
timestamp: float,
) -> CancelEvent:
"""Simulate cancel with random latency."""
lat = max(1.0, self._rng.gauss(self._cfg.cancel_latency_ms, self._cfg.cancel_latency_std_ms))
event = CancelEvent(
order_id=order_id,
requested_time=timestamp,
executed_time=timestamp + lat / 1000.0,
filled_before_cancel=0.0,
latency_ms=round(lat, 2),
)
self._events.append(event)
return event
@property
def events(self) -> list:
return self._events
def fills(self) -> list[FillEvent]:
return [e for e in self._events if isinstance(e, FillEvent)]
def cancels(self) -> list[CancelEvent]:
return [e for e in self._events if isinstance(e, CancelEvent)]
# ── Adverse selection estimator ────────────────────────────
def adverse_selection_intensity(
fills: list[FillEvent],
future_mids: list[float],
horizon_events: int = 5,
) -> dict:
"""Compute how often fills are followed by adverse price moves.
A fill is adverse if mid price moves against the maker within
horizon_events subsequent trades.
"""
if not fills or len(future_mids) < horizon_events:
return {"adverse_rate": 0, "mean_cost_bps": 0, "n_fills": len(fills)}
adverse_count = 0
adverse_costs = []
n = len(future_mids)
for i, fill in enumerate(fills):
future_idx = min(i + horizon_events, n - 1)
mid_now = future_mids[i] if i < n else 0
mid_future = future_mids[future_idx]
if mid_now <= 0 or mid_future <= 0:
continue
move_bps = (mid_future - mid_now) / mid_now * 10000
if (fill.side == "bid" and move_bps < 0) or (fill.side == "ask" and move_bps > 0):
adverse_count += 1
adverse_costs.append(abs(move_bps))
return {
"adverse_rate": round(adverse_count / len(fills), 4) if fills else 0,
"mean_cost_bps": round(sum(adverse_costs) / max(len(adverse_costs), 1), 2),
"total_cost_bps": round(sum(adverse_costs), 2),
"n_fills": len(fills),
}
class QueueAwareFillModel:
"""Realistic queue-priority fill model for paper trading.
Unlike random fills, this models whether an aggressor trade at a given
price level would exhaust the queue ahead of our order. An order fills
only when the total aggressor volume at that price exceeds the volume
of orders ahead of ours in the FIFO queue.
Usage:
model = QueueAwareFillModel()
fill_info = model.check_fill(
aggressor_side="buy",
agg_size=0.005,
our_price=50000.0,
our_size=0.001,
depth_ahead=0.002,
)
"""
def __init__(self, base_fill_prob: float = 0.20):
self._base_fill_prob = base_fill_prob
self._fill_count: int = 0
self._skip_count: int = 0
def check_fill(
self,
aggressor_side: str,
agg_size: float,
agg_price: float,
our_price: float,
our_size: float,
depth_ahead: float,
) -> dict:
"""Determine if a market order would fill our limit order.
Args:
aggressor_side: "buy" (market buy hits asks) or "sell" (market sell hits bids)
agg_size: size of the aggressor trade
agg_price: price of the aggressor trade
our_price: our limit order price
our_size: our order size
depth_ahead: total size of orders ahead of ours in the queue at this price
Returns:
dict with filled (bool), fill_size, reason
"""
price_match = False
if aggressor_side == "buy" and agg_price >= our_price:
price_match = True
elif aggressor_side == "sell" and agg_price <= our_price:
price_match = True
if not price_match:
return {"filled": False, "fill_size": 0.0, "reason": "price_not_crossed"}
remaining_after_queue = agg_size - depth_ahead
if remaining_after_queue <= 0:
self._skip_count += 1
return {"filled": False, "fill_size": 0.0, "reason": "queue_not_reached"}
fill_size = min(our_size, remaining_after_queue)
self._fill_count += 1
return {
"filled": True,
"fill_size": round(fill_size, 8),
"fill_ratio": round(fill_size / our_size, 4),
"reason": f"reached_queue_pos",
"depth_consumed": round(depth_ahead + fill_size, 8),
}
def estimate_depth_ahead(
self,
our_price: float,
our_side: str,
best_bid: float,
best_ask: float,
bid_depth: float,
ask_depth: float,
) -> float:
"""Estimate the volume ahead of our order at a price level.
This is a heuristic since HL doesn't expose queue position. We estimate
based on whether we're at the best level and how much total depth is there.
"""
at_best = (
(our_side == "bid" and our_price >= best_bid) or
(our_side == "ask" and our_price <= best_ask)
)
if not at_best:
return float("inf")
if our_side == "bid":
return bid_depth * 0.5
else:
return ask_depth * 0.5
@property
def fill_count(self) -> int:
return self._fill_count
@property
def skip_count(self) -> int:
return self._skip_count
def fill_rate(self) -> float:
total = self._fill_count + self._skip_count
return self._fill_count / total if total > 0 else 0.0
def reset(self):
self._fill_count = 0
self._skip_count = 0