diff --git a/nautilus_trader/execution/manager.pxd b/nautilus_trader/execution/manager.pxd index 2b04bfa40afe..6ec6eaf2bd58 100644 --- a/nautilus_trader/execution/manager.pxd +++ b/nautilus_trader/execution/manager.pxd @@ -67,6 +67,7 @@ cdef class OrderManager: cpdef void cancel_order(self, Order order) cpdef void modify_order_quantity(self, Order order, Quantity new_quantity) cpdef void create_new_submit_order(self, Order order, PositionId position_id=*, ClientId client_id=*) + cpdef bint should_manage_order(self, Order order) # -- EVENT HANDLERS ------------------------------------------------------------------------------- diff --git a/nautilus_trader/execution/manager.pyx b/nautilus_trader/execution/manager.pyx index fe493c9668f0..3896ead82bf9 100644 --- a/nautilus_trader/execution/manager.pyx +++ b/nautilus_trader/execution/manager.pyx @@ -264,6 +264,28 @@ cdef class OrderManager: else: self._submit_order_handler(submit) + cpdef bint should_manage_order(self, Order order): + """ + Check if the given order should be managed. + + Parameters + ---------- + order : Order + The order the check. + + Returns + ------- + bool + True if the order should be managed, else False. + + """ + Condition.not_none(order, "order") + + if self.active_local: + return order.is_active_local_c() + else: + return not order.is_active_local_c() + # -- EVENT HANDLERS ------------------------------------------------------------------------------- cpdef void handle_event(self, Event event): @@ -386,21 +408,21 @@ cdef class OrderManager: if child_order is None: raise RuntimeError(f"Cannot find OTO child order for {repr(client_order_id)}") # pragma: no cover + if not self.should_manage_order(child_order): + continue # Not being managed + if self.debug: self._log.info(f"Processing OTO child order {child_order}.", LogColor.MAGENTA) self._log.info(f"{parent_filled_qty=}.", LogColor.MAGENTA) - if self.active_local and not child_order.is_active_local_c(): - continue - if child_order.position_id is None: child_order.position_id = position_id if parent_filled_qty._mem.raw != child_order.leaves_qty._mem.raw: self.modify_order_quantity(child_order, parent_filled_qty) - if (self.active_local and not child_order.is_active_local_c()) or self._submit_order_handler is None: - return # Order does not need to be released + if self._submit_order_handler is None: + return # No handler to submit if not child_order.client_order_id in self._submit_order_commands: self.create_new_submit_order( @@ -418,8 +440,10 @@ cdef class OrderManager: if self.debug: self._log.info(f"Processing OCO contingent order {contingent_order}.", LogColor.MAGENTA) + if not self.should_manage_order(contingent_order): + continue # Not being managed if contingent_order.is_closed_c(): - continue + continue # Already completed if contingent_order.client_order_id != order.client_order_id: self.cancel_order(contingent_order) elif order.contingency_type == ContingencyType.OUO: @@ -453,9 +477,11 @@ cdef class OrderManager: contingent_order = self._cache.order(client_order_id) if contingent_order is None: raise RuntimeError(f"Cannot find contingent order for {repr(client_order_id)}") # pragma: no cover + if not self.should_manage_order(contingent_order): + continue # Not being managed if client_order_id == order.client_order_id: continue # Already being handled - if contingent_order.is_closed_c() or (self.active_local and not contingent_order.is_active_local_c()): + if contingent_order.is_closed_c(): self._submit_order_commands.pop(order.client_order_id, None) continue # Already completed @@ -505,10 +531,14 @@ cdef class OrderManager: cdef Order contingent_order for client_order_id in order.linked_order_ids: contingent_order = self._cache.order(client_order_id) - assert contingent_order + if contingent_order is None: + raise RuntimeError(f"Cannot find OCO contingent order for {repr(client_order_id)}") # pragma: no cover + + if not self.should_manage_order(contingent_order): + continue # Not being managed if client_order_id == order.client_order_id: continue # Already being handled # pragma: no cover - if contingent_order.is_closed_c() or (self.active_local and not contingent_order.is_active_local_c()): + if contingent_order.is_closed_c(): continue # Already completed # pragma: no cover if order.contingency_type == ContingencyType.OTO: diff --git a/nautilus_trader/trading/strategy.pyx b/nautilus_trader/trading/strategy.pyx index d64fc50726c1..b9ff728e2e05 100644 --- a/nautilus_trader/trading/strategy.pyx +++ b/nautilus_trader/trading/strategy.pyx @@ -1528,10 +1528,7 @@ cdef class Strategy(Actor): return if self.manage_contingent_orders and self._manager is not None: - if isinstance(event, OrderEvent): - order = self.cache.order(event.client_order_id) - if order is not None and not order.is_active_local_c(): - self._manager.handle_event(event) + self._manager.handle_event(event) try: # Send to specific event handler diff --git a/tests/unit_tests/execution/test_emulator_list.py b/tests/unit_tests/execution/test_emulator_list.py index 2a70ef98f71e..74f132e54707 100644 --- a/tests/unit_tests/execution/test_emulator_list.py +++ b/tests/unit_tests/execution/test_emulator_list.py @@ -29,6 +29,7 @@ from nautilus_trader.config import ExecEngineConfig from nautilus_trader.config import RiskEngineConfig from nautilus_trader.config.common import OrderEmulatorConfig +from nautilus_trader.config.common import StrategyConfig from nautilus_trader.data.engine import DataEngine from nautilus_trader.execution.emulator import OrderEmulator from nautilus_trader.execution.engine import ExecutionEngine @@ -64,7 +65,7 @@ class TestOrderEmulatorWithOrderLists: - def setup(self): + def setup(self) -> None: # Fixture Setup self.clock = TestClock() self.logger = Logger( @@ -150,6 +151,7 @@ def setup(self): cache=self.cache, clock=self.clock, logger=self.logger, + support_contingent_orders=False, ) self.exec_client = BacktestExecClient( @@ -183,7 +185,7 @@ def setup(self): self.emulator.start() self.strategy.start() - def test_submit_stop_order_bulk_then_emulates(self): + def test_submit_stop_order_bulk_then_emulates(self) -> None: # Arrange stop1 = self.strategy.order_factory.stop_market( instrument_id=ETHUSDT_PERP_BINANCE.id, @@ -226,7 +228,7 @@ def test_submit_stop_order_bulk_then_emulates(self): assert stop2 in self.emulator.get_matching_core(ETHUSDT_PERP_BINANCE.id).get_orders() assert stop3 in self.emulator.get_matching_core(ETHUSDT_PERP_BINANCE.id).get_orders() - def test_submit_bracket_order_with_limit_entry_then_emulates_sl_tp(self): + def test_submit_bracket_order_with_limit_entry_then_emulates_sl_tp(self) -> None: # Arrange bracket = self.strategy.order_factory.bracket( instrument_id=ETHUSDT_PERP_BINANCE.id, @@ -252,7 +254,7 @@ def test_submit_bracket_order_with_limit_entry_then_emulates_sl_tp(self): bracket.first, ] - def test_submit_bracket_order_with_stop_limit_entry_then_emulates_sl_tp(self): + def test_submit_bracket_order_with_stop_limit_entry_then_emulates_sl_tp(self) -> None: # Arrange bracket = self.strategy.order_factory.bracket( instrument_id=ETHUSDT_PERP_BINANCE.id, @@ -280,7 +282,9 @@ def test_submit_bracket_order_with_stop_limit_entry_then_emulates_sl_tp(self): bracket.first, ] - def test_submit_bracket_order_with_market_entry_immediately_submits_then_emulates_sl_tp(self): + def test_submit_bracket_order_with_market_entry_immediately_submits_then_emulates_sl_tp( + self, + ) -> None: # Arrange bracket = self.strategy.order_factory.bracket( instrument_id=ETHUSDT_PERP_BINANCE.id, @@ -302,7 +306,7 @@ def test_submit_bracket_order_with_market_entry_immediately_submits_then_emulate assert self.emulator.get_matching_core(ETHUSDT_PERP_BINANCE.id) is None assert self.exec_engine.command_count == 1 - def test_submit_bracket_when_entry_filled_then_emulates_sl_and_tp(self): + def test_submit_bracket_when_entry_filled_then_emulates_sl_and_tp(self) -> None: # Arrange bracket = self.strategy.order_factory.bracket( instrument_id=ETHUSDT_PERP_BINANCE.id, @@ -346,7 +350,7 @@ def test_submit_bracket_when_entry_filled_then_emulates_sl_and_tp(self): assert bracket.orders[2].position_id == position_id assert self.exec_engine.command_count == 1 - def test_modify_emulated_sl_quantity_also_updates_tp(self): + def test_modify_emulated_sl_quantity_also_updates_tp(self) -> None: # Arrange bracket = self.strategy.order_factory.bracket( instrument_id=ETHUSDT_PERP_BINANCE.id, @@ -397,7 +401,7 @@ def test_modify_emulated_sl_quantity_also_updates_tp(self): assert bracket.orders[1].position_id == position_id assert bracket.orders[2].position_id == position_id - def test_modify_emulated_tp_price(self): + def test_modify_emulated_tp_price(self) -> None: # Arrange bracket = self.strategy.order_factory.bracket( instrument_id=ETHUSDT_PERP_BINANCE.id, @@ -447,7 +451,7 @@ def test_modify_emulated_tp_price(self): assert bracket.orders[1].position_id == position_id assert bracket.orders[2].position_id == position_id - def test_submit_bracket_when_stop_limit_entry_filled_then_emulates_sl_and_tp(self): + def test_submit_bracket_when_stop_limit_entry_filled_then_emulates_sl_and_tp(self) -> None: # Arrange bracket = self.strategy.order_factory.bracket( instrument_id=ETHUSDT_PERP_BINANCE.id, @@ -502,8 +506,8 @@ def test_submit_bracket_when_stop_limit_entry_filled_then_emulates_sl_and_tp(sel ) def test_rejected_oto_entry_cancels_contingencies( self, - contingency_type, - ): + contingency_type: ContingencyType, + ) -> None: # Arrange bracket = self.strategy.order_factory.bracket( instrument_id=ETHUSDT_PERP_BINANCE.id, @@ -549,8 +553,8 @@ def test_rejected_oto_entry_cancels_contingencies( ) def test_cancel_bracket( self, - contingency_type, - ): + contingency_type: ContingencyType, + ) -> None: # Arrange bracket = self.strategy.order_factory.bracket( instrument_id=ETHUSDT_PERP_BINANCE.id, @@ -594,8 +598,8 @@ def test_cancel_bracket( ) def test_cancel_oto_entry_cancels_contingencies( self, - contingency_type, - ): + contingency_type: ContingencyType, + ) -> None: # Arrange bracket = self.strategy.order_factory.bracket( instrument_id=ETHUSDT_PERP_BINANCE.id, @@ -639,8 +643,8 @@ def test_cancel_oto_entry_cancels_contingencies( ) def test_expired_oto_entry_then_cancels_contingencies( self, - contingency_type, - ): + contingency_type: ContingencyType, + ) -> None: # Arrange bracket = self.strategy.order_factory.bracket( instrument_id=ETHUSDT_PERP_BINANCE.id, @@ -686,8 +690,8 @@ def test_expired_oto_entry_then_cancels_contingencies( ) def test_update_oto_entry_updates_quantity_of_contingencies( self, - contingency_type, - ): + contingency_type: ContingencyType, + ) -> None: # Arrange bracket = self.strategy.order_factory.bracket( instrument_id=ETHUSDT_PERP_BINANCE.id, @@ -733,8 +737,8 @@ def test_update_oto_entry_updates_quantity_of_contingencies( ) def test_triggered_sl_submits_market_order( self, - contingency_type, - ): + contingency_type: ContingencyType, + ) -> None: # Arrange bracket = self.strategy.order_factory.bracket( instrument_id=ETHUSDT_PERP_BINANCE.id, @@ -796,8 +800,8 @@ def test_triggered_sl_submits_market_order( ) def test_triggered_stop_limit_tp_submits_limit_order( self, - contingency_type, - ): + contingency_type: ContingencyType, + ) -> None: # Arrange bracket = self.strategy.order_factory.bracket( instrument_id=ETHUSDT_PERP_BINANCE.id, @@ -868,8 +872,8 @@ def test_triggered_stop_limit_tp_submits_limit_order( ) def test_triggered_then_filled_tp_cancels_sl( self, - contingency_type, - ): + contingency_type: ContingencyType, + ) -> None: # Arrange bracket = self.strategy.order_factory.bracket( instrument_id=ETHUSDT_PERP_BINANCE.id, @@ -939,7 +943,7 @@ def test_triggered_then_filled_tp_cancels_sl( assert not matching_core.order_exists(sl_order.client_order_id) assert not matching_core.order_exists(tp_order.client_order_id) - def test_triggered_then_partially_filled_oco_sl_cancels_tp(self): + def test_triggered_then_partially_filled_oco_sl_cancels_tp(self) -> None: # Arrange bracket = self.strategy.order_factory.bracket( instrument_id=ETHUSDT_PERP_BINANCE.id, @@ -1012,7 +1016,7 @@ def test_triggered_then_partially_filled_oco_sl_cancels_tp(self): assert not matching_core.order_exists(sl_order.client_order_id) assert not matching_core.order_exists(tp_order.client_order_id) - def test_triggered_then_partially_filled_ouo_sl_updated_tp(self): + def test_triggered_then_partially_filled_ouo_sl_updated_tp(self) -> None: # Arrange bracket = self.strategy.order_factory.bracket( instrument_id=ETHUSDT_PERP_BINANCE.id, @@ -1090,7 +1094,9 @@ def test_triggered_then_partially_filled_ouo_sl_updated_tp(self): assert not matching_core.order_exists(sl_order.client_order_id) assert matching_core.order_exists(tp_order.client_order_id) - def test_released_order_with_quote_quantity_sets_contingent_orders_to_base_quantity(self): + def test_released_order_with_quote_quantity_sets_contingent_orders_to_base_quantity( + self, + ) -> None: # Arrange bracket = self.strategy.order_factory.bracket( instrument_id=ETHUSDT_PERP_BINANCE.id, @@ -1138,7 +1144,7 @@ def test_released_order_with_quote_quantity_sets_contingent_orders_to_base_quant assert sl_order.leaves_qty == ETHUSDT_PERP_BINANCE.make_qty(0.002) assert tp_order.leaves_qty == ETHUSDT_PERP_BINANCE.make_qty(0.002) - def test_restart_emulator_with_emulated_parent(self): + def test_restart_emulator_with_emulated_parent(self) -> None: # Arrange bracket = self.strategy.order_factory.bracket( instrument_id=ETHUSDT_PERP_BINANCE.id, @@ -1172,7 +1178,7 @@ def test_restart_emulator_with_emulated_parent(self): assert bracket.orders[1].status == OrderStatus.INITIALIZED assert bracket.orders[2].status == OrderStatus.INITIALIZED - def test_restart_emulator_with_partially_filled_parent(self): + def test_restart_emulator_with_partially_filled_parent(self) -> None: # Arrange bracket = self.strategy.order_factory.bracket( instrument_id=ETHUSDT_PERP_BINANCE.id, @@ -1213,7 +1219,7 @@ def test_restart_emulator_with_partially_filled_parent(self): assert bracket.orders[1].status == OrderStatus.EMULATED assert bracket.orders[2].status == OrderStatus.EMULATED - def test_restart_emulator_then_cancel_bracket(self): + def test_restart_emulator_then_cancel_bracket(self) -> None: # Arrange bracket = self.strategy.order_factory.bracket( instrument_id=ETHUSDT_PERP_BINANCE.id, @@ -1245,7 +1251,7 @@ def test_restart_emulator_then_cancel_bracket(self): assert bracket.orders[1].status == OrderStatus.CANCELED assert bracket.orders[2].status == OrderStatus.CANCELED - def test_restart_emulator_with_closed_parent_position(self): + def test_restart_emulator_with_closed_parent_position(self) -> None: # Arrange bracket = self.strategy.order_factory.bracket( instrument_id=ETHUSDT_PERP_BINANCE.id, @@ -1296,3 +1302,242 @@ def test_restart_emulator_with_closed_parent_position(self): assert closing_order.status == OrderStatus.FILLED assert bracket.orders[1].status == OrderStatus.CANCELED assert bracket.orders[2].status == OrderStatus.CANCELED + + def test_managed_contingent_orders_with_canceled_bracket(self) -> None: + # Arrange + bracket = self.strategy.order_factory.bracket( + instrument_id=ETHUSDT_PERP_BINANCE.id, + order_side=OrderSide.BUY, + quantity=ETHUSDT_PERP_BINANCE.make_qty(10), + sl_trigger_price=ETHUSDT_PERP_BINANCE.make_price(4900.0), + tp_order_type=OrderType.LIMIT_IF_TOUCHED, + tp_price=ETHUSDT_PERP_BINANCE.make_price(5150.0), + tp_trigger_price=ETHUSDT_PERP_BINANCE.make_price(5100.0), + emulation_trigger=TriggerType.BID_ASK, + contingency_type=ContingencyType.OUO, + ) + + config = StrategyConfig( + manage_contingent_orders=True, + manage_gtd_expiry=True, + ) + strategy = Strategy(config=config) + strategy.register( + trader_id=self.trader_id, + portfolio=self.portfolio, + msgbus=self.msgbus, + cache=self.cache, + clock=self.clock, + logger=self.logger, + ) + strategy.start() + + # Prepare market + tick = TestDataStubs.quote_tick( + instrument=ETHUSDT_PERP_BINANCE, + bid_price=5000.0, + ask_price=5000.0, + ) + + self.data_engine.process(tick) + self.exchange.process_quote_tick(tick) + self.exchange.process(0) + + # Submit order + strategy.submit_order_list( + order_list=bracket, + position_id=PositionId("P-001"), + ) + self.exchange.process(0) + + tick = TestDataStubs.quote_tick( + instrument=ETHUSDT_PERP_BINANCE, + bid_price=5100.0, + ask_price=5100.0, + ) + + self.data_engine.process(tick) + self.exchange.process_quote_tick(tick) + self.exchange.process(0) + + # Act + tp_order = self.cache.order(bracket.orders[2].client_order_id) + strategy.cancel_order(tp_order) + self.exchange.process(0) + + # Assert + matching_core = self.emulator.get_matching_core(ETHUSDT_PERP_BINANCE.id) + entry_order = self.cache.order(bracket.orders[0].client_order_id) + sl_order = self.cache.order(bracket.orders[1].client_order_id) + tp_order = self.cache.order(bracket.orders[2].client_order_id) + assert self.exec_engine.command_count == 3 + assert len(self.emulator.get_submit_order_commands()) == 1 + assert self.cache.orders_emulated_count() == 0 + assert entry_order.status == OrderStatus.FILLED + assert sl_order.status == OrderStatus.CANCELED + assert tp_order.status == OrderStatus.CANCELED + assert sl_order.quantity == Quantity.from_int(10) + assert tp_order.quantity == Quantity.from_int(10) + assert not matching_core.order_exists(entry_order.client_order_id) + assert not matching_core.order_exists(sl_order.client_order_id) + assert not matching_core.order_exists(tp_order.client_order_id) + + def test_managed_contingent_orders_with_modified_open_order(self) -> None: + # Arrange + bracket = self.strategy.order_factory.bracket( + instrument_id=ETHUSDT_PERP_BINANCE.id, + order_side=OrderSide.BUY, + quantity=ETHUSDT_PERP_BINANCE.make_qty(10), + sl_trigger_price=ETHUSDT_PERP_BINANCE.make_price(4900.0), + tp_order_type=OrderType.LIMIT_IF_TOUCHED, + tp_price=ETHUSDT_PERP_BINANCE.make_price(5150.0), + tp_trigger_price=ETHUSDT_PERP_BINANCE.make_price(5100.0), + emulation_trigger=TriggerType.BID_ASK, + contingency_type=ContingencyType.OUO, + ) + + config = StrategyConfig( + manage_contingent_orders=True, + manage_gtd_expiry=True, + ) + strategy = Strategy(config=config) + strategy.register( + trader_id=self.trader_id, + portfolio=self.portfolio, + msgbus=self.msgbus, + cache=self.cache, + clock=self.clock, + logger=self.logger, + ) + strategy.start() + + # Prepare market + tick = TestDataStubs.quote_tick( + instrument=ETHUSDT_PERP_BINANCE, + bid_price=5000.0, + ask_price=5000.0, + ) + + self.data_engine.process(tick) + self.exchange.process_quote_tick(tick) + self.exchange.process(0) + + # Submit order + strategy.submit_order_list( + order_list=bracket, + position_id=PositionId("P-001"), + ) + self.exchange.process(0) + + tick = TestDataStubs.quote_tick( + instrument=ETHUSDT_PERP_BINANCE, + bid_price=5100.0, + ask_price=5100.0, + ) + + self.data_engine.process(tick) + self.exchange.process_quote_tick(tick) + self.exchange.process(0) + + # Act + new_quantity = Quantity.from_int(5) + tp_order = self.cache.order(bracket.orders[2].client_order_id) + strategy.modify_order(tp_order, new_quantity) + self.exchange.process(0) + + # Assert + matching_core = self.emulator.get_matching_core(ETHUSDT_PERP_BINANCE.id) + entry_order = self.cache.order(bracket.orders[0].client_order_id) + sl_order = self.cache.order(bracket.orders[1].client_order_id) + tp_order = self.cache.order(bracket.orders[2].client_order_id) + assert self.exec_engine.command_count == 3 + assert len(self.emulator.get_submit_order_commands()) == 2 + assert self.cache.orders_emulated_count() == 1 + assert entry_order.status == OrderStatus.FILLED + assert sl_order.status == OrderStatus.EMULATED + assert tp_order.status == OrderStatus.ACCEPTED + assert sl_order.quantity == new_quantity + assert tp_order.quantity == new_quantity + assert not matching_core.order_exists(entry_order.client_order_id) + assert matching_core.order_exists(sl_order.client_order_id) + assert not matching_core.order_exists(tp_order.client_order_id) + + def test_managed_contingent_orders_with_modified_emulated_order(self) -> None: + # Arrange + bracket = self.strategy.order_factory.bracket( + instrument_id=ETHUSDT_PERP_BINANCE.id, + order_side=OrderSide.BUY, + quantity=ETHUSDT_PERP_BINANCE.make_qty(10), + sl_trigger_price=ETHUSDT_PERP_BINANCE.make_price(4900.0), + tp_order_type=OrderType.LIMIT_IF_TOUCHED, + tp_price=ETHUSDT_PERP_BINANCE.make_price(5150.0), + tp_trigger_price=ETHUSDT_PERP_BINANCE.make_price(5100.0), + emulation_trigger=TriggerType.BID_ASK, + contingency_type=ContingencyType.OUO, + ) + + config = StrategyConfig( + manage_contingent_orders=True, + manage_gtd_expiry=True, + ) + strategy = Strategy(config=config) + strategy.register( + trader_id=self.trader_id, + portfolio=self.portfolio, + msgbus=self.msgbus, + cache=self.cache, + clock=self.clock, + logger=self.logger, + ) + strategy.start() + + # Prepare market + tick = TestDataStubs.quote_tick( + instrument=ETHUSDT_PERP_BINANCE, + bid_price=5000.0, + ask_price=5000.0, + ) + + self.data_engine.process(tick) + self.exchange.process_quote_tick(tick) + self.exchange.process(0) + + # Submit order + strategy.submit_order_list( + order_list=bracket, + position_id=PositionId("P-001"), + ) + self.exchange.process(0) + + tick = TestDataStubs.quote_tick( + instrument=ETHUSDT_PERP_BINANCE, + bid_price=5100.0, + ask_price=5100.0, + ) + + self.data_engine.process(tick) + self.exchange.process_quote_tick(tick) + self.exchange.process(0) + + # Act + new_quantity = Quantity.from_int(5) + sl_order = self.cache.order(bracket.orders[1].client_order_id) + strategy.modify_order(sl_order, new_quantity) + self.exchange.process(0) + + # Assert + matching_core = self.emulator.get_matching_core(ETHUSDT_PERP_BINANCE.id) + entry_order = self.cache.order(bracket.orders[0].client_order_id) + sl_order = self.cache.order(bracket.orders[1].client_order_id) + tp_order = self.cache.order(bracket.orders[2].client_order_id) + assert self.exec_engine.command_count == 3 + assert len(self.emulator.get_submit_order_commands()) == 2 + assert self.cache.orders_emulated_count() == 1 + assert entry_order.status == OrderStatus.FILLED + assert sl_order.status == OrderStatus.EMULATED + assert tp_order.status == OrderStatus.ACCEPTED + assert sl_order.quantity == new_quantity + assert tp_order.quantity == new_quantity + assert not matching_core.order_exists(entry_order.client_order_id) + assert matching_core.order_exists(sl_order.client_order_id) + assert not matching_core.order_exists(tp_order.client_order_id)