From fafcc3091e5025e21e1af375939f6ed48650b20f Mon Sep 17 00:00:00 2001 From: Filip Macek Date: Thu, 23 Jan 2025 09:31:36 +0100 Subject: [PATCH] Port process cancel for OrderMatchingEngine in Rust (#2231) --- nautilus_core/backtest/src/exchange.rs | 37 +++++- .../backtest/src/matching_engine/mod.rs | 40 +++++- .../backtest/src/matching_engine/tests.rs | 125 +++++++++++++++++- nautilus_core/execution/src/matching_core.rs | 12 ++ nautilus_core/model/src/orders/any.rs | 60 +++++++++ 5 files changed, 270 insertions(+), 4 deletions(-) diff --git a/nautilus_core/backtest/src/exchange.rs b/nautilus_core/backtest/src/exchange.rs index f812b4038a1d..a79cfe709776 100644 --- a/nautilus_core/backtest/src/exchange.rs +++ b/nautilus_core/backtest/src/exchange.rs @@ -495,8 +495,41 @@ impl SimulatedExchange { todo!("reset") } - pub fn process_trading_command(&mut self, _command: TradingCommand) { - todo!("process trading command") + pub fn process_trading_command(&mut self, command: TradingCommand) { + if let Some(matching_engine) = self.matching_engines.get_mut(&command.instrument_id()) { + let account_id = if let Some(exec_client) = &self.exec_client { + exec_client.account_id + } else { + panic!("Execution client should be initialized"); + }; + match command { + TradingCommand::SubmitOrder(mut command) => { + matching_engine.process_order(&mut command.order, account_id) + } + TradingCommand::ModifyOrder(ref command) => { + matching_engine.process_modify(command, account_id) + } + TradingCommand::CancelOrder(ref command) => { + matching_engine.process_cancel(command, account_id) + } + TradingCommand::CancelAllOrders(ref command) => { + matching_engine.process_cancel_all(command, account_id) + } + TradingCommand::BatchCancelOrders(ref command) => { + matching_engine.process_batch_cancel(command, account_id) + } + TradingCommand::QueryOrder(ref command) => { + matching_engine.process_query_order(command, account_id) + } + TradingCommand::SubmitOrderList(mut command) => { + for order in &mut command.order_list.orders { + matching_engine.process_order(order, account_id); + } + } + } + } else { + panic!("Matching engine should be initialized"); + } } pub fn generate_fresh_account_state(&self) { diff --git a/nautilus_core/backtest/src/matching_engine/mod.rs b/nautilus_core/backtest/src/matching_engine/mod.rs index 1744516e0df9..2d320f0ab3be 100644 --- a/nautilus_core/backtest/src/matching_engine/mod.rs +++ b/nautilus_core/backtest/src/matching_engine/mod.rs @@ -35,7 +35,10 @@ use std::{ use chrono::TimeDelta; use nautilus_common::{cache::Cache, msgbus::MessageBus}; use nautilus_core::{AtomicTime, UnixNanos, UUID4}; -use nautilus_execution::matching_core::OrderMatchingCore; +use nautilus_execution::{ + matching_core::OrderMatchingCore, + messages::{BatchCancelOrders, CancelAllOrders, CancelOrder, ModifyOrder, QueryOrder}, +}; use nautilus_model::{ data::{order::BookOrder, Bar, BarType, OrderBookDelta, OrderBookDeltas, QuoteTick, TradeTick}, enums::{ @@ -707,6 +710,41 @@ impl OrderMatchingEngine { } } + pub fn process_modify(&self, command: &ModifyOrder, account_id: AccountId) { + todo!("implement process_modify") + } + + pub fn process_cancel(&mut self, command: &CancelOrder, account_id: AccountId) { + match self.core.get_order(command.client_order_id) { + Some(passive_order) => { + if passive_order.is_inflight() || passive_order.is_open() { + self.cancel_order(&OrderAny::from(passive_order.to_owned()), None); + } + } + None => self.generate_order_cancel_rejected( + command.trader_id, + command.strategy_id, + account_id, + command.instrument_id, + command.client_order_id, + command.venue_order_id, + Ustr::from(format!("Order {} not found", command.client_order_id).as_str()), + ), + } + } + + pub fn process_cancel_all(&self, command: &CancelAllOrders, account_id: AccountId) { + todo!("implement process_cancel_all") + } + + pub fn process_batch_cancel(&self, command: &BatchCancelOrders, account_id: AccountId) { + todo!("implement process_batch_cancel") + } + + pub fn process_query_order(&self, command: &QueryOrder, account_id: AccountId) { + todo!("implement process_query_order") + } + fn process_market_order(&mut self, order: &mut OrderAny) { if order.time_in_force() == TimeInForce::AtTheOpen || order.time_in_force() == TimeInForce::AtTheClose diff --git a/nautilus_core/backtest/src/matching_engine/tests.rs b/nautilus_core/backtest/src/matching_engine/tests.rs index ba6d8fecfcc1..6f74efb3e7e7 100644 --- a/nautilus_core/backtest/src/matching_engine/tests.rs +++ b/nautilus_core/backtest/src/matching_engine/tests.rs @@ -27,6 +27,7 @@ use nautilus_common::{ }, }; use nautilus_core::{AtomicTime, UnixNanos, UUID4}; +use nautilus_execution::messages::CancelOrder; use nautilus_model::{ data::{BookOrder, OrderBookDelta}, enums::{ @@ -37,7 +38,10 @@ use nautilus_model::{ order::rejected::OrderRejectedBuilder, OrderEventAny, OrderEventType, OrderFilled, OrderRejected, }, - identifiers::{stubs::account_id, AccountId, ClientOrderId, PositionId, TradeId, VenueOrderId}, + identifiers::{ + stubs::account_id, AccountId, ClientId, ClientOrderId, PositionId, StrategyId, TradeId, + TraderId, VenueOrderId, + }, instruments::{ stubs::{crypto_perpetual_ethusdt, equity_aapl, futures_contract_es}, CryptoPerpetual, Equity, InstrumentAny, @@ -1319,3 +1323,122 @@ fn test_process_stop_limit_order_triggered_filled( assert_eq!(order_filled.last_px, Price::from("1500.00")); assert_eq!(order_filled.last_qty, Quantity::from("1.000")); } + +#[rstest] +fn test_process_cancel_command_valid( + instrument_eth_usdt: InstrumentAny, + orderbook_delta_sell: OrderBookDelta, + mut msgbus: MessageBus, + order_event_handler: ShareableMessageHandler, + account_id: AccountId, + time: AtomicTime, +) { + msgbus.register( + msgbus.switchboard.exec_engine_process, + order_event_handler.clone(), + ); + // create normal l2 engine without reject_stop_orders config param + let mut engine_l2 = get_order_matching_engine_l2( + instrument_eth_usdt.clone(), + Rc::new(RefCell::new(msgbus)), + None, + None, + None, + ); + let client_order_id = ClientOrderId::from("O-19700101-000000-001-001-1"); + + // create BUY LIMIT order bellow current ask, so it wont be filled + let mut limit_order = OrderTestBuilder::new(OrderType::Limit) + .instrument_id(instrument_eth_usdt.id()) + .side(OrderSide::Buy) + .price(Price::from("1495.00")) + .quantity(Quantity::from("1.000")) + .client_order_id(client_order_id) + .build(); + // create cancel command for limit order above + let cancel_command = CancelOrder::new( + TraderId::from("TRADER-001"), + ClientId::from("CLIENT-001"), + StrategyId::from("STRATEGY-001"), + instrument_eth_usdt.id(), + client_order_id, + VenueOrderId::from("V1"), + UUID4::new(), + UnixNanos::default(), + ) + .unwrap(); + + engine_l2.process_order_book_delta(&orderbook_delta_sell); + engine_l2.process_order(&mut limit_order, account_id); + engine_l2.process_cancel(&cancel_command, account_id); + + // check we have received OrderAccepted and then OrderCanceled event + let saved_messages = get_order_event_handler_messages(order_event_handler); + assert_eq!(saved_messages.len(), 2); + let order_event_first = saved_messages.first().unwrap(); + let order_accepted = match order_event_first { + OrderEventAny::Accepted(order_accepted) => order_accepted, + _ => panic!("Expected OrderAccepted event in first message"), + }; + assert_eq!(order_accepted.client_order_id, client_order_id); + let order_event_second = saved_messages.get(1).unwrap(); + let order_canceled = match order_event_second { + OrderEventAny::Canceled(order_canceled) => order_canceled, + _ => panic!("Expected OrderCanceled event in second message"), + }; + assert_eq!(order_canceled.client_order_id, client_order_id); +} + +#[rstest] +fn test_process_cancel_command_order_not_found( + instrument_eth_usdt: InstrumentAny, + orderbook_delta_sell: OrderBookDelta, + mut msgbus: MessageBus, + order_event_handler: ShareableMessageHandler, + account_id: AccountId, + time: AtomicTime, +) { + msgbus.register( + msgbus.switchboard.exec_engine_process, + order_event_handler.clone(), + ); + // create normal l2 engine without reject_stop_orders config param + let mut engine_l2 = get_order_matching_engine_l2( + instrument_eth_usdt.clone(), + Rc::new(RefCell::new(msgbus)), + None, + None, + None, + ); + + let client_order_id = ClientOrderId::from("O-19700101-000000-001-001-1"); + let account_id = AccountId::from("ACCOUNT-001"); + let cancel_command = CancelOrder::new( + TraderId::from("TRADER-001"), + ClientId::from("CLIENT-001"), + StrategyId::from("STRATEGY-001"), + instrument_eth_usdt.id(), + client_order_id, + VenueOrderId::from("V1"), + UUID4::new(), + UnixNanos::default(), + ) + .unwrap(); + + // process cancel command for order which doesn't exists + engine_l2.process_cancel(&cancel_command, account_id); + + // check we have received OrderCancelRejected event + let saved_messages = get_order_event_handler_messages(order_event_handler); + assert_eq!(saved_messages.len(), 1); + let order_event = saved_messages.first().unwrap(); + let order_rejected = match order_event { + OrderEventAny::CancelRejected(order_rejected) => order_rejected, + _ => panic!("Expected OrderRejected event in first message"), + }; + assert_eq!(order_rejected.client_order_id, client_order_id); + assert_eq!( + order_rejected.reason, + Ustr::from(format!("Order {client_order_id} not found").as_str()) + ); +} diff --git a/nautilus_core/execution/src/matching_core.rs b/nautilus_core/execution/src/matching_core.rs index 99a0a2509123..dc5515fb3650 100644 --- a/nautilus_core/execution/src/matching_core.rs +++ b/nautilus_core/execution/src/matching_core.rs @@ -83,6 +83,18 @@ impl OrderMatchingCore { self.price_increment.precision } + #[must_use] + pub fn get_order(&self, client_order_id: ClientOrderId) -> Option<&PassiveOrderAny> { + self.orders_bid + .iter() + .find(|o| o.client_order_id() == client_order_id) + .or_else(|| { + self.orders_ask + .iter() + .find(|o| o.client_order_id() == client_order_id) + }) + } + #[must_use] pub fn get_orders_bid(&self) -> &[PassiveOrderAny] { self.orders_bid.as_slice() diff --git a/nautilus_core/model/src/orders/any.rs b/nautilus_core/model/src/orders/any.rs index c6d40e27336f..a666719dc10e 100644 --- a/nautilus_core/model/src/orders/any.rs +++ b/nautilus_core/model/src/orders/any.rs @@ -1247,6 +1247,22 @@ impl PassiveOrderAny { } } + #[must_use] + pub fn is_open(&self) -> bool { + match self { + Self::Limit(order) => order.is_open(), + Self::Stop(order) => order.is_open(), + } + } + + #[must_use] + pub fn is_inflight(&self) -> bool { + match self { + Self::Limit(order) => order.is_inflight(), + Self::Stop(order) => order.is_inflight(), + } + } + #[must_use] pub fn expire_time(&self) -> Option { match self { @@ -1314,6 +1330,26 @@ impl LimitOrderAny { } } + #[must_use] + pub fn is_open(&self) -> bool { + match self { + Self::Limit(order) => order.is_open(), + Self::MarketToLimit(order) => order.is_open(), + Self::StopLimit(order) => order.is_open(), + Self::TrailingStopLimit(order) => order.is_open(), + } + } + + #[must_use] + pub fn is_inflight(&self) -> bool { + match self { + Self::Limit(order) => order.is_inflight(), + Self::MarketToLimit(order) => order.is_inflight(), + Self::StopLimit(order) => order.is_inflight(), + Self::TrailingStopLimit(order) => order.is_inflight(), + } + } + #[must_use] pub fn expire_time(&self) -> Option { match self { @@ -1395,6 +1431,30 @@ impl StopOrderAny { } } + #[must_use] + pub fn is_open(&self) -> bool { + match self { + Self::LimitIfTouched(order) => order.is_open(), + Self::MarketIfTouched(order) => order.is_open(), + Self::StopLimit(order) => order.is_open(), + Self::StopMarket(order) => order.is_open(), + Self::TrailingStopLimit(order) => order.is_open(), + Self::TrailingStopMarket(order) => order.is_open(), + } + } + + #[must_use] + pub fn is_inflight(&self) -> bool { + match self { + Self::LimitIfTouched(order) => order.is_inflight(), + Self::MarketIfTouched(order) => order.is_inflight(), + Self::StopLimit(order) => order.is_inflight(), + Self::StopMarket(order) => order.is_inflight(), + Self::TrailingStopLimit(order) => order.is_inflight(), + Self::TrailingStopMarket(order) => order.is_inflight(), + } + } + #[must_use] pub fn expire_time(&self) -> Option { match self {