diff --git a/nautilus_core/backtest/src/matching_engine.rs b/nautilus_core/backtest/src/matching_engine.rs index a84e5d14e3a6..c9c0760227c1 100644 --- a/nautilus_core/backtest/src/matching_engine.rs +++ b/nautilus_core/backtest/src/matching_engine.rs @@ -19,7 +19,7 @@ #![allow(dead_code)] #![allow(unused_variables)] -use std::{any::Any, collections::HashMap, rc::Rc}; +use std::{any::Any, cell::RefCell, collections::HashMap, rc::Rc}; use nautilus_common::{cache::Cache, msgbus::MessageBus}; use nautilus_core::{nanos::UnixNanos, time::AtomicTime, uuid::UUID4}; @@ -29,7 +29,10 @@ use nautilus_model::{ bar::{Bar, BarType}, delta::OrderBookDelta, }, - enums::{AccountType, BookType, LiquiditySide, MarketStatus, OmsType, OrderSide, OrderType}, + enums::{ + AccountType, BookType, ContingencyType, LiquiditySide, MarketStatus, OmsType, OrderSide, + OrderStatus, OrderType, + }, events::order::{ OrderAccepted, OrderCancelRejected, OrderCanceled, OrderEventAny, OrderExpired, OrderFilled, OrderModifyRejected, OrderRejected, OrderTriggered, OrderUpdated, @@ -96,7 +99,7 @@ pub struct OrderMatchingEngine { pub config: OrderMatchingEngineConfig, clock: &'static AtomicTime, msgbus: Rc, - cache: Rc, + cache: Rc>, book: OrderBook, core: OrderMatchingCore, target_bid: Option, @@ -124,7 +127,7 @@ impl OrderMatchingEngine { account_type: AccountType, clock: &'static AtomicTime, msgbus: Rc, - cache: Rc, + cache: Rc>, config: OrderMatchingEngineConfig, ) -> Self { let book = OrderBook::new(book_type, instrument.id()); @@ -221,155 +224,192 @@ impl OrderMatchingEngine { // -- TRADING COMMANDS ---------------------------------------------------- #[allow(clippy::needless_return)] pub fn process_order(&mut self, order: &OrderAny, account_id: AccountId) { - if self.core.order_exists(order.client_order_id()) { - self.generate_order_rejected(order, "Order already exists".into()); - return; - } + // enter the scope where you will borrow a cache + { + let cache_borrow = self.cache.as_ref().borrow(); + + if self.core.order_exists(order.client_order_id()) { + self.generate_order_rejected(order, "Order already exists".into()); + return; + } + + // Index identifiers + self.account_ids.insert(order.trader_id(), account_id); + + // Check for instrument expiration or activation + if EXPIRING_INSTRUMENT_TYPES.contains(&self.instrument.instrument_class()) { + if let Some(activation_ns) = self.instrument.activation_ns() { + if self.clock.get_time_ns() < activation_ns { + self.generate_order_rejected( + order, + format!( + "Contract {} is not yet active, activation {}", + self.instrument.id(), + self.instrument.activation_ns().unwrap() + ) + .into(), + ); + return; + } + } + if let Some(expiration_ns) = self.instrument.expiration_ns() { + if self.clock.get_time_ns() >= expiration_ns { + self.generate_order_rejected( + order, + format!( + "Contract {} has expired, expiration {}", + self.instrument.id(), + self.instrument.expiration_ns().unwrap() + ) + .into(), + ); + return; + } + } + } + + // Contingent orders checks + if self.config.support_contingent_orders { + if let Some(parent_order_id) = order.parent_order_id() { + println!("Search for parent order {}", parent_order_id); + let parent_order = cache_borrow.order(&parent_order_id); + if parent_order.is_none() + || parent_order.unwrap().contingency_type().unwrap() != ContingencyType::Oto + { + panic!("OTO parent not found"); + } + if let Some(parent_order) = parent_order { + let parent_order_status = parent_order.status(); + let order_is_open = order.is_open(); + if parent_order.status() == OrderStatus::Rejected && order.is_open() { + self.generate_order_rejected( + order, + format!("Rejected OTO order from {}", parent_order_id).into(), + ); + return; + } else if parent_order.status() == OrderStatus::Accepted + && parent_order.status() == OrderStatus::Triggered + { + log::info!( + "Pending OTO order {} triggers from {}", + order.client_order_id(), + parent_order_id + ); + return; + } + } + } + } - // Index identifiers - self.account_ids.insert(order.trader_id(), account_id); + // Check fo valid order quantity precision + if order.quantity().precision != self.instrument.size_precision() { + self.generate_order_rejected( + order, + format!( + "Invalid order quantity precision for order {}, was {} when {} size precision is {}", + order.client_order_id(), + order.quantity().precision, + self.instrument.id(), + self.instrument.size_precision() + ) + .into(), + ); + return; + } - // Check for instrument expiration or activation - if EXPIRING_INSTRUMENT_TYPES.contains(&self.instrument.instrument_class()) { - if let Some(activation_ns) = self.instrument.activation_ns() { - if self.clock.get_time_ns() < activation_ns { + // Check for valid order price precision + if let Some(price) = order.price() { + if price.precision != self.instrument.price_precision() { self.generate_order_rejected( order, format!( - "Contract {} is not yet active, activation {}", + "Invalid order price precision for order {}, was {} when {} price precision is {}", + order.client_order_id(), + price.precision, self.instrument.id(), - self.instrument.activation_ns().unwrap() + self.instrument.price_precision() ) - .into(), + .into(), ); - return; } + return; } - if let Some(expiration_ns) = self.instrument.expiration_ns() { - if self.clock.get_time_ns() >= expiration_ns { + + // Check for valid order trigger price precision + if let Some(trigger_price) = order.trigger_price() { + if trigger_price.precision != self.instrument.price_precision() { self.generate_order_rejected( order, format!( - "Contract {} has expired, expiration {}", + "Invalid order trigger price precision for order {}, was {} when {} price precision is {}", + order.client_order_id(), + trigger_price.precision, self.instrument.id(), - self.instrument.expiration_ns().unwrap() + self.instrument.price_precision() ) - .into(), + .into(), ); return; } } - } - - // Check fo valid order quantity precision - if order.quantity().precision != self.instrument.size_precision() { - self.generate_order_rejected( - order, - format!( - "Invalid order quantity precision for order {}, was {} when {} size precision is {}", - order.client_order_id(), - order.quantity().precision, - self.instrument.id(), - self.instrument.size_precision() - ) - .into(), - ); - return; - } - // Check for valid order price precision - if let Some(price) = order.price() { - if price.precision != self.instrument.price_precision() { + // Get position if exists + let position: Option<&Position> = cache_borrow + .position_for_order(&order.client_order_id()) + .or_else(|| { + if self.oms_type == OmsType::Netting { + let position_id = PositionId::new( + format!("{}-{}", order.instrument_id(), order.strategy_id()).as_str(), + ) + .unwrap(); + cache_borrow.position(&position_id) + } else { + None + } + }); + + // Check not shorting an equity without a MARGIN account + if order.order_side() == OrderSide::Sell + && self.account_type != AccountType::Margin + && matches!(self.instrument, InstrumentAny::Equity(_)) + && (position.is_none() + || !order.would_reduce_only(position.unwrap().side, position.unwrap().quantity)) + { + let position_string = position.map_or("None".to_string(), |pos| pos.id.to_string()); self.generate_order_rejected( order, format!( - "Invalid order price precision for order {}, was {} when {} price precision is {}", - order.client_order_id(), - price.precision, - self.instrument.id(), - self.instrument.price_precision() + "Short selling not permitted on a CASH account with position {position_string} and order {order}", ) .into(), ); + return; } - return; - } - // Check for valid order trigger price precision - if let Some(trigger_price) = order.trigger_price() { - if trigger_price.precision != self.instrument.price_precision() { + // Check reduce-only instruction + if self.config.use_reduce_only + && order.is_reduce_only() + && !order.is_closed() + && position.map_or(true, |pos| { + pos.is_closed() + || (order.is_buy() && pos.is_long()) + || (order.is_sell() && pos.is_short()) + }) + { self.generate_order_rejected( order, format!( - "Invalid order trigger price precision for order {}, was {} when {} price precision is {}", + "Reduce-only order {} ({}-{}) would have increased position", order.client_order_id(), - trigger_price.precision, - self.instrument.id(), - self.instrument.price_precision() + order.order_type().to_string().to_uppercase(), + order.order_side().to_string().to_uppercase() ) - .into(), + .into(), ); return; } } - // Get position if exists - let position: Option<&Position> = self - .cache - .position_for_order(&order.client_order_id()) - .or_else(|| { - if self.oms_type == OmsType::Netting { - let position_id = PositionId::new( - format!("{}-{}", order.instrument_id(), order.strategy_id()).as_str(), - ) - .unwrap(); - self.cache.position(&position_id) - } else { - None - } - }); - - // Check not shorting an equity without a MARGIN account - if order.order_side() == OrderSide::Sell - && self.account_type != AccountType::Margin - && matches!(self.instrument, InstrumentAny::Equity(_)) - && (position.is_none() - || !order.would_reduce_only(position.unwrap().side, position.unwrap().quantity)) - { - let position_string = position.map_or("None".to_string(), |pos| pos.id.to_string()); - self.generate_order_rejected( - order, - format!( - "Short selling not permitted on a CASH account with position {position_string} and order {order}", - ) - .into(), - ); - return; - } - - // Check reduce-only instruction - if self.config.use_reduce_only - && order.is_reduce_only() - && !order.is_closed() - && position.map_or(true, |pos| { - pos.is_closed() - || (order.is_buy() && pos.is_long()) - || (order.is_sell() && pos.is_short()) - }) - { - self.generate_order_rejected( - order, - format!( - "Reduce-only order {} ({}-{}) would have increased position", - order.client_order_id(), - order.order_type().to_string().to_uppercase(), - order.order_side().to_string().to_uppercase() - ) - .into(), - ); - return; - } - match order.order_type() { OrderType::Market => self.process_market_order(order), OrderType::Limit => self.process_limit_order(order), @@ -774,7 +814,7 @@ impl OrderMatchingEngine { //////////////////////////////////////////////////////////////////////////////// #[cfg(test)] mod tests { - use std::{rc::Rc, sync::LazyLock}; + use std::{cell::RefCell, rc::Rc, sync::LazyLock}; use chrono::{TimeZone, Utc}; use nautilus_common::{ @@ -785,17 +825,21 @@ mod tests { MessageBus, }, }; - use nautilus_core::{nanos::UnixNanos, time::AtomicTime}; + use nautilus_core::{nanos::UnixNanos, time::AtomicTime, uuid::UUID4}; use nautilus_model::{ - enums::{AccountType, BookType, OmsType, OrderSide}, - events::order::{OrderEventAny, OrderEventType}, - identifiers::AccountId, + enums::{ + AccountType, BookType, ContingencyType, OmsType, OrderSide, TimeInForce, TriggerType, + }, + events::order::{OrderEventAny, OrderEventType, OrderRejected}, + identifiers::{AccountId, ClientOrderId, StrategyId, TraderId}, instruments::{ any::InstrumentAny, equity::Equity, stubs::{futures_contract_es, *}, }, - orders::stubs::TestOrderStubs, + orders::{ + any::OrderAny, market::MarketOrder, stop_market::StopMarketOrder, stubs::TestOrderStubs, + }, types::{price::Price, quantity::Quantity}, }; use rstest::{fixture, rstest}; @@ -850,10 +894,11 @@ mod tests { fn get_order_matching_engine( instrument: InstrumentAny, msgbus: Rc, + cache: Option>>, account_type: Option, config: Option, ) -> OrderMatchingEngine { - let cache = Rc::new(Cache::default()); + let cache = cache.unwrap_or(Rc::new(RefCell::new(Cache::default()))); let config = config.unwrap_or_default(); OrderMatchingEngine::new( instrument, @@ -897,7 +942,8 @@ mod tests { ); // Create engine and process order - let mut engine = get_order_matching_engine(instrument.clone(), Rc::new(msgbus), None, None); + let mut engine = + get_order_matching_engine(instrument.clone(), Rc::new(msgbus), None, None, None); let order = TestOrderStubs::market_order( instrument.id(), OrderSide::Buy, @@ -947,7 +993,8 @@ mod tests { ); // Create engine and process order - let mut engine = get_order_matching_engine(instrument.clone(), Rc::new(msgbus), None, None); + let mut engine = + get_order_matching_engine(instrument.clone(), Rc::new(msgbus), None, None, None); let order = TestOrderStubs::market_order( instrument.id(), OrderSide::Buy, @@ -984,7 +1031,7 @@ mod tests { // Create engine and process order let mut engine = - get_order_matching_engine(instrument_es.clone(), Rc::new(msgbus), None, None); + get_order_matching_engine(instrument_es.clone(), Rc::new(msgbus), None, None, None); let order = TestOrderStubs::market_order( instrument_es.id(), OrderSide::Buy, @@ -1021,7 +1068,7 @@ mod tests { // Create engine and process order let mut engine = - get_order_matching_engine(instrument_es.clone(), Rc::new(msgbus), None, None); + get_order_matching_engine(instrument_es.clone(), Rc::new(msgbus), None, None, None); let limit_order = TestOrderStubs::limit_order( instrument_es.id(), OrderSide::Sell, @@ -1060,7 +1107,7 @@ mod tests { // Create engine and process order let mut engine = - get_order_matching_engine(instrument_es.clone(), Rc::new(msgbus), None, None); + get_order_matching_engine(instrument_es.clone(), Rc::new(msgbus), None, None, None); let stop_order = TestOrderStubs::stop_market_order( instrument_es.id(), OrderSide::Sell, @@ -1100,7 +1147,8 @@ mod tests { ); // Create engine and process order - let mut engine = get_order_matching_engine(instrument.clone(), Rc::new(msgbus), None, None); + let mut engine = + get_order_matching_engine(instrument.clone(), Rc::new(msgbus), None, None, None); let order = TestOrderStubs::market_order( instrument.id(), OrderSide::Sell, @@ -1150,8 +1198,13 @@ mod tests { use_position_ids: false, use_random_ids: false, }; - let mut engine = - get_order_matching_engine(instrument_es.clone(), Rc::new(msgbus), None, Some(config)); + let mut engine = get_order_matching_engine( + instrument_es.clone(), + Rc::new(msgbus), + None, + None, + Some(config), + ); let market_order = TestOrderStubs::market_order_reduce( instrument_es.id(), OrderSide::Buy, @@ -1172,4 +1225,125 @@ mod tests { Ustr::from("Reduce-only order O-19700101-000000-001-001-1 (MARKET-BUY) would have increased position") ); } + + #[rstest] + fn test_order_matching_engine_contingent_orders_errors( + mut msgbus: MessageBus, + order_event_handler: ShareableMessageHandler, + account_id: AccountId, + time: AtomicTime, + instrument_es: InstrumentAny, + ) { + // Register saving message handler to exec engine endpoint + msgbus.register( + msgbus.switchboard.exec_engine_process.as_str(), + order_event_handler.clone(), + ); + + // Create engine (with reduce_only option) and process order + let config = OrderMatchingEngineConfig { + use_reduce_only: false, + bar_execution: false, + reject_stop_orders: false, + support_gtd_orders: false, + support_contingent_orders: true, + use_position_ids: false, + use_random_ids: false, + }; + let cache = Rc::new(RefCell::new(Cache::default())); + let mut engine = get_order_matching_engine( + instrument_es.clone(), + Rc::new(msgbus), + Some(cache.clone()), + None, + Some(config), + ); + + let entry_client_order_id = ClientOrderId::from("O-19700101-000000-001-001-1"); + let stop_loss_client_order_id = ClientOrderId::from("O-19700101-000000-001-001-2"); + + // Create entry market order + let mut entry_order = OrderAny::Market( + MarketOrder::new( + TraderId::default(), + StrategyId::default(), + instrument_es.id(), + entry_client_order_id, + OrderSide::Buy, + Quantity::from("1"), + TimeInForce::Gtc, + UUID4::new(), + UnixNanos::default(), + false, + false, + Some(ContingencyType::Oto), // <- set contingency type to OTO + None, + None, + None, + None, + None, + None, + None, + ) + .unwrap(), + ); + // Set entry order status to Rejected with proper event + let rejected_event = OrderRejected::default(); + entry_order + .apply(OrderEventAny::Rejected(rejected_event)) + .unwrap(); + + // Create stop loss order + let stop_order = OrderAny::StopMarket( + StopMarketOrder::new( + entry_order.trader_id(), + entry_order.strategy_id(), + entry_order.instrument_id(), + stop_loss_client_order_id, + OrderSide::Sell, + entry_order.quantity(), + Price::from("0.95"), + TriggerType::BidAsk, + TimeInForce::Gtc, + None, + true, + false, + None, + None, + None, + Some(ContingencyType::Oto), + None, + None, + Some(entry_client_order_id), // <- parent order id set from entry order + None, + None, + None, + None, + UUID4::new(), + UnixNanos::default(), + ) + .unwrap(), + ); + // Make it Accepted + let accepted_stop_order = TestOrderStubs::make_accepted_order(&stop_order); + + // 1. save entry order in the cache as it will be loaded by the matching engine + // 2. send the stop loss order which has parent of entry order + cache + .as_ref() + .borrow_mut() + .add_order(entry_order.clone(), None, None, false) + .unwrap(); + engine.process_order(&accepted_stop_order, account_id); + + // Get messages and test + let saved_messages = get_order_event_handler_messages(order_event_handler); + assert_eq!(saved_messages.len(), 1); + let first_message = saved_messages.first().unwrap(); + assert_eq!(first_message.event_type(), OrderEventType::Rejected); + assert_eq!( + first_message.message().unwrap(), + Ustr::from(format!("Rejected OTO order from {}", entry_client_order_id).as_str()) + ); + } } diff --git a/nautilus_core/common/src/cache/mod.rs b/nautilus_core/common/src/cache/mod.rs index 20fe478fbb4b..36302b6d0723 100644 --- a/nautilus_core/common/src/cache/mod.rs +++ b/nautilus_core/common/src/cache/mod.rs @@ -1397,7 +1397,7 @@ impl Cache { // } } - self.orders.insert(client_order_id, order); + self.orders.insert(client_order_id, order.clone()); Ok(()) } diff --git a/nautilus_core/model/src/orders/any.rs b/nautilus_core/model/src/orders/any.rs index 01ae63378e98..f3fe97b8a74c 100644 --- a/nautilus_core/model/src/orders/any.rs +++ b/nautilus_core/model/src/orders/any.rs @@ -32,8 +32,8 @@ use super::{ }; use crate::{ enums::{ - LiquiditySide, OrderSide, OrderSideSpecified, OrderStatus, OrderType, PositionSide, - TriggerType, + ContingencyType, LiquiditySide, OrderSide, OrderSideSpecified, OrderStatus, OrderType, + PositionSide, TriggerType, }, events::order::OrderEventAny, identifiers::{ @@ -545,6 +545,36 @@ impl OrderAny { Self::TrailingStopMarket(order) => order.is_sell(), } } + + #[must_use] + pub fn parent_order_id(&self) -> Option { + match self { + Self::Limit(order) => order.parent_order_id, + Self::LimitIfTouched(order) => order.parent_order_id, + Self::Market(order) => order.parent_order_id, + Self::MarketIfTouched(order) => order.parent_order_id, + Self::MarketToLimit(order) => order.parent_order_id, + Self::StopLimit(order) => order.parent_order_id, + Self::StopMarket(order) => order.parent_order_id, + Self::TrailingStopLimit(order) => order.parent_order_id, + Self::TrailingStopMarket(order) => order.parent_order_id, + } + } + + #[must_use] + pub fn contingency_type(&self) -> Option { + match self { + Self::Limit(order) => order.contingency_type, + Self::LimitIfTouched(order) => order.contingency_type, + Self::Market(order) => order.contingency_type, + Self::MarketIfTouched(order) => order.contingency_type, + Self::MarketToLimit(order) => order.contingency_type, + Self::StopLimit(order) => order.contingency_type, + Self::StopMarket(order) => order.contingency_type, + Self::TrailingStopLimit(order) => order.contingency_type, + Self::TrailingStopMarket(order) => order.contingency_type, + } + } } impl PartialEq for OrderAny {