diff --git a/RELEASES.md b/RELEASES.md index 0b842b7a8cdd..567fb17eddb6 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -4,9 +4,12 @@ Released on TBC (UTC). ### Enhancements - Added `WebSocketClient` connection headers, thanks @ruthvik125 and @twitu +- Added `support_contingent_orders` option for venues (to simulate venues which do not support contingent orders) +- Added `StrategyConfig.manage_contingent_orders` option (to automatically manage **open** contingenct orders) ### Breaking Changes - Transformed orders will now retain the original `ts_init` timestamp +- Removed unimplemented `batch_more` option for `Strategy.modify_order` - Dropped support for Python 3.9 ### Fixes @@ -175,7 +178,7 @@ Released on 31st July 2023 (UTC). - Fixed dictionary representation of orders for `venue_order_id` (for three order types) - Fixed `Currency` registration with core global map on creation - Fixed serialization of `OrderInitialized.exec_algorithm_params` to spec (bytes rather than string) -- Fixed assignment of position IDs for contingency orders (when parent filled) +- Fixed assignment of position IDs for contingent orders (when parent filled) - Fixed `PENDING_CANCEL` -> `EXPIRED` as valid state transition (real world possibility) - Fixed fill handling of `reduce_only` orders when partially filled - Fixed Binance reconciliation which was requesting reports for the same symbol multiple times @@ -253,8 +256,8 @@ Released on 19th May 2023 (UTC). - Fixed handling of emulated order contingencies (not based on status of spawned algorithm orders) - Fixed sending execution algorithm commands from strategy - Fixed `OrderEmulator` releasing of already closed orders -- Fixed `MatchingEngine` processing of reduce only for child contingency orders -- Fixed `MatchingEngine` position ID assignment for child contingency orders +- Fixed `MatchingEngine` processing of reduce only for child contingent orders +- Fixed `MatchingEngine` position ID assignment for child contingent orders - Fixed `Actor` handling of historical data from requests (will now call `on_historical_data` regardless of state), thanks for reporting @miller-moore - Fixed pyarrow schema dictionary index keys being too narrow (int8 -> int16), thanks for reporting @rterbush @@ -301,7 +304,7 @@ Released on 30th April 2023 (UTC). - Added `TWAPExecAlgorithm` and `TWAPExecAlgorithmConfig` to examples - Build out `ExecAlgorithm` base class for implementing 'first class' execution algorithms - Rewired execution for improved flow flexibility between emulated orders, execution algorithms and the `RiskEngine` -- Improved handling for `OrderEmulator` updating of contingency orders from execution algorithms +- Improved handling for `OrderEmulator` updating of contingent orders from execution algorithms - Defined public API for instruments, can now import directly from `nautilus_trader.model.instruments` (denest namespace) - Defined public API for orders, can now import directly from `nautilus_trader.model.orders` (denest namespace) - Defined public API for order book, can now import directly from `nautilus_trader.model.orderbook` (denest namespace) @@ -309,7 +312,7 @@ Released on 30th April 2023 (UTC). - Refined build and added additional `debug` Makefile convenience targets ### Fixes -- Fixed processing of contingency orders when in a pending update state +- Fixed processing of contingent orders when in a pending update state - Fixed calculation of PnL for flipped positions (only book realized PnL against open position) - Fixed `WebSocketClient` session disconnect, thanks for reporting @miller-moore - Added missing `BinanceSymbolFilterType.NOTIONAL` @@ -618,7 +621,7 @@ Released on 28th November 2022 (UTC). - Renamed `Instrument.get_cost_currency(...)` to `Instrument.get_settlement_currency(...)` (more accurate terminology) ### Enhancements -- Added emulated contingency orders capability to `OrderEmulator` +- Added emulated contingent orders capability to `OrderEmulator` - Moved `test_kit` module to main package to support downstream project/package testing ### Fixes @@ -650,7 +653,7 @@ Released on 18th November 2022 (UTC). - Fixed bar aggregation start times for bar specs outside typical intervals (60-SECOND rather than 1-MINUTE etc) - Fixed backtest engine main loop ordering of time events with identically timestamped data - Fixed `ModifyOrder` message `str` and `repr` when no quantity -- Fixed OCO contingency orders which were actually implemented as OUO for backtests +- Fixed OCO contingent orders which were actually implemented as OUO for backtests - Fixed various bugs for Interactive Brokers integration, thanks @limx0 and @rsmb7z - Fixed pyarrow version parsing, thanks @ghill2 - Fixed returning venue from InstrumentId, thanks @rsmb7z @@ -1466,7 +1469,7 @@ Released on 12th September 2021. - Added order custom user tags - Added `Actor.register_warning_event` (also applicable to `TradingStrategy`) - Added `Actor.deregister_warning_event` (also applicable to `TradingStrategy`) -- Added `ContingencyType` enum (for contingency orders in an `OrderList`) +- Added `ContingencyType` enum (for contingent orders in an `OrderList`) - All order types can now be `reduce_only` (#437) - Refined backtest configuration options - Improved efficiency of `UUID4` using the Rust `fastuuid` Python bindings diff --git a/docs/concepts/execution.md b/docs/concepts/execution.md index e4462a63e319..25ac8821bc1f 100644 --- a/docs/concepts/execution.md +++ b/docs/concepts/execution.md @@ -215,7 +215,7 @@ e.g. `O-20230404-001-000-E1` (for the first spawned order) ```{note} The "primary" and "secondary" / "spawn" terminology was specifically chosen to avoid conflict -or confusion with the "parent" and "child" contingency orders terminology (an execution algorithm may also deal with contingent orders). +or confusion with the "parent" and "child" contingent orders terminology (an execution algorithm may also deal with contingent orders). ``` ### Managing execution algorithm orders diff --git a/docs/concepts/orders.md b/docs/concepts/orders.md index e1a0672a13f6..bcf7a3bf419d 100644 --- a/docs/concepts/orders.md +++ b/docs/concepts/orders.md @@ -98,7 +98,7 @@ of the stop price based on the offset from the 'market' (bid, ask or last price - `TICKS` - The offset is based on a number of ticks - `PRICE_TIER` - The offset is based on an exchange specific price tier -### Contingency Orders +### Contingent Orders More advanced relationships can be specified between orders such as assigning child order(s) which will only trigger when the parent order is activated or filled, or linking orders together which will cancel or reduce in quantity contingent on each other. More documentation for these options can be found in the [advanced order guide](advanced/advanced_orders.md). diff --git a/nautilus_trader/backtest/engine.pyx b/nautilus_trader/backtest/engine.pyx index c1ca5ce42137..e1a65894fa45 100644 --- a/nautilus_trader/backtest/engine.pyx +++ b/nautilus_trader/backtest/engine.pyx @@ -362,6 +362,7 @@ cdef class BacktestEngine: bar_execution: bool = True, reject_stop_orders: bool = True, support_gtd_orders: bool = True, + support_contingent_orders: bool = True, use_position_ids: bool = True, use_random_ids: bool = False, use_reduce_only: bool = True, @@ -404,6 +405,9 @@ cdef class BacktestEngine: If stop orders are rejected on submission if trigger price is in the market. support_gtd_orders : bool, default True If orders with GTD time in force will be supported by the venue. + support_contingent_orders : bool, default True + If contingent orders will be supported/respected by the venue. + If False then its expected the strategy will be managing any contingent orders. use_position_ids : bool, default True If venue position IDs will be generated on order fills. use_random_ids : bool, default False @@ -455,6 +459,7 @@ cdef class BacktestEngine: bar_execution=bar_execution, reject_stop_orders=reject_stop_orders, support_gtd_orders=support_gtd_orders, + support_contingent_orders=support_contingent_orders, use_position_ids=use_position_ids, use_random_ids=use_random_ids, use_reduce_only=use_reduce_only, diff --git a/nautilus_trader/backtest/exchange.pxd b/nautilus_trader/backtest/exchange.pxd index e0190bb2518c..187c07d7c2c5 100644 --- a/nautilus_trader/backtest/exchange.pxd +++ b/nautilus_trader/backtest/exchange.pxd @@ -84,6 +84,8 @@ cdef class SimulatedExchange: """If stop orders are rejected on submission if in the market.\n\n:returns: `bool`""" cdef readonly bint support_gtd_orders """If orders with GTD time in force will be supported by the venue.\n\n:returns: `bool`""" + cdef readonly bint support_contingent_orders + """If contingent orders will be supported/respected by the venue.\n\n:returns: `bool`""" cdef readonly bint use_position_ids """If venue position IDs will be generated on order fills.\n\n:returns: `bool`""" cdef readonly bint use_random_ids diff --git a/nautilus_trader/backtest/exchange.pyx b/nautilus_trader/backtest/exchange.pyx index 25dcb46154f5..92a61ed57df4 100644 --- a/nautilus_trader/backtest/exchange.pyx +++ b/nautilus_trader/backtest/exchange.pyx @@ -102,6 +102,9 @@ cdef class SimulatedExchange: If stop orders are rejected on submission if in the market. support_gtd_orders : bool, default True If orders with GTD time in force will be supported by the venue. + support_contingent_orders : bool, default True + If contingent orders will be supported/respected by the venue. + If False then its expected the strategy will be managing any contingent orders. use_position_ids : bool, default True If venue position IDs will be generated on order fills. use_random_ids : bool, default False @@ -147,6 +150,7 @@ cdef class SimulatedExchange: bint bar_execution = True, bint reject_stop_orders = True, bint support_gtd_orders = True, + bint support_contingent_orders = True, bint use_position_ids = True, bint use_random_ids = False, bint use_reduce_only = True, @@ -187,6 +191,7 @@ cdef class SimulatedExchange: self.bar_execution = bar_execution self.reject_stop_orders = reject_stop_orders self.support_gtd_orders = support_gtd_orders + self.support_contingent_orders = support_contingent_orders self.use_position_ids = use_position_ids self.use_random_ids = use_random_ids self.use_reduce_only = use_reduce_only @@ -335,6 +340,7 @@ cdef class SimulatedExchange: bar_execution=self.bar_execution, reject_stop_orders=self.reject_stop_orders, support_gtd_orders=self.support_gtd_orders, + support_contingent_orders=self.support_contingent_orders, use_position_ids=self.use_position_ids, use_random_ids=self.use_random_ids, use_reduce_only=self.use_reduce_only, diff --git a/nautilus_trader/backtest/matching_engine.pxd b/nautilus_trader/backtest/matching_engine.pxd index 83dc061c04f2..ad07fc91716a 100644 --- a/nautilus_trader/backtest/matching_engine.pxd +++ b/nautilus_trader/backtest/matching_engine.pxd @@ -79,6 +79,7 @@ cdef class OrderMatchingEngine: cdef bint _bar_execution cdef bint _reject_stop_orders cdef bint _support_gtd_orders + cdef bint _support_contingent_orders cdef bint _use_position_ids cdef bint _use_random_ids cdef bint _use_reduce_only diff --git a/nautilus_trader/backtest/matching_engine.pyx b/nautilus_trader/backtest/matching_engine.pyx index d0b2982a2071..c813ce2da6f0 100644 --- a/nautilus_trader/backtest/matching_engine.pyx +++ b/nautilus_trader/backtest/matching_engine.pyx @@ -125,6 +125,9 @@ cdef class OrderMatchingEngine: If stop orders are rejected if already in the market on submitting. support_gtd_orders : bool, default True If orders with GTD time in force will be supported by the venue. + support_contingent_orders : bool, default True + If contingent orders will be supported/respected by the venue. + If False then its expected the strategy will be managing any contingent orders. use_position_ids : bool, default True If venue position IDs will be generated on order fills. use_random_ids : bool, default False @@ -149,6 +152,7 @@ cdef class OrderMatchingEngine: bint bar_execution = True, bint reject_stop_orders = True, bint support_gtd_orders = True, + bint support_contingent_orders = True, bint use_position_ids = True, bint use_random_ids = False, bint use_reduce_only = True, @@ -172,6 +176,7 @@ cdef class OrderMatchingEngine: self._bar_execution = bar_execution self._reject_stop_orders = reject_stop_orders self._support_gtd_orders = support_gtd_orders + self._support_contingent_orders = support_contingent_orders self._use_position_ids = use_position_ids self._use_random_ids = use_random_ids self._use_reduce_only = use_reduce_only @@ -638,7 +643,7 @@ cdef class OrderMatchingEngine: self._account_ids[order.trader_id] = account_id cdef Order parent - if order.parent_order_id is not None: + if self._support_contingent_orders and order.parent_order_id is not None: parent = self.cache.order(order.parent_order_id) assert parent is not None and parent.contingency_type == ContingencyType.OTO, "OTO parent not found" if parent.status_c() == OrderStatus.REJECTED and order.is_open_c(): @@ -1680,7 +1685,10 @@ cdef class OrderMatchingEngine: # Remove order from market self._core.delete_order(order) - # Check contingency orders + if not self._support_contingent_orders: + return + + # Check contingent orders cdef ClientOrderId client_order_id cdef Order child_order if order.contingency_type == ContingencyType.OTO: @@ -1824,7 +1832,7 @@ cdef class OrderMatchingEngine: self._core.add_order(order) cpdef void expire_order(self, Order order): - if order.contingency_type != ContingencyType.NO_CONTINGENCY: + if self._support_contingent_orders and order.contingency_type != ContingencyType.NO_CONTINGENCY: self._cancel_contingent_orders(order) self._generate_order_expired(order) @@ -1843,7 +1851,7 @@ cdef class OrderMatchingEngine: self._generate_order_canceled(order) - if order.contingency_type != ContingencyType.NO_CONTINGENCY and cancel_contingencies: + if self._support_contingent_orders and order.contingency_type != ContingencyType.NO_CONTINGENCY and cancel_contingencies: self._cancel_contingent_orders(order) cpdef void update_order( @@ -1895,7 +1903,7 @@ cdef class OrderMatchingEngine: raise ValueError( f"invalid `OrderType` was {order.order_type}") # pragma: no cover (design-time error) - if order.contingency_type != ContingencyType.NO_CONTINGENCY and update_contingencies: + if self._support_contingent_orders and order.contingency_type != ContingencyType.NO_CONTINGENCY and update_contingencies: self._update_contingent_orders(order) cpdef void trigger_stop_order(self, Order order): @@ -1959,7 +1967,7 @@ cdef class OrderMatchingEngine: ) cdef void _cancel_contingent_orders(self, Order order): - # Iterate all contingency orders and cancel if active + # Iterate all contingent orders and cancel if active cdef ClientOrderId client_order_id cdef Order contingent_order for client_order_id in order.linked_order_ids: diff --git a/nautilus_trader/backtest/node.py b/nautilus_trader/backtest/node.py index 47b49c4968f0..2cf35f37fd61 100644 --- a/nautilus_trader/backtest/node.py +++ b/nautilus_trader/backtest/node.py @@ -202,6 +202,7 @@ def _create_engine( frozen_account=config.frozen_account, reject_stop_orders=config.reject_stop_orders, support_gtd_orders=config.support_gtd_orders, + support_contingent_orders=config.support_contingent_orders, use_position_ids=config.use_position_ids, use_random_ids=config.use_random_ids, use_reduce_only=config.use_reduce_only, diff --git a/nautilus_trader/config/backtest.py b/nautilus_trader/config/backtest.py index eecc7a8af8cf..216859ea1e15 100644 --- a/nautilus_trader/config/backtest.py +++ b/nautilus_trader/config/backtest.py @@ -55,6 +55,7 @@ class BacktestVenueConfig(NautilusConfig, frozen=True): bar_execution: bool = True reject_stop_orders: bool = True support_gtd_orders: bool = True + support_contingent_orders: bool = True use_position_ids: bool = True use_random_ids: bool = False use_reduce_only: bool = True diff --git a/nautilus_trader/config/common.py b/nautilus_trader/config/common.py index c2335ab72017..0e4eb2a597b9 100644 --- a/nautilus_trader/config/common.py +++ b/nautilus_trader/config/common.py @@ -440,8 +440,9 @@ class StrategyConfig(NautilusConfig, kw_only=True, frozen=True): external_order_claims : list[str], optional The external order claim instrument IDs. External orders for matching instrument IDs will be associated with (claimed by) the strategy. - manage_contingencies : bool, default False - If OUO and OCO **open** contingency orders should be managed automatically by the strategy. + manage_contingent_orders : bool, default False + If OUO and OCO **open** contingent orders should be managed automatically by the strategy. + Any emulated orders which are active local will be managed by the `OrderEmulator` instead. manage_gtd_expiry : bool, default False If all order GTD time in force expirations should be managed by the strategy. If True then will ensure open orders have their GTD timers re-activated on start. @@ -452,7 +453,7 @@ class StrategyConfig(NautilusConfig, kw_only=True, frozen=True): order_id_tag: str | None = None oms_type: str | None = None external_order_claims: list[str] | None = None - manage_contingencies: bool = False + manage_contingent_orders: bool = False manage_gtd_expiry: bool = False diff --git a/nautilus_trader/execution/emulator.pyx b/nautilus_trader/execution/emulator.pyx index 31af6284ae06..0acc309f2f04 100644 --- a/nautilus_trader/execution/emulator.pyx +++ b/nautilus_trader/execution/emulator.pyx @@ -125,6 +125,7 @@ cdef class OrderEmulator(Actor): msgbus=msgbus, cache=cache, component_name=type(self).__name__, + active_local=True, submit_order_handler=self._handle_submit_order, cancel_order_handler=self._cancel_order, modify_order_handler=self._update_order, @@ -254,18 +255,7 @@ cdef class OrderEmulator(Actor): self._log.info(f"{RECV}{EVT} {event}.", LogColor.MAGENTA) self.event_count += 1 - if isinstance(event, OrderRejected): - self._manager.handle_order_rejected(event) - elif isinstance(event, OrderCanceled): - self._manager.handle_order_canceled(event) - elif isinstance(event, OrderExpired): - self._manager.handle_order_expired(event) - elif isinstance(event, OrderUpdated): - self._manager.handle_order_updated(event) - elif isinstance(event, OrderFilled): - self._manager.handle_order_filled(event) - elif isinstance(event, PositionEvent): - self._manager.handle_position_event(event) + self._manager.handle_event(event) if not isinstance(event, OrderEvent): return diff --git a/nautilus_trader/execution/manager.pxd b/nautilus_trader/execution/manager.pxd index a4e9731bbb04..2b04bfa40afe 100644 --- a/nautilus_trader/execution/manager.pxd +++ b/nautilus_trader/execution/manager.pxd @@ -49,6 +49,7 @@ cdef class OrderManager: cdef MessageBus _msgbus cdef Cache _cache + cdef readonly bint active_local cdef readonly bint debug cdef dict _submit_order_commands @@ -69,7 +70,7 @@ cdef class OrderManager: # -- EVENT HANDLERS ------------------------------------------------------------------------------- - cpdef void handle_position_event(self, PositionEvent event) + cpdef void handle_event(self, Event event) cpdef void handle_order_rejected(self, OrderRejected rejected) cpdef void handle_order_canceled(self, OrderCanceled canceled) cpdef void handle_order_expired(self, OrderExpired expired) @@ -77,6 +78,7 @@ cdef class OrderManager: cpdef void handle_order_filled(self, OrderFilled filled) cpdef void handle_contingencies(self, Order order) cpdef void handle_contingencies_update(self, Order order) + cpdef void handle_position_event(self, PositionEvent event) # -- EGRESS --------------------------------------------------------------------------------------- diff --git a/nautilus_trader/execution/manager.pyx b/nautilus_trader/execution/manager.pyx index 5cd8edee3d52..fe493c9668f0 100644 --- a/nautilus_trader/execution/manager.pyx +++ b/nautilus_trader/execution/manager.pyx @@ -71,6 +71,8 @@ cdef class OrderManager: The cache for the order manager. component_name : str The component name for the order manager. + active_local : str + If the manager if for active local orders. submit_order_handler : Callable[[SubmitOrder], None], optional The handler to call when submitting orders. cancel_order_handler : Callable[[Order], None], optional @@ -86,6 +88,8 @@ cdef class OrderManager: If `submit_order_handler` is not ``None`` and not of type `Callable`. TypeError If `cancel_order_handler` is not ``None`` and not of type `Callable`. + TypeError + If `modify_order_handler` is not ``None`` and not of type `Callable`. """ def __init__( @@ -95,6 +99,7 @@ cdef class OrderManager: MessageBus msgbus, Cache cache not None, str component_name not None, + bint active_local, submit_order_handler: Optional[Callable[[SubmitOrder], None]] = None, cancel_order_handler: Optional[Callable[[Order], None]] = None, modify_order_handler: Optional[Callable[[Order, Quantity], None]] = None, @@ -103,12 +108,14 @@ cdef class OrderManager: Condition.valid_string(component_name, "component_name") Condition.callable_or_none(submit_order_handler, "submit_order_handler") Condition.callable_or_none(cancel_order_handler, "cancel_order_handler") + Condition.callable_or_none(modify_order_handler, "modify_order_handler") self._clock = clock self._log = LoggerAdapter(component_name=component_name, logger=logger) self._msgbus = msgbus self._cache = cache + self.active_local = active_local self.debug = debug self._submit_order_handler = submit_order_handler self._cancel_order_handler = cancel_order_handler @@ -259,9 +266,30 @@ cdef class OrderManager: # -- EVENT HANDLERS ------------------------------------------------------------------------------- - cpdef void handle_position_event(self, PositionEvent event): - Condition.not_none(event, "event") - # TBC + cpdef void handle_event(self, Event event): + """ + Handle the given `event`. + + If a handler for the given event is not implemented then this will simply be a no-op. + + Parameters + ---------- + event : Event + The event to handle + + """ + if isinstance(event, OrderRejected): + self.handle_order_rejected(event) + elif isinstance(event, OrderCanceled): + self.handle_order_canceled(event) + elif isinstance(event, OrderExpired): + self.handle_order_expired(event) + elif isinstance(event, OrderUpdated): + self.handle_order_updated(event) + elif isinstance(event, OrderFilled): + self.handle_order_filled(event) + elif isinstance(event, PositionEvent): + self.handle_position_event(event) cpdef void handle_order_rejected(self, OrderRejected rejected): Condition.not_none(rejected, "rejected") @@ -362,7 +390,7 @@ cdef class OrderManager: self._log.info(f"Processing OTO child order {child_order}.", LogColor.MAGENTA) self._log.info(f"{parent_filled_qty=}.", LogColor.MAGENTA) - if not child_order.is_active_local_c(): + if self.active_local and not child_order.is_active_local_c(): continue if child_order.position_id is None: @@ -371,7 +399,7 @@ cdef class OrderManager: if parent_filled_qty._mem.raw != child_order.leaves_qty._mem.raw: self.modify_order_quantity(child_order, parent_filled_qty) - if not child_order.is_active_local_c() or self._submit_order_handler is None: + 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 not child_order.client_order_id in self._submit_order_commands: @@ -427,7 +455,7 @@ cdef class OrderManager: raise RuntimeError(f"Cannot find contingent order for {repr(client_order_id)}") # pragma: no cover if client_order_id == order.client_order_id: continue # Already being handled - if contingent_order.is_closed_c() or not contingent_order.is_active_local_c(): + if contingent_order.is_closed_c() or (self.active_local and not contingent_order.is_active_local_c()): self._submit_order_commands.pop(order.client_order_id, None) continue # Already completed @@ -480,7 +508,7 @@ cdef class OrderManager: assert contingent_order if client_order_id == order.client_order_id: continue # Already being handled # pragma: no cover - if contingent_order.is_closed_c() or contingent_order.emulation_trigger == TriggerType.NO_TRIGGER: + if contingent_order.is_closed_c() or (self.active_local and not contingent_order.is_active_local_c()): continue # Already completed # pragma: no cover if order.contingency_type == ContingencyType.OTO: @@ -490,6 +518,10 @@ cdef class OrderManager: if quantity._mem.raw != contingent_order.quantity._mem.raw: self.modify_order_quantity(contingent_order, quantity) + cpdef void handle_position_event(self, PositionEvent event): + Condition.not_none(event, "event") + # TBC + # -- EGRESS --------------------------------------------------------------------------------------- cpdef void send_emulator_command(self, TradingCommand command): diff --git a/nautilus_trader/trading/strategy.pxd b/nautilus_trader/trading/strategy.pxd index f18ad56d5177..8f9b1912680d 100644 --- a/nautilus_trader/trading/strategy.pxd +++ b/nautilus_trader/trading/strategy.pxd @@ -19,6 +19,7 @@ from nautilus_trader.common.clock cimport Clock from nautilus_trader.common.factories cimport OrderFactory from nautilus_trader.common.logging cimport Logger from nautilus_trader.common.timer cimport TimeEvent +from nautilus_trader.execution.manager cimport OrderManager from nautilus_trader.execution.messages cimport CancelOrder from nautilus_trader.execution.messages cimport ModifyOrder from nautilus_trader.execution.messages cimport TradingCommand @@ -67,6 +68,7 @@ from nautilus_trader.portfolio.base cimport PortfolioFacade cdef class Strategy(Actor): + cdef OrderManager _manager cdef readonly PortfolioFacade portfolio """The read-only portfolio for the strategy.\n\n:returns: `PortfolioFacade`""" @@ -78,8 +80,8 @@ cdef class Strategy(Actor): """The order management system for the strategy.\n\n:returns: `OmsType`""" cdef readonly list external_order_claims """The external order claims instrument IDs for the strategy.\n\n:returns: `list[InstrumentId]`""" - cdef readonly bint manage_contingencies - """If contingency orders should be managed automatically by the strategy.\n\n:returns: `bool`""" + cdef readonly bint manage_contingent_orders + """If contingent orders should be managed automatically by the strategy.\n\n:returns: `bool`""" cdef readonly bint manage_gtd_expiry """If all order GTD time in force expirations should be managed automatically by the strategy.\n\n:returns: `bool`""" @@ -142,7 +144,6 @@ cdef class Strategy(Actor): Price price=*, Price trigger_price=*, ClientId client_id=*, - bint batch_more=*, ) cpdef void cancel_order(self, Order order, ClientId client_id=*) cpdef void cancel_orders(self, list orders, ClientId client_id=*) diff --git a/nautilus_trader/trading/strategy.pyx b/nautilus_trader/trading/strategy.pyx index e8b1ed48e5d0..d64fc50726c1 100644 --- a/nautilus_trader/trading/strategy.pyx +++ b/nautilus_trader/trading/strategy.pyx @@ -151,7 +151,7 @@ cdef class Strategy(Actor): self.config = config self.oms_type = oms_type_from_str(str(config.oms_type).upper()) if config.oms_type else OmsType.UNSPECIFIED self.external_order_claims = self._parse_external_order_claims(config.external_order_claims) - self.manage_contingencies = config.manage_contingencies + self.manage_contingent_orders = config.manage_contingent_orders self.manage_gtd_expiry = config.manage_gtd_expiry # Public components @@ -160,6 +160,9 @@ cdef class Strategy(Actor): self.portfolio = None # Initialized when registered self.order_factory = None # Initialized when registered + # Order management + self._manager = None # Initialized when registered + # Register warning events self.register_warning_event(OrderDenied) self.register_warning_event(OrderRejected) @@ -277,8 +280,21 @@ cdef class Strategy(Actor): self.order_factory = OrderFactory( trader_id=self.trader_id, strategy_id=self.id, - clock=self.clock, - cache=self.cache, + clock=clock, + cache=cache, + ) + + self._manager = OrderManager( + clock=clock, + logger=logger, + msgbus=msgbus, + cache=cache, + component_name=type(self).__name__, + active_local=False, + submit_order_handler=None, + cancel_order_handler=self.cancel_order, + modify_order_handler=self.modify_order, + debug=True, # Set True for debugging ) # Required subscriptions @@ -367,13 +383,14 @@ cdef class Strategy(Actor): if self.order_factory: self.order_factory.reset() - self._pending_requests.clear() - self._indicators.clear() self._indicators_for_quotes.clear() self._indicators_for_trades.clear() self._indicators_for_bars.clear() + if self._manager: + self._manager.reset() + self.on_reset() # -- ABSTRACT METHODS ----------------------------------------------------------------------------- @@ -897,7 +914,6 @@ cdef class Strategy(Actor): Price price = None, Price trigger_price = None, ClientId client_id = None, - bint batch_more = False, ): """ Modify the given order with optional parameters and routing instructions. @@ -925,11 +941,6 @@ cdef class Strategy(Actor): client_id : ClientId, optional The specific client ID for the command. If ``None`` then will be inferred from the venue in the instrument ID. - batch_more : bool, default False - Indicates if this command should be batched (grouped) with subsequent modify order - commands for the venue. When set to `True`, we expect more calls to `modify_order` - which will add to the current batch. Final processing of the batch occurs on a call - with `batch_more=False`. For proper behavior, maintain the correct call sequence. Raises ------ @@ -943,10 +954,6 @@ cdef class Strategy(Actor): If the order is already closed or at `PENDING_CANCEL` status then the command will not be generated, and a warning will be logged. - The `batch_more` flag is an advanced feature which may have unintended consequences if not - called in the correct sequence. If a series of `batch_more=True` calls are not followed by - a `batch_more=False`, then no command will be sent from the strategy. - References ---------- https://www.onixs.biz/fix-dictionary/5.0.SP2/msgType_G_71.html @@ -955,10 +962,6 @@ cdef class Strategy(Actor): Condition.true(self.trader_id is not None, "The strategy has not been registered") Condition.not_none(order, "order") - if batch_more: - self._log.error("The `batch_more` feature is not currently implemented.") - return - cdef ModifyOrder command = self._create_modify_order( order=order, quantity=quantity, @@ -1524,6 +1527,12 @@ cdef class Strategy(Actor): if self._fsm.state != ComponentState.RUNNING: 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) + try: # Send to specific event handler if isinstance(event, OrderInitialized): diff --git a/tests/unit_tests/backtest/test_config.py b/tests/unit_tests/backtest/test_config.py index 030cb35e31e6..a3fdad122cb9 100644 --- a/tests/unit_tests/backtest/test_config.py +++ b/tests/unit_tests/backtest/test_config.py @@ -197,7 +197,7 @@ def test_run_config_to_json(self) -> None: ) json = msgspec.json.encode(run_config) result = len(msgspec.json.encode(json)) - assert result == 986 # UNIX + assert result == 1030 # UNIX @pytest.mark.skipif(sys.platform == "win32", reason="redundant to also test Windows") def test_run_config_parse_obj(self) -> None: @@ -218,7 +218,7 @@ def test_run_config_parse_obj(self) -> None: assert isinstance(config, BacktestRunConfig) node = BacktestNode(configs=[config]) assert isinstance(node, BacktestNode) - assert len(raw) == 737 # UNIX + assert len(raw) == 770 # UNIX @pytest.mark.skipif(sys.platform == "win32", reason="redundant to also test Windows") def test_backtest_data_config_to_dict(self) -> None: @@ -239,7 +239,7 @@ def test_backtest_data_config_to_dict(self) -> None: ) json = msgspec.json.encode(run_config) result = len(msgspec.json.encode(json)) - assert result == 1798 + assert result == 1842 @pytest.mark.skipif(sys.platform == "win32", reason="redundant to also test Windows") def test_backtest_run_config_id(self) -> None: @@ -247,7 +247,7 @@ def test_backtest_run_config_id(self) -> None: print("token:", token) value: bytes = msgspec.json.encode(self.backtest_config.dict(), enc_hook=json_encoder) print("token_value:", value.decode()) - assert token == "d1add7c871b0bdd762b495345e394276431eda714a00d839037df33e8a427fd1" # UNIX + assert token == "1e0c0ddaf6d9a53a1885b1a78102dfcb62d0418374472b091a36899fc25c9004" # UNIX @pytest.mark.skip(reason="fix after merge") @pytest.mark.parametrize( diff --git a/tests/unit_tests/trading/test_strategy.py b/tests/unit_tests/trading/test_strategy.py index 9914ddbd76bb..b88944361b95 100644 --- a/tests/unit_tests/trading/test_strategy.py +++ b/tests/unit_tests/trading/test_strategy.py @@ -40,6 +40,7 @@ from nautilus_trader.model.currencies import USD from nautilus_trader.model.data import Bar from nautilus_trader.model.enums import AccountType +from nautilus_trader.model.enums import ContingencyType from nautilus_trader.model.enums import OmsType from nautilus_trader.model.enums import OrderSide from nautilus_trader.model.enums import OrderStatus @@ -75,7 +76,7 @@ class TestStrategy: - def setup(self): + def setup(self) -> None: # Fixture Setup self.clock = TestClock() self.logger = Logger( @@ -139,6 +140,8 @@ def setup(self): clock=self.clock, logger=self.logger, latency_model=LatencyModel(0), + support_contingent_orders=False, + use_reduce_only=False, ) self.data_client = BacktestMarketDataClient( @@ -183,7 +186,7 @@ def setup(self): self.data_engine.start() self.exec_engine.start() - def test_strategy_to_importable_config_with_no_specific_config(self): + def test_strategy_to_importable_config_with_no_specific_config(self) -> None: # Arrange config = StrategyConfig() @@ -201,17 +204,17 @@ def test_strategy_to_importable_config_with_no_specific_config(self): "order_id_tag": None, "strategy_id": None, "external_order_claims": None, - "manage_contingencies": False, + "manage_contingent_orders": False, "manage_gtd_expiry": False, } - def test_strategy_to_importable_config(self): + def test_strategy_to_importable_config(self) -> None: # Arrange config = StrategyConfig( order_id_tag="001", strategy_id="ALPHA-01", external_order_claims=["ETHUSDT-PERP.DYDX"], - manage_contingencies=True, + manage_contingent_orders=True, manage_gtd_expiry=True, ) @@ -229,11 +232,11 @@ def test_strategy_to_importable_config(self): "order_id_tag": "001", "strategy_id": "ALPHA-01", "external_order_claims": ["ETHUSDT-PERP.DYDX"], - "manage_contingencies": True, + "manage_contingent_orders": True, "manage_gtd_expiry": True, } - def test_strategy_equality(self): + def test_strategy_equality(self) -> None: # Arrange strategy1 = Strategy(config=StrategyConfig(order_id_tag="AUD/USD-001")) strategy2 = Strategy(config=StrategyConfig(order_id_tag="AUD/USD-001")) @@ -244,7 +247,7 @@ def test_strategy_equality(self): assert strategy1 == strategy2 assert strategy2 != strategy3 - def test_str_and_repr(self): + def test_str_and_repr(self) -> None: # Arrange strategy = Strategy(config=StrategyConfig(order_id_tag="GBP/USD-MM")) @@ -252,14 +255,14 @@ def test_str_and_repr(self): assert str(strategy) == "Strategy-GBP/USD-MM" assert repr(strategy) == "Strategy(Strategy-GBP/USD-MM)" - def test_id(self): + def test_id(self) -> None: # Arrange strategy = Strategy() # Act, Assert assert strategy.id == StrategyId("Strategy-None") - def test_initialization(self): + def test_initialization(self) -> None: # Arrange strategy = Strategy(config=StrategyConfig(order_id_tag="001")) strategy.register( @@ -275,7 +278,7 @@ def test_initialization(self): assert strategy.state == ComponentState.READY assert not strategy.indicators_initialized() - def test_on_save_when_not_overridden_does_nothing(self): + def test_on_save_when_not_overridden_does_nothing(self) -> None: # Arrange strategy = Strategy() strategy.register( @@ -293,7 +296,7 @@ def test_on_save_when_not_overridden_does_nothing(self): # Assert assert True # Exception not raised - def test_on_load_when_not_overridden_does_nothing(self): + def test_on_load_when_not_overridden_does_nothing(self) -> None: # Arrange strategy = Strategy() strategy.register( @@ -311,7 +314,7 @@ def test_on_load_when_not_overridden_does_nothing(self): # Assert assert True # Exception not raised - def test_save_when_not_registered_logs_error(self): + def test_save_when_not_registered_logs_error(self) -> None: # Arrange config = StrategyConfig() @@ -329,7 +332,7 @@ def test_save_when_not_registered_logs_error(self): # Assert assert True # Exception not raised - def test_save_when_user_code_raises_error_logs_and_reraises(self): + def test_save_when_user_code_raises_error_logs_and_reraises(self) -> None: # Arrange strategy = KaboomStrategy() strategy.register( @@ -345,7 +348,7 @@ def test_save_when_user_code_raises_error_logs_and_reraises(self): with pytest.raises(RuntimeError): strategy.save() - def test_load_when_user_code_raises_error_logs_and_reraises(self): + def test_load_when_user_code_raises_error_logs_and_reraises(self) -> None: # Arrange strategy = KaboomStrategy() strategy.register( @@ -361,7 +364,7 @@ def test_load_when_user_code_raises_error_logs_and_reraises(self): with pytest.raises(RuntimeError): strategy.load({"something": b"123456"}) - def test_load(self): + def test_load(self) -> None: # Arrange strategy = Strategy() strategy.register( @@ -373,7 +376,7 @@ def test_load(self): logger=self.logger, ) - state = {} + state: dict[str, bytes] = {} # Act strategy.load(state) @@ -382,7 +385,7 @@ def test_load(self): # TODO: Write a users custom save method assert True - def test_reset(self): + def test_reset(self) -> None: # Arrange bar_type = TestDataStubs.bartype_audusd_1min_bid() strategy = MockStrategy(bar_type) @@ -417,7 +420,7 @@ def test_reset(self): assert strategy.ema1.count == 0 assert strategy.ema2.count == 0 - def test_dispose(self): + def test_dispose(self) -> None: # Arrange bar_type = TestDataStubs.bartype_audusd_1min_bid() strategy = MockStrategy(bar_type) @@ -439,7 +442,7 @@ def test_dispose(self): assert "on_dispose" in strategy.calls assert strategy.is_disposed - def test_save_load(self): + def test_save_load(self) -> None: # Arrange bar_type = TestDataStubs.bartype_audusd_1min_bid() strategy = MockStrategy(bar_type) @@ -461,7 +464,7 @@ def test_save_load(self): assert "on_save" in strategy.calls assert strategy.is_initialized - def test_register_indicator_for_quote_ticks_when_already_registered(self): + def test_register_indicator_for_quote_ticks_when_already_registered(self) -> None: # Arrange strategy = Strategy() strategy.register( @@ -485,7 +488,7 @@ def test_register_indicator_for_quote_ticks_when_already_registered(self): assert ema1 in strategy.registered_indicators assert ema2 in strategy.registered_indicators - def test_register_indicator_for_trade_ticks_when_already_registered(self): + def test_register_indicator_for_trade_ticks_when_already_registered(self) -> None: # Arrange strategy = Strategy() strategy.register( @@ -509,7 +512,7 @@ def test_register_indicator_for_trade_ticks_when_already_registered(self): assert ema1 in strategy.registered_indicators assert ema2 in strategy.registered_indicators - def test_register_indicator_for_bars_when_already_registered(self): + def test_register_indicator_for_bars_when_already_registered(self) -> None: # Arrange strategy = Strategy() strategy.register( @@ -534,7 +537,7 @@ def test_register_indicator_for_bars_when_already_registered(self): assert ema1 in strategy.registered_indicators assert ema2 in strategy.registered_indicators - def test_register_indicator_for_multiple_data_sources(self): + def test_register_indicator_for_multiple_data_sources(self) -> None: # Arrange strategy = Strategy() strategy.register( @@ -558,7 +561,7 @@ def test_register_indicator_for_multiple_data_sources(self): assert len(strategy.registered_indicators) == 1 assert ema in strategy.registered_indicators - def test_handle_quote_tick_updates_indicator_registered_for_quote_ticks(self): + def test_handle_quote_tick_updates_indicator_registered_for_quote_ticks(self) -> None: # Arrange strategy = Strategy() strategy.register( @@ -582,7 +585,7 @@ def test_handle_quote_tick_updates_indicator_registered_for_quote_ticks(self): # Assert assert ema.count == 2 - def test_handle_quote_ticks_with_no_ticks_logs_and_continues(self): + def test_handle_quote_ticks_with_no_ticks_logs_and_continues(self) -> None: # Arrange strategy = KaboomStrategy() strategy.register( @@ -603,7 +606,7 @@ def test_handle_quote_ticks_with_no_ticks_logs_and_continues(self): # Assert assert ema.count == 0 - def test_handle_quote_ticks_updates_indicator_registered_for_quote_ticks(self): + def test_handle_quote_ticks_updates_indicator_registered_for_quote_ticks(self) -> None: # Arrange strategy = Strategy() strategy.register( @@ -626,7 +629,7 @@ def test_handle_quote_ticks_updates_indicator_registered_for_quote_ticks(self): # Assert assert ema.count == 1 - def test_handle_trade_tick_updates_indicator_registered_for_trade_ticks(self): + def test_handle_trade_tick_updates_indicator_registered_for_trade_ticks(self) -> None: # Arrange strategy = Strategy() strategy.register( @@ -650,7 +653,7 @@ def test_handle_trade_tick_updates_indicator_registered_for_trade_ticks(self): # Assert assert ema.count == 2 - def test_handle_trade_ticks_updates_indicator_registered_for_trade_ticks(self): + def test_handle_trade_ticks_updates_indicator_registered_for_trade_ticks(self) -> None: # Arrange strategy = Strategy() strategy.register( @@ -673,7 +676,7 @@ def test_handle_trade_ticks_updates_indicator_registered_for_trade_ticks(self): # Assert assert ema.count == 1 - def test_handle_trade_ticks_with_no_ticks_logs_and_continues(self): + def test_handle_trade_ticks_with_no_ticks_logs_and_continues(self) -> None: # Arrange strategy = Strategy() strategy.register( @@ -694,7 +697,7 @@ def test_handle_trade_ticks_with_no_ticks_logs_and_continues(self): # Assert assert ema.count == 0 - def test_handle_bar_updates_indicator_registered_for_bars(self): + def test_handle_bar_updates_indicator_registered_for_bars(self) -> None: # Arrange bar_type = TestDataStubs.bartype_audusd_1min_bid() strategy = Strategy() @@ -718,7 +721,7 @@ def test_handle_bar_updates_indicator_registered_for_bars(self): # Assert assert ema.count == 2 - def test_handle_bars_updates_indicator_registered_for_bars(self): + def test_handle_bars_updates_indicator_registered_for_bars(self) -> None: # Arrange bar_type = TestDataStubs.bartype_audusd_1min_bid() strategy = Strategy() @@ -741,7 +744,7 @@ def test_handle_bars_updates_indicator_registered_for_bars(self): # Assert assert ema.count == 1 - def test_handle_bars_with_no_bars_logs_and_continues(self): + def test_handle_bars_with_no_bars_logs_and_continues(self) -> None: # Arrange bar_type = TestDataStubs.bartype_gbpusd_1sec_mid() strategy = Strategy() @@ -763,7 +766,7 @@ def test_handle_bars_with_no_bars_logs_and_continues(self): # Assert assert ema.count == 0 - def test_stop_cancels_a_running_time_alert(self): + def test_stop_cancels_a_running_time_alert(self) -> None: # Arrange bar_type = TestDataStubs.bartype_audusd_1min_bid() strategy = MockStrategy(bar_type) @@ -786,7 +789,7 @@ def test_stop_cancels_a_running_time_alert(self): # Assert assert strategy.clock.timer_count == 0 - def test_stop_cancels_a_running_timer(self): + def test_stop_cancels_a_running_timer(self) -> None: # Arrange bar_type = TestDataStubs.bartype_audusd_1min_bid() strategy = MockStrategy(bar_type) @@ -814,7 +817,7 @@ def test_stop_cancels_a_running_timer(self): # Assert assert strategy.clock.timer_count == 0 - def test_start_when_manage_gtd_reactivates_timers(self): + def test_start_when_manage_gtd_reactivates_timers(self) -> None: # Arrange config = StrategyConfig(manage_gtd_expiry=True) strategy = Strategy(config) @@ -859,7 +862,7 @@ def test_start_when_manage_gtd_reactivates_timers(self): "GTD-EXPIRY:O-19700101-0000-000-None-2", ] - def test_start_when_manage_gtd_and_order_past_expiration_then_cancels(self): + def test_start_when_manage_gtd_and_order_past_expiration_then_cancels(self) -> None: # Arrange config = StrategyConfig(manage_gtd_expiry=True) strategy = Strategy(config) @@ -894,7 +897,7 @@ def test_start_when_manage_gtd_and_order_past_expiration_then_cancels(self): assert strategy.clock.timer_count == 0 assert order1.is_pending_cancel - def test_submit_order_when_duplicate_id_then_denies(self): + def test_submit_order_when_duplicate_id_then_denies(self) -> None: # Arrange strategy = Strategy() strategy.register( @@ -931,7 +934,7 @@ def test_submit_order_when_duplicate_id_then_denies(self): # Assert assert order2.status == OrderStatus.DENIED - def test_submit_order_with_valid_order_successfully_submits(self): + def test_submit_order_with_valid_order_successfully_submits(self) -> None: # Arrange strategy = Strategy() strategy.register( @@ -960,7 +963,7 @@ def test_submit_order_with_valid_order_successfully_submits(self): assert not strategy.cache.is_order_open(order.client_order_id) assert strategy.cache.is_order_closed(order.client_order_id) - def test_submit_order_with_managed_gtd_starts_timer(self): + def test_submit_order_with_managed_gtd_starts_timer(self) -> None: # Arrange config = StrategyConfig(manage_gtd_expiry=True) strategy = Strategy(config) @@ -989,7 +992,7 @@ def test_submit_order_with_managed_gtd_starts_timer(self): assert strategy.clock.timer_count == 1 assert strategy.clock.timer_names == ["GTD-EXPIRY:O-19700101-0000-000-None-1"] - def test_submit_order_with_managed_gtd_when_immediately_filled_cancels_timer(self): + def test_submit_order_with_managed_gtd_when_immediately_filled_cancels_timer(self) -> None: # Arrange config = StrategyConfig(manage_gtd_expiry=True) strategy = Strategy(config) @@ -1019,7 +1022,7 @@ def test_submit_order_with_managed_gtd_when_immediately_filled_cancels_timer(sel assert strategy.clock.timer_count == 0 assert order.status == OrderStatus.FILLED - def test_submit_order_list_with_duplicate_order_list_id_then_denies(self): + def test_submit_order_list_with_duplicate_order_list_id_then_denies(self) -> None: # Arrange strategy = Strategy() strategy.register( @@ -1083,7 +1086,7 @@ def test_submit_order_list_with_duplicate_order_list_id_then_denies(self): assert stop_loss.status == OrderStatus.DENIED assert take_profit.status == OrderStatus.DENIED - def test_submit_order_list_with_duplicate_order_id_then_denies(self): + def test_submit_order_list_with_duplicate_order_id_then_denies(self) -> None: # Arrange strategy = Strategy() strategy.register( @@ -1140,7 +1143,7 @@ def test_submit_order_list_with_duplicate_order_id_then_denies(self): assert stop_loss.status == OrderStatus.DENIED assert take_profit.status == OrderStatus.DENIED - def test_submit_order_list_with_valid_order_successfully_submits(self): + def test_submit_order_list_with_valid_order_successfully_submits(self) -> None: # Arrange strategy = Strategy() strategy.register( @@ -1174,7 +1177,7 @@ def test_submit_order_list_with_valid_order_successfully_submits(self): assert entry.status == OrderStatus.ACCEPTED assert entry in strategy.cache.orders_open() - def test_submit_order_list_with_managed_gtd_starts_timer(self): + def test_submit_order_list_with_managed_gtd_starts_timer(self) -> None: # Arrange config = StrategyConfig(manage_gtd_expiry=True) strategy = Strategy(config) @@ -1207,7 +1210,7 @@ def test_submit_order_list_with_managed_gtd_starts_timer(self): assert strategy.clock.timer_count == 1 assert strategy.clock.timer_names == ["GTD-EXPIRY:O-19700101-0000-000-None-1"] - def test_submit_order_list_with_managed_gtd_when_immediately_filled_cancels_timer(self): + def test_submit_order_list_with_managed_gtd_when_immediately_filled_cancels_timer(self) -> None: # Arrange config = StrategyConfig(manage_gtd_expiry=True) strategy = Strategy(config) @@ -1242,7 +1245,7 @@ def test_submit_order_list_with_managed_gtd_when_immediately_filled_cancels_time assert bracket.orders[1].status == OrderStatus.ACCEPTED assert bracket.orders[2].status == OrderStatus.ACCEPTED - def test_cancel_gtd_expiry(self): + def test_cancel_gtd_expiry(self) -> None: # Arrange config = StrategyConfig(manage_gtd_expiry=True) strategy = Strategy(config) @@ -1272,7 +1275,7 @@ def test_cancel_gtd_expiry(self): # Assert assert strategy.clock.timer_count == 0 - def test_cancel_order(self): + def test_cancel_order(self) -> None: # Arrange strategy = Strategy() strategy.register( @@ -1307,7 +1310,7 @@ def test_cancel_order(self): assert not strategy.cache.is_order_open(order.client_order_id) assert strategy.cache.is_order_closed(order.client_order_id) - def test_cancel_order_when_pending_cancel_does_not_submit_command(self): + def test_cancel_order_when_pending_cancel_does_not_submit_command(self) -> None: # Arrange strategy = Strategy() strategy.register( @@ -1341,7 +1344,7 @@ def test_cancel_order_when_pending_cancel_does_not_submit_command(self): assert strategy.cache.is_order_open(order.client_order_id) assert not strategy.cache.is_order_closed(order.client_order_id) - def test_cancel_order_when_closed_does_not_submit_command(self): + def test_cancel_order_when_closed_does_not_submit_command(self) -> None: # Arrange strategy = Strategy() strategy.register( @@ -1375,7 +1378,7 @@ def test_cancel_order_when_closed_does_not_submit_command(self): assert not strategy.cache.is_order_open(order.client_order_id) assert strategy.cache.is_order_closed(order.client_order_id) - def test_modify_order_when_pending_cancel_does_not_submit_command(self): + def test_modify_order_when_pending_cancel_does_not_submit_command(self) -> None: # Arrange strategy = Strategy() strategy.register( @@ -1409,7 +1412,7 @@ def test_modify_order_when_pending_cancel_does_not_submit_command(self): # Assert assert self.exec_engine.command_count == 1 - def test_modify_order_when_closed_does_not_submit_command(self): + def test_modify_order_when_closed_does_not_submit_command(self) -> None: # Arrange strategy = Strategy() strategy.register( @@ -1443,7 +1446,7 @@ def test_modify_order_when_closed_does_not_submit_command(self): # Assert assert self.exec_engine.command_count == 1 - def test_modify_order_when_no_changes_does_not_submit_command(self): + def test_modify_order_when_no_changes_does_not_submit_command(self) -> None: # Arrange strategy = Strategy() strategy.register( @@ -1474,7 +1477,7 @@ def test_modify_order_when_no_changes_does_not_submit_command(self): # Assert assert self.exec_engine.command_count == 1 - def test_modify_order(self): + def test_modify_order(self) -> None: # Arrange strategy = Strategy() strategy.register( @@ -1514,7 +1517,7 @@ def test_modify_order(self): assert not strategy.cache.is_order_closed(order.client_order_id) assert strategy.portfolio.is_flat(order.instrument_id) - def test_cancel_orders(self): + def test_cancel_orders(self) -> None: # Arrange strategy = Strategy() strategy.register( @@ -1552,7 +1555,7 @@ def test_cancel_orders(self): # Assert # TODO: WIP! - def test_cancel_all_orders(self): + def test_cancel_all_orders(self) -> None: # Arrange strategy = Strategy() strategy.register( @@ -1595,7 +1598,7 @@ def test_cancel_all_orders(self): assert order1 in self.cache.orders_closed() assert order2 in strategy.cache.orders_closed() - def test_close_position_when_position_already_closed_does_nothing(self): + def test_close_position_when_position_already_closed_does_nothing(self) -> None: # Arrange strategy = Strategy() strategy.register( @@ -1635,7 +1638,7 @@ def test_close_position_when_position_already_closed_does_nothing(self): # Assert assert strategy.portfolio.is_completely_flat() - def test_close_position(self): + def test_close_position(self) -> None: # Arrange strategy = Strategy() strategy.register( @@ -1670,7 +1673,7 @@ def test_close_position(self): if order.side == OrderSide.SELL: assert order.tags == "EXIT" - def test_close_all_positions(self): + def test_close_all_positions(self) -> None: # Arrange strategy = Strategy() strategy.register( @@ -1681,8 +1684,6 @@ def test_close_all_positions(self): clock=self.clock, logger=self.logger, ) - - # Start strategy and submit orders to open positions strategy.start() order1 = strategy.order_factory.market( @@ -1714,3 +1715,145 @@ def test_close_all_positions(self): for order in orders: if order.side == OrderSide.SELL: assert order.tags == "EXIT" + + @pytest.mark.parametrize( + ("contingency_type"), + [ + ContingencyType.OCO, + ContingencyType.OUO, + ], + ) + def test_managed_contingenies_when_canceled_entry_then_cancels_oto_orders( + self, + contingency_type: ContingencyType, + ) -> None: + # Arrange + 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() + + bracket = strategy.order_factory.bracket( + USDJPY_SIM.id, + OrderSide.BUY, + Quantity.from_int(100_000), + entry_price=Price.from_str("80.000"), + sl_trigger_price=Price.from_str("90.000"), + tp_price=Price.from_str("90.500"), + entry_order_type=OrderType.LIMIT, + contingency_type=contingency_type, + ) + + strategy.submit_order_list(bracket) + self.exchange.process(0) + + # Act + strategy.cancel_order(bracket.first) + self.exchange.process(0) + + # Assert + assert bracket.orders[0].status == OrderStatus.CANCELED + assert bracket.orders[1].status == OrderStatus.PENDING_CANCEL + assert bracket.orders[2].status == OrderStatus.PENDING_CANCEL + + @pytest.mark.parametrize( + ("contingency_type"), + [ + ContingencyType.OCO, + ContingencyType.OUO, + ], + ) + def test_managed_contingenies_when_canceled_bracket_then_cancels_contingent_order( + self, + contingency_type: ContingencyType, + ) -> None: + # Arrange + 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() + + bracket = strategy.order_factory.bracket( + USDJPY_SIM.id, + OrderSide.BUY, + Quantity.from_int(100_000), + sl_trigger_price=Price.from_str("90.000"), + tp_price=Price.from_str("90.500"), + entry_order_type=OrderType.MARKET, + contingency_type=contingency_type, + ) + + strategy.submit_order_list(bracket) + self.exchange.process(0) + + # Act + strategy.cancel_order(bracket.orders[1]) + self.exchange.process(0) + + # Assert + assert bracket.orders[0].status == OrderStatus.FILLED + assert bracket.orders[1].status == OrderStatus.CANCELED + assert bracket.orders[2].status == OrderStatus.PENDING_CANCEL + + def test_managed_contingenies_when_modify_bracket_then_modifies_ouo_order( + self, + ) -> None: + # Arrange + 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() + + bracket = strategy.order_factory.bracket( + USDJPY_SIM.id, + OrderSide.BUY, + Quantity.from_int(100_000), + sl_trigger_price=Price.from_str("90.000"), + tp_price=Price.from_str("90.500"), + entry_order_type=OrderType.MARKET, + contingency_type=ContingencyType.OUO, + ) + + strategy.submit_order_list(bracket) + self.exchange.process(0) + + # Act + new_quantity = Quantity.from_int(50_000) + strategy.modify_order(bracket.orders[1], new_quantity) + self.exchange.process(0) + + # Assert + assert bracket.orders[0].status == OrderStatus.FILLED + assert bracket.orders[1].status == OrderStatus.ACCEPTED + assert bracket.orders[2].status == OrderStatus.PENDING_UPDATE + assert bracket.orders[1].quantity == new_quantity