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:
+182
@@ -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),
|
||||
}
|
||||
Reference in New Issue
Block a user