Queue position of an order

See also

It's recommended to first start with the Order tracking and Limit order book examples.

Overview

In this example, we will use a complete limit order book to track the queue position of an order. This example assumes a First-In-First-Out (FIFO) matching algorithm is being utilized. FIFO is the most common algorithm used in modern matching engines.

When market participants have active orders in different instruments and contract months, knowing exact queue positions can be important in managing execution risk.

The ability to track orders as they move throughout the limit order book can be utilized as a trading signal in automated strategies.

Design

We will use the MBO schema ("L3" data), which assigns a unique ID to every order in the book, to exactly track this queue position. The logic for this calculation is a straightforward extension of our limit order book example.

We will also use the MBP-10 schema ("L2" data) to estimate queue position, assuming we did not have access to MBO data.

Suppose denotes the probability of a cancel in front when your order is positioned in the queue.

  • When you first join the queue, , since all cancels must happen in front of you.
  • When you are at the front of the queue, , since all cancels must happen from behind.

A naive assumption is that orders cancel uniformly through the queue, i.e. . In reality, cancels are more likely to happen behind you vs. in front of you, since the orders in front of you have value from their time priority.

We can model this cancellation bias with a parameter , ranging from -1 to 1. results in an unbiased, uniform distribution.

We'll take a look at this uniform cancellation estimation (), as well as a few weighted estimations that assumes a higher probability of cancels from behind ( and ).

Example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
from __future__ import annotations
import os
import random
from collections import defaultdict
from dataclasses import dataclass, field
from itertools import takewhile
import databento as db
import matplotlib.pyplot as plt
import pandas as pd
from sortedcontainers import SortedDict

@dataclass(slots=True)
class PriceLevel:
    price: int
    size: int = 0
    count: int = 0

    def __str__(self) -> str:
        price = self.price / db.FIXED_PRICE_SCALE
        return f"{self.size:4} @ {price:6.2f} | {self.count:2} order(s)"


@dataclass(slots=True)
class LevelOrders:
    price: int
    orders: list[db.MBOMsg] = field(default_factory=list, compare=False)

    def __bool__(self) -> bool:
        return bool(self.orders)

    @property
    def level(self) -> PriceLevel:
        return PriceLevel(
            price=self.price,
            count=sum(1 for o in self.orders if not o.flags & db.RecordFlags.F_TOB),
            size=sum(o.size for o in self.orders),
        )


@dataclass(slots=True)
class Book:
    orders_by_id: dict[int, db.MBOMsg] = field(default_factory=dict)
    offers: SortedDict[int, LevelOrders] = field(default_factory=SortedDict)
    bids: SortedDict[int, LevelOrders] = field(default_factory=SortedDict)

    def bbo(self) -> tuple[PriceLevel | None, PriceLevel | None]:
        return self.get_bid_level(), self.get_ask_level()

    def get_bid_level(self, idx: int = 0) -> PriceLevel | None:
        if self.bids and len(self.bids) > idx:
            # Reverse for bids to get highest prices first
            return self.bids.peekitem(-(idx + 1))[1].level
        return None

    def get_ask_level(self, idx: int = 0) -> PriceLevel | None:
        if self.offers and len(self.offers) > idx:
            return self.offers.peekitem(idx)[1].level
        return None

    def get_bid_level_by_px(self, px: int) -> PriceLevel | None:
        try:
            return self._get_level(px, "B").level
        except KeyError:
            return None

    def get_ask_level_by_px(self, px: int) -> PriceLevel | None:
        try:
            return self._get_level(px, "A").level
        except KeyError:
            return None

    def get_order(self, id: int) -> db.MBOMsg | None:
        return self.orders_by_id.get(id)

    def get_queue_pos(self, id: int) -> int | None:
        order = self.get_order(id)
        if not order:
            return None
        level = self._get_level(order.price, order.side)
        return sum(order.size for order in takewhile(lambda o: o.order_id != id, level.orders))

    def get_snapshot(self, level_count: int = 1) -> list[db.BidAskPair]:
        snapshots = []
        for level in range(level_count):
            ba_pair = db.BidAskPair()
            bid = self.get_bid_level(level)
            if bid:
                ba_pair.bid_px = bid.price
                ba_pair.bid_sz = bid.size
                ba_pair.bid_ct = bid.count
            ask = self.get_ask_level(level)
            if ask:
                ba_pair.ask_px = ask.price
                ba_pair.ask_sz = ask.size
                ba_pair.ask_ct = ask.count
            snapshots.append(ba_pair)
        return snapshots

    def apply(self, mbo: db.MBOMsg) -> None:
        # Trade, Fill, or None: no change
        if mbo.action in ("T", "F", "N"):
            return
        # Clear book: remove all resting orders
        if mbo.action == "R":
            self._clear()
            return
        # side=N is only valid with Trade, Fill, and Clear actions
        assert mbo.side in ("A", "B")
        # UNDEF_PRICE indicates the book level should be removed
        if mbo.price == db.UNDEF_PRICE and mbo.flags & db.RecordFlags.F_TOB:
            self._side_levels(mbo.side).clear()
            return
        # Add: insert a new order
        if mbo.action == "A":
            self._add(mbo)
        # Cancel: partially or fully cancel some size from a resting order
        elif mbo.action == "C":
            self._cancel(mbo)
        # Modify: change the price and/or size of a resting order
        elif mbo.action == "M":
            self._modify(mbo)
        else:
            raise ValueError(f"Unknown action={mbo.action}")

    def _clear(self) -> None:
        self.orders_by_id.clear()
        self.offers.clear()
        self.bids.clear()

    def _add(self, mbo: db.MBOMsg) -> None:
        if mbo.flags & db.RecordFlags.F_TOB:
            levels = self._side_levels(mbo.side)
            levels.clear()
            levels[mbo.price] = LevelOrders(price=mbo.price, orders=[mbo])
        else:
            level = self._get_or_insert_level(mbo.price, mbo.side)
            assert mbo.order_id not in self.orders_by_id
            self.orders_by_id[mbo.order_id] = mbo
            level.orders.append(mbo)

    def _cancel(self, mbo: db.MBOMsg) -> None:
        order = self.orders_by_id[mbo.order_id]
        level = self._get_level(mbo.price, mbo.side)
        assert order.size >= mbo.size
        order.size -= mbo.size
        # If the full size is cancelled, remove the order from the book
        if order.size == 0:
            self.orders_by_id.pop(mbo.order_id)
            level.orders.remove(order)
            # If the level is now empty, remove it from the book
            if not level:
                self._remove_level(mbo.price, mbo.side)

    def _modify(self, mbo: db.MBOMsg) -> None:
        order = self.orders_by_id.get(mbo.order_id)
        if order is None:
            # If order not found, treat it as an add
            self._add(mbo)
            return
        assert order.side == mbo.side, f"Order {order} changed side to {mbo.side}"
        level = self._get_level(order.price, order.side)
        if order.price != mbo.price:
            # Changing price loses priority
            level.orders.remove(order)
            if not level:
                self._remove_level(order.price, mbo.side)
            level = self._get_or_insert_level(mbo.price, mbo.side)
            level.orders.append(mbo)
        elif order.size < mbo.size:
            # Increasing size loses priority
            level.orders.remove(order)
            level.orders.append(mbo)
        else:
            # Update in place
            level.orders[level.orders.index(order)] = mbo
        self.orders_by_id[mbo.order_id] = mbo

    def _side_levels(self, side: str) -> SortedDict:
        if side == "A":
            return self.offers
        if side == "B":
            return self.bids
        raise ValueError(f"Invalid {side =}")

    def _get_level(self, price: int, side: str) -> LevelOrders:
        levels = self._side_levels(side)
        if price not in levels:
            raise KeyError(f"No price level found for {price =} and {side =}")
        return levels[price]

    def _get_or_insert_level(self, price: int, side: str) -> LevelOrders:
        levels = self._side_levels(side)
        if price in levels:
            return levels[price]
        level = LevelOrders(price=price)
        levels[price] = level
        return level

    def _remove_level(self, price: int, side: str) -> None:
        levels = self._side_levels(side)
        levels.pop(price)


@dataclass(slots=True)
class Market:
    books: defaultdict[int, defaultdict[int, Book]] = field(
        default_factory=lambda: defaultdict(lambda: defaultdict(Book)),
    )

    def get_books_by_pub(self, instrument_id: int) -> defaultdict[int, Book]:
        return self.books[instrument_id]

    def get_book(self, instrument_id: int, publisher_id: int) -> Book:
        return self.books[instrument_id][publisher_id]

    def bbo(
        self,
        instrument_id: int,
        publisher_id: int,
    ) -> tuple[PriceLevel | None, PriceLevel | None]:
        return self.books[instrument_id][publisher_id].bbo()

    def aggregated_bbo(
        self,
        instrument_id: int,
    ) -> tuple[PriceLevel | None, PriceLevel | None]:
        agg_bbo: list[PriceLevel | None] = [None, None]
        # max for bids, min for asks
        for idx, reducer in [(0, max), (1, min)]:
            all_best = [b.bbo()[idx] for b in self.books[instrument_id].values()]
            all_best = [b for b in all_best if b]
            if not all_best:
                continue
            best_price = reducer(b.price for b in all_best)
            best = [b for b in all_best if b.price == best_price]
            agg_bbo[idx] = PriceLevel(
                price=best_price,
                size=sum(b.size for b in best),
                count=sum(b.count for b in best),
            )
        return tuple(agg_bbo)

    def apply(self, mbo: db.MBOMsg) -> None:
        book = self.books[mbo.instrument_id][mbo.publisher_id]
        book.apply(mbo)


def apply_bias(x: float, k: float) -> float:
    """
    Apply bias to the random number `x` in the range [0, 1].

    -1 < k < 0 results in a bias towards 1.
    k == 0 results in a uniform distribution
    0 < k < 1 results in a bias towards 0

    k == -1 always returns 1
    k == 1 always returns 0
    """
    assert 0 <= x <= 1  # Random number between 0 and 1
    assert -1 <= k <= 1  # Bias between -1 and 1

    if k <= 0:
        return x ** (1 + k)
    else:
        return 1 - (1 - x) ** (1 - k)


if __name__ == "__main__":
    # Create a historical client
    client = db.Historical("$YOUR_API_KEY")

    # Set parameters
    dataset = "GLBX.MDP3"
    start_date = "2025-02-12"
    end_date = "2025-02-13"
    symbol = "ES.v.0"

    # For this example, we will retroactively pick an order to track
    order_id = 6414447712675
    order_add_time = 1739375799442261697
    order_fill_time = 1739375974175277429
    order_price = 6064000000000
    order_size = 1

    # Now we will request MBO data.
    mbo_data_path = "glbx-mdp3-20250212.mbo.dbn.zst"
    if os.path.exists(mbo_data_path):
        mbo_data = db.DBNStore.from_file(mbo_data_path)
    else:
        mbo_data = client.timeseries.get_range(
            dataset=dataset,
            start=start_date,
            end=end_date,
            symbols=symbol,
            stype_in="continuous",
            schema="mbo",
            path=mbo_data_path,
        )

    # Next, we will record all updates at this price while the order was active
    price_level_msgs: list[dict] = []
    book = Book()
    for mbo_msg in mbo_data:
        # Process MBO msg
        book.apply(mbo_msg)

        # Only record updates when order is active
        if order_add_time <= mbo_msg.ts_event < order_fill_time:
            # Wait for end of event
            if mbo_msg.flags & db.RecordFlags.F_LAST:
                # Get total size at price and actual queue position
                price_level_msg = {
                    "ts_event": mbo_msg.ts_event,
                    "Depth at price level": book.get_ask_level_by_px(order_price).size,
                    "Queue position": book.get_queue_pos(order_id),
                }
                price_level_msgs.append(price_level_msg)

    # Construct a DataFrame from MBO updates
    df = pd.DataFrame.from_records(price_level_msgs)
    df["Order lifetime (s)"] = (df["ts_event"] - df["ts_event"][0]) / 1e9
    df = df.set_index("Order lifetime (s)")

    # Plot actual queue position using MBO data
    ax = df[["Depth at price level", "Queue position"]].plot(
        drawstyle="steps-post",
        ylabel="Contracts",
    )

    def generate_simulations(df: pd.DataFrame, k: float) -> pd.Series:
        """
        Generate simulations for estimated queue position.
        """
        # Run simulations for estimated queue position
        sim_list = [df.index.to_list()]

        # Order is starting at the back of the queue
        total_size_list = df["Depth at price level"].to_list()
        start_queue_pos = total_size_list[0]

        # Run 100 simulations
        for i in range(100):
            c_total_size = start_queue_pos  # Current size at price level
            c_queue_pos = start_queue_pos - order_size  # Current estimated queue position

            estimated_queue_pos = []
            for total_size in total_size_list:
                # Decrease in size at price level. A cancel occurred
                if total_size < c_total_size:
                    cancel_size = c_total_size - total_size
                    others_size = total_size - order_size  # Size of rest of level

                    random_number = apply_bias(random.random(), k)
                    if random_number < (c_queue_pos / others_size):
                        c_queue_pos -= cancel_size

                    c_queue_pos = min(max(c_queue_pos, 0), others_size)

                c_total_size = total_size
                estimated_queue_pos.append(c_queue_pos)

            sim_list.append(estimated_queue_pos)

        col_names = [f"sim_{i}" for i in range(len(sim_list))]
        df_sim = pd.DataFrame(dict(zip(col_names, sim_list)))
        df_sim = df_sim.set_index(df.index)

        return df_sim.quantile(0.50, axis=1)

    # Next, we will also download MBP-10 data for estimating queue position
    mbp10_data_path = "glbx-mdp3-20250212.mbp-10.dbn.zst"
    if os.path.exists(mbp10_data_path):
        mbp10_data = db.DBNStore.from_file(mbp10_data_path)
    else:
        mbp10_data = client.timeseries.get_range(
            dataset=dataset,
            start=start_date,
            end=end_date,
            symbols=symbol,
            stype_in="continuous",
            schema="mbp-10",
            path=mbp10_data_path,
        )

    # Now we will use the MBP-10 data to record similar information as above
    mbp10_price_level_msgs: list[dict] = []
    for mbp10_msg in mbp10_data:
        if order_add_time <= mbp10_msg.ts_event < order_fill_time:
            for level_pair in mbp10_msg.levels:
                if level_pair.ask_px == order_price:
                    # Get total size at price
                    mbp_price_level_msg = {
                        "ts_event": mbp10_msg.ts_event,
                        "Depth at price level": level_pair.ask_sz,
                    }
                    mbp10_price_level_msgs.append(mbp_price_level_msg)

    # Construct a DataFrame from MBP-10 updates
    df_mbp10 = pd.DataFrame.from_records(mbp10_price_level_msgs)
    df_mbp10["Order lifetime (s)"] = (df_mbp10["ts_event"] - df_mbp10["ts_event"][0]) / 1e9
    df_mbp10 = df_mbp10.set_index("Order lifetime (s)")

    # Run simulations using various weights
    for k in (0, -0.8, -0.95):
        df_mbp10[f"Estimated queue position (k={k})"] = generate_simulations(df_mbp10, k)

    # Add estimated queue position using MBP-10 data to plot
    df_mbp10.filter(regex="Estimated", axis=1).plot(
        ax=ax,
        drawstyle="steps-post",
        title="Actual vs. estimated queue position",
        style=["C3", "C5", "C2"],
        ylim=(0, 85),
    )
    plt.show()

Results

We can show how the exact queue position of the order within its price level decreases over time (orange), even as orders are added and removed from the book (purple). The naive model assuming uniform cancellation (green) does a poor job estimating the position, but the biased models perform better.