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.
263 lines
8.5 KiB
Python
263 lines
8.5 KiB
Python
"""
|
|
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,
|
|
}
|