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:
+262
@@ -0,0 +1,262 @@
|
||||
"""
|
||||
Order book queue position model.
|
||||
|
||||
Simulates where a limit order sits in the price-time FIFO queue
|
||||
and computes fill probability, expected queue time, and greeks
|
||||
for queue position management.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueuePosition:
|
||||
"""Position of an order in the queue at a given price level."""
|
||||
|
||||
price: float
|
||||
side: str # "bid" or "ask"
|
||||
size: float # order size
|
||||
position: int # position in queue (0 = front)
|
||||
total_queue: int # total orders ahead at this price
|
||||
total_size: float # total size ahead at this price (excluding our order)
|
||||
arrival_time: float # simulation time order was placed
|
||||
|
||||
@property
|
||||
def is_front(self) -> bool:
|
||||
return self.position == 0
|
||||
|
||||
@property
|
||||
def queue_ratio(self) -> float:
|
||||
"""Fraction of total size we represent at this level."""
|
||||
total = self.total_size + self.size
|
||||
return self.size / total if total > 0 else 1.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueueLevel:
|
||||
"""Aggregated data for a single price level in the book."""
|
||||
|
||||
price: float
|
||||
total_size: float
|
||||
order_count: int
|
||||
oldest_age: float # simulation time of oldest order
|
||||
|
||||
|
||||
class QueueModel:
|
||||
"""Manages queue positions for maker orders on both sides.
|
||||
|
||||
Tracks where our orders sit in the FIFO queue at each price level.
|
||||
Simulates queue progression as trades eat through levels.
|
||||
|
||||
Usage:
|
||||
qm = QueueModel()
|
||||
qm.place_order("bid", 50000.0, 0.01, sim_time=100.0)
|
||||
qm.process_trade("bid", 50000.0, 0.005, sim_time=100.5)
|
||||
status = qm.order_status("bid", 50000.0)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._bids: dict[float, list[dict]] = defaultdict(list) # price → [{size, time, ours}]
|
||||
self._asks: dict[float, list[dict]] = defaultdict(list)
|
||||
self._our_orders: dict[str, dict] = {} # order_id → {price, side, size, time, filled}
|
||||
|
||||
def place_order(
|
||||
self,
|
||||
side: str,
|
||||
price: float,
|
||||
size: float,
|
||||
sim_time: float,
|
||||
order_id: str | None = None,
|
||||
) -> str:
|
||||
"""Place a new maker order. Returns order_id."""
|
||||
oid = order_id or f"qt{abs(hash(str(sim_time) + side + str(price))):08x}"
|
||||
book = self._bids if side == "bid" else self._asks
|
||||
|
||||
entry = {"size": size, "time": sim_time, "ours": True, "oid": oid}
|
||||
book[price].append(entry)
|
||||
|
||||
self._our_orders[oid] = {
|
||||
"oid": oid,
|
||||
"price": price,
|
||||
"side": side,
|
||||
"size": size,
|
||||
"time": sim_time,
|
||||
"filled": 0.0,
|
||||
"status": "active",
|
||||
}
|
||||
return oid
|
||||
|
||||
def cancel_order(self, order_id: str, sim_time: float) -> float:
|
||||
"""Cancel an order. Returns filled amount before cancel."""
|
||||
order = self._our_orders.get(order_id)
|
||||
if not order:
|
||||
return 0.0
|
||||
|
||||
book = self._bids if order["side"] == "bid" else self._asks
|
||||
price = order["price"]
|
||||
size = order["size"]
|
||||
|
||||
# Remove from queue
|
||||
if price in book:
|
||||
book[price] = [o for o in book[price] if o.get("oid") != order_id]
|
||||
|
||||
order["status"] = "cancelled"
|
||||
return order["filled"]
|
||||
|
||||
def process_trade(
|
||||
self,
|
||||
aggressor_side: str, # "buy" = market buy (hits asks), "sell" = market sell (hits bids)
|
||||
price: float,
|
||||
size: float,
|
||||
sim_time: float,
|
||||
fee_taker: float = 0.0005,
|
||||
) -> list[dict]:
|
||||
"""Process an aggressor trade. Returns list of our fill events.
|
||||
|
||||
A buy trade eats through asks (price ≤ trade price).
|
||||
A sell trade eats through bids (price ≥ trade price).
|
||||
"""
|
||||
fills = []
|
||||
remaining = size
|
||||
|
||||
if aggressor_side == "buy":
|
||||
target_book = self._asks
|
||||
prices = sorted(target_book.keys()) # lowest ask first
|
||||
else:
|
||||
target_book = self._bids
|
||||
prices = sorted(target_book.keys(), reverse=True) # highest bid first
|
||||
|
||||
for px in prices:
|
||||
if aggressor_side == "buy" and px > price:
|
||||
break
|
||||
if aggressor_side == "sell" and px < price:
|
||||
break
|
||||
|
||||
orders = target_book[px]
|
||||
while orders and remaining > 0:
|
||||
order = orders[0]
|
||||
eat = min(order["size"], remaining)
|
||||
order["size"] -= eat
|
||||
remaining -= eat
|
||||
|
||||
if order.get("ours"):
|
||||
oid = order["oid"]
|
||||
if oid in self._our_orders:
|
||||
self._our_orders[oid]["filled"] += eat
|
||||
fills.append({
|
||||
"order_id": oid,
|
||||
"price": px,
|
||||
"size": eat,
|
||||
"side": order.get("_side", ""),
|
||||
"time": sim_time,
|
||||
"fee": round(eat * px * fee_taker, 6),
|
||||
"aggressor": aggressor_side,
|
||||
})
|
||||
|
||||
if order["size"] <= 1e-12:
|
||||
orders.pop(0)
|
||||
|
||||
if not orders:
|
||||
del target_book[px]
|
||||
|
||||
if remaining <= 0:
|
||||
break
|
||||
|
||||
# Mark fully filled orders
|
||||
for oid, order in self._our_orders.items():
|
||||
if abs(order["filled"] - order["size"]) < 1e-10 and order["status"] == "active":
|
||||
order["status"] = "filled"
|
||||
|
||||
return fills
|
||||
|
||||
def order_status(self, order_id: str) -> dict | None:
|
||||
"""Get current status of a placed order."""
|
||||
return self._our_orders.get(order_id)
|
||||
|
||||
def queue_position(self, side: str, price: float, order_id: str) -> QueuePosition | None:
|
||||
"""Get queue position info for a specific order."""
|
||||
order = self._our_orders.get(order_id)
|
||||
if not order:
|
||||
return None
|
||||
|
||||
book = self._bids if side == "bid" else self._asks
|
||||
orders = book.get(price, [])
|
||||
pos = 0
|
||||
ahead_size = 0.0
|
||||
found = False
|
||||
|
||||
for o in orders:
|
||||
if o.get("oid") == order_id:
|
||||
found = True
|
||||
break
|
||||
pos += 1
|
||||
ahead_size += o["size"]
|
||||
|
||||
if not found:
|
||||
return None
|
||||
|
||||
return QueuePosition(
|
||||
price=price,
|
||||
side=side,
|
||||
size=order["size"] - order["filled"],
|
||||
position=pos,
|
||||
total_queue=len(orders),
|
||||
total_size=ahead_size,
|
||||
arrival_time=order["time"],
|
||||
)
|
||||
|
||||
def top_of_book(self) -> dict:
|
||||
"""Get best bid/ask with total sizes."""
|
||||
best_bid = max(self._bids) if self._bids else 0
|
||||
best_ask = min(self._asks) if self._asks else 0
|
||||
bid_size = sum(o["size"] for o in self._bids.get(best_bid, []))
|
||||
ask_size = sum(o["size"] for o in self._asks.get(best_ask, []))
|
||||
return {
|
||||
"best_bid": best_bid,
|
||||
"best_ask": best_ask,
|
||||
"bid_size": bid_size,
|
||||
"ask_size": ask_size,
|
||||
"spread": best_ask - best_bid if best_bid and best_ask else 0,
|
||||
}
|
||||
|
||||
def active_orders(self) -> list[dict]:
|
||||
return [o for o in self._our_orders.values() if o["status"] == "active"]
|
||||
|
||||
|
||||
# ── Fill probability estimation ─────────────────────────────
|
||||
|
||||
def fill_probability(
|
||||
queue_pos: int,
|
||||
total_queue_depth: float,
|
||||
order_size: float,
|
||||
arrival_rate: float, # trades/sec at this level
|
||||
time_horizon: float, # seconds
|
||||
) -> dict:
|
||||
"""Estimate fill probability for an order at given queue position.
|
||||
|
||||
Uses a Poisson thinning model: each arriving trade has probability
|
||||
of reaching this queue position.
|
||||
|
||||
Returns prob and expected fill time.
|
||||
"""
|
||||
if queue_pos == 0:
|
||||
prob = 1.0 - math.exp(-arrival_rate * time_horizon)
|
||||
expected_time = 1.0 / arrival_rate if arrival_rate > 0 else float("inf")
|
||||
else:
|
||||
# Probability trade reaches position k: depends on trade sizes vs queue
|
||||
lam = arrival_rate * time_horizon
|
||||
depth_at_level = total_queue_depth / max(queue_pos, 1)
|
||||
thin_factor = max(0.0, 1.0 - depth_at_level / (order_size * 10)) # heuristic
|
||||
prob = (1.0 - math.exp(-lam)) * thin_factor
|
||||
expected_time = time_horizon / max(prob, 1e-6)
|
||||
|
||||
return {
|
||||
"fill_probability": round(prob, 6),
|
||||
"expected_fill_time_s": round(min(expected_time, 86400), 2),
|
||||
"queue_position": queue_pos,
|
||||
"time_horizon_s": time_horizon,
|
||||
}
|
||||
Reference in New Issue
Block a user