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