50d63e1ecc
- 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).
298 lines
9.2 KiB
Python
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
|