Skip to content
All library documents

Trade-Based Matching and Queue Position Modeling in Backtests

Article NautilusTrader

Summary

This guide explains how a backtest engine can use trade ticks as evidence for executing resting orders, and how that behavior changes with L1, L2, or L3 market data. Trade aggressor direction determines which passive side can be filled. The guide describes price references used during matching, quantity limits, optional liquidity consumption, and how matching state is restored afterward. It also explains how book and trade feeds interact, including cases where a trade occurs at a price absent from the latest depth snapshot.

A separate section covers queue position estimates for limit orders. Visible size ahead is tracked as orders enter, trades consume the queue, and book changes or snapshots alter the estimate. L1 queue tracking uses quote movements and displayed size as evidence, while L3 tracking can follow individual book orders. These are simulation rules, not guarantees of live execution: estimates use only visible historical data and cannot account for hidden orders or all venue priority rules. Unknown aggressor trades may reduce queues on both sides, producing optimistic fills.

Key ideas

  • Trade aggressor direction indicates which resting order side a trade may fill.
  • Matching behavior differs across L1, L2, and L3 data, including how trade size constrains fills.
  • Liquidity consumption can prevent the same trade volume from supporting multiple fills.
  • Queue tracking estimates displayed quantity ahead for limit orders and updates it as trades and book changes arrive.
  • Historical queue estimates omit hidden liquidity and venue-specific priority rules, and unknown aggressors can make fills optimistic.

Tags

Full text
# Trade-Based Execution


# Trade-Based Execution

Trade ticks trigger matching by default when a venue has `trade_execution=True`. A trade provides
evidence that liquidity traded at its price, so it can fill resting orders on the passive side.

Set `trade_execution=False` to use trades as strategy data without treating them as execution
liquidity for ordinary resting orders:

```python
from decimal import Decimal

from nautilus_trader.config import BacktestVenueConfig
from nautilus_trader.execution import MakerTakerFeeModel
from nautilus_trader.model import AccountType
from nautilus_trader.model import BookType
from nautilus_trader.model import OmsType

venue = BacktestVenueConfig(
    name="SIM",
    oms_type=OmsType.NETTING,
    account_type=AccountType.CASH,
    book_type=BookType.L1_MBP,
    starting_balances=["100_000 USD"],
    trade_execution=False,
    fee_model=MakerTakerFeeModel(
        maker_rate=Decimal("0"),
        taker_rate=Decimal("0"),
    ),
)
```

When trade execution is disabled, behavior depends on the venue's book type:

- With L1 data, accepted trade ticks update the L1 book but skip matching and maintenance. Later
  quote ticks or executable bars drive that work.
- With L2 or L3 data, accepted trade ticks advance `LastPrice` and run trailing-stop maintenance
  for all trigger types. They can trigger `LastPrice` stop orders, which fill against existing book
  liquidity. The tick does not match resting limits or trigger stop orders that use other trigger
  types. It also runs enabled GTD expiry and instrument-expiration checks.

## Trade-driven matching

The engine temporarily moves its matching references to the trade price:

- A `SELL` trade can match resting BUY orders.
- A `BUY` trade can match resting SELL orders.
- A `NO_AGGRESSOR` trade can affect both sides because the passive side is unknown.

L1 trades update both simulated top-of-book levels to the trade price and size. L2 and L3 depth books
remain unchanged; only the matching core's transient bid, ask, and last prices move for the iteration.

### Fill determination

When a trade triggers a limit fill:

- With L1 data, the engine uses the trade's volume even when the simulated book contains the trade
  price. Resting maker orders fill at their limit price. Taker orders use the trade price when it
  satisfies their limit; otherwise, they retain the limit-price fallback.
- With L2 or L3 data, the engine fills against crossed book levels. If the book does not represent
  the trade price, it can create a trade-driven fill at the order's limit price.
- A trade-driven fill is capped at `min(order.leaves_qty, trade.size)`.

With `liquidity_consumption=False`, the same trade size can support more than one order during an
iteration. With `liquidity_consumption=True`, trade-driven fills share a consumption counter, so
their total cannot exceed the unconsumed trade size. Each L1 trade has a fresh budget, including
successive trades with the same price and size. Once that budget is exhausted, L1 fills do not
fall back to book liquidity.

The L1 budget applies to limit fills on both sides and remains in effect across subsequent matching
and settlement passes until fresh market data replaces it.

For example, with L2 or L3 data, a `SELL` trade at 100.00 can fill a BUY LIMIT at 100.05. If no book
level represents that fill, the engine uses 100.05 rather than granting the better trade price.

### Matching-state restoration

After the iteration, the engine restores matching references from the available market baseline:

- With L2 or L3 data, the depth book remains the independent source of bid and ask state.
- With an L1 quote baseline, the non-aggressor side is restored from the latest quote.
- With trade-only L1 data, there is no quote baseline to restore, so the latest trade continues to
  define the available top-of-book state.

This distinction matters when interpreting a stream of trades without quotes. Repeated trades can
move the simulated L1 state, but quote-backed L1 matching does not progressively discard the
non-aggressor side of the latest quote.

## Aggressor sides

The **aggressor** is the participant that crossed the spread:

- `SELL`: A seller hit the bid. The trade can fill a resting BUY order.
- `BUY`: A buyer lifted the ask. The trade can fill a resting SELL order.
- `NO_AGGRESSOR`: The data does not identify the aggressor. The engine considers both sides where
  the feature requires a side.

A trade with aggressor side `BUY` provides evidence for passive `SELL` orders, not `BUY` orders. A
trade with aggressor side `SELL` provides evidence for passive `BUY` orders, not `SELL` orders.

## Combining book and trade data

Book updates establish the spread and visible depth. Trade ticks provide execution evidence between
those updates. This is useful when depth snapshots are throttled and a trade occurs at a price that
the latest snapshot does not contain.

Use the two feeds with care:

- A trade must have the opposite aggressor side to fill a resting order.
- A book update can cross an order independently of a trade.
- A fill at a missing trade-price level uses the trade-driven quantity cap.
- With consumption enabled, the engine accounts for trade volume already removed from an L2 or L3
  book before triggered orders consume the remaining depth.

## Queue position tracking

Set `queue_position=True` with `trade_execution=True` to track displayed quantity ahead of each
LIMIT order:

```python
from decimal import Decimal

from nautilus_trader.execution import MakerTakerFeeModel

venue = BacktestVenueConfig(
    name="SIM",
    oms_type=OmsType.NETTING,
    account_type=AccountType.MARGIN,
    book_type=BookType.L2_MBP,
    starting_balances=["100_000 USD"],
    trade_execution=True,
    queue_position=True,
    fee_model=MakerTakerFeeModel(
        maker_rate=Decimal("0"),
        taker_rate=Decimal("0"),
    ),
)
```

Sandbox paper trading uses the same matching-engine flags. Pass them on
`SandboxExecutionClientConfig` (defaults remain off, matching current sandbox
behavior):

```python
from decimal import Decimal

from nautilus_trader.adapters.sandbox import SandboxExecutionClientConfig
from nautilus_trader.execution import MakerTakerFeeModel
from nautilus_trader.model import BookType
from nautilus_trader.model import Money
from nautilus_trader.model import Venue

config = SandboxExecutionClientConfig(
    venue=Venue("BINANCE"),
    starting_balances=[Money.from_str("10_000 USDT")],
    book_type=BookType.L2_MBP,
    trade_execution=True,
    queue_position=True,
    liquidity_consumption=True,
    fee_model=MakerTakerFeeModel(
        maker_rate=Decimal("0.001"),
        taker_rate=Decimal("0.001"),
    ),
)
```

The sandbox `venue` must match the data client's instrument venue, and the
strategy must subscribe to trades (and L2/L3 deltas when using depth).

### Queue lifecycle

1. On acceptance, a LIMIT order snapshots same-side displayed size at its price.
1. Correct-side trades at that price reduce the quantity ahead.
1. The order becomes fill-eligible when the quantity ahead reaches zero.
1. Only trade volume beyond the cleared queue is available to fill on that tick.

For example:

1. The bid at 100.00 contains 100 units.
1. A BUY LIMIT for 50 units joins with 100 units ahead.
1. A `SELL` trade for 80 units reduces the queue ahead to 20.
1. A `SELL` trade for 30 units clears the queue and leaves 10 units available to fill.
1. The next correct-side trade can fill the remaining order quantity.

### Book changes

For L2 books and aggregate L3 updates:

- A DELETE clears the price level and its queue.
- An UPDATE caps quantity ahead at the level's new displayed size.
- A completed book snapshot rebases each tracked queue position against the new visible
  quantity at its price: quantity ahead is capped at the snapshot size, while newly added
  liquidity does not move an existing simulated order further back. Snapshot batches may start
  with a `F_SNAPSHOT` clear and finish with a later `F_LAST` delta.
- A `BookDepth` replacement applies the same rebase rule after the full depth replacement.

For L3 MBO books:

- A per-order DELETE advances the queue by that order's remaining tracked size.
- A size decrease advances the queue by the difference.
- A size increase keeps the larger order ahead.
- A price change removes the book order from the tracked queue.
- A completed book snapshot retains only surviving tracked order IDs ahead, each capped at
  its previous quantity.

Changing a simulated order's price resets its queue position at the new level. A quantity-only
change retains the progress already made.

### L1 queue tracking

With `BookType.L1_MBP`, trade ticks reduce quantity ahead while quotes provide price-move and
displayed-size evidence:

- A move away through the order's price clears the queue.
- A move toward the order preserves the queue.
- A return to a previously visible level caps quantity ahead at the new displayed size.
- A quote at the order's price caps quantity ahead at the same-side displayed size.
- A displayed-size increase preserves queue progress.
- An order behind the BBO remains pending until a quote reaches its price or a trade crosses it.

### Limitations

- Queue tracking applies only to `LIMIT` orders.
- Each simulated order has an independent queue estimate.
- The initial estimate is limited to book state visible at acceptance.
- Historical data cannot reveal hidden orders or every venue-specific priority rule.

:::warning[Unknown aggressor side]
`NO_AGGRESSOR` trades reduce queues on both sides. This can clear a queue and fill an order
earlier than reality, so it is optimistic from the strategy's execution perspective.
:::

Shown in full with attribution under the source's licence. Licence: LGPL-3.0

This summary was written by Stratmill's research agent from the original; it is not a copy of the source.