Skip to content

Commit bcbbcc0

Browse files
committed
Fix matching engine trade processing when no aggressor side
1 parent 5d72dee commit bcbbcc0

File tree

3 files changed

+218
-5
lines changed

3 files changed

+218
-5
lines changed

RELEASES.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,6 @@ Released on TBD (UTC).
8383
- Upgraded `sqlx` crate to v0.8.6
8484
- Upgraded `tokio` crate to v1.45.1
8585

86-
8786
### Fixes
8887
- Fixed portfolio account updates leading to incorrect balances (#2632, #2637), thanks for reporting @bartolootrit and @DeirhX
8988
- Fixed portfolio handling of `OrderExpired` events not updating state (margin requirements may change)
@@ -98,6 +97,7 @@ Released on TBD (UTC).
9897
- Fixed message bus subscription matching logic in Rust (#2646), thanks @twitu
9998
- Fixed trailing stop market fill behavior when top-level exhausted to align with market orders (#2540), thanks for reporting @stastnypremysl
10099
- Fixed stop limit fill behavior on initial trigger where the limit order was continuing to fill as a taker beyond available liquidity, thanks for reporting @hope2see
100+
- Fixed matching engine trade processing when aggressor side is `NO_AGGRESSOR` (we can still update the matching core)
101101
- Fixed modifying and updating trailing stop orders (#2619), thanks @hope2see
102102
- Fixed processing activated trailing stop update when no trigger price, thanks for reporting @hope2see
103103
- Fixed terminating backtest on `AccountError` when streaming, the exception needed to be reraised to interrupt the streaming of chunks (#2546), thanks for reporting @stastnypremysl

nautilus_trader/backtest/matching_engine.pyx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,12 @@ cdef class OrderMatchingEngine:
571571
self._core.set_bid_raw(price_raw)
572572
if price_raw > self._core.ask_raw:
573573
self._core.set_ask_raw(price_raw)
574+
elif aggressor_side == AggressorSide.NO_AGGRESSOR:
575+
# Update both bid and ask when no specific aggressor
576+
if price_raw <= self._core.bid_raw:
577+
self._core.set_bid_raw(price_raw)
578+
if price_raw >= self._core.ask_raw:
579+
self._core.set_ask_raw(price_raw)
574580
else:
575581
aggressor_side_str = aggressor_side_to_str(aggressor_side)
576582
raise RuntimeError( # pragma: no cover (design-time error)

tests/unit_tests/backtest/test_matching_engine.py

Lines changed: 211 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@
2222
from nautilus_trader.backtest.models import MakerTakerFeeModel
2323
from nautilus_trader.common.component import MessageBus
2424
from nautilus_trader.common.component import TestClock
25-
from nautilus_trader.model.data import QuoteTick
2625
from nautilus_trader.model.enums import AccountType
26+
from nautilus_trader.model.enums import AggressorSide
2727
from nautilus_trader.model.enums import BookType
2828
from nautilus_trader.model.enums import InstrumentCloseType
2929
from nautilus_trader.model.enums import MarketStatusAction
@@ -68,6 +68,7 @@ def setup(self):
6868
oms_type=OmsType.NETTING,
6969
account_type=AccountType.MARGIN,
7070
reject_stop_orders=True,
71+
trade_execution=True,
7172
msgbus=self.msgbus,
7273
cache=self.cache,
7374
clock=self.clock,
@@ -114,10 +115,10 @@ def test_instrument_close_expiry_closes_position(self) -> None:
114115
# Arrange
115116
exec_messages = []
116117
self.msgbus.register("ExecEngine.process", lambda x: exec_messages.append(x))
117-
tick: QuoteTick = TestDataStubs.quote_tick(
118+
quote = TestDataStubs.quote_tick(
118119
instrument=self.instrument,
119120
)
120-
self.matching_engine.process_quote_tick(tick)
121+
self.matching_engine.process_quote_tick(quote)
121122
order: MarketOrder = TestExecStubs.limit_order(
122123
instrument=self.instrument,
123124
)
@@ -126,7 +127,7 @@ def test_instrument_close_expiry_closes_position(self) -> None:
126127
# Act
127128
instrument_close = TestDataStubs.instrument_close(
128129
instrument_id=self.instrument_id,
129-
price=Price(2, 2),
130+
price=Price.from_str("2.00"),
130131
close_type=InstrumentCloseType.CONTRACT_EXPIRED,
131132
ts_event=2,
132133
)
@@ -176,3 +177,209 @@ def test_process_order_book_depth_10(self) -> None:
176177
# Assert
177178
assert self.matching_engine.best_ask_price() == depth.asks[0].price
178179
assert self.matching_engine.best_bid_price() == depth.bids[0].price
180+
181+
def test_process_trade_buyer_aggressor(self) -> None:
182+
# Arrange
183+
trade = TestDataStubs.trade_tick(
184+
instrument=self.instrument,
185+
price=1000.0,
186+
aggressor_side=AggressorSide.BUYER,
187+
)
188+
189+
# Act
190+
self.matching_engine.process_trade_tick(trade)
191+
192+
# Assert - Buyer aggressor should set ask price
193+
assert self.matching_engine.best_ask_price() == Price.from_str("1000.0")
194+
195+
def test_process_trade_seller_aggressor(self) -> None:
196+
# Arrange
197+
trade = TestDataStubs.trade_tick(
198+
instrument=self.instrument,
199+
price=1000.0,
200+
aggressor_side=AggressorSide.SELLER,
201+
)
202+
203+
# Act
204+
self.matching_engine.process_trade_tick(trade)
205+
206+
# Assert - Seller aggressor should set bid price
207+
assert self.matching_engine.best_bid_price() == Price.from_str("1000.0")
208+
209+
def test_process_trade_tick_no_aggressor_above_ask(self) -> None:
210+
# Arrange - Set initial bid/ask spread
211+
quote = TestDataStubs.quote_tick(
212+
instrument=self.instrument,
213+
bid_price=990.0,
214+
ask_price=1010.0,
215+
)
216+
self.matching_engine.process_quote_tick(quote)
217+
218+
# Trade above ask with no aggressor
219+
trade = TestDataStubs.trade_tick(
220+
instrument=self.instrument,
221+
price=1020.0,
222+
aggressor_side=AggressorSide.NO_AGGRESSOR,
223+
)
224+
225+
# Act
226+
self.matching_engine.process_trade_tick(trade)
227+
228+
# Assert - L1_MBP book update_trade_tick sets both bid/ask to trade price
229+
# Then NO_AGGRESSOR logic doesn't modify further since 1020 >= 1020 (ask)
230+
assert self.matching_engine.best_ask_price() == Price.from_str("1020.0")
231+
assert self.matching_engine.best_bid_price() == Price.from_str("1020.0")
232+
233+
def test_process_trade_tick_no_aggressor_within_spread(self) -> None:
234+
# Arrange - Set initial bid/ask spread
235+
quote = TestDataStubs.quote_tick(
236+
instrument=self.instrument,
237+
bid_price=990.0,
238+
ask_price=1010.0,
239+
)
240+
self.matching_engine.process_quote_tick(quote)
241+
242+
# Trade within the spread with no aggressor
243+
trade = TestDataStubs.trade_tick(
244+
instrument=self.instrument,
245+
price=1000.0,
246+
aggressor_side=AggressorSide.NO_AGGRESSOR,
247+
)
248+
249+
# Act
250+
self.matching_engine.process_trade_tick(trade)
251+
252+
# Assert - L1_MBP book update_trade_tick sets both bid/ask to trade price
253+
assert self.matching_engine.best_bid_price() == Price.from_str("1000.0")
254+
assert self.matching_engine.best_ask_price() == Price.from_str("1000.0")
255+
256+
def test_process_trade_tick_no_aggressor_below_bid(self) -> None:
257+
# Arrange - Set initial bid/ask spread
258+
quote = TestDataStubs.quote_tick(
259+
instrument=self.instrument,
260+
bid_price=1000.0,
261+
ask_price=1020.0,
262+
)
263+
self.matching_engine.process_quote_tick(quote)
264+
265+
# Trade below current bid with no aggressor
266+
trade = TestDataStubs.trade_tick(
267+
instrument=self.instrument,
268+
price=990.0,
269+
aggressor_side=AggressorSide.NO_AGGRESSOR,
270+
)
271+
272+
# Act
273+
self.matching_engine.process_trade_tick(trade)
274+
275+
# Assert - L1_MBP book update_trade_tick sets both bid/ask to trade price
276+
assert self.matching_engine.best_bid_price() == Price.from_str("990.0")
277+
assert self.matching_engine.best_ask_price() == Price.from_str("990.0")
278+
279+
def test_process_trade_tick_no_aggressor_at_bid_and_ask(self) -> None:
280+
# Arrange - Set initial bid/ask spread
281+
quote = TestDataStubs.quote_tick(
282+
instrument=self.instrument,
283+
bid_price=995.0,
284+
ask_price=1005.0,
285+
)
286+
self.matching_engine.process_quote_tick(quote)
287+
288+
# Trade exactly at bid level with no aggressor
289+
trade1 = TestDataStubs.trade_tick(
290+
instrument=self.instrument,
291+
price=995.0,
292+
aggressor_side=AggressorSide.NO_AGGRESSOR,
293+
)
294+
295+
# Act
296+
self.matching_engine.process_trade_tick(trade1)
297+
298+
# Assert - L1_MBP book update_trade_tick sets both bid/ask to trade price
299+
assert self.matching_engine.best_bid_price() == Price.from_str("995.0")
300+
assert self.matching_engine.best_ask_price() == Price.from_str("995.0")
301+
302+
# Trade exactly at ask level with no aggressor
303+
trade2 = TestDataStubs.trade_tick(
304+
instrument=self.instrument,
305+
price=1005.0,
306+
aggressor_side=AggressorSide.NO_AGGRESSOR,
307+
)
308+
309+
# Act
310+
self.matching_engine.process_trade_tick(trade2)
311+
312+
# Assert - L1_MBP book update_trade_tick sets both bid/ask to trade price
313+
assert self.matching_engine.best_bid_price() == Price.from_str("1005.0")
314+
assert self.matching_engine.best_ask_price() == Price.from_str("1005.0")
315+
316+
def test_process_trade_tick_with_trade_execution_disabled(self) -> None:
317+
# Arrange - Create matching engine with trade_execution=False
318+
matching_engine = OrderMatchingEngine(
319+
instrument=self.instrument,
320+
raw_id=0,
321+
fill_model=FillModel(),
322+
fee_model=MakerTakerFeeModel(),
323+
book_type=BookType.L1_MBP,
324+
oms_type=OmsType.NETTING,
325+
account_type=AccountType.MARGIN,
326+
reject_stop_orders=True,
327+
trade_execution=False, # Disabled
328+
msgbus=self.msgbus,
329+
cache=self.cache,
330+
clock=self.clock,
331+
)
332+
333+
# Process trade tick with BUYER aggressor
334+
trade = TestDataStubs.trade_tick(
335+
instrument=self.instrument,
336+
price=1000.0,
337+
aggressor_side=AggressorSide.BUYER,
338+
)
339+
340+
# Act
341+
matching_engine.process_trade_tick(trade)
342+
343+
# Assert - With trade_execution=False, only book update happens, no aggressor logic
344+
# L1_MBP book update_trade_tick sets both bid/ask to trade price
345+
assert matching_engine.best_bid_price() == Price.from_str("1000.0")
346+
assert matching_engine.best_ask_price() == Price.from_str("1000.0")
347+
348+
def test_trade_execution_difference_buyer_aggressor(self) -> None:
349+
# This test demonstrates that trade_execution=True vs False produces the same result
350+
# for L1_MBP books since update_trade_tick sets both bid/ask to trade price anyway
351+
352+
# Test with trade_execution=True (our main matching engine)
353+
trade_tick_enabled = TestDataStubs.trade_tick(
354+
instrument=self.instrument,
355+
price=1000.0,
356+
aggressor_side=AggressorSide.BUYER,
357+
)
358+
self.matching_engine.process_trade_tick(trade_tick_enabled)
359+
360+
# Test with trade_execution=False
361+
matching_engine = OrderMatchingEngine(
362+
instrument=self.instrument,
363+
raw_id=1,
364+
fill_model=FillModel(),
365+
fee_model=MakerTakerFeeModel(),
366+
book_type=BookType.L1_MBP,
367+
oms_type=OmsType.NETTING,
368+
account_type=AccountType.MARGIN,
369+
reject_stop_orders=True,
370+
trade_execution=False, # Disabled
371+
msgbus=self.msgbus,
372+
cache=self.cache,
373+
clock=self.clock,
374+
)
375+
376+
trade = TestDataStubs.trade_tick(
377+
instrument=self.instrument,
378+
price=1000.0,
379+
aggressor_side=AggressorSide.BUYER,
380+
)
381+
matching_engine.process_trade_tick(trade)
382+
383+
# Assert - Both should have same result for L1_MBP
384+
assert self.matching_engine.best_bid_price() == matching_engine.best_bid_price()
385+
assert self.matching_engine.best_ask_price() == matching_engine.best_ask_price()

0 commit comments

Comments
 (0)