@@ -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
18892065def _create_bar_execution_matching_engine () -> OrderMatchingEngine :
18902066 clock = TestClock ()
0 commit comments