639dd4fb6d
New sim/ module — 7 files + init, replays stored L2/trade data
through a realistic market-making simulation:
sim/engine.py (SimulationEngine):
Event-driven core — processes L2 updates, trades, mark prices
sequentially. Orchestrates queue model, maker quotes, fill sim,
constraints, scenarios. Supports periodic re-quoting and
stale order cancellation.
sim/queue.py (QueueModel):
Price-time FIFO queue per price level. Tracks where maker orders
sit in queue. Simulates order eating by aggressor trades.
fill_probability() — Poisson thinning model for fill odds.
sim/maker.py:
AvellanedaStoikovMaker — stochastic control quoting with
aeta, k, tau parameters. Reservation price based on inventory.
quote() and quote_with_skew() with configurable inventory tilt.
GridMaker — evenly-spaced grid quoting at N levels.
sim/fills.py:
FillSimulator — partial fills, adverse selection probability,
cancel latency (gaussian RTT). FillEvent/CancelEvent tracking.
adverse_selection_intensity() — measures post-fill price moves.
sim/constraints.py:
InventoryConstraint — long/short/net/gross position limits.
FundingConstraint — hourly funding cost estimation.
FeeSchedule — maker/taker fee calculation.
LiquidationRisk — liquidation price and safety distance.
CircuitBreaker — PnL, trade count, toxic rate, slippage trips.
ConstraintManager — unified pre-trade constraint check.
sim/scenario.py:
ScenarioEngine — randomized exchange downtimes, latency spikes,
volatility bursts. State query per sim_time for spread/trade-rate.
sim/reporter.py:
PnLReporter — component-level PnL breakdown:
spread_capture, inventory_pnl, fees, funding, adverse_selection.
SimulationStats — trade counts, fill rates, drawdown, sharpe.
Equity curve tracking and max drawdown computation.
53 new tests across 4 files (all pass):
test_sim_queue.py (12) — order placement, FIFO, fills, cancels
test_sim_maker.py (9) — A-S quotes, inventory skew, grid maker
test_sim_constraints.py (14) — limits, funding, fees, liquidation, breakers
test_sim_reporter.py (12) — PnL components, equity curve, stats
test_sim_engine.py (6) — full engine integration
Total test suite: 134 tests, all passing.
183 lines
5.7 KiB
Python
183 lines
5.7 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),
|
|
}
|