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