[BUG] L1 trade execution reuses consumed book liquidity across distinct trade ticks

Author: GwangPyoCreated Sep 17, 2026Updated Sep 17, 2026
Labelsbug

Summary

With BookType::L1_MBP, trade_execution=true, liquidity_consumption=true, and queue_position=true, distinct trade ticks with the same price and size fail to fill resting limit orders once the initial queue has cleared.

Even though incoming trade ticks have distinct trade IDs and increasing timestamps, the engine falls back to persistent book-level consumption instead of allocating a fresh per-trade budget.

Environment

  • Base commit: 2114cf6f761429e0adb5ca9596fcd7b895b16011
  • Rust: rustc 1.97.1
  • Platform: Linux x86_64
  • Reproducer: Integration test in crates/execution/tests/matching_engine.rs (minimal Rust test, no external data required)

Expected and actual behavior

For the buy-side case:

Event Expected effect
Quote: bid 54.59 @ 1.000, ask 54.62 @ 1.000 Establish displayed depth
Submit buy limit 3.000 @ 54.59 Queue ahead is 1.000
Sell-aggressor trade 2.000 @ 54.59, ID T-1 Consume 1.000 ahead and fill 1.000 of our order
Sell-aggressor trade 2.000 @ 54.59, ID T-2, later timestamp Fill the remaining 2.000 of our order

Expected fill quantities: [1.000, 2.000].

Actual fill quantities before the proposed fix: [1.000]. The second trade produces no additional fill.

The mirrored sell-side case at 54.62, with buy-aggressor trades, fails the same way. The regression repeats each sequence three times to check replenished orders as well as partial fills.

Reproducer

Add the following test to crates/execution/tests/matching_engine.rs. It uses that file's existing fixtures, imports, and event-handler helpers.

rust
#[rstest]
#[case(OrderSide::Buy, AggressorSide::Sell, "54.59")]
#[case(OrderSide::Sell, AggressorSide::Buy, "54.62")]
fn test_l1_identical_trade_ticks_have_independent_fill_budgets(
    account_id: AccountId,
    instrument_eth_usdt: InstrumentAny,
    #[case] side: OrderSide,
    #[case] aggressor: AggressorSide,
    #[case] price: &str,
) {
    let cache = Rc::new(RefCell::new(Cache::default()));
    let handler = order_event_handler_with_cache(Rc::clone(&cache));
    let config = OrderMatchingEngineConfig {
        trade_execution: true,
        queue_position: true,
        liquidity_consumption: true,
        ..Default::default()
    };
    let mut engine = OrderMatchingEngine::new(
        instrument_eth_usdt.clone(),
        1,
        FillModelHandle::default(),
        FeeModelAny::default().into(),
        BookType::L1_MBP,
        OmsType::Netting,
        AccountType::Margin,
        Rc::new(RefCell::new(TestClock::new())),
        cache,
        config,
    );
    for cycle in 0..3_u64 {
        let timestamp = cycle * 3;
        engine.process_quote_tick(&QuoteTick::new(
            instrument_eth_usdt.id(),
            Price::from("54.59"),
            Price::from("54.62"),
            Quantity::from("1.000"),
            Quantity::from("1.000"),
            UnixNanos::from(timestamp),
            UnixNanos::from(timestamp),
        ));
        let mut order = OrderTestBuilder::new(OrderType::Limit)
            .instrument_id(instrument_eth_usdt.id())
            .side(side)
            .price(Price::from(price))
            .quantity(Quantity::from("3.000"))
            .client_order_id(ClientOrderId::new(format!("O-REPEAT-{cycle}")))
            .submit(true)
            .build();
        engine.process_order(&mut order, account_id);
        clear_order_event_handler_messages(&handler);
        for step in 1..=2_u64 {
            engine.process_trade_tick(&TradeTick::new(
                instrument_eth_usdt.id(),
                Price::from(price),
                Quantity::from("2.000"),
                aggressor,
                TradeId::new(format!("T-{}", timestamp + step)),
                UnixNanos::from(timestamp + step),
                UnixNanos::from(timestamp + step),
            ));
        }
        let quantities: Vec<Quantity> = get_order_event_handler_messages(&handler)
            .iter()
            .filter_map(|event| match event {
                OrderEventAny::Filled(fill) => Some(fill.last_qty),
                _ => None,
            })
            .collect();
        assert_eq!(quantities, vec![Quantity::from("1.000"), Quantity::from("2.000")]);
    }
}

Run from the NautilusTrader repository root:

bash
CARGO_BUILD_JOBS=1 cargo test -p nautilus-execution --test matching_engine \
  test_l1_identical_trade_ticks_have_independent_fill_budgets -- --nocapture

Before the fix, both cases fail with:

assertion `left == right` failed
  left: [Quantity(1.000)]
 right: [Quantity(1.000), Quantity(2.000)]

test result: FAILED. 0 passed; 2 failed

Cause

Affected code: crates/execution/src/matching_engine/mod.rs.

  1. process_trade_tick updates the L1 book from the incoming trade. It correctly resets trade_consumption to zero for each trade tick.

  2. determine_limit_price_and_volume checks whether the simulated book fills contain the trade price:

    rust
    let fills_at_trade_price = fills.iter().any(|(px, _)| *px == trade_price);
    if !fills_at_trade_price && self.core.is_limit_matched(...) {
        // Use trade_size - trade_consumption, then return early.
    }
  3. Since the L1 book already reflects the trade price, fills_at_trade_price can be true. The per-trade branch is skipped and execution reaches apply_liquidity_consumption.

  4. apply_liquidity_consumption stores (original_size, consumed) by book price. It resets consumption when the observed level size changes, not on each new trade event:

    rust
    if *original_size != level_size.raw {
        *original_size = level_size.raw;
        *consumed = 0;
    }
    let available = original_size.saturating_sub(*consumed);
  5. A subsequent trade with the same price and size therefore reuses the previous book-consumption entry. Also, book consumption is applied before fill_limit_order caps fills by the queue excess; that later cap reconciles trade_consumption, not the book-consumption entry.

The missing reset is not in trade_consumption. The issue is routing trade-derived L1 liquidity into the book-consumption path.

Proposed fix

Use the existing per-trade budget for matched L1 limit orders regardless of whether the simulated book fills already contain the trade price:

diff
- if !fills_at_trade_price
+ if (self.book_type == BookType::L1_MBP || !fills_at_trade_price)
      && self.core.is_limit_matched(order.order_side_specified(), order_price)

If that branch has no remaining trade volume, return an empty fill list instead of falling through to book liquidity. The existing queue cap still limits the actual fill quantity.

Verification

Check Result
Regression test before fix Both buy and sell cases fail: [1.000] instead of [1.000, 2.000]
Regression test with fix Both cases pass
Full matching_engine test target with fix 249 passed; 1 failed; 1 ignored

Full-target command:

bash
CARGO_BUILD_JOBS=1 cargo test -p nautilus-execution --test matching_engine

Note: The single remaining failure (test_l1_queue_position_at_bbo_trades_decrement_queue) is unrelated and fails identically without this change.

Impact

This undercounts partial fills and prevents resting orders from filling during repeated same-price trades in L1 backtests, artificially lowering fill counts and simulated turnover.

Source: nautechsystems/nautilus_trader