Files
ftdt-quant-lab/sim/queue.py
T
ramseshk 639dd4fb6d 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.
2026-08-07 14:39:59 +08:00

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,
}