feat: Phase 3 — event-driven market-making simulator + 53 tests

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.
This commit is contained in:
ramseshk
2026-08-07 14:39:59 +08:00
parent fcfc136384
commit 639dd4fb6d
13 changed files with 1989 additions and 0 deletions
+182
View File
@@ -0,0 +1,182 @@
"""
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),
}