[BUG] L1 trade execution reuses consumed book liquidity across distinct trade ticks
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.
#[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:
CARGO_BUILD_JOBS=1 cargo test -p nautilus-execution --test matching_engine \
test_l1_identical_trade_ticks_have_independent_fill_budgets -- --nocaptureBefore 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 failedCause
Affected code: crates/execution/src/matching_engine/mod.rs.
process_trade_tickupdates the L1 book from the incoming trade. It correctly resetstrade_consumptionto zero for each trade tick.determine_limit_price_and_volumechecks whether the simulated book fills contain the trade price: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. }Since the L1 book already reflects the trade price,
fills_at_trade_pricecan be true. The per-trade branch is skipped and execution reachesapply_liquidity_consumption.apply_liquidity_consumptionstores(original_size, consumed)by book price. It resets consumption when the observed level size changes, not on each new trade event:if *original_size != level_size.raw { *original_size = level_size.raw; *consumed = 0; } let available = original_size.saturating_sub(*consumed);A subsequent trade with the same price and size therefore reuses the previous book-consumption entry. Also, book consumption is applied before
fill_limit_ordercaps fills by the queue excess; that later cap reconcilestrade_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:
- 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:
CARGO_BUILD_JOBS=1 cargo test -p nautilus-execution --test matching_engineNote: 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