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