Skip to content

Commit 021aaed

Browse files
committed
Fix trade execution fills discarded with liquidity_consumption
- Fix fills discarded when trade price not in book with consumption - Return early to bypass _apply_liquidity_consumption for trade fills - Add tests for L2_MBP + trade_execution + liquidity_consumption
1 parent 19c88d7 commit 021aaed

File tree

3 files changed

+182
-1
lines changed

3 files changed

+182
-1
lines changed

RELEASES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Released on TBD (UTC).
1313
### Security
1414

1515
### Fixes
16+
- Fixed trade execution fills discarded with `liquidity_consumption`
1617
- Fixed backtest clock monotonicity with time alerts (#3384), thanks @draphi
1718
- Fixed order updated panic during reconciliation (#3380), thanks for reporting @santivazq
1819
- Fixed trailing stops default price type (#3379), thanks @KaulSe

nautilus_trader/backtest/engine.pyx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5575,7 +5575,11 @@ cdef class OrderMatchingEngine:
55755575
f"Trade execution fill: {fill_qty} @ {trade_price} "
55765576
f"(trade_size: {self._last_trade_size}, book had {len(fills)} fills)",
55775577
)
5578-
fills = [(trade_price, fill_qty)]
5578+
5579+
# Trade execution fills already account for consumption via _trade_consumption.
5580+
# Return early to bypass _apply_liquidity_consumption which would incorrectly
5581+
# discard these fills when the trade price isn't in the order book.
5582+
return [(trade_price, fill_qty)]
55795583
55805584
if (
55815585
fills

tests/unit_tests/backtest/test_matching_engine.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1885,6 +1885,182 @@ def test_trade_consumption_resets_on_fresh_trade(
18851885
assert filled_events[1].last_qty == Quantity.from_str("10.000")
18861886
assert filled_events[2].last_qty == Quantity.from_str("20.000")
18871887

1888+
@pytest.mark.parametrize(
1889+
("order_side", "aggressor_side", "order_price", "trade_price", "book_bid", "book_ask"),
1890+
[
1891+
# BUY order above trade price, SELLER trade
1892+
(OrderSide.BUY, AggressorSide.SELLER, "211.35", "211.32", "211.30", "211.40"),
1893+
# SELL order below trade price, BUYER trade
1894+
(OrderSide.SELL, AggressorSide.BUYER, "211.35", "211.38", "211.30", "211.40"),
1895+
],
1896+
ids=["buy_above_seller_trade", "sell_below_buyer_trade"],
1897+
)
1898+
def test_trade_execution_fill_with_liquidity_consumption_l2_mbp(
1899+
self,
1900+
order_side: OrderSide,
1901+
aggressor_side: AggressorSide,
1902+
order_price: str,
1903+
trade_price: str,
1904+
book_bid: str,
1905+
book_ask: str,
1906+
) -> None:
1907+
"""
1908+
Test that trade execution fills work correctly with liquidity_consumption=True
1909+
on L2_MBP when trade price differs from order price.
1910+
1911+
Regression test for bug where _apply_liquidity_consumption checked ORDER BOOK
1912+
for available liquidity at the trade price, discarding fills when the trade
1913+
price wasn't in the book.
1914+
1915+
"""
1916+
matching_engine = OrderMatchingEngine(
1917+
instrument=self.instrument,
1918+
raw_id=0,
1919+
fill_model=FillModel(),
1920+
fee_model=MakerTakerFeeModel(),
1921+
book_type=BookType.L2_MBP,
1922+
oms_type=OmsType.NETTING,
1923+
account_type=AccountType.MARGIN,
1924+
reject_stop_orders=True,
1925+
trade_execution=True,
1926+
liquidity_consumption=True,
1927+
msgbus=self.msgbus,
1928+
cache=self.cache,
1929+
clock=self.clock,
1930+
)
1931+
1932+
# Arrange
1933+
snapshot = TestDataStubs.order_book_snapshot(
1934+
instrument=self.instrument,
1935+
bid_price=float(book_bid),
1936+
ask_price=float(book_ask),
1937+
bid_size=100.0,
1938+
ask_size=100.0,
1939+
)
1940+
matching_engine.process_order_book_deltas(snapshot)
1941+
1942+
messages: list[Any] = []
1943+
self.msgbus.register("ExecEngine.process", messages.append)
1944+
1945+
order = TestExecStubs.limit_order(
1946+
instrument=self.instrument,
1947+
order_side=order_side,
1948+
price=Price.from_str(order_price),
1949+
quantity=self.instrument.make_qty(50.0),
1950+
)
1951+
matching_engine.process_order(order, self.account_id)
1952+
messages.clear()
1953+
1954+
# Act
1955+
trade = TestDataStubs.trade_tick(
1956+
instrument=self.instrument,
1957+
price=float(trade_price),
1958+
size=100.0,
1959+
aggressor_side=aggressor_side,
1960+
)
1961+
matching_engine.process_trade_tick(trade)
1962+
1963+
# Assert
1964+
filled_events = [m for m in messages if isinstance(m, OrderFilled)]
1965+
assert len(filled_events) == 1, (
1966+
f"{order_side.name} LIMIT at {order_price} should fill on "
1967+
f"{aggressor_side.name} trade at {trade_price} with L2_MBP + liquidity_consumption"
1968+
)
1969+
assert filled_events[0].last_qty == Quantity.from_str("50.000")
1970+
assert filled_events[0].last_px == Price.from_str(trade_price)
1971+
1972+
@pytest.mark.parametrize(
1973+
("order_side", "aggressor_side", "order_price", "trade_price", "book_bid", "book_ask"),
1974+
[
1975+
(OrderSide.BUY, AggressorSide.SELLER, "211.35", "211.32", "211.30", "211.40"),
1976+
(OrderSide.SELL, AggressorSide.BUYER, "211.35", "211.38", "211.30", "211.40"),
1977+
],
1978+
ids=["buy_orders_seller_trade", "sell_orders_buyer_trade"],
1979+
)
1980+
def test_trade_execution_consumption_prevents_overfill_l2_mbp(
1981+
self,
1982+
order_side: OrderSide,
1983+
aggressor_side: AggressorSide,
1984+
order_price: str,
1985+
trade_price: str,
1986+
book_bid: str,
1987+
book_ask: str,
1988+
) -> None:
1989+
"""
1990+
Test that trade consumption tracking works correctly with L2_MBP when multiple
1991+
orders compete for the same trade tick.
1992+
1993+
Verifies that _trade_consumption tracking still functions when trade execution
1994+
fills bypass _apply_liquidity_consumption.
1995+
1996+
"""
1997+
matching_engine = OrderMatchingEngine(
1998+
instrument=self.instrument,
1999+
raw_id=0,
2000+
fill_model=FillModel(),
2001+
fee_model=MakerTakerFeeModel(),
2002+
book_type=BookType.L2_MBP,
2003+
oms_type=OmsType.NETTING,
2004+
account_type=AccountType.MARGIN,
2005+
reject_stop_orders=True,
2006+
trade_execution=True,
2007+
liquidity_consumption=True,
2008+
msgbus=self.msgbus,
2009+
cache=self.cache,
2010+
clock=self.clock,
2011+
)
2012+
2013+
# Arrange
2014+
snapshot = TestDataStubs.order_book_snapshot(
2015+
instrument=self.instrument,
2016+
bid_price=float(book_bid),
2017+
ask_price=float(book_ask),
2018+
bid_size=100.0,
2019+
ask_size=100.0,
2020+
)
2021+
matching_engine.process_order_book_deltas(snapshot)
2022+
2023+
messages: list[Any] = []
2024+
self.msgbus.register("ExecEngine.process", messages.append)
2025+
2026+
order1 = TestExecStubs.limit_order(
2027+
instrument=self.instrument,
2028+
order_side=order_side,
2029+
price=Price.from_str(order_price),
2030+
quantity=self.instrument.make_qty(30.0),
2031+
client_order_id=TestIdStubs.client_order_id(1),
2032+
)
2033+
matching_engine.process_order(order1, self.account_id)
2034+
2035+
order2 = TestExecStubs.limit_order(
2036+
instrument=self.instrument,
2037+
order_side=order_side,
2038+
price=Price.from_str(order_price),
2039+
quantity=self.instrument.make_qty(30.0),
2040+
client_order_id=TestIdStubs.client_order_id(2),
2041+
)
2042+
matching_engine.process_order(order2, self.account_id)
2043+
messages.clear()
2044+
2045+
# Act - trade tick with 50 qty (less than total order qty of 60)
2046+
trade = TestDataStubs.trade_tick(
2047+
instrument=self.instrument,
2048+
price=float(trade_price),
2049+
size=50.0,
2050+
aggressor_side=aggressor_side,
2051+
)
2052+
matching_engine.process_trade_tick(trade)
2053+
2054+
# Assert
2055+
filled_events = [m for m in messages if isinstance(m, OrderFilled)]
2056+
assert len(filled_events) == 2, (
2057+
f"Expected 2 fills for {order_side.name} orders, got {len(filled_events)}"
2058+
)
2059+
assert filled_events[0].last_qty == Quantity.from_str("30.000")
2060+
assert filled_events[1].last_qty == Quantity.from_str("20.000")
2061+
assert filled_events[0].last_px == Price.from_str(trade_price)
2062+
assert filled_events[1].last_px == Price.from_str(trade_price)
2063+
18882064

18892065
def _create_bar_execution_matching_engine() -> OrderMatchingEngine:
18902066
clock = TestClock()

0 commit comments

Comments
 (0)