1use std::{cell::RefCell, fmt::Debug, rc::Rc};
17
18use ahash::AHashMap;
19use nautilus_common::{cache::Cache, clock::Clock, messages::execution::SubmitOrder};
20use nautilus_core::UUID4;
21use nautilus_model::{
22 enums::{ContingencyType, OrderStatus},
23 events::{
24 OrderCanceled, OrderEventAny, OrderExpired, OrderFilled, OrderRejected, OrderUpdated,
25 },
26 identifiers::{ClientId, ClientOrderId, PositionId},
27 instruments::Instrument,
28 orders::{Order, OrderAny},
29 types::Quantity,
30};
31
32use super::OrderManagerAction;
33
34pub struct OrderManager {
41 clock: Rc<RefCell<dyn Clock>>,
42 cache: Rc<RefCell<Cache>>,
43 active_local: bool,
44 submit_order_commands: AHashMap<ClientOrderId, SubmitOrder>,
45 oto_target_quantities: AHashMap<ClientOrderId, Quantity>,
46}
47
48impl Debug for OrderManager {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 f.debug_struct(stringify!(OrderManager))
51 .field("pending_commands", &self.submit_order_commands.len())
52 .finish()
53 }
54}
55
56impl OrderManager {
57 pub fn new(
59 clock: Rc<RefCell<dyn Clock>>,
60 cache: Rc<RefCell<Cache>>,
61 active_local: bool,
62 ) -> Self {
63 Self {
64 clock,
65 cache,
66 active_local,
67 submit_order_commands: AHashMap::new(),
68 oto_target_quantities: AHashMap::new(),
69 }
70 }
71
72 #[must_use]
73 pub fn get_submit_order_commands(&self) -> AHashMap<ClientOrderId, SubmitOrder> {
75 self.submit_order_commands.clone()
76 }
77
78 pub fn cache_submit_order_command(&mut self, command: SubmitOrder) {
80 self.submit_order_commands
81 .insert(command.client_order_id, command);
82 }
83
84 pub fn pop_submit_order_command(
86 &mut self,
87 client_order_id: ClientOrderId,
88 ) -> Option<SubmitOrder> {
89 self.submit_order_commands.remove(&client_order_id)
90 }
91
92 pub fn reset(&mut self) {
94 self.submit_order_commands.clear();
95 self.oto_target_quantities.clear();
96 }
97
98 pub fn cancel_order(&mut self, order: &OrderAny) -> Vec<OrderManagerAction> {
100 let client_order_id = order.client_order_id();
101 let cache = self.cache.borrow();
102
103 if cache.is_order_pending_cancel_local(&client_order_id) {
104 return Vec::new();
105 }
106
107 if order.is_closed() || cache.is_order_closed(&client_order_id) {
108 log::warn!("Cannot cancel order: already closed");
109 return Vec::new();
110 }
111
112 drop(cache);
113 self.submit_order_commands.remove(&client_order_id);
114
115 vec![OrderManagerAction::CancelLocal(order.clone())]
116 }
117
118 pub fn modify_order_quantity(
120 &mut self,
121 order: &OrderAny,
122 new_quantity: Quantity,
123 ) -> Vec<OrderManagerAction> {
124 vec![OrderManagerAction::ModifyLocalQuantity {
125 order: order.clone(),
126 quantity: new_quantity,
127 }]
128 }
129
130 pub fn create_new_submit_order(
134 &mut self,
135 order: &OrderAny,
136 position_id: Option<PositionId>,
137 client_id: Option<ClientId>,
138 correlation_id: Option<UUID4>,
139 ) -> anyhow::Result<Vec<OrderManagerAction>> {
140 let mut actions = Vec::new();
141 let order_exists = self.cache.borrow().order_exists(&order.client_order_id());
142
143 self.cache
144 .borrow_mut()
145 .add_order(order.clone(), position_id, client_id, true)?;
146
147 if !order_exists {
148 actions.push(initialized_action(order));
149 }
150
151 let submit = SubmitOrder::new(
152 order.trader_id(),
153 client_id,
154 order.strategy_id(),
155 order.instrument_id(),
156 order.client_order_id(),
157 order.init_event().clone(),
158 order.exec_algorithm_id(),
159 position_id,
160 None, UUID4::new(),
162 self.clock.borrow().timestamp_ns(),
163 correlation_id,
164 );
165
166 self.cache_submit_order_command(submit.clone());
167
168 if order.emulation_trigger().is_some() {
169 actions.push(OrderManagerAction::SubmitToEmulator(submit));
170 } else {
171 match order.exec_algorithm_id() {
172 Some(exec_algorithm_id) => {
173 actions.push(OrderManagerAction::SubmitToAlgorithm {
174 command: submit,
175 exec_algorithm_id,
176 });
177 }
178 None => actions.push(OrderManagerAction::SubmitToRisk(submit)),
179 }
180 }
181
182 Ok(actions)
183 }
184
185 #[must_use]
186 pub fn should_manage_order(&self, order: &OrderAny) -> bool {
188 self.active_local == order.is_active_local()
189 }
190
191 pub fn handle_event(&mut self, event: &OrderEventAny) -> Vec<OrderManagerAction> {
196 match event {
197 OrderEventAny::Denied(event) => {
198 self.submit_order_commands.remove(&event.client_order_id);
199 self.oto_target_quantities.remove(&event.client_order_id);
200 let order = self.cache.borrow().order_owned(&event.client_order_id);
201
202 match order {
203 Some(order)
204 if order.previous_status() == Some(OrderStatus::Released)
205 && order.contingency_type().is_some() =>
206 {
207 self.handle_contingencies(&order)
208 }
209 _ => Vec::new(),
210 }
211 }
212 OrderEventAny::Rejected(event) => self.handle_order_rejected(*event),
213 OrderEventAny::Canceled(event) => self.handle_order_canceled(*event),
214 OrderEventAny::Expired(event) => self.handle_order_expired(*event),
215 OrderEventAny::Updated(event) => self.handle_order_updated(*event),
216 OrderEventAny::ModifyRejected(event) => {
217 self.oto_target_quantities.remove(&event.client_order_id);
218 Vec::new()
219 }
220 OrderEventAny::Filled(event) => self.handle_order_filled(event),
221 _ => Vec::new(),
222 }
223 }
224
225 pub fn handle_order_rejected(&mut self, rejected: OrderRejected) -> Vec<OrderManagerAction> {
227 self.oto_target_quantities.remove(&rejected.client_order_id);
228 let Some(order) = self.cache.borrow().order_owned(&rejected.client_order_id) else {
229 log::error!(
230 "Cannot handle `OrderRejected`: order for client_order_id: {} not found, {}",
231 rejected.client_order_id,
232 rejected
233 );
234 return Vec::new();
235 };
236
237 if order.contingency_type().is_some() {
238 self.handle_contingencies(&order)
239 } else {
240 Vec::new()
241 }
242 }
243
244 pub fn handle_order_canceled(&mut self, canceled: OrderCanceled) -> Vec<OrderManagerAction> {
245 self.oto_target_quantities.remove(&canceled.client_order_id);
246 let Some(order) = self.cache.borrow().order_owned(&canceled.client_order_id) else {
247 log::error!(
248 "Cannot handle `OrderCanceled`: order for client_order_id: {} not found, {}",
249 canceled.client_order_id,
250 canceled
251 );
252 return Vec::new();
253 };
254
255 if order.contingency_type().is_some() {
256 self.handle_contingencies(&order)
257 } else {
258 Vec::new()
259 }
260 }
261
262 pub fn handle_order_expired(&mut self, expired: OrderExpired) -> Vec<OrderManagerAction> {
263 self.oto_target_quantities.remove(&expired.client_order_id);
264 let Some(order) = self.cache.borrow().order_owned(&expired.client_order_id) else {
265 log::error!(
266 "Cannot handle `OrderExpired`: order for client_order_id: {} not found, {}",
267 expired.client_order_id,
268 expired
269 );
270 return Vec::new();
271 };
272
273 if order.contingency_type().is_some() {
274 self.handle_contingencies(&order)
275 } else {
276 Vec::new()
277 }
278 }
279
280 pub fn handle_order_updated(&mut self, updated: OrderUpdated) -> Vec<OrderManagerAction> {
281 let Some(order) = self.cache.borrow().order_owned(&updated.client_order_id) else {
282 log::error!(
283 "Cannot handle `OrderUpdated`: order for client_order_id: {} not found, {}",
284 updated.client_order_id,
285 updated
286 );
287 return Vec::new();
288 };
289
290 let mut actions = Vec::new();
291
292 if self.should_manage_order(&order) && !order.is_closed() {
293 if let Some(quantity) = self
294 .oto_target_quantities
295 .get(&updated.client_order_id)
296 .copied()
297 .filter(|quantity| *quantity != order.quantity())
298 .filter(|quantity| order.filled_qty() < *quantity)
299 {
300 actions.extend(self.modify_order_quantity(&order, quantity));
301 }
302 } else {
303 self.oto_target_quantities.remove(&updated.client_order_id);
304 }
305
306 if order.contingency_type().is_some() {
307 actions.extend(self.handle_contingencies_update(&order));
308 }
309
310 actions
311 }
312
313 pub fn handle_order_filled(&mut self, filled: &OrderFilled) -> Vec<OrderManagerAction> {
314 let Some(order) = self.cache.borrow().order_owned(&filled.client_order_id) else {
315 log::error!(
316 "Cannot handle `OrderFilled`: order for client_order_id: {} not found, {}",
317 filled.client_order_id,
318 filled
319 );
320 return Vec::new();
321 };
322
323 let mut actions = Vec::new();
324
325 if !self.should_manage_order(&order) || order.is_closed() {
326 self.oto_target_quantities.remove(&filled.client_order_id);
327 }
328
329 match order.contingency_type() {
330 Some(ContingencyType::Oto) => {
331 let cached_position_id = self
332 .cache
333 .borrow()
334 .position_id(&order.client_order_id())
335 .copied();
336
337 if let (Some(fill_position_id), Some(cached_position_id)) =
338 (filled.position_id, cached_position_id)
339 && fill_position_id != cached_position_id
340 {
341 log::error!(
342 "Cannot handle OTO fill: event position {fill_position_id} does not match cached position {cached_position_id}"
343 );
344 return actions;
345 }
346
347 let position_id = filled.position_id.or(cached_position_id);
348 let client_id = self
349 .cache
350 .borrow()
351 .client_id(&order.client_order_id())
352 .copied();
353
354 let (parent_filled_qty, is_spawn_active) = match order.exec_spawn_id() {
355 Some(spawn_id) => {
356 let cache = self.cache.borrow();
357 let Some(filled_qty) = cache.exec_spawn_total_filled_qty(&spawn_id, false)
358 else {
359 log::error!("Failed to get spawn filled quantity for {spawn_id}");
360 return actions;
361 };
362 let is_spawn_active = cache
363 .exec_spawn_total_leaves_qty(&spawn_id, true)
364 .is_some_and(|leaves_qty| leaves_qty.is_positive());
365 (filled_qty, is_spawn_active)
366 }
367 None => (order.filled_qty(), false),
368 };
369
370 let Some(linked_orders) = order.linked_order_ids() else {
371 log::error!("No linked orders found for OTO order");
372 return actions;
373 };
374
375 for client_order_id in linked_orders {
376 if order.parent_order_id().as_ref() == Some(client_order_id) {
377 continue;
378 }
379
380 let Some(mut child_order) = self.cache.borrow().order_owned(client_order_id)
381 else {
382 log::error!(
383 "Cannot find OTO child order for client_order_id: {client_order_id}"
384 );
385 continue;
386 };
387
388 if !self.should_manage_order(&child_order) || child_order.is_closed() {
389 self.oto_target_quantities.remove(client_order_id);
390 continue;
391 }
392
393 if self.active_local && child_order.position_id().is_none() {
394 child_order.set_position_id(position_id);
395 }
396
397 let Some(child_quantity) = self.position_adjusted_oto_quantity(
398 &order,
399 &child_order,
400 parent_filled_qty,
401 child_order.filled_qty(),
402 position_id,
403 ) else {
404 continue;
405 };
406
407 if child_quantity.is_zero() {
408 self.oto_target_quantities.remove(client_order_id);
409
410 if order.is_closed() && !is_spawn_active {
411 actions.extend(self.cancel_order(&child_order));
412 }
413 continue;
414 }
415
416 actions.extend(self.sync_oto_quantity(&child_order, child_quantity));
417
418 if child_quantity.is_positive()
419 && self.active_local
420 && !self
421 .submit_order_commands
422 .contains_key(&child_order.client_order_id())
423 {
424 match self.create_new_submit_order(
425 &child_order,
426 position_id,
427 client_id,
428 None,
429 ) {
430 Ok(new_actions) => actions.extend(new_actions),
431 Err(e) => log::error!("Failed to create new submit order: {e}"),
432 }
433 }
434 }
435 }
436 Some(ContingencyType::Oco) => {
437 let Some(linked_orders) = order.linked_order_ids() else {
438 log::error!("No linked orders found for OCO order");
439 return actions;
440 };
441
442 for client_order_id in linked_orders {
443 let Some(contingent_order) = self.cache.borrow().order_owned(client_order_id)
444 else {
445 log::error!(
446 "Cannot find OCO contingent order for client_order_id: {client_order_id}"
447 );
448 continue;
449 };
450
451 if !self.should_manage_order(&contingent_order) || contingent_order.is_closed()
453 {
454 continue;
455 }
456
457 if contingent_order.client_order_id() != order.client_order_id() {
458 actions.extend(self.cancel_order(&contingent_order));
459 }
460 }
461 }
462 Some(ContingencyType::Ouo) => actions.extend(self.handle_contingencies(&order)),
463 _ => {}
464 }
465
466 actions
467 }
468
469 pub fn handle_contingencies(&mut self, order: &OrderAny) -> Vec<OrderManagerAction> {
470 let mut actions = Vec::new();
471
472 let (filled_qty, leaves_qty, is_spawn_active) =
473 if let Some(exec_spawn_id) = order.exec_spawn_id() {
474 let cache = self.cache.borrow();
475 let Some(filled) = cache.exec_spawn_total_filled_qty(&exec_spawn_id, false) else {
476 log::error!("Failed to get spawn filled quantity for {exec_spawn_id}");
477 return actions;
478 };
479 let leaves = cache
480 .exec_spawn_total_leaves_qty(&exec_spawn_id, true)
481 .unwrap_or_else(|| Quantity::zero(filled.precision));
482
483 (filled, leaves, leaves.is_positive())
484 } else {
485 (order.filled_qty(), order.leaves_qty(), false)
486 };
487
488 let Some(linked_orders) = order.linked_order_ids() else {
489 log::error!("No linked orders found");
490 return actions;
491 };
492
493 for client_order_id in linked_orders {
494 if order.contingency_type() == Some(ContingencyType::Oto)
495 && order.parent_order_id().as_ref() == Some(client_order_id)
496 {
497 continue;
498 }
499
500 let Some(contingent_order) = self.cache.borrow().order_owned(client_order_id) else {
501 log::error!("Cannot find contingent order for client_order_id: {client_order_id}");
502 continue;
503 };
504
505 if !self.should_manage_order(&contingent_order) {
506 self.oto_target_quantities.remove(client_order_id);
507 continue;
508 }
509
510 if client_order_id == &order.client_order_id() {
511 continue;
512 }
513
514 if contingent_order.is_closed() {
515 self.submit_order_commands.remove(client_order_id);
516 self.oto_target_quantities.remove(client_order_id);
517 continue;
518 }
519
520 match order.contingency_type() {
521 Some(ContingencyType::Oto) => {
522 if filled_qty.is_zero()
523 && order.is_closed()
524 && (order.exec_spawn_id().is_none() || !is_spawn_active)
525 {
526 self.oto_target_quantities.remove(client_order_id);
527 actions.extend(self.cancel_order(&contingent_order));
528 continue;
529 }
530
531 let position_id = self.oto_parent_position_id(order);
532
533 let Some(child_quantity) = self.position_adjusted_oto_quantity(
534 order,
535 &contingent_order,
536 filled_qty,
537 contingent_order.filled_qty(),
538 position_id,
539 ) else {
540 continue;
541 };
542
543 if child_quantity.is_zero() {
544 self.oto_target_quantities.remove(client_order_id);
545
546 if order.is_closed()
547 && (order.exec_spawn_id().is_none() || !is_spawn_active)
548 {
549 actions.extend(self.cancel_order(&contingent_order));
550 }
551 } else {
552 actions.extend(self.sync_oto_quantity(&contingent_order, child_quantity));
553 }
554 }
555 Some(ContingencyType::Oco)
556 if order.is_closed()
557 && (order.exec_spawn_id().is_none() || !is_spawn_active) =>
558 {
559 actions.extend(self.cancel_order(&contingent_order));
560 }
561 Some(ContingencyType::Ouo) => {
562 if (leaves_qty.is_zero() && order.exec_spawn_id().is_some())
563 || (order.is_closed()
564 && (order.exec_spawn_id().is_none() || !is_spawn_active))
565 || contingent_order.filled_qty() >= leaves_qty
566 {
567 actions.extend(self.cancel_order(&contingent_order));
568 } else if leaves_qty != contingent_order.leaves_qty() {
569 actions.extend(self.modify_order_quantity(&contingent_order, leaves_qty));
570 }
571 }
572 _ => {}
573 }
574 }
575
576 actions
577 }
578
579 pub fn handle_contingencies_update(&mut self, order: &OrderAny) -> Vec<OrderManagerAction> {
580 let mut actions = Vec::new();
581 let contingency_type = order.contingency_type();
582
583 let quantity = match order.exec_spawn_id() {
584 Some(exec_spawn_id) => {
585 if let Some(qty) = self
586 .cache
587 .borrow()
588 .exec_spawn_total_quantity(&exec_spawn_id, true)
589 {
590 qty
591 } else {
592 log::error!("Failed to get spawn total quantity for {exec_spawn_id}");
593 return actions;
594 }
595 }
596 None => order.quantity(),
597 };
598
599 if quantity.is_zero() {
600 return actions;
601 }
602
603 let leaves_qty = self.ouo_leaves_qty(order);
604
605 let oto_filled_qty = if contingency_type == Some(ContingencyType::Oto) {
606 Some(match order.exec_spawn_id() {
607 Some(exec_spawn_id) => {
608 if let Some(qty) = self
609 .cache
610 .borrow()
611 .exec_spawn_total_filled_qty(&exec_spawn_id, false)
612 {
613 qty
614 } else {
615 log::error!("Failed to get spawn filled quantity for {exec_spawn_id}");
616 return actions;
617 }
618 }
619 None => order.filled_qty(),
620 })
621 } else {
622 None
623 };
624
625 let Some(linked_orders) = order.linked_order_ids() else {
626 log::error!("No linked orders found for contingent order");
627 return actions;
628 };
629
630 for client_order_id in linked_orders {
631 if contingency_type == Some(ContingencyType::Oto)
632 && order.parent_order_id().as_ref() == Some(client_order_id)
633 {
634 continue;
635 }
636
637 let Some(contingent_order) = self.cache.borrow().order_owned(client_order_id) else {
638 log::error!(
639 "Cannot find OCO contingent order for client_order_id: {client_order_id}"
640 );
641 continue;
642 };
643
644 if !self.should_manage_order(&contingent_order) {
645 self.oto_target_quantities.remove(client_order_id);
646 continue;
647 }
648
649 if client_order_id == &order.client_order_id() || contingent_order.is_closed() {
650 self.oto_target_quantities.remove(client_order_id);
651 continue;
652 }
653
654 match contingency_type {
655 Some(ContingencyType::Oto) => {
656 let child_quantity =
657 if let Some(filled_qty) = oto_filled_qty.filter(Quantity::is_positive) {
658 let position_id = self.oto_parent_position_id(order);
659
660 let Some(quantity) = self.position_adjusted_oto_quantity(
661 order,
662 &contingent_order,
663 filled_qty,
664 contingent_order.filled_qty(),
665 position_id,
666 ) else {
667 continue;
668 };
669 quantity
670 } else {
671 quantity
672 };
673
674 actions.extend(self.sync_oto_quantity(&contingent_order, child_quantity));
675 }
676 Some(ContingencyType::Ouo) if leaves_qty == contingent_order.leaves_qty() => {}
679 Some(ContingencyType::Ouo) if contingent_order.filled_qty() >= quantity => {
680 actions.extend(self.cancel_order(&contingent_order));
681 }
682 Some(ContingencyType::Ouo) => {
683 let quantity = contingent_order.filled_qty() + leaves_qty;
685 actions.extend(self.modify_order_quantity(&contingent_order, quantity));
686 }
687 _ => {}
688 }
689 }
690
691 actions
692 }
693
694 fn ouo_leaves_qty(&self, order: &OrderAny) -> Quantity {
696 match order.exec_spawn_id() {
697 Some(exec_spawn_id) => self
698 .cache
699 .borrow()
700 .exec_spawn_total_leaves_qty(&exec_spawn_id, true)
701 .unwrap_or_else(|| Quantity::zero(order.quantity().precision)),
702 None => order.leaves_qty(),
703 }
704 }
705
706 fn oto_parent_position_id(&self, order: &OrderAny) -> Option<PositionId> {
707 if let Some(position_id) = order.position_id() {
708 return Some(position_id);
709 }
710
711 let cache = self.cache.borrow();
712
713 cache
714 .position_id(&order.client_order_id())
715 .copied()
716 .or_else(|| {
717 order.exec_spawn_id().and_then(|spawn_id| {
718 cache
719 .orders_for_exec_spawn(&spawn_id)
720 .into_iter()
721 .find_map(|spawn_order| spawn_order.position_id())
722 })
723 })
724 }
725
726 fn position_adjusted_oto_quantity(
727 &self,
728 parent_order: &OrderAny,
729 child_order: &OrderAny,
730 filled_qty: Quantity,
731 child_filled_qty: Quantity,
732 position_id: Option<PositionId>,
733 ) -> Option<Quantity> {
734 let Some(position_id) = position_id else {
735 return Some(filled_qty);
736 };
737
738 let cache = self.cache.borrow();
739 let Some(parent_instrument) = cache.instrument(&parent_order.instrument_id()) else {
740 log::error!(
741 "Cannot size OTO orders: instrument {} not found in cache",
742 parent_order.instrument_id()
743 );
744 return None;
745 };
746
747 let Some(child_instrument) = cache.instrument(&child_order.instrument_id()) else {
748 log::error!(
749 "Cannot size OTO order {}: instrument {} not found in cache",
750 child_order.client_order_id(),
751 child_order.instrument_id()
752 );
753 return None;
754 };
755
756 let increment = child_instrument.size_increment();
757 if increment.is_zero() {
758 log::error!(
759 "Cannot size OTO orders: size increment is zero for {}",
760 child_order.instrument_id()
761 );
762 return None;
763 }
764
765 let capped = if parent_instrument.is_spread() || !child_order.is_reduce_only() {
766 filled_qty
767 } else {
768 let Some(position) = cache.position(&position_id) else {
769 log::error!("Cannot size OTO orders: position {position_id} not found in cache");
770 return None;
771 };
772
773 let precision = child_filled_qty.precision.max(position.quantity.precision);
774
775 let (Some(child_filled_qty), Some(position_qty)) = (
776 child_filled_qty.checked_rescale(precision),
777 position.quantity.checked_rescale(precision),
778 ) else {
779 log::error!("Cannot size OTO orders: position quantity scale alignment overflow");
780 return None;
781 };
782
783 let position_cap = child_filled_qty.saturating_add(position_qty);
784 filled_qty.min(position_cap)
785 };
786
787 let Some(target) = capped.checked_floor_to_increment(increment) else {
788 log::error!("Cannot size OTO orders: quantity scale alignment overflow");
789 return None;
790 };
791
792 if child_instrument
793 .min_quantity()
794 .is_some_and(|min_quantity| target < min_quantity)
795 {
796 return Some(Quantity::zero(child_instrument.size_precision()));
797 }
798
799 Some(target)
800 }
801
802 fn sync_oto_quantity(
803 &mut self,
804 order: &OrderAny,
805 quantity: Quantity,
806 ) -> Vec<OrderManagerAction> {
807 let client_order_id = order.client_order_id();
808
809 if quantity.is_zero() {
810 self.oto_target_quantities.remove(&client_order_id);
811 return Vec::new();
812 }
813
814 if order.filled_qty() >= quantity {
815 self.oto_target_quantities.remove(&client_order_id);
816 return self.cancel_order(order);
817 }
818
819 match self.oto_target_quantities.get(&client_order_id).copied() {
820 Some(target) if target == quantity => return Vec::new(),
821 Some(_) if order.quantity() == quantity && !order.is_pending_update() => {
822 self.oto_target_quantities.insert(client_order_id, quantity);
823 return Vec::new();
824 }
825 Some(_) => {}
826 None if order.quantity() == quantity => return Vec::new(),
827 None => {}
828 }
829
830 self.oto_target_quantities.insert(client_order_id, quantity);
831 self.modify_order_quantity(order, quantity)
832 }
833}
834
835fn initialized_action(order: &OrderAny) -> OrderManagerAction {
836 let event = OrderEventAny::Initialized(order.init_event().clone());
837 OrderManagerAction::PublishInitialized(event)
838}
839
840#[cfg(test)]
841mod tests {
842 use std::{cell::RefCell, rc::Rc};
843
844 use nautilus_common::{cache::Cache, clock::VirtualClock};
845 use nautilus_core::{UUID4, UnixNanos};
846 use nautilus_model::{
847 enums::{ContingencyType, OmsType, OrderSide, OrderStatus, OrderType, TriggerType},
848 events::order::spec::{
849 OrderAcceptedSpec, OrderCanceledSpec, OrderDeniedSpec, OrderEmulatedSpec,
850 OrderExpiredSpec, OrderModifyRejectedSpec, OrderPendingUpdateSpec, OrderRejectedSpec,
851 OrderReleasedSpec, OrderSubmittedSpec, OrderUpdatedSpec,
852 },
853 identifiers::{
854 AccountId, ClientOrderId, ExecAlgorithmId, InstrumentId, StrategyId, TradeId, TraderId,
855 VenueOrderId,
856 },
857 instruments::{
858 Instrument, InstrumentAny,
859 stubs::{audusd_sim, currency_pair_btcusdt, futures_spread_es},
860 },
861 orders::{
862 Order, OrderTestBuilder,
863 stubs::{OrderFilledTestBuilder, TestOrderEventStubs},
864 },
865 position::Position,
866 types::{Money, Price, Quantity},
867 };
868 use rstest::rstest;
869
870 use super::*;
871
872 #[rstest]
875 fn test_handle_event_unhandled_events_are_noop() {
876 let submitted = OrderEventAny::Submitted(
877 OrderSubmittedSpec::builder()
878 .trader_id(TraderId::from("TRADER-001"))
879 .strategy_id(StrategyId::from("STRATEGY-001"))
880 .instrument_id(InstrumentId::from("BTC-USDT.OKX"))
881 .client_order_id(ClientOrderId::from("O-001"))
882 .account_id(AccountId::from("ACCOUNT-001"))
883 .build(),
884 );
885 let accepted = OrderEventAny::Accepted(
886 OrderAcceptedSpec::builder()
887 .trader_id(TraderId::from("TRADER-001"))
888 .strategy_id(StrategyId::from("STRATEGY-001"))
889 .instrument_id(InstrumentId::from("BTC-USDT.OKX"))
890 .client_order_id(ClientOrderId::from("O-001"))
891 .venue_order_id(VenueOrderId::from("V-001"))
892 .account_id(AccountId::from("ACCOUNT-001"))
893 .build(),
894 );
895
896 match submitted {
897 OrderEventAny::Rejected(_) => panic!("Should not match"),
898 OrderEventAny::Canceled(_) => panic!("Should not match"),
899 OrderEventAny::Expired(_) => panic!("Should not match"),
900 OrderEventAny::Updated(_) => panic!("Should not match"),
901 OrderEventAny::Filled(_) => panic!("Should not match"),
902 _ => {}
903 }
904
905 match accepted {
906 OrderEventAny::Rejected(_) => panic!("Should not match"),
907 OrderEventAny::Canceled(_) => panic!("Should not match"),
908 OrderEventAny::Expired(_) => panic!("Should not match"),
909 OrderEventAny::Updated(_) => panic!("Should not match"),
910 OrderEventAny::Filled(_) => panic!("Should not match"),
911 _ => {}
912 }
913 }
914
915 #[rstest]
916 #[case(None)]
917 #[case(Some(ContingencyType::Oto))]
918 #[case(Some(ContingencyType::Oco))]
919 #[case(Some(ContingencyType::Ouo))]
920 fn test_denied_release_clears_pending_state_and_manages_contingencies(
921 #[case] contingency: Option<ContingencyType>,
922 ) {
923 let (clock, cache) = create_test_components();
924 let mut manager = OrderManager::new(clock, cache.clone(), true);
925 let mut builder = OrderTestBuilder::new(OrderType::Market);
926 builder
927 .instrument_id(audusd_sim().id())
928 .side(OrderSide::Buy)
929 .quantity(Quantity::from(100_000))
930 .linked_order_ids(vec![ClientOrderId::from("O-SIBLING")]);
931
932 if let Some(contingency) = contingency {
933 builder.contingency_type(contingency);
934 }
935
936 let order = builder.build();
937 let sibling = OrderTestBuilder::new(OrderType::StopMarket)
938 .instrument_id(order.instrument_id())
939 .client_order_id(ClientOrderId::from("O-SIBLING"))
940 .side(OrderSide::Sell)
941 .trigger_price(Price::from("0.99900"))
942 .quantity(Quantity::from(100_000))
943 .emulation_trigger(TriggerType::BidAsk)
944 .build();
945 cache
946 .borrow_mut()
947 .add_order(order.clone(), None, None, false)
948 .unwrap();
949 cache
950 .borrow_mut()
951 .add_order(sibling.clone(), None, None, false)
952 .unwrap();
953 cache
954 .borrow_mut()
955 .update_order(&OrderEventAny::Released(
956 OrderReleasedSpec::builder()
957 .client_order_id(order.client_order_id())
958 .build(),
959 ))
960 .unwrap();
961 let denied = OrderEventAny::Denied(
962 OrderDeniedSpec::builder()
963 .client_order_id(order.client_order_id())
964 .reason("TRADING_HALTED".into())
965 .build(),
966 );
967 cache.borrow_mut().update_order(&denied).unwrap();
968 manager.cache_submit_order_command(make_submit_command(&order));
969 manager
970 .oto_target_quantities
971 .insert(order.client_order_id(), Quantity::from(40_000));
972
973 let actions = manager.handle_event(&denied);
974
975 assert!(manager.submit_order_commands.is_empty());
976 assert!(manager.oto_target_quantities.is_empty());
977
978 if contingency.is_some() {
979 assert!(
980 matches!(actions.as_slice(), [OrderManagerAction::CancelLocal(order)] if order == &sibling)
981 );
982 } else {
983 assert!(actions.is_empty());
984 }
985 }
986
987 #[rstest]
988 fn test_actionable_events_for_missing_orders_are_noops() {
989 let (clock, cache) = create_test_components();
990 let mut manager = OrderManager::new(clock, cache, false);
991 let trader_id = TraderId::from("TRADER-001");
992 let strategy_id = StrategyId::from("STRATEGY-001");
993 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
994 let client_order_id = ClientOrderId::from("O-MISSING");
995 let account_id = AccountId::from("ACCOUNT-001");
996 let events = [
997 OrderEventAny::Rejected(
998 OrderRejectedSpec::builder()
999 .trader_id(trader_id)
1000 .strategy_id(strategy_id)
1001 .instrument_id(instrument_id)
1002 .client_order_id(client_order_id)
1003 .account_id(account_id)
1004 .reason("test rejection".into())
1005 .build(),
1006 ),
1007 OrderEventAny::Canceled(
1008 OrderCanceledSpec::builder()
1009 .trader_id(trader_id)
1010 .strategy_id(strategy_id)
1011 .instrument_id(instrument_id)
1012 .client_order_id(client_order_id)
1013 .account_id(account_id)
1014 .build(),
1015 ),
1016 OrderEventAny::Expired(
1017 OrderExpiredSpec::builder()
1018 .trader_id(trader_id)
1019 .strategy_id(strategy_id)
1020 .instrument_id(instrument_id)
1021 .client_order_id(client_order_id)
1022 .account_id(account_id)
1023 .build(),
1024 ),
1025 OrderEventAny::Updated(
1026 OrderUpdatedSpec::builder()
1027 .trader_id(trader_id)
1028 .strategy_id(strategy_id)
1029 .instrument_id(instrument_id)
1030 .client_order_id(client_order_id)
1031 .quantity(Quantity::from(100_000))
1032 .venue_order_id(VenueOrderId::from("V-MISSING"))
1033 .account_id(account_id)
1034 .build(),
1035 ),
1036 ];
1037
1038 for event in events {
1039 assert!(manager.handle_event(&event).is_empty());
1040 }
1041 }
1042
1043 fn create_test_components() -> (Rc<RefCell<dyn Clock>>, Rc<RefCell<Cache>>) {
1044 let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(VirtualClock::new()));
1045 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1046 (clock, cache)
1047 }
1048
1049 fn create_test_stop_order() -> OrderAny {
1050 let instrument = audusd_sim();
1051 OrderTestBuilder::new(OrderType::StopMarket)
1052 .instrument_id(instrument.id())
1053 .side(OrderSide::Buy)
1054 .trigger_price(Price::from("1.00050"))
1055 .quantity(Quantity::from(100_000))
1056 .emulation_trigger(TriggerType::BidAsk)
1057 .build()
1058 }
1059
1060 fn make_submit_command(order: &OrderAny) -> SubmitOrder {
1063 SubmitOrder::new(
1064 order.trader_id(),
1065 None,
1066 order.strategy_id(),
1067 order.instrument_id(),
1068 order.client_order_id(),
1069 order.init_event().clone(),
1070 None,
1071 None,
1072 None,
1073 UUID4::new(),
1074 UnixNanos::default(),
1075 None, )
1077 }
1078
1079 #[rstest]
1080 fn test_create_new_submit_order_returns_emulator_submit_action() {
1081 let (clock, cache) = create_test_components();
1082 let mut manager = OrderManager::new(clock, cache, true);
1083 let order = create_test_stop_order();
1084
1085 let actions = manager
1086 .create_new_submit_order(&order, None, None, None)
1087 .unwrap();
1088
1089 assert_eq!(actions.len(), 2);
1090 assert!(matches!(
1091 &actions[0],
1092 OrderManagerAction::PublishInitialized(OrderEventAny::Initialized(event))
1093 if event.client_order_id == order.client_order_id()
1094 ));
1095 assert!(matches!(
1096 &actions[1],
1097 OrderManagerAction::SubmitToEmulator(command)
1098 if command.client_order_id == order.client_order_id()
1099 ));
1100 assert!(
1101 manager
1102 .submit_order_commands
1103 .contains_key(&order.client_order_id())
1104 );
1105 }
1106
1107 #[rstest]
1108 fn test_reset_clears_submit_commands_and_oto_targets() {
1109 let (clock, cache) = create_test_components();
1110 let mut manager = OrderManager::new(clock, cache, true);
1111 let order = create_test_stop_order();
1112 let client_order_id = order.client_order_id();
1113 manager.cache_submit_order_command(make_submit_command(&order));
1114 manager
1115 .oto_target_quantities
1116 .insert(client_order_id, Quantity::from(40_000));
1117
1118 manager.reset();
1119
1120 assert!(manager.submit_order_commands.is_empty());
1121 assert!(manager.oto_target_quantities.is_empty());
1122 }
1123
1124 #[rstest]
1125 fn test_create_new_submit_order_returns_risk_submit_action() {
1126 let (clock, cache) = create_test_components();
1127 let mut manager = OrderManager::new(clock, cache, true);
1128 let order = OrderTestBuilder::new(OrderType::Limit)
1129 .instrument_id(audusd_sim().id())
1130 .side(OrderSide::Buy)
1131 .price(Price::from("1.00000"))
1132 .quantity(Quantity::from(100_000))
1133 .build();
1134
1135 let actions = manager
1136 .create_new_submit_order(&order, None, None, None)
1137 .unwrap();
1138
1139 assert_eq!(actions.len(), 2);
1140 assert!(matches!(
1141 &actions[0],
1142 OrderManagerAction::PublishInitialized(OrderEventAny::Initialized(event))
1143 if event.client_order_id == order.client_order_id()
1144 ));
1145 assert!(matches!(
1146 &actions[1],
1147 OrderManagerAction::SubmitToRisk(command)
1148 if command.client_order_id == order.client_order_id()
1149 ));
1150 }
1151
1152 #[rstest]
1153 fn test_create_new_submit_order_returns_risk_action_for_none_trigger() {
1154 let (clock, cache) = create_test_components();
1155 let mut manager = OrderManager::new(clock, cache, true);
1156 let order = OrderTestBuilder::new(OrderType::Market)
1157 .instrument_id(audusd_sim().id())
1158 .side(OrderSide::Buy)
1159 .quantity(Quantity::from(100_000))
1160 .build();
1161
1162 let actions = manager
1163 .create_new_submit_order(&order, None, None, None)
1164 .unwrap();
1165
1166 assert_eq!(actions.len(), 2);
1167 assert!(order.emulation_trigger().is_none());
1168 assert!(matches!(
1169 &actions[1],
1170 OrderManagerAction::SubmitToRisk(command)
1171 if command.client_order_id == order.client_order_id()
1172 ));
1173 }
1174
1175 #[rstest]
1176 fn test_create_new_submit_order_returns_algorithm_submit_action() {
1177 let (clock, cache) = create_test_components();
1178 let mut manager = OrderManager::new(clock, cache, true);
1179 let exec_algorithm_id = ExecAlgorithmId::from("ALG-001");
1180 let client_order_id = ClientOrderId::from("O-001");
1181 let order = OrderTestBuilder::new(OrderType::Limit)
1182 .client_order_id(client_order_id)
1183 .instrument_id(audusd_sim().id())
1184 .side(OrderSide::Buy)
1185 .price(Price::from("1.00000"))
1186 .quantity(Quantity::from(100_000))
1187 .exec_algorithm_id(exec_algorithm_id)
1188 .exec_spawn_id(client_order_id)
1189 .build();
1190
1191 let actions = manager
1192 .create_new_submit_order(&order, None, None, None)
1193 .unwrap();
1194
1195 assert_eq!(actions.len(), 2);
1196 assert!(matches!(
1197 &actions[1],
1198 OrderManagerAction::SubmitToAlgorithm {
1199 command,
1200 exec_algorithm_id: action_exec_algorithm_id,
1201 } if command.client_order_id == order.client_order_id()
1202 && *action_exec_algorithm_id == exec_algorithm_id
1203 ));
1204 }
1205
1206 #[rstest]
1207 fn test_create_new_submit_order_does_not_republish_initialized_for_existing_order() {
1208 let (clock, cache) = create_test_components();
1209 let mut manager = OrderManager::new(clock, cache.clone(), true);
1210 let order = create_test_stop_order();
1211 cache
1212 .borrow_mut()
1213 .add_order(order.clone(), None, None, true)
1214 .unwrap();
1215
1216 let actions = manager
1217 .create_new_submit_order(&order, None, None, None)
1218 .unwrap();
1219
1220 assert_eq!(actions.len(), 1);
1221 assert!(matches!(
1222 &actions[0],
1223 OrderManagerAction::SubmitToEmulator(command)
1224 if command.client_order_id == order.client_order_id()
1225 ));
1226 }
1227
1228 #[rstest]
1229 fn test_cancel_order_returns_cancel_local_action() {
1230 let (clock, cache) = create_test_components();
1231 let mut manager = OrderManager::new(clock, cache.clone(), true);
1232 let order = create_test_stop_order();
1233 cache
1234 .borrow_mut()
1235 .add_order(order.clone(), None, None, false)
1236 .unwrap();
1237 manager
1238 .submit_order_commands
1239 .insert(order.client_order_id(), make_submit_command(&order));
1240
1241 let actions = manager.cancel_order(&order);
1242
1243 assert!(matches!(
1244 actions.as_slice(),
1245 [OrderManagerAction::CancelLocal(action_order)]
1246 if action_order.client_order_id() == order.client_order_id()
1247 ));
1248 assert!(
1249 !manager
1250 .submit_order_commands
1251 .contains_key(&order.client_order_id()),
1252 "expected cancel action path to remove the submit command",
1253 );
1254 }
1255
1256 #[rstest]
1257 fn test_modify_order_quantity_returns_modify_local_action() {
1258 let (clock, cache) = create_test_components();
1259 let mut manager = OrderManager::new(clock, cache, true);
1260 let order = create_test_stop_order();
1261 let new_quantity = Quantity::from(50_000);
1262
1263 let actions = manager.modify_order_quantity(&order, new_quantity);
1264
1265 assert!(matches!(
1266 actions.as_slice(),
1267 [OrderManagerAction::ModifyLocalQuantity { order: action_order, quantity }]
1268 if action_order.client_order_id() == order.client_order_id()
1269 && *quantity == new_quantity
1270 ));
1271 }
1272
1273 #[rstest]
1274 fn test_sync_oto_quantity_reasserts_same_quantity_while_pending_update() {
1275 let (clock, cache) = create_test_components();
1276 let mut manager = OrderManager::new(clock, cache.clone(), false);
1277 let client_order_id = ClientOrderId::from("O-CHILD");
1278 let order = OrderTestBuilder::new(OrderType::Limit)
1279 .instrument_id(audusd_sim().id())
1280 .client_order_id(client_order_id)
1281 .side(OrderSide::Sell)
1282 .price(Price::from("1.00100"))
1283 .quantity(Quantity::from(100_000))
1284 .submit(true)
1285 .build();
1286 cache
1287 .borrow_mut()
1288 .add_order(order.clone(), None, None, false)
1289 .unwrap();
1290 apply_accepted(&cache, &order, "V-CHILD");
1291 let pending_update = OrderEventAny::PendingUpdate(
1292 OrderPendingUpdateSpec::builder()
1293 .trader_id(order.trader_id())
1294 .strategy_id(order.strategy_id())
1295 .instrument_id(order.instrument_id())
1296 .client_order_id(client_order_id)
1297 .account_id(AccountId::from("ACCOUNT-001"))
1298 .venue_order_id(VenueOrderId::from("V-CHILD"))
1299 .build(),
1300 );
1301 cache.borrow_mut().update_order(&pending_update).unwrap();
1302 manager
1303 .oto_target_quantities
1304 .insert(client_order_id, Quantity::from(40_000));
1305 let cached_order = cache.borrow().order_owned(&client_order_id).unwrap();
1306
1307 let actions = manager.sync_oto_quantity(&cached_order, Quantity::from(100_000));
1308
1309 assert!(matches!(
1310 actions.as_slice(),
1311 [OrderManagerAction::ModifyLocalQuantity { order, quantity }]
1312 if order.client_order_id() == client_order_id
1313 && *quantity == Quantity::from(100_000)
1314 ));
1315 assert_eq!(
1316 manager.oto_target_quantities.get(&client_order_id),
1317 Some(&Quantity::from(100_000)),
1318 );
1319 }
1320
1321 #[rstest]
1322 fn test_handle_event_unhandled_events_return_no_actions() {
1323 let (clock, cache) = create_test_components();
1324 let mut manager = OrderManager::new(clock, cache, true);
1325 let order = create_test_stop_order();
1326 let event = OrderEventAny::Submitted(
1327 OrderSubmittedSpec::builder()
1328 .trader_id(order.trader_id())
1329 .strategy_id(order.strategy_id())
1330 .instrument_id(order.instrument_id())
1331 .client_order_id(order.client_order_id())
1332 .account_id(AccountId::from("ACCOUNT-001"))
1333 .build(),
1334 );
1335
1336 let actions = manager.handle_event(&event);
1337
1338 assert!(actions.is_empty());
1339 }
1340
1341 #[rstest]
1342 fn test_handle_order_filled_skips_missing_oco_contingent_order() {
1343 let (clock, cache) = create_test_components();
1344 let mut manager = OrderManager::new(clock, cache.clone(), true);
1345 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
1346 let missing_client_order_id = ClientOrderId::from("O-MISSING");
1347 let valid_client_order_id = ClientOrderId::from("O-CHILD");
1348 let order = OrderTestBuilder::new(OrderType::Limit)
1349 .instrument_id(instrument.id())
1350 .client_order_id(ClientOrderId::from("O-PARENT"))
1351 .side(OrderSide::Buy)
1352 .price(Price::from("1.00000"))
1353 .quantity(Quantity::from(100_000))
1354 .contingency_type(ContingencyType::Oco)
1355 .linked_order_ids(vec![missing_client_order_id, valid_client_order_id])
1356 .build();
1357 let child_order = OrderTestBuilder::new(OrderType::Limit)
1358 .instrument_id(instrument.id())
1359 .client_order_id(valid_client_order_id)
1360 .side(OrderSide::Buy)
1361 .price(Price::from("1.00000"))
1362 .quantity(Quantity::from(100_000))
1363 .build();
1364 cache
1365 .borrow_mut()
1366 .add_order(order.clone(), None, None, false)
1367 .unwrap();
1368 cache
1369 .borrow_mut()
1370 .add_order(child_order.clone(), None, None, false)
1371 .unwrap();
1372 manager
1373 .submit_order_commands
1374 .insert(valid_client_order_id, make_submit_command(&child_order));
1375 let filled = match TestOrderEventStubs::filled(
1376 &order,
1377 &instrument,
1378 None,
1379 None,
1380 None,
1381 None,
1382 None,
1383 None,
1384 None,
1385 Some(AccountId::from("SIM-001")),
1386 ) {
1387 OrderEventAny::Filled(event) => event,
1388 event => panic!("expected OrderFilled, was {event:?}"),
1389 };
1390
1391 let actions = manager.handle_order_filled(&filled);
1392
1393 assert!(matches!(
1394 actions.as_slice(),
1395 [OrderManagerAction::CancelLocal(action_order)]
1396 if action_order.client_order_id() == valid_client_order_id
1397 ));
1398 assert!(
1399 !manager
1400 .submit_order_commands
1401 .contains_key(&valid_client_order_id)
1402 );
1403 }
1404
1405 #[rstest]
1406 fn test_handle_event_non_active_local_manager_returns_oco_cancel_action() {
1407 let (clock, cache) = create_test_components();
1408 let mut manager = OrderManager::new(clock, cache.clone(), false);
1409 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
1410 let parent_id = ClientOrderId::from("O-PARENT");
1411 let child_id = ClientOrderId::from("O-CHILD");
1412 let order = OrderTestBuilder::new(OrderType::Limit)
1413 .instrument_id(instrument.id())
1414 .client_order_id(parent_id)
1415 .side(OrderSide::Buy)
1416 .price(Price::from("1.00000"))
1417 .quantity(Quantity::from(100_000))
1418 .contingency_type(ContingencyType::Oco)
1419 .linked_order_ids(vec![parent_id, child_id])
1420 .submit(true)
1421 .build();
1422 let child_order = OrderTestBuilder::new(OrderType::Limit)
1423 .instrument_id(instrument.id())
1424 .client_order_id(child_id)
1425 .side(OrderSide::Buy)
1426 .price(Price::from("1.00000"))
1427 .quantity(Quantity::from(100_000))
1428 .submit(true)
1429 .build();
1430 cache
1431 .borrow_mut()
1432 .add_order(order.clone(), None, None, false)
1433 .unwrap();
1434 cache
1435 .borrow_mut()
1436 .add_order(child_order.clone(), None, None, false)
1437 .unwrap();
1438 manager
1439 .submit_order_commands
1440 .insert(child_id, make_submit_command(&child_order));
1441 let event = TestOrderEventStubs::filled(
1442 &order,
1443 &instrument,
1444 None,
1445 None,
1446 None,
1447 None,
1448 None,
1449 None,
1450 None,
1451 Some(AccountId::from("SIM-001")),
1452 );
1453
1454 let actions = manager.handle_event(&event);
1455
1456 assert!(matches!(
1457 actions.as_slice(),
1458 [OrderManagerAction::CancelLocal(order)]
1459 if order.client_order_id() == child_id
1460 ));
1461 assert!(
1462 !manager.submit_order_commands.contains_key(&child_id),
1463 "cancel action must remove the cached submit command",
1464 );
1465 }
1466
1467 #[rstest]
1468 fn test_handle_order_filled_skips_missing_oto_child_order() {
1469 let (clock, cache) = create_test_components();
1470 let mut manager = OrderManager::new(clock, cache.clone(), true);
1471 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
1472 let missing_client_order_id = ClientOrderId::from("O-MISSING");
1473 let valid_client_order_id = ClientOrderId::from("O-CHILD");
1474 let order = OrderTestBuilder::new(OrderType::Limit)
1475 .instrument_id(instrument.id())
1476 .client_order_id(ClientOrderId::from("O-PARENT"))
1477 .side(OrderSide::Buy)
1478 .price(Price::from("1.00000"))
1479 .quantity(Quantity::from(100_000))
1480 .contingency_type(ContingencyType::Oto)
1481 .linked_order_ids(vec![missing_client_order_id, valid_client_order_id])
1482 .submit(true)
1483 .build();
1484 let child_order = OrderTestBuilder::new(OrderType::Limit)
1485 .instrument_id(instrument.id())
1486 .client_order_id(valid_client_order_id)
1487 .side(OrderSide::Buy)
1488 .price(Price::from("1.00000"))
1489 .quantity(Quantity::from(100_000))
1490 .build();
1491 cache
1492 .borrow_mut()
1493 .add_order(order.clone(), None, None, false)
1494 .unwrap();
1495 cache
1496 .borrow_mut()
1497 .add_order(child_order, None, None, false)
1498 .unwrap();
1499 let filled = match apply_fill(
1500 &cache,
1501 &order,
1502 &instrument,
1503 "T-PARTIAL",
1504 Quantity::from(40_000),
1505 ) {
1506 OrderEventAny::Filled(event) => event,
1507 event => panic!("expected OrderFilled, was {event:?}"),
1508 };
1509
1510 let actions = manager.handle_order_filled(&filled);
1511
1512 assert_eq!(actions.len(), 2);
1513 assert!(matches!(
1514 &actions[0],
1515 OrderManagerAction::ModifyLocalQuantity { order, quantity }
1516 if order.client_order_id() == valid_client_order_id
1517 && *quantity == Quantity::from(40_000)
1518 ));
1519 assert!(matches!(
1520 &actions[1],
1521 OrderManagerAction::SubmitToRisk(command)
1522 if command.client_order_id == valid_client_order_id
1523 ));
1524 assert!(
1525 manager
1526 .submit_order_commands
1527 .contains_key(&valid_client_order_id)
1528 );
1529 }
1530
1531 #[rstest]
1532 fn test_handle_contingencies_skips_missing_linked_order() {
1533 let (clock, cache) = create_test_components();
1534 let mut manager = OrderManager::new(clock, cache, true);
1535 let instrument = audusd_sim();
1536 let order = OrderTestBuilder::new(OrderType::Limit)
1537 .instrument_id(instrument.id())
1538 .client_order_id(ClientOrderId::from("O-PARENT"))
1539 .side(OrderSide::Buy)
1540 .price(Price::from("1.00000"))
1541 .quantity(Quantity::from(100_000))
1542 .contingency_type(ContingencyType::Oco)
1543 .linked_order_ids(vec![ClientOrderId::from("O-MISSING")])
1544 .build();
1545
1546 let actions = manager.handle_contingencies(&order);
1547
1548 assert!(actions.is_empty());
1549 assert!(manager.submit_order_commands.is_empty());
1550 }
1551
1552 #[rstest]
1553 fn test_handle_contingencies_update_skips_missing_linked_order() {
1554 let (clock, cache) = create_test_components();
1555 let mut manager = OrderManager::new(clock, cache, true);
1556 let instrument = audusd_sim();
1557 let order = OrderTestBuilder::new(OrderType::Limit)
1558 .instrument_id(instrument.id())
1559 .client_order_id(ClientOrderId::from("O-PARENT"))
1560 .side(OrderSide::Buy)
1561 .price(Price::from("1.00000"))
1562 .quantity(Quantity::from(100_000))
1563 .contingency_type(ContingencyType::Oco)
1564 .linked_order_ids(vec![ClientOrderId::from("O-MISSING")])
1565 .build();
1566
1567 let actions = manager.handle_contingencies_update(&order);
1568
1569 assert!(actions.is_empty());
1570 assert!(manager.submit_order_commands.is_empty());
1571 }
1572
1573 #[rstest]
1574 fn test_oto_child_contingency_handlers_do_not_drive_parent() {
1575 let (clock, cache) = create_test_components();
1576 let mut terminal_manager = OrderManager::new(clock.clone(), cache.clone(), false);
1577 let mut update_manager = OrderManager::new(clock, cache.clone(), false);
1578 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
1579 let parent_id = ClientOrderId::from("O-PARENT");
1580 let child_id = ClientOrderId::from("O-CHILD");
1581 let parent = OrderTestBuilder::new(OrderType::Limit)
1582 .instrument_id(instrument.id())
1583 .client_order_id(parent_id)
1584 .side(OrderSide::Buy)
1585 .price(Price::from("1.00000"))
1586 .quantity(Quantity::from(100_000))
1587 .contingency_type(ContingencyType::Oto)
1588 .linked_order_ids(vec![child_id])
1589 .submit(true)
1590 .build();
1591 let child = OrderTestBuilder::new(OrderType::Limit)
1592 .instrument_id(instrument.id())
1593 .client_order_id(child_id)
1594 .side(OrderSide::Sell)
1595 .price(Price::from("1.00100"))
1596 .quantity(Quantity::from(100_000))
1597 .contingency_type(ContingencyType::Oto)
1598 .linked_order_ids(vec![parent_id])
1599 .parent_order_id(parent_id)
1600 .submit(true)
1601 .build();
1602 cache
1603 .borrow_mut()
1604 .add_order(parent.clone(), None, None, false)
1605 .unwrap();
1606 cache
1607 .borrow_mut()
1608 .add_order(child.clone(), None, None, false)
1609 .unwrap();
1610 apply_accepted(&cache, &parent, "V-PARENT");
1611 apply_accepted(&cache, &child, "V-CHILD");
1612 apply_fill(
1613 &cache,
1614 &child,
1615 &instrument,
1616 "T-CHILD",
1617 Quantity::from(40_000),
1618 );
1619 let cached_child = cache.borrow().order_owned(&child_id).unwrap();
1620
1621 let terminal_actions = terminal_manager.handle_contingencies(&cached_child);
1622 let update_actions = update_manager.handle_contingencies_update(&cached_child);
1623
1624 assert!(terminal_actions.is_empty());
1625 assert!(update_actions.is_empty());
1626 assert!(terminal_manager.oto_target_quantities.is_empty());
1627 assert!(update_manager.oto_target_quantities.is_empty());
1628 }
1629
1630 #[rstest]
1631 fn test_oto_chained_child_drives_its_child_contingencies() {
1632 let (clock, cache) = create_test_components();
1633 let mut terminal_manager = OrderManager::new(clock.clone(), cache.clone(), false);
1634 let mut update_manager = OrderManager::new(clock, cache.clone(), false);
1635 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
1636 let root_id = ClientOrderId::from("O-ROOT");
1637 let parent_id = ClientOrderId::from("O-PARENT");
1638 let child_id = ClientOrderId::from("O-CHILD");
1639 let parent = OrderTestBuilder::new(OrderType::Limit)
1640 .instrument_id(instrument.id())
1641 .client_order_id(parent_id)
1642 .side(OrderSide::Buy)
1643 .price(Price::from("1.00000"))
1644 .quantity(Quantity::from(120_000))
1645 .contingency_type(ContingencyType::Oto)
1646 .linked_order_ids(vec![child_id])
1647 .parent_order_id(root_id)
1648 .submit(true)
1649 .build();
1650 let child = OrderTestBuilder::new(OrderType::Limit)
1651 .instrument_id(instrument.id())
1652 .client_order_id(child_id)
1653 .side(OrderSide::Sell)
1654 .price(Price::from("1.00100"))
1655 .quantity(Quantity::from(100_000))
1656 .parent_order_id(parent_id)
1657 .submit(true)
1658 .build();
1659 cache
1660 .borrow_mut()
1661 .add_order(parent.clone(), None, None, false)
1662 .unwrap();
1663 cache
1664 .borrow_mut()
1665 .add_order(child.clone(), None, None, false)
1666 .unwrap();
1667 apply_accepted(&cache, &parent, "V-PARENT");
1668 apply_accepted(&cache, &child, "V-CHILD");
1669 let cached_parent = cache.borrow().order_owned(&parent_id).unwrap();
1670
1671 let update_actions = update_manager.handle_contingencies_update(&cached_parent);
1672 let terminal = apply_terminal(&cache, &cached_parent, TerminalEvent::Canceled);
1673 let terminal_actions = terminal_manager.handle_event(&terminal);
1674
1675 assert!(matches!(
1676 update_actions.as_slice(),
1677 [OrderManagerAction::ModifyLocalQuantity { order, quantity }]
1678 if order.client_order_id() == child_id
1679 && *quantity == Quantity::from(120_000)
1680 ));
1681 assert!(matches!(
1682 terminal_actions.as_slice(),
1683 [OrderManagerAction::CancelLocal(order)]
1684 if order.client_order_id() == child_id
1685 ));
1686 }
1687
1688 #[rstest]
1689 fn test_cancel_order_skips_when_pending_cancel_local() {
1690 let (clock, cache) = create_test_components();
1691 let mut manager = OrderManager::new(clock, cache.clone(), true);
1692 let order = create_test_stop_order();
1693 cache
1694 .borrow_mut()
1695 .add_order(order.clone(), None, None, false)
1696 .unwrap();
1697 cache.borrow_mut().update_order_pending_cancel_local(&order);
1698 manager
1699 .submit_order_commands
1700 .insert(order.client_order_id(), make_submit_command(&order));
1701
1702 manager.cancel_order(&order);
1703
1704 assert!(
1705 manager
1706 .submit_order_commands
1707 .contains_key(&order.client_order_id()),
1708 "pending-cancel-local gate should short-circuit before removing the submit command",
1709 );
1710 }
1711
1712 #[rstest]
1713 fn test_cancel_order_skips_when_passed_order_is_closed() {
1714 let (clock, cache) = create_test_components();
1718 let mut manager = OrderManager::new(clock, cache.clone(), true);
1719
1720 let mut order = OrderTestBuilder::new(OrderType::StopMarket)
1721 .instrument_id(audusd_sim().id())
1722 .side(OrderSide::Buy)
1723 .trigger_price(Price::from("1.00050"))
1724 .quantity(Quantity::from(100_000))
1725 .emulation_trigger(TriggerType::BidAsk)
1726 .submit(true)
1727 .build();
1728
1729 cache
1730 .borrow_mut()
1731 .add_order(order.clone(), None, None, false)
1732 .unwrap();
1733
1734 let canceled_event =
1735 TestOrderEventStubs::canceled(&order, AccountId::from("ACCOUNT-001"), None);
1736 order.apply(canceled_event).unwrap();
1737
1738 assert!(order.is_closed());
1739 assert!(!cache.borrow().is_order_closed(&order.client_order_id()));
1740
1741 manager
1742 .submit_order_commands
1743 .insert(order.client_order_id(), make_submit_command(&order));
1744
1745 manager.cancel_order(&order);
1746
1747 assert!(
1748 manager
1749 .submit_order_commands
1750 .contains_key(&order.client_order_id()),
1751 "closed-order gate should short-circuit on the local state when the cache index is stale",
1752 );
1753 }
1754
1755 #[rstest]
1756 fn test_cancel_order_skips_when_cache_index_marks_closed() {
1757 let (clock, cache) = create_test_components();
1761 let mut manager = OrderManager::new(clock, cache.clone(), true);
1762
1763 let mut order = OrderTestBuilder::new(OrderType::StopMarket)
1764 .instrument_id(audusd_sim().id())
1765 .side(OrderSide::Buy)
1766 .trigger_price(Price::from("1.00050"))
1767 .quantity(Quantity::from(100_000))
1768 .emulation_trigger(TriggerType::BidAsk)
1769 .submit(true)
1770 .build();
1771
1772 cache
1773 .borrow_mut()
1774 .add_order(order.clone(), None, None, false)
1775 .unwrap();
1776
1777 let stale_order = order.clone();
1778
1779 let canceled_event =
1780 TestOrderEventStubs::canceled(&order, AccountId::from("ACCOUNT-001"), None);
1781 order = cache.borrow_mut().update_order(&canceled_event).unwrap();
1782
1783 assert!(cache.borrow().is_order_closed(&order.client_order_id()));
1784
1785 manager.submit_order_commands.insert(
1786 stale_order.client_order_id(),
1787 make_submit_command(&stale_order),
1788 );
1789
1790 manager.cancel_order(&stale_order);
1791
1792 assert!(
1793 manager
1794 .submit_order_commands
1795 .contains_key(&stale_order.client_order_id()),
1796 "closed-order gate should short-circuit even when the passed reference is stale",
1797 );
1798 }
1799
1800 #[rstest]
1801 fn test_handle_contingencies_update_syncs_quantity_for_ouo_sibling() {
1802 let (clock, cache) = create_test_components();
1803 let mut manager = OrderManager::new(clock, cache.clone(), true);
1804 let instrument = audusd_sim();
1805 let child = OrderTestBuilder::new(OrderType::Limit)
1806 .instrument_id(instrument.id())
1807 .client_order_id(ClientOrderId::from("O-CHILD"))
1808 .side(OrderSide::Sell)
1809 .price(Price::from("1.00100"))
1810 .quantity(Quantity::from(50_000))
1811 .build();
1812 cache
1813 .borrow_mut()
1814 .add_order(child.clone(), None, None, false)
1815 .unwrap();
1816 let parent = OrderTestBuilder::new(OrderType::Limit)
1817 .instrument_id(instrument.id())
1818 .client_order_id(ClientOrderId::from("O-PARENT"))
1819 .side(OrderSide::Buy)
1820 .price(Price::from("1.00000"))
1821 .quantity(Quantity::from(100_000))
1822 .contingency_type(ContingencyType::Ouo)
1823 .linked_order_ids(vec![child.client_order_id()])
1824 .build();
1825
1826 let actions = manager.handle_contingencies_update(&parent);
1827
1828 assert_eq!(actions.len(), 1);
1829 assert!(matches!(
1830 &actions[0],
1831 OrderManagerAction::ModifyLocalQuantity { order, quantity }
1832 if order.client_order_id() == child.client_order_id()
1833 && *quantity == Quantity::from(100_000)
1834 ));
1835 }
1836
1837 #[rstest]
1838 fn test_handle_contingencies_update_syncs_oto_child_before_parent_fill() {
1839 let (clock, cache) = create_test_components();
1840 let mut manager = OrderManager::new(clock, cache.clone(), false);
1841 let instrument = audusd_sim();
1842 let child = OrderTestBuilder::new(OrderType::Limit)
1843 .instrument_id(instrument.id())
1844 .client_order_id(ClientOrderId::from("O-CHILD"))
1845 .side(OrderSide::Sell)
1846 .price(Price::from("1.00100"))
1847 .quantity(Quantity::from(100_000))
1848 .submit(true)
1849 .build();
1850 cache
1851 .borrow_mut()
1852 .add_order(child.clone(), None, None, false)
1853 .unwrap();
1854 let parent = OrderTestBuilder::new(OrderType::Limit)
1855 .instrument_id(instrument.id())
1856 .client_order_id(ClientOrderId::from("O-PARENT"))
1857 .side(OrderSide::Buy)
1858 .price(Price::from("1.00000"))
1859 .quantity(Quantity::from(120_000))
1860 .contingency_type(ContingencyType::Oto)
1861 .linked_order_ids(vec![child.client_order_id()])
1862 .submit(true)
1863 .build();
1864
1865 let actions = manager.handle_contingencies_update(&parent);
1866
1867 assert!(matches!(
1868 actions.as_slice(),
1869 [OrderManagerAction::ModifyLocalQuantity { order, quantity }]
1870 if order.client_order_id() == child.client_order_id()
1871 && *quantity == Quantity::from(120_000)
1872 ));
1873 assert_eq!(
1874 manager.oto_target_quantities.get(&child.client_order_id()),
1875 Some(&Quantity::from(120_000)),
1876 );
1877 }
1878
1879 #[rstest]
1880 fn test_handle_contingencies_update_clears_target_for_closed_oto_child() {
1881 let (clock, cache) = create_test_components();
1882 let mut manager = OrderManager::new(clock, cache.clone(), false);
1883 let instrument = audusd_sim();
1884 let child = OrderTestBuilder::new(OrderType::Limit)
1885 .instrument_id(instrument.id())
1886 .client_order_id(ClientOrderId::from("O-CHILD"))
1887 .side(OrderSide::Sell)
1888 .price(Price::from("1.00100"))
1889 .quantity(Quantity::from(100_000))
1890 .submit(true)
1891 .build();
1892 cache
1893 .borrow_mut()
1894 .add_order(child.clone(), None, None, false)
1895 .unwrap();
1896 apply_terminal(&cache, &child, TerminalEvent::Canceled);
1897 manager
1898 .oto_target_quantities
1899 .insert(child.client_order_id(), Quantity::from(40_000));
1900 let parent = OrderTestBuilder::new(OrderType::Limit)
1901 .instrument_id(instrument.id())
1902 .client_order_id(ClientOrderId::from("O-PARENT"))
1903 .side(OrderSide::Buy)
1904 .price(Price::from("1.00000"))
1905 .quantity(Quantity::from(100_000))
1906 .contingency_type(ContingencyType::Oto)
1907 .linked_order_ids(vec![child.client_order_id()])
1908 .submit(true)
1909 .build();
1910
1911 let actions = manager.handle_contingencies_update(&parent);
1912
1913 assert!(actions.is_empty());
1914 assert!(manager.oto_target_quantities.is_empty());
1915 }
1916
1917 #[rstest]
1918 fn test_handle_contingencies_update_cancels_ouo_sibling_filled_past_target() {
1919 let (clock, cache) = create_test_components();
1920 let mut manager = OrderManager::new(clock, cache.clone(), false);
1921 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
1922 let child = OrderTestBuilder::new(OrderType::Limit)
1923 .instrument_id(instrument.id())
1924 .client_order_id(ClientOrderId::from("O-CHILD"))
1925 .side(OrderSide::Sell)
1926 .price(Price::from("1.00100"))
1927 .quantity(Quantity::from(100_000))
1928 .submit(true)
1929 .build();
1930 cache
1931 .borrow_mut()
1932 .add_order(child.clone(), None, None, false)
1933 .unwrap();
1934 apply_fill(
1935 &cache,
1936 &child,
1937 &instrument,
1938 "T-CHILD",
1939 Quantity::from(60_000),
1940 );
1941 let parent = OrderTestBuilder::new(OrderType::Limit)
1942 .instrument_id(instrument.id())
1943 .client_order_id(ClientOrderId::from("O-PARENT"))
1944 .side(OrderSide::Buy)
1945 .price(Price::from("1.00000"))
1946 .quantity(Quantity::from(50_000))
1947 .contingency_type(ContingencyType::Ouo)
1948 .linked_order_ids(vec![child.client_order_id()])
1949 .submit(true)
1950 .build();
1951
1952 let actions = manager.handle_contingencies_update(&parent);
1953
1954 assert!(matches!(
1955 actions.as_slice(),
1956 [OrderManagerAction::CancelLocal(order)]
1957 if order.client_order_id() == child.client_order_id()
1958 ));
1959 }
1960
1961 #[rstest]
1962 #[case::unchanged_leaves(Quantity::from(100_000), None)]
1963 #[case::reduced_leaves(Quantity::from(80_000), Some(Quantity::from(55_000)))]
1964 fn test_handle_contingencies_update_syncs_ouo_sibling_to_leaves_after_partial_fill(
1965 #[case] parent_quantity: Quantity,
1966 #[case] expected_sibling_quantity: Option<Quantity>,
1967 ) {
1968 let (clock, cache) = create_test_components();
1969 let mut manager = OrderManager::new(clock, cache.clone(), false);
1970 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
1971 let parent_id = ClientOrderId::from("O-PARENT");
1972 let sibling_id = ClientOrderId::from("O-SIBLING");
1973 let parent = OrderTestBuilder::new(OrderType::Limit)
1974 .instrument_id(instrument.id())
1975 .client_order_id(parent_id)
1976 .side(OrderSide::Buy)
1977 .price(Price::from("1.00000"))
1978 .quantity(parent_quantity)
1979 .contingency_type(ContingencyType::Ouo)
1980 .linked_order_ids(vec![parent_id, sibling_id])
1981 .submit(true)
1982 .build();
1983
1984 let sibling = OrderTestBuilder::new(OrderType::Limit)
1986 .instrument_id(instrument.id())
1987 .client_order_id(sibling_id)
1988 .side(OrderSide::Sell)
1989 .price(Price::from("1.00100"))
1990 .quantity(Quantity::from(75_000))
1991 .submit(true)
1992 .build();
1993 cache
1994 .borrow_mut()
1995 .add_order(parent.clone(), None, None, false)
1996 .unwrap();
1997 cache
1998 .borrow_mut()
1999 .add_order(sibling, None, None, false)
2000 .unwrap();
2001 apply_fill(
2002 &cache,
2003 &parent,
2004 &instrument,
2005 "T-OUO",
2006 Quantity::from(25_000),
2007 );
2008 let parent = cache.borrow().order_owned(&parent_id).unwrap();
2009
2010 let actions = manager.handle_contingencies_update(&parent);
2011
2012 let sibling_quantity = match actions.as_slice() {
2013 [] => None,
2014 [OrderManagerAction::ModifyLocalQuantity { order, quantity }]
2015 if order.client_order_id() == sibling_id =>
2016 {
2017 Some(*quantity)
2018 }
2019 other => panic!("Unexpected actions {other:?}"),
2020 };
2021
2022 assert_eq!(sibling_quantity, expected_sibling_quantity);
2023 }
2024
2025 #[rstest]
2026 #[case::sibling_filled_below_leg_leaves(60_000, 0, 25_000, 85_000)]
2027 #[case::sibling_filled_past_leg_leaves(60_000, 40_000, 25_000, 45_000)]
2028 fn test_handle_contingencies_update_keeps_ouo_sibling_fills(
2029 #[case] leg_quantity: u64,
2030 #[case] leg_filled: u64,
2031 #[case] sibling_filled: u64,
2032 #[case] expected_sibling_quantity: u64,
2033 ) {
2034 let (clock, cache) = create_test_components();
2035 let mut manager = OrderManager::new(clock, cache.clone(), false);
2036 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
2037 let leg_id = ClientOrderId::from("O-LEG");
2038 let sibling_id = ClientOrderId::from("O-SIBLING");
2039 let leg = OrderTestBuilder::new(OrderType::Limit)
2040 .instrument_id(instrument.id())
2041 .client_order_id(leg_id)
2042 .side(OrderSide::Sell)
2043 .price(Price::from("1.00100"))
2044 .quantity(Quantity::from(leg_quantity))
2045 .contingency_type(ContingencyType::Ouo)
2046 .linked_order_ids(vec![sibling_id])
2047 .submit(true)
2048 .build();
2049 let sibling = OrderTestBuilder::new(OrderType::Limit)
2050 .instrument_id(instrument.id())
2051 .client_order_id(sibling_id)
2052 .side(OrderSide::Sell)
2053 .price(Price::from("1.00200"))
2054 .quantity(Quantity::from(100_000))
2055 .submit(true)
2056 .build();
2057 cache
2058 .borrow_mut()
2059 .add_order(leg.clone(), None, None, false)
2060 .unwrap();
2061 cache
2062 .borrow_mut()
2063 .add_order(sibling.clone(), None, None, false)
2064 .unwrap();
2065 apply_accepted(&cache, &leg, "V-LEG");
2066 apply_accepted(&cache, &sibling, "V-SIBLING");
2067 let leg = cache.borrow().order_owned(&leg_id).unwrap();
2068 let sibling = cache.borrow().order_owned(&sibling_id).unwrap();
2069
2070 if leg_filled > 0 {
2071 apply_fill(
2072 &cache,
2073 &leg,
2074 &instrument,
2075 "T-LEG",
2076 Quantity::from(leg_filled),
2077 );
2078 }
2079
2080 apply_fill(
2081 &cache,
2082 &sibling,
2083 &instrument,
2084 "T-SIBLING",
2085 Quantity::from(sibling_filled),
2086 );
2087 let leg = cache.borrow().order_owned(&leg_id).unwrap();
2088
2089 let actions = manager.handle_contingencies_update(&leg);
2090
2091 assert!(matches!(
2093 actions.as_slice(),
2094 [OrderManagerAction::ModifyLocalQuantity { order, quantity }]
2095 if order.client_order_id() == sibling_id
2096 && *quantity == Quantity::from(expected_sibling_quantity)
2097 ));
2098 }
2099
2100 #[rstest]
2101 fn test_handle_contingencies_update_reciprocal_ouo_resize_keeps_both_legs() {
2102 let (clock, cache) = create_test_components();
2103 let mut manager = OrderManager::new(clock, cache.clone(), false);
2104 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
2105 let leg_id = ClientOrderId::from("O-LEG");
2106 let sibling_id = ClientOrderId::from("O-SIBLING");
2107 let leg = OrderTestBuilder::new(OrderType::Limit)
2108 .instrument_id(instrument.id())
2109 .client_order_id(leg_id)
2110 .side(OrderSide::Sell)
2111 .price(Price::from("1.00100"))
2112 .quantity(Quantity::from(100_000))
2113 .contingency_type(ContingencyType::Ouo)
2114 .linked_order_ids(vec![sibling_id])
2115 .submit(true)
2116 .build();
2117 let sibling = OrderTestBuilder::new(OrderType::StopMarket)
2118 .instrument_id(instrument.id())
2119 .client_order_id(sibling_id)
2120 .side(OrderSide::Sell)
2121 .trigger_price(Price::from("0.99000"))
2122 .quantity(Quantity::from(75_000))
2123 .contingency_type(ContingencyType::Ouo)
2124 .linked_order_ids(vec![leg_id])
2125 .submit(true)
2126 .build();
2127 cache
2128 .borrow_mut()
2129 .add_order(leg.clone(), None, None, false)
2130 .unwrap();
2131 cache
2132 .borrow_mut()
2133 .add_order(sibling.clone(), None, None, false)
2134 .unwrap();
2135 apply_accepted(&cache, &leg, "V-LEG");
2136 apply_accepted(&cache, &sibling, "V-SIBLING");
2137 let leg = cache.borrow().order_owned(&leg_id).unwrap();
2138 apply_fill(&cache, &leg, &instrument, "T-LEG", Quantity::from(25_000));
2139
2140 let resize = |order_id: &ClientOrderId, quantity: u64| {
2141 let order = cache.borrow().order_owned(order_id).unwrap();
2142 let event = OrderEventAny::Updated(
2143 OrderUpdatedSpec::builder()
2144 .trader_id(order.trader_id())
2145 .strategy_id(order.strategy_id())
2146 .instrument_id(order.instrument_id())
2147 .client_order_id(order.client_order_id())
2148 .quantity(Quantity::from(quantity))
2149 .venue_order_id(order.venue_order_id().unwrap())
2150 .account_id(AccountId::from("ACCOUNT-001"))
2151 .build(),
2152 );
2153 cache.borrow_mut().update_order(&event).unwrap();
2154 cache.borrow().order_owned(order_id).unwrap()
2155 };
2156
2157 let leg = resize(&leg_id, 40_000);
2159 let leg_actions = manager.handle_contingencies_update(&leg);
2160 let sibling = resize(&sibling_id, 15_000);
2161 let sibling_actions = manager.handle_contingencies_update(&sibling);
2162
2163 assert!(matches!(
2164 leg_actions.as_slice(),
2165 [OrderManagerAction::ModifyLocalQuantity { order, quantity }]
2166 if order.client_order_id() == sibling_id
2167 && *quantity == Quantity::from(15_000)
2168 ));
2169 assert!(sibling_actions.is_empty());
2170 }
2171
2172 #[rstest]
2173 fn test_handle_contingencies_update_syncs_ouo_sibling_to_spawn_leaves() {
2174 let (clock, cache) = create_test_components();
2175 let mut manager = OrderManager::new(clock, cache.clone(), false);
2176 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
2177 let spawn_id = ClientOrderId::from("O-SPAWN");
2178 let first_id = ClientOrderId::from("O-SPAWN-1");
2179 let second_id = ClientOrderId::from("O-SPAWN-2");
2180 let sibling_id = ClientOrderId::from("O-SIBLING");
2181 let exec_algorithm_id = ExecAlgorithmId::from("TWAP");
2182
2183 let spawned_leg = |client_order_id: ClientOrderId, quantity: u64| {
2184 OrderTestBuilder::new(OrderType::Limit)
2185 .instrument_id(instrument.id())
2186 .client_order_id(client_order_id)
2187 .side(OrderSide::Buy)
2188 .price(Price::from("1.00000"))
2189 .quantity(Quantity::from(quantity))
2190 .contingency_type(ContingencyType::Ouo)
2191 .linked_order_ids(vec![sibling_id])
2192 .exec_algorithm_id(exec_algorithm_id)
2193 .exec_spawn_id(spawn_id)
2194 .submit(true)
2195 .build()
2196 };
2197
2198 let first = spawned_leg(first_id, 30_000);
2199 let second = spawned_leg(second_id, 70_000);
2200 let sibling = OrderTestBuilder::new(OrderType::Limit)
2201 .instrument_id(instrument.id())
2202 .client_order_id(sibling_id)
2203 .side(OrderSide::Sell)
2204 .price(Price::from("1.00100"))
2205 .quantity(Quantity::from(100_000))
2206 .submit(true)
2207 .build();
2208
2209 for order in [&first, &second, &sibling] {
2210 cache
2211 .borrow_mut()
2212 .add_order(order.clone(), None, None, false)
2213 .unwrap();
2214 }
2215
2216 apply_fill(
2217 &cache,
2218 &first,
2219 &instrument,
2220 "T-SPAWN-1",
2221 Quantity::from(10_000),
2222 );
2223 let first = cache.borrow().order_owned(&first_id).unwrap();
2224
2225 let actions = manager.handle_contingencies_update(&first);
2226
2227 assert!(matches!(
2229 actions.as_slice(),
2230 [OrderManagerAction::ModifyLocalQuantity { order, quantity }]
2231 if order.client_order_id() == sibling_id
2232 && *quantity == Quantity::from(90_000)
2233 ));
2234 }
2235
2236 #[rstest]
2237 fn test_handle_contingencies_update_does_not_sync_quantity_for_oco_sibling() {
2238 let (clock, cache) = create_test_components();
2239 let mut manager = OrderManager::new(clock, cache.clone(), true);
2240 let instrument = audusd_sim();
2241 let child = OrderTestBuilder::new(OrderType::Limit)
2242 .instrument_id(instrument.id())
2243 .client_order_id(ClientOrderId::from("O-CHILD"))
2244 .side(OrderSide::Sell)
2245 .price(Price::from("1.00100"))
2246 .quantity(Quantity::from(50_000))
2247 .build();
2248 cache
2249 .borrow_mut()
2250 .add_order(child.clone(), None, None, false)
2251 .unwrap();
2252 let parent = OrderTestBuilder::new(OrderType::Limit)
2253 .instrument_id(instrument.id())
2254 .client_order_id(ClientOrderId::from("O-PARENT"))
2255 .side(OrderSide::Buy)
2256 .price(Price::from("1.00000"))
2257 .quantity(Quantity::from(100_000))
2258 .contingency_type(ContingencyType::Oco)
2259 .linked_order_ids(vec![child.client_order_id()])
2260 .build();
2261
2262 let actions = manager.handle_contingencies_update(&parent);
2263
2264 assert!(actions.is_empty());
2265 }
2266
2267 #[derive(Debug, Clone, Copy)]
2268 enum TerminalEvent {
2269 Canceled,
2270 Expired,
2271 Rejected,
2272 }
2273
2274 fn apply_fill(
2275 cache: &Rc<RefCell<Cache>>,
2276 order: &OrderAny,
2277 instrument: &InstrumentAny,
2278 trade_id: &str,
2279 last_qty: Quantity,
2280 ) -> OrderEventAny {
2281 let event = OrderFilledTestBuilder::new(order, instrument)
2282 .trade_id(TradeId::from(trade_id))
2283 .last_qty(last_qty)
2284 .account_id(AccountId::from("ACCOUNT-001"))
2285 .without_position_id()
2286 .build();
2287 cache.borrow_mut().update_order(&event).unwrap();
2288 event
2289 }
2290
2291 fn apply_position_fill(
2292 cache: &Rc<RefCell<Cache>>,
2293 order: &OrderAny,
2294 instrument: &InstrumentAny,
2295 position_id: PositionId,
2296 trade_id: &str,
2297 last_qty: Quantity,
2298 commission: Option<Money>,
2299 ) -> OrderEventAny {
2300 let mut builder = OrderFilledTestBuilder::new(order, instrument);
2301 builder
2302 .trade_id(TradeId::from(trade_id))
2303 .position_id(position_id)
2304 .last_qty(last_qty)
2305 .account_id(AccountId::from("ACCOUNT-001"));
2306
2307 if let Some(commission) = commission {
2308 builder.commission(commission);
2309 } else {
2310 builder.without_commission();
2311 }
2312 let event = builder.build();
2313 cache.borrow_mut().update_order(&event).unwrap();
2314 let OrderEventAny::Filled(fill) = &event else {
2315 unreachable!();
2316 };
2317
2318 if cache.borrow().position_exists(&position_id) {
2319 cache
2320 .borrow_mut()
2321 .update_position_from_fill(position_id, fill)
2322 .unwrap();
2323 } else {
2324 let position = Position::new(instrument, fill.clone());
2325 cache
2326 .borrow_mut()
2327 .add_position(&position, OmsType::Netting)
2328 .unwrap();
2329 }
2330
2331 event
2332 }
2333
2334 fn apply_accepted(cache: &Rc<RefCell<Cache>>, order: &OrderAny, venue_order_id: &str) {
2335 let event = TestOrderEventStubs::accepted(
2336 order,
2337 AccountId::from("ACCOUNT-001"),
2338 VenueOrderId::from(venue_order_id),
2339 );
2340 cache.borrow_mut().update_order(&event).unwrap();
2341 }
2342
2343 fn apply_update(
2344 cache: &Rc<RefCell<Cache>>,
2345 order: &OrderAny,
2346 quantity: Quantity,
2347 ) -> OrderEventAny {
2348 let event = OrderEventAny::Updated(
2349 OrderUpdatedSpec::builder()
2350 .trader_id(order.trader_id())
2351 .strategy_id(order.strategy_id())
2352 .instrument_id(order.instrument_id())
2353 .client_order_id(order.client_order_id())
2354 .quantity(quantity)
2355 .venue_order_id(VenueOrderId::from("V-CHILD"))
2356 .account_id(AccountId::from("ACCOUNT-001"))
2357 .build(),
2358 );
2359 cache.borrow_mut().update_order(&event).unwrap();
2360 event
2361 }
2362
2363 fn apply_terminal(
2364 cache: &Rc<RefCell<Cache>>,
2365 order: &OrderAny,
2366 terminal: TerminalEvent,
2367 ) -> OrderEventAny {
2368 let event = match terminal {
2369 TerminalEvent::Canceled => OrderEventAny::Canceled(
2370 OrderCanceledSpec::builder()
2371 .trader_id(order.trader_id())
2372 .strategy_id(order.strategy_id())
2373 .instrument_id(order.instrument_id())
2374 .client_order_id(order.client_order_id())
2375 .account_id(AccountId::from("ACCOUNT-001"))
2376 .build(),
2377 ),
2378 TerminalEvent::Expired => OrderEventAny::Expired(
2379 OrderExpiredSpec::builder()
2380 .trader_id(order.trader_id())
2381 .strategy_id(order.strategy_id())
2382 .instrument_id(order.instrument_id())
2383 .client_order_id(order.client_order_id())
2384 .account_id(AccountId::from("ACCOUNT-001"))
2385 .build(),
2386 ),
2387 TerminalEvent::Rejected => OrderEventAny::Rejected(
2388 OrderRejectedSpec::builder()
2389 .trader_id(order.trader_id())
2390 .strategy_id(order.strategy_id())
2391 .instrument_id(order.instrument_id())
2392 .client_order_id(order.client_order_id())
2393 .account_id(AccountId::from("ACCOUNT-001"))
2394 .reason("test rejection".into())
2395 .build(),
2396 ),
2397 };
2398 cache.borrow_mut().update_order(&event).unwrap();
2399 event
2400 }
2401
2402 #[rstest]
2403 fn test_should_manage_order_selects_symmetric_ownership_classes() {
2404 let (clock, cache) = create_test_components();
2405 let active_local = OrderTestBuilder::new(OrderType::Limit)
2406 .instrument_id(audusd_sim().id())
2407 .side(OrderSide::Buy)
2408 .price(Price::from("1.00000"))
2409 .quantity(Quantity::from(100_000))
2410 .build();
2411 let non_active_local = OrderTestBuilder::new(OrderType::Limit)
2412 .instrument_id(audusd_sim().id())
2413 .side(OrderSide::Buy)
2414 .price(Price::from("1.00000"))
2415 .quantity(Quantity::from(100_000))
2416 .submit(true)
2417 .build();
2418 let emulator_manager = OrderManager::new(clock.clone(), cache.clone(), true);
2419 let strategy_manager = OrderManager::new(clock, cache, false);
2420
2421 assert!(emulator_manager.should_manage_order(&active_local));
2422 assert!(!emulator_manager.should_manage_order(&non_active_local));
2423 assert!(!strategy_manager.should_manage_order(&active_local));
2424 assert!(strategy_manager.should_manage_order(&non_active_local));
2425 }
2426
2427 #[rstest]
2428 #[case::strategy(false, true, false)]
2429 #[case::emulator(true, false, true)]
2430 fn test_handle_oto_partial_fill_resizes_once_and_submits_only_for_active_local(
2431 #[case] active_local: bool,
2432 #[case] child_submitted: bool,
2433 #[case] expects_submit: bool,
2434 ) {
2435 let (clock, cache) = create_test_components();
2436 let mut manager = OrderManager::new(clock, cache.clone(), active_local);
2437 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
2438 let parent_id = ClientOrderId::from("O-PARENT");
2439 let child_id = ClientOrderId::from("O-CHILD");
2440 let parent = OrderTestBuilder::new(OrderType::Limit)
2441 .instrument_id(instrument.id())
2442 .client_order_id(parent_id)
2443 .side(OrderSide::Buy)
2444 .price(Price::from("1.00000"))
2445 .quantity(Quantity::from(100_000))
2446 .contingency_type(ContingencyType::Oto)
2447 .linked_order_ids(vec![child_id])
2448 .submit(true)
2449 .build();
2450 let child = OrderTestBuilder::new(OrderType::Limit)
2451 .instrument_id(instrument.id())
2452 .client_order_id(child_id)
2453 .side(OrderSide::Sell)
2454 .price(Price::from("1.00100"))
2455 .quantity(Quantity::from(100_000))
2456 .submit(child_submitted)
2457 .build();
2458 cache
2459 .borrow_mut()
2460 .add_order(parent.clone(), None, None, false)
2461 .unwrap();
2462 cache
2463 .borrow_mut()
2464 .add_order(child.clone(), None, None, false)
2465 .unwrap();
2466 let event = apply_fill(
2467 &cache,
2468 &parent,
2469 &instrument,
2470 "T-PARTIAL",
2471 Quantity::from(40_000),
2472 );
2473
2474 let actions = manager.handle_event(&event);
2475 let repeated_actions = manager.handle_event(&event);
2476
2477 assert_eq!(actions.len(), if expects_submit { 2 } else { 1 });
2478 assert!(matches!(
2479 &actions[0],
2480 OrderManagerAction::ModifyLocalQuantity { order, quantity }
2481 if order.client_order_id() == child_id
2482 && *quantity == Quantity::from(40_000)
2483 ));
2484
2485 if expects_submit {
2486 assert!(matches!(
2487 &actions[1],
2488 OrderManagerAction::SubmitToRisk(command)
2489 if command.client_order_id == child_id
2490 ));
2491 }
2492 assert!(repeated_actions.is_empty());
2493
2494 let modify_rejected = OrderEventAny::ModifyRejected(
2495 OrderModifyRejectedSpec::builder()
2496 .trader_id(child.trader_id())
2497 .strategy_id(child.strategy_id())
2498 .instrument_id(child.instrument_id())
2499 .client_order_id(child_id)
2500 .reason("test rejection".into())
2501 .build(),
2502 );
2503 assert!(manager.handle_event(&modify_rejected).is_empty());
2504 let retry_actions = manager.handle_event(&event);
2505 assert!(matches!(
2506 retry_actions.as_slice(),
2507 [OrderManagerAction::ModifyLocalQuantity { order, quantity }]
2508 if order.client_order_id() == child_id
2509 && *quantity == Quantity::from(40_000)
2510 ));
2511
2512 let next_event = apply_fill(
2513 &cache,
2514 &parent,
2515 &instrument,
2516 "T-PARTIAL-2",
2517 Quantity::from(60_000),
2518 );
2519 let next_actions = manager.handle_event(&next_event);
2520
2521 assert!(next_actions.is_empty());
2522 assert_eq!(
2523 manager.oto_target_quantities.get(&child_id),
2524 Some(&Quantity::from(100_000)),
2525 );
2526 }
2527
2528 #[rstest]
2529 fn test_handle_oto_spread_fill_preserves_positionless_sizing() {
2530 let (clock, cache) = create_test_components();
2531 let mut manager = OrderManager::new(clock, cache.clone(), false);
2532 let instrument = InstrumentAny::FuturesSpread(futures_spread_es());
2533 let position_id = PositionId::from("P-SPREAD");
2534 let parent_id = ClientOrderId::from("O-PARENT");
2535 let child_id = ClientOrderId::from("O-CHILD");
2536 let parent = OrderTestBuilder::new(OrderType::Limit)
2537 .instrument_id(instrument.id())
2538 .client_order_id(parent_id)
2539 .side(OrderSide::Buy)
2540 .price(Price::from("100.00"))
2541 .quantity(Quantity::from(10))
2542 .contingency_type(ContingencyType::Oto)
2543 .linked_order_ids(vec![child_id])
2544 .submit(true)
2545 .build();
2546 let child = OrderTestBuilder::new(OrderType::Limit)
2547 .instrument_id(instrument.id())
2548 .client_order_id(child_id)
2549 .side(OrderSide::Sell)
2550 .price(Price::from("101.00"))
2551 .quantity(Quantity::from(10))
2552 .submit(true)
2553 .build();
2554
2555 cache
2556 .borrow_mut()
2557 .add_instrument(instrument.clone())
2558 .unwrap();
2559
2560 for order in [&parent, &child] {
2561 cache
2562 .borrow_mut()
2563 .add_order(order.clone(), None, None, false)
2564 .unwrap();
2565 }
2566 let mut builder = OrderFilledTestBuilder::new(&parent, &instrument);
2567 let event = builder
2568 .trade_id(TradeId::from("T-SPREAD"))
2569 .position_id(position_id)
2570 .last_qty(Quantity::from(4))
2571 .account_id(AccountId::from("ACCOUNT-001"))
2572 .without_commission()
2573 .build();
2574 cache.borrow_mut().update_order(&event).unwrap();
2575
2576 let actions = manager.handle_event(&event);
2577 let repeated_actions = manager.handle_event(&event);
2578 let cached_parent = cache.borrow().order_owned(&parent_id).unwrap();
2579
2580 assert!(matches!(
2581 actions.as_slice(),
2582 [OrderManagerAction::ModifyLocalQuantity { order, quantity }]
2583 if order.client_order_id() == child_id
2584 && *quantity == Quantity::from(4)
2585 ));
2586 assert!(repeated_actions.is_empty());
2587 assert_eq!(cached_parent.position_id(), Some(position_id));
2588 assert!(!cache.borrow().position_exists(&position_id));
2589 assert!(manager.handle_contingencies(&cached_parent).is_empty());
2590 assert!(
2591 manager
2592 .handle_contingencies_update(&cached_parent)
2593 .is_empty()
2594 );
2595 }
2596
2597 #[rstest]
2598 fn test_handle_oto_sizes_mixed_instrument_child_to_its_increment() {
2599 let (clock, cache) = create_test_components();
2600 let mut manager = OrderManager::new(clock, cache.clone(), false);
2601 let mut parent_instrument = currency_pair_btcusdt();
2602 parent_instrument.size_increment = Quantity::from("0.005000");
2603 let mut child_instrument = parent_instrument.clone();
2604 child_instrument.id = InstrumentId::from("BTC-USDT-EXIT.OKX");
2605 child_instrument.size_increment = Quantity::from("0.010000");
2606 let parent_instrument = InstrumentAny::CurrencyPair(parent_instrument);
2607 let child_instrument = InstrumentAny::CurrencyPair(child_instrument);
2608 let position_id = PositionId::from("P-MIXED");
2609 let parent_id = ClientOrderId::from("O-PARENT");
2610 let child_id = ClientOrderId::from("O-CHILD");
2611 let parent = OrderTestBuilder::new(OrderType::Limit)
2612 .instrument_id(parent_instrument.id())
2613 .client_order_id(parent_id)
2614 .side(OrderSide::Buy)
2615 .price(Price::from("50000.00"))
2616 .quantity(Quantity::from("1.000000"))
2617 .contingency_type(ContingencyType::Oto)
2618 .linked_order_ids(vec![child_id])
2619 .submit(true)
2620 .build();
2621 let child = OrderTestBuilder::new(OrderType::StopMarket)
2622 .instrument_id(child_instrument.id())
2623 .client_order_id(child_id)
2624 .side(OrderSide::Sell)
2625 .trigger_price(Price::from("49000.00"))
2626 .quantity(Quantity::from("1.000000"))
2627 .reduce_only(true)
2628 .submit(true)
2629 .build();
2630
2631 for instrument in [&parent_instrument, &child_instrument] {
2632 cache
2633 .borrow_mut()
2634 .add_instrument(instrument.clone())
2635 .unwrap();
2636 }
2637
2638 for order in [&parent, &child] {
2639 cache
2640 .borrow_mut()
2641 .add_order(order.clone(), None, None, false)
2642 .unwrap();
2643 }
2644 let event = apply_position_fill(
2645 &cache,
2646 &parent,
2647 &parent_instrument,
2648 position_id,
2649 "T-PARENT",
2650 Quantity::from("1.000000"),
2651 Some(Money::from("0.00040000 BTC")),
2652 );
2653
2654 let actions = manager.handle_event(&event);
2655 let position = cache.borrow().position_owned(&position_id).unwrap();
2656
2657 assert!(matches!(
2658 actions.as_slice(),
2659 [OrderManagerAction::ModifyLocalQuantity { order, quantity }]
2660 if order.client_order_id() == child_id
2661 && *quantity == Quantity::from("0.990000")
2662 && quantity.raw().is_multiple_of(child_instrument.size_increment().raw())
2663 && order.instrument_id() == child_instrument.id()
2664 ));
2665 let OrderManagerAction::ModifyLocalQuantity { order, quantity } = &actions[0] else {
2666 unreachable!();
2667 };
2668 let mut resized = order.clone();
2669 resized.set_quantity(*quantity);
2670 resized.set_leaves_qty(*quantity);
2671
2672 assert_eq!(position.quantity, Quantity::from("0.999600"));
2673 assert!(resized.would_reduce_only(position.side, position.quantity));
2674 }
2675
2676 #[rstest]
2677 fn test_handle_oto_base_commission_sizes_both_exits_to_net_position() {
2678 let (clock, cache) = create_test_components();
2679 let mut manager = OrderManager::new(clock, cache.clone(), true);
2680 let mut instrument = currency_pair_btcusdt();
2681 instrument.size_increment = Quantity::from("0.005000");
2682 let instrument = InstrumentAny::CurrencyPair(instrument);
2683 let position_id = PositionId::from("P-COMMISSION");
2684 let parent_id = ClientOrderId::from("O-PARENT");
2685 let stop_id = ClientOrderId::from("O-STOP");
2686 let take_id = ClientOrderId::from("O-TAKE");
2687 let parent = OrderTestBuilder::new(OrderType::Limit)
2688 .instrument_id(instrument.id())
2689 .client_order_id(parent_id)
2690 .side(OrderSide::Buy)
2691 .price(Price::from("50000.00"))
2692 .quantity(Quantity::from("1.000000"))
2693 .contingency_type(ContingencyType::Oto)
2694 .linked_order_ids(vec![stop_id, take_id])
2695 .submit(true)
2696 .build();
2697 let stop = OrderTestBuilder::new(OrderType::StopMarket)
2698 .instrument_id(instrument.id())
2699 .client_order_id(stop_id)
2700 .side(OrderSide::Sell)
2701 .trigger_price(Price::from("49000.00"))
2702 .quantity(Quantity::from("1.000000"))
2703 .reduce_only(true)
2704 .contingency_type(ContingencyType::Oco)
2705 .linked_order_ids(vec![stop_id, take_id])
2706 .parent_order_id(parent_id)
2707 .build();
2708 let take = OrderTestBuilder::new(OrderType::Limit)
2709 .instrument_id(instrument.id())
2710 .client_order_id(take_id)
2711 .side(OrderSide::Sell)
2712 .price(Price::from("51000.00"))
2713 .quantity(Quantity::from("1.000000"))
2714 .reduce_only(true)
2715 .contingency_type(ContingencyType::Oco)
2716 .linked_order_ids(vec![stop_id, take_id])
2717 .parent_order_id(parent_id)
2718 .build();
2719
2720 cache
2721 .borrow_mut()
2722 .add_instrument(instrument.clone())
2723 .unwrap();
2724
2725 for order in [&parent, &stop, &take] {
2726 cache
2727 .borrow_mut()
2728 .add_order(order.clone(), None, None, false)
2729 .unwrap();
2730 }
2731 let first = apply_position_fill(
2732 &cache,
2733 &parent,
2734 &instrument,
2735 position_id,
2736 "T-PARENT-1",
2737 Quantity::from("0.600000"),
2738 Some(Money::from("0.00040000 BTC")),
2739 );
2740
2741 let first_actions = manager.handle_event(&first);
2742 let repeated_first_actions = manager.handle_event(&first);
2743 let first_position = cache.borrow().position_owned(&position_id).unwrap();
2744
2745 assert_eq!(first_position.quantity, Quantity::from("0.599600"));
2746 assert_eq!(first_actions.len(), 4);
2747 for (actions, child_id) in first_actions
2748 .as_chunks::<2>()
2749 .0
2750 .iter()
2751 .zip([stop_id, take_id])
2752 {
2753 let OrderManagerAction::ModifyLocalQuantity { order, quantity } = &actions[0] else {
2754 panic!("expected child quantity update");
2755 };
2756 let mut resized = order.clone();
2757 resized.set_quantity(*quantity);
2758 resized.set_leaves_qty(*quantity);
2759
2760 assert_eq!(order.client_order_id(), child_id);
2761 assert_eq!(*quantity, Quantity::from("0.595000"));
2762 assert!(resized.would_reduce_only(first_position.side, first_position.quantity));
2763 assert!(matches!(
2764 &actions[1],
2765 OrderManagerAction::SubmitToRisk(command)
2766 if command.client_order_id == child_id
2767 && command.position_id == Some(position_id)
2768 ));
2769 }
2770 assert!(repeated_first_actions.is_empty());
2771
2772 let second = apply_position_fill(
2773 &cache,
2774 &parent,
2775 &instrument,
2776 position_id,
2777 "T-PARENT-2",
2778 Quantity::from("0.400000"),
2779 Some(Money::from("0.00040000 BTC")),
2780 );
2781
2782 let second_actions = manager.handle_event(&second);
2783 let repeated_second_actions = manager.handle_event(&second);
2784 let second_position = cache.borrow().position_owned(&position_id).unwrap();
2785
2786 assert_eq!(second_position.quantity, Quantity::from("0.999200"));
2787 assert_eq!(second_actions.len(), 2);
2788 for (action, child_id) in second_actions.iter().zip([stop_id, take_id]) {
2789 let OrderManagerAction::ModifyLocalQuantity { order, quantity } = action else {
2790 panic!("expected child quantity update");
2791 };
2792 let mut resized = order.clone();
2793 resized.set_quantity(*quantity);
2794 resized.set_leaves_qty(*quantity);
2795
2796 assert_eq!(order.client_order_id(), child_id);
2797 assert_eq!(*quantity, Quantity::from("0.995000"));
2798 assert!(resized.would_reduce_only(second_position.side, second_position.quantity));
2799 }
2800 assert!(repeated_second_actions.is_empty());
2801 assert_eq!(manager.submit_order_commands.len(), 2);
2802
2803 let cached_parent = cache.borrow().order_owned(&parent_id).unwrap();
2804 assert!(manager.handle_contingencies(&cached_parent).is_empty());
2805 assert!(
2806 manager
2807 .handle_contingencies_update(&cached_parent)
2808 .is_empty()
2809 );
2810 }
2811
2812 #[rstest]
2813 fn test_handle_oto_sizes_partially_filled_child_by_leaves_quantity() {
2814 let (clock, cache) = create_test_components();
2815 let mut manager = OrderManager::new(clock, cache.clone(), false);
2816 let mut instrument = currency_pair_btcusdt();
2817 instrument.size_increment = Quantity::from("0.005000");
2818 let instrument = InstrumentAny::CurrencyPair(instrument);
2819 let position_id = PositionId::from("P-COMMISSION");
2820 let parent_id = ClientOrderId::from("O-PARENT");
2821 let child_id = ClientOrderId::from("O-CHILD");
2822 let parent = OrderTestBuilder::new(OrderType::Limit)
2823 .instrument_id(instrument.id())
2824 .client_order_id(parent_id)
2825 .side(OrderSide::Buy)
2826 .price(Price::from("50000.00"))
2827 .quantity(Quantity::from("1.000000"))
2828 .contingency_type(ContingencyType::Oto)
2829 .linked_order_ids(vec![child_id])
2830 .submit(true)
2831 .build();
2832 let child = OrderTestBuilder::new(OrderType::StopMarket)
2833 .instrument_id(instrument.id())
2834 .client_order_id(child_id)
2835 .side(OrderSide::Sell)
2836 .trigger_price(Price::from("49000.00"))
2837 .quantity(Quantity::from("1.000000"))
2838 .reduce_only(true)
2839 .submit(true)
2840 .build();
2841
2842 cache
2843 .borrow_mut()
2844 .add_instrument(instrument.clone())
2845 .unwrap();
2846
2847 for order in [&parent, &child] {
2848 cache
2849 .borrow_mut()
2850 .add_order(order.clone(), None, None, false)
2851 .unwrap();
2852 }
2853 let first_parent_fill = apply_position_fill(
2854 &cache,
2855 &parent,
2856 &instrument,
2857 position_id,
2858 "T-PARENT-1",
2859 Quantity::from("0.600000"),
2860 Some(Money::from("0.00040000 BTC")),
2861 );
2862 let first_actions = manager.handle_event(&first_parent_fill);
2863 assert!(matches!(
2864 first_actions.as_slice(),
2865 [OrderManagerAction::ModifyLocalQuantity { quantity, .. }]
2866 if *quantity == Quantity::from("0.595000")
2867 ));
2868
2869 apply_accepted(&cache, &child, "V-CHILD");
2870 apply_update(&cache, &child, Quantity::from("0.595000"));
2871 let child = cache.borrow().order_owned(&child_id).unwrap();
2872 let child_fill = apply_position_fill(
2873 &cache,
2874 &child,
2875 &instrument,
2876 position_id,
2877 "T-CHILD",
2878 Quantity::from("0.400000"),
2879 None,
2880 );
2881 manager.handle_event(&child_fill);
2882 let position = cache.borrow().position_owned(&position_id).unwrap();
2883 assert_eq!(position.quantity, Quantity::from("0.199600"));
2884
2885 let parent = cache.borrow().order_owned(&parent_id).unwrap();
2886 let second_parent_fill = apply_position_fill(
2887 &cache,
2888 &parent,
2889 &instrument,
2890 position_id,
2891 "T-PARENT-2",
2892 Quantity::from("0.400000"),
2893 Some(Money::from("0.00040000 BTC")),
2894 );
2895 let actions = manager.handle_event(&second_parent_fill);
2896 let position = cache.borrow().position_owned(&position_id).unwrap();
2897
2898 let [OrderManagerAction::ModifyLocalQuantity { order, quantity }] = actions.as_slice()
2899 else {
2900 panic!("expected child quantity update");
2901 };
2902 let leaves_qty = *quantity - order.filled_qty();
2903 let mut resized = order.clone();
2904 resized.set_quantity(*quantity);
2905 resized.set_leaves_qty(leaves_qty);
2906
2907 assert_eq!(position.quantity, Quantity::from("0.599200"));
2908 assert_eq!(order.filled_qty(), Quantity::from("0.400000"));
2909 assert_eq!(*quantity, Quantity::from("0.995000"));
2910 assert_eq!(leaves_qty, Quantity::from("0.595000"));
2911 assert!(resized.would_reduce_only(position.side, position.quantity));
2912 }
2913
2914 #[rstest]
2915 fn test_handle_oto_child_base_commission_reduces_total_target() {
2916 let (clock, cache) = create_test_components();
2917 let mut manager = OrderManager::new(clock, cache.clone(), false);
2918 let mut instrument = currency_pair_btcusdt();
2919 instrument.size_increment = Quantity::from("0.005000");
2920 let instrument = InstrumentAny::CurrencyPair(instrument);
2921 let position_id = PositionId::from("P-CHILD-COMMISSION");
2922 let parent_id = ClientOrderId::from("O-PARENT");
2923 let child_id = ClientOrderId::from("O-CHILD");
2924 let parent = OrderTestBuilder::new(OrderType::Limit)
2925 .instrument_id(instrument.id())
2926 .client_order_id(parent_id)
2927 .side(OrderSide::Buy)
2928 .price(Price::from("50000.00"))
2929 .quantity(Quantity::from("1.000000"))
2930 .contingency_type(ContingencyType::Oto)
2931 .linked_order_ids(vec![child_id])
2932 .submit(true)
2933 .build();
2934 let child = OrderTestBuilder::new(OrderType::StopMarket)
2935 .instrument_id(instrument.id())
2936 .client_order_id(child_id)
2937 .side(OrderSide::Sell)
2938 .trigger_price(Price::from("49000.00"))
2939 .quantity(Quantity::from("1.000000"))
2940 .reduce_only(true)
2941 .submit(true)
2942 .build();
2943
2944 cache
2945 .borrow_mut()
2946 .add_instrument(instrument.clone())
2947 .unwrap();
2948
2949 for order in [&parent, &child] {
2950 cache
2951 .borrow_mut()
2952 .add_order(order.clone(), None, None, false)
2953 .unwrap();
2954 }
2955 apply_accepted(&cache, &child, "V-CHILD");
2956 let parent_fill = apply_position_fill(
2957 &cache,
2958 &parent,
2959 &instrument,
2960 position_id,
2961 "T-PARENT",
2962 Quantity::from("1.000000"),
2963 None,
2964 );
2965 assert!(manager.handle_event(&parent_fill).is_empty());
2966
2967 let child = cache.borrow().order_owned(&child_id).unwrap();
2968 let child_fill = apply_position_fill(
2969 &cache,
2970 &child,
2971 &instrument,
2972 position_id,
2973 "T-CHILD",
2974 Quantity::from("0.400000"),
2975 Some(Money::from("0.00100000 BTC")),
2976 );
2977 assert!(manager.handle_event(&child_fill).is_empty());
2978
2979 let position = cache.borrow().position_owned(&position_id).unwrap();
2980 let parent = cache.borrow().order_owned(&parent_id).unwrap();
2981 let actions = manager.handle_contingencies(&parent);
2982
2983 assert_eq!(position.quantity, Quantity::from("0.599000"));
2984 assert!(matches!(
2985 actions.as_slice(),
2986 [OrderManagerAction::ModifyLocalQuantity { order, quantity }]
2987 if order.client_order_id() == child_id
2988 && order.filled_qty() == Quantity::from("0.400000")
2989 && *quantity == Quantity::from("0.995000")
2990 ));
2991 }
2992
2993 #[rstest]
2994 fn test_handle_oto_non_reduce_only_child_uses_parent_fill_quantity() {
2995 let (clock, cache) = create_test_components();
2996 let mut manager = OrderManager::new(clock, cache.clone(), false);
2997 let instrument = InstrumentAny::CurrencyPair(currency_pair_btcusdt());
2998 let position_id = PositionId::from("P-REENTRY");
2999 let parent_id = ClientOrderId::from("O-PARENT");
3000 let child_id = ClientOrderId::from("O-CHILD");
3001 let entry = OrderTestBuilder::new(OrderType::Market)
3002 .instrument_id(instrument.id())
3003 .client_order_id(ClientOrderId::from("O-ENTRY"))
3004 .side(OrderSide::Buy)
3005 .quantity(Quantity::from("1.000000"))
3006 .submit(true)
3007 .build();
3008 let parent = OrderTestBuilder::new(OrderType::Market)
3009 .instrument_id(instrument.id())
3010 .client_order_id(parent_id)
3011 .side(OrderSide::Sell)
3012 .quantity(Quantity::from("1.000000"))
3013 .contingency_type(ContingencyType::Oto)
3014 .linked_order_ids(vec![child_id])
3015 .submit(true)
3016 .build();
3017 let child = OrderTestBuilder::new(OrderType::Market)
3018 .instrument_id(instrument.id())
3019 .client_order_id(child_id)
3020 .side(OrderSide::Buy)
3021 .quantity(Quantity::from("0.500000"))
3022 .submit(true)
3023 .build();
3024
3025 cache
3026 .borrow_mut()
3027 .add_instrument(instrument.clone())
3028 .unwrap();
3029 cache
3030 .borrow_mut()
3031 .add_order(entry.clone(), Some(position_id), None, false)
3032 .unwrap();
3033 apply_accepted(&cache, &entry, "V-ENTRY");
3034 let entry = cache
3035 .borrow()
3036 .order_owned(&entry.client_order_id())
3037 .unwrap();
3038 apply_position_fill(
3039 &cache,
3040 &entry,
3041 &instrument,
3042 position_id,
3043 "T-ENTRY",
3044 Quantity::from("1.000000"),
3045 None,
3046 );
3047 cache
3048 .borrow_mut()
3049 .add_order(parent.clone(), Some(position_id), None, false)
3050 .unwrap();
3051 cache
3052 .borrow_mut()
3053 .add_order(child, Some(position_id), None, false)
3054 .unwrap();
3055 apply_accepted(&cache, &parent, "V-PARENT");
3056 let parent = cache.borrow().order_owned(&parent_id).unwrap();
3057 let event = apply_position_fill(
3058 &cache,
3059 &parent,
3060 &instrument,
3061 position_id,
3062 "T-PARENT",
3063 Quantity::from("1.000000"),
3064 None,
3065 );
3066
3067 let actions = manager.handle_event(&event);
3068
3069 assert!(cache.borrow().position(&position_id).unwrap().is_closed());
3070 assert!(matches!(
3071 actions.as_slice(),
3072 [OrderManagerAction::ModifyLocalQuantity { order, quantity }]
3073 if order.client_order_id() == child_id
3074 && *quantity == Quantity::from("1.000000")
3075 ));
3076 }
3077
3078 #[rstest]
3079 fn test_handle_oto_zero_target_clears_stored_positive_target() {
3080 let (clock, cache) = create_test_components();
3081 let mut manager = OrderManager::new(clock, cache.clone(), false);
3082 let mut instrument = currency_pair_btcusdt();
3083 instrument.size_increment = Quantity::from("0.005000");
3084 let instrument = InstrumentAny::CurrencyPair(instrument);
3085 let position_id = PositionId::from("P-CLEAR-TARGET");
3086 let parent_id = ClientOrderId::from("O-PARENT");
3087 let child_id = ClientOrderId::from("O-CHILD");
3088 let parent = OrderTestBuilder::new(OrderType::Limit)
3089 .instrument_id(instrument.id())
3090 .client_order_id(parent_id)
3091 .side(OrderSide::Buy)
3092 .price(Price::from("50000.00"))
3093 .quantity(Quantity::from("1.000000"))
3094 .contingency_type(ContingencyType::Oto)
3095 .linked_order_ids(vec![child_id])
3096 .submit(true)
3097 .build();
3098 let child = OrderTestBuilder::new(OrderType::StopMarket)
3099 .instrument_id(instrument.id())
3100 .client_order_id(child_id)
3101 .side(OrderSide::Sell)
3102 .trigger_price(Price::from("49000.00"))
3103 .quantity(Quantity::from("1.000000"))
3104 .reduce_only(true)
3105 .submit(true)
3106 .build();
3107 let external_exit = OrderTestBuilder::new(OrderType::Market)
3108 .instrument_id(instrument.id())
3109 .client_order_id(ClientOrderId::from("O-EXTERNAL-EXIT"))
3110 .side(OrderSide::Sell)
3111 .quantity(Quantity::from("0.598000"))
3112 .submit(true)
3113 .build();
3114
3115 cache
3116 .borrow_mut()
3117 .add_instrument(instrument.clone())
3118 .unwrap();
3119
3120 for order in [&parent, &child, &external_exit] {
3121 cache
3122 .borrow_mut()
3123 .add_order(order.clone(), Some(position_id), None, false)
3124 .unwrap();
3125 }
3126 apply_accepted(&cache, &parent, "V-PARENT");
3127 apply_accepted(&cache, &external_exit, "V-EXTERNAL-EXIT");
3128 let parent = cache.borrow().order_owned(&parent_id).unwrap();
3129 let external_exit = cache
3130 .borrow()
3131 .order_owned(&external_exit.client_order_id())
3132 .unwrap();
3133 let first = apply_position_fill(
3134 &cache,
3135 &parent,
3136 &instrument,
3137 position_id,
3138 "T-PARENT-1",
3139 Quantity::from("0.600000"),
3140 Some(Money::from("0.00040000 BTC")),
3141 );
3142 let first_actions = manager.handle_event(&first);
3143 assert!(matches!(
3144 first_actions.as_slice(),
3145 [OrderManagerAction::ModifyLocalQuantity { quantity, .. }]
3146 if *quantity == Quantity::from("0.595000")
3147 ));
3148 assert_eq!(
3149 manager.oto_target_quantities.get(&child_id),
3150 Some(&Quantity::from("0.595000"))
3151 );
3152
3153 apply_position_fill(
3154 &cache,
3155 &external_exit,
3156 &instrument,
3157 position_id,
3158 "T-EXTERNAL-EXIT",
3159 Quantity::from("0.598000"),
3160 None,
3161 );
3162 let parent = cache.borrow().order_owned(&parent_id).unwrap();
3163 let second = apply_position_fill(
3164 &cache,
3165 &parent,
3166 &instrument,
3167 position_id,
3168 "T-PARENT-2",
3169 Quantity::from("0.001000"),
3170 None,
3171 );
3172 let second_actions = manager.handle_event(&second);
3173
3174 assert_eq!(
3175 cache.borrow().position(&position_id).unwrap().quantity,
3176 Quantity::from("0.002600")
3177 );
3178 assert!(second_actions.is_empty());
3179 assert!(!manager.oto_target_quantities.contains_key(&child_id));
3180 }
3181
3182 #[rstest]
3183 #[case::none(None)]
3184 #[case::zero_base(Some(Money::from("0 BTC")))]
3185 #[case::quote(Some(Money::from("10 USDT")))]
3186 fn test_oto_quantity_preserves_gross_without_base_commission(
3187 #[case] commission: Option<Money>,
3188 ) {
3189 let (clock, cache) = create_test_components();
3190 let manager = OrderManager::new(clock, cache.clone(), true);
3191 let mut instrument = currency_pair_btcusdt();
3192 instrument.size_increment = Quantity::from("0.005000");
3193 let instrument = InstrumentAny::CurrencyPair(instrument);
3194 let position_id = PositionId::from("P-COMMISSION");
3195 let parent = OrderTestBuilder::new(OrderType::Limit)
3196 .instrument_id(instrument.id())
3197 .client_order_id(ClientOrderId::from("O-PARENT"))
3198 .side(OrderSide::Buy)
3199 .price(Price::from("50000.00"))
3200 .quantity(Quantity::from("0.600000"))
3201 .reduce_only(true)
3202 .contingency_type(ContingencyType::Oto)
3203 .linked_order_ids(vec![ClientOrderId::from("O-CHILD")])
3204 .submit(true)
3205 .build();
3206
3207 cache
3208 .borrow_mut()
3209 .add_instrument(instrument.clone())
3210 .unwrap();
3211 cache
3212 .borrow_mut()
3213 .add_order(parent.clone(), None, None, false)
3214 .unwrap();
3215 apply_position_fill(
3216 &cache,
3217 &parent,
3218 &instrument,
3219 position_id,
3220 "T-PARENT",
3221 Quantity::from("0.600000"),
3222 commission,
3223 );
3224 let position = cache.borrow().position_owned(&position_id).unwrap();
3225
3226 let quantity = manager
3227 .position_adjusted_oto_quantity(
3228 &parent,
3229 &parent,
3230 Quantity::from("0.600000"),
3231 Quantity::from("0.000000"),
3232 Some(position_id),
3233 )
3234 .unwrap();
3235
3236 assert_eq!(position.quantity, Quantity::from("0.600000"));
3237 assert_eq!(quantity, Quantity::from("0.600000"));
3238 }
3239
3240 #[rstest]
3241 #[case::position(true, false)]
3242 #[case::instrument(false, true)]
3243 fn test_oto_handlers_require_complete_position_state(
3244 #[case] add_instrument: bool,
3245 #[case] add_position: bool,
3246 ) {
3247 let (clock, cache) = create_test_components();
3248 let mut manager = OrderManager::new(clock, cache.clone(), true);
3249 let instrument = InstrumentAny::CurrencyPair(currency_pair_btcusdt());
3250 let position_id = PositionId::from("P-MISSING");
3251 let parent_id = ClientOrderId::from("O-PARENT");
3252 let child_id = ClientOrderId::from("O-CHILD");
3253 let parent = OrderTestBuilder::new(OrderType::Limit)
3254 .instrument_id(instrument.id())
3255 .client_order_id(parent_id)
3256 .side(OrderSide::Buy)
3257 .price(Price::from("50000.00"))
3258 .quantity(Quantity::from("1.000000"))
3259 .contingency_type(ContingencyType::Oto)
3260 .linked_order_ids(vec![child_id])
3261 .submit(true)
3262 .build();
3263 let child = OrderTestBuilder::new(OrderType::StopMarket)
3264 .instrument_id(instrument.id())
3265 .client_order_id(child_id)
3266 .side(OrderSide::Sell)
3267 .trigger_price(Price::from("49000.00"))
3268 .quantity(Quantity::from("1.000000"))
3269 .reduce_only(true)
3270 .build();
3271 let event = TestOrderEventStubs::filled(
3272 &parent,
3273 &instrument,
3274 Some(TradeId::from("T-PARENT")),
3275 Some(position_id),
3276 None,
3277 None,
3278 None,
3279 None,
3280 None,
3281 Some(AccountId::from("ACCOUNT-001")),
3282 );
3283 let OrderEventAny::Filled(fill) = &event else {
3284 unreachable!();
3285 };
3286
3287 cache
3288 .borrow_mut()
3289 .add_order(parent, Some(position_id), None, false)
3290 .unwrap();
3291 cache
3292 .borrow_mut()
3293 .add_order(child, None, None, false)
3294 .unwrap();
3295 cache.borrow_mut().update_order(&event).unwrap();
3296
3297 if add_instrument {
3298 cache
3299 .borrow_mut()
3300 .add_instrument(instrument.clone())
3301 .unwrap();
3302 }
3303
3304 if add_position {
3305 let position = Position::new(&instrument, fill.clone());
3306 cache
3307 .borrow_mut()
3308 .add_position(&position, OmsType::Netting)
3309 .unwrap();
3310 }
3311
3312 let fill_actions = manager.handle_event(&event);
3313 let cached_parent = cache.borrow().order_owned(&parent_id).unwrap();
3314 let contingency_actions = manager.handle_contingencies(&cached_parent);
3315 let update_actions = manager.handle_contingencies_update(&cached_parent);
3316
3317 assert!(fill_actions.is_empty());
3318 assert!(contingency_actions.is_empty());
3319 assert!(update_actions.is_empty());
3320 assert!(manager.submit_order_commands.is_empty());
3321 }
3322
3323 #[rstest]
3324 fn test_oto_quantity_rejects_zero_size_increment() {
3325 let (clock, cache) = create_test_components();
3326 let manager = OrderManager::new(clock, cache.clone(), true);
3327 let mut instrument = currency_pair_btcusdt();
3328 instrument.size_increment = Quantity::zero(instrument.size_precision);
3329 let instrument = InstrumentAny::CurrencyPair(instrument);
3330 let position_id = PositionId::from("P-ZERO-INCREMENT");
3331 let parent = OrderTestBuilder::new(OrderType::Limit)
3332 .instrument_id(instrument.id())
3333 .side(OrderSide::Buy)
3334 .price(Price::from("50000.00"))
3335 .quantity(Quantity::from("1.000000"))
3336 .build();
3337 let event = TestOrderEventStubs::filled(
3338 &parent,
3339 &instrument,
3340 Some(TradeId::from("T-PARENT")),
3341 Some(position_id),
3342 None,
3343 None,
3344 None,
3345 None,
3346 None,
3347 Some(AccountId::from("ACCOUNT-001")),
3348 );
3349 let OrderEventAny::Filled(fill) = event else {
3350 unreachable!();
3351 };
3352 let position = Position::new(&instrument, fill);
3353
3354 cache.borrow_mut().add_instrument(instrument).unwrap();
3355 cache
3356 .borrow_mut()
3357 .add_position(&position, OmsType::Netting)
3358 .unwrap();
3359
3360 assert_eq!(
3361 manager.position_adjusted_oto_quantity(
3362 &parent,
3363 &parent,
3364 Quantity::from("1.000000"),
3365 Quantity::from("0.000000"),
3366 Some(position_id),
3367 ),
3368 None,
3369 );
3370 }
3371
3372 #[rstest]
3373 fn test_handle_oto_waits_for_instrument_minimum_quantity() {
3374 let (clock, cache) = create_test_components();
3375 let mut manager = OrderManager::new(clock, cache.clone(), true);
3376 let mut instrument = currency_pair_btcusdt();
3377 instrument.size_increment = Quantity::from("0.005000");
3378 instrument.min_quantity = Some(Quantity::from("0.010000"));
3379 let instrument = InstrumentAny::CurrencyPair(instrument);
3380 let position_id = PositionId::from("P-MINIMUM");
3381 let parent_id = ClientOrderId::from("O-PARENT");
3382 let child_id = ClientOrderId::from("O-CHILD");
3383 let parent = OrderTestBuilder::new(OrderType::Limit)
3384 .instrument_id(instrument.id())
3385 .client_order_id(parent_id)
3386 .side(OrderSide::Buy)
3387 .price(Price::from("50000.00"))
3388 .quantity(Quantity::from("0.012000"))
3389 .contingency_type(ContingencyType::Oto)
3390 .linked_order_ids(vec![child_id])
3391 .submit(true)
3392 .build();
3393 let child = OrderTestBuilder::new(OrderType::StopMarket)
3394 .instrument_id(instrument.id())
3395 .client_order_id(child_id)
3396 .side(OrderSide::Sell)
3397 .trigger_price(Price::from("49000.00"))
3398 .quantity(Quantity::from("0.012000"))
3399 .reduce_only(true)
3400 .build();
3401
3402 cache
3403 .borrow_mut()
3404 .add_instrument(instrument.clone())
3405 .unwrap();
3406
3407 for order in [&parent, &child] {
3408 cache
3409 .borrow_mut()
3410 .add_order(order.clone(), None, None, false)
3411 .unwrap();
3412 }
3413 let first = apply_position_fill(
3414 &cache,
3415 &parent,
3416 &instrument,
3417 position_id,
3418 "T-PARENT-1",
3419 Quantity::from("0.006000"),
3420 None,
3421 );
3422
3423 let first_actions = manager.handle_event(&first);
3424
3425 assert!(first_actions.is_empty());
3426 assert!(manager.submit_order_commands.is_empty());
3427
3428 let parent = cache.borrow().order_owned(&parent_id).unwrap();
3429 let second = apply_position_fill(
3430 &cache,
3431 &parent,
3432 &instrument,
3433 position_id,
3434 "T-PARENT-2",
3435 Quantity::from("0.006000"),
3436 None,
3437 );
3438 let second_actions = manager.handle_event(&second);
3439
3440 assert!(matches!(
3441 second_actions.as_slice(),
3442 [
3443 OrderManagerAction::ModifyLocalQuantity { order, quantity },
3444 OrderManagerAction::SubmitToRisk(command),
3445 ] if order.client_order_id() == child_id
3446 && *quantity == Quantity::from("0.010000")
3447 && command.client_order_id == child_id
3448 ));
3449 }
3450
3451 #[rstest]
3452 fn test_handle_oto_waits_for_executable_quantity_and_preserves_residual() {
3453 let (clock, cache) = create_test_components();
3454 let mut manager = OrderManager::new(clock, cache.clone(), true);
3455 let mut instrument = currency_pair_btcusdt();
3456 instrument.size_increment = Quantity::from("0.005000");
3457 let instrument = InstrumentAny::CurrencyPair(instrument);
3458 let position_id = PositionId::from("P-RESIDUAL");
3459 let parent_id = ClientOrderId::from("O-PARENT");
3460 let child_id = ClientOrderId::from("O-CHILD");
3461 let parent = OrderTestBuilder::new(OrderType::Limit)
3462 .instrument_id(instrument.id())
3463 .client_order_id(parent_id)
3464 .side(OrderSide::Buy)
3465 .price(Price::from("50000.00"))
3466 .quantity(Quantity::from("0.010000"))
3467 .contingency_type(ContingencyType::Oto)
3468 .linked_order_ids(vec![child_id])
3469 .submit(true)
3470 .build();
3471 let child = OrderTestBuilder::new(OrderType::StopMarket)
3472 .instrument_id(instrument.id())
3473 .client_order_id(child_id)
3474 .side(OrderSide::Sell)
3475 .trigger_price(Price::from("49000.00"))
3476 .quantity(Quantity::from("0.010000"))
3477 .reduce_only(true)
3478 .build();
3479
3480 cache
3481 .borrow_mut()
3482 .add_instrument(instrument.clone())
3483 .unwrap();
3484
3485 for order in [&parent, &child] {
3486 cache
3487 .borrow_mut()
3488 .add_order(order.clone(), None, None, false)
3489 .unwrap();
3490 }
3491 let first = apply_position_fill(
3492 &cache,
3493 &parent,
3494 &instrument,
3495 position_id,
3496 "T-PARENT-1",
3497 Quantity::from("0.003000"),
3498 Some(Money::from("0.00040000 BTC")),
3499 );
3500
3501 let first_actions = manager.handle_event(&first);
3502 let first_position = cache.borrow().position_owned(&position_id).unwrap();
3503
3504 assert!(first_actions.is_empty());
3505 assert_eq!(first_position.quantity, Quantity::from("0.002600"));
3506 assert!(manager.submit_order_commands.is_empty());
3507
3508 let second = apply_position_fill(
3509 &cache,
3510 &parent,
3511 &instrument,
3512 position_id,
3513 "T-PARENT-2",
3514 Quantity::from("0.007000"),
3515 Some(Money::from("0.00040000 BTC")),
3516 );
3517
3518 let second_actions = manager.handle_event(&second);
3519 let position = cache.borrow().position_owned(&position_id).unwrap();
3520
3521 assert_eq!(position.quantity, Quantity::from("0.009200"));
3522 assert!(matches!(
3523 second_actions.as_slice(),
3524 [
3525 OrderManagerAction::ModifyLocalQuantity { order, quantity },
3526 OrderManagerAction::SubmitToRisk(command),
3527 ] if order.client_order_id() == child_id
3528 && *quantity == Quantity::from("0.005000")
3529 && command.client_order_id == child_id
3530 ));
3531 assert_eq!(
3532 position.quantity - Quantity::from("0.005000"),
3533 Quantity::from("0.004200"),
3534 );
3535 }
3536
3537 #[rstest]
3538 #[case::accepted(false)]
3539 #[case::emulated(true)]
3540 fn test_handle_oto_zero_target_waits_for_more_executable_quantity(#[case] active_local: bool) {
3541 let (clock, cache) = create_test_components();
3542 let mut manager = OrderManager::new(clock, cache.clone(), active_local);
3543 let mut instrument = currency_pair_btcusdt();
3544 instrument.size_increment = Quantity::from("0.005000");
3545 let instrument = InstrumentAny::CurrencyPair(instrument);
3546 let position_id = PositionId::from("P-WAIT");
3547 let parent_id = ClientOrderId::from("O-PARENT");
3548 let child_id = ClientOrderId::from("O-CHILD");
3549 let parent = OrderTestBuilder::new(OrderType::Limit)
3550 .instrument_id(instrument.id())
3551 .client_order_id(parent_id)
3552 .side(OrderSide::Buy)
3553 .price(Price::from("50000.00"))
3554 .quantity(Quantity::from("0.010000"))
3555 .contingency_type(ContingencyType::Oto)
3556 .linked_order_ids(vec![child_id])
3557 .submit(true)
3558 .build();
3559 let child = OrderTestBuilder::new(OrderType::StopMarket)
3560 .instrument_id(instrument.id())
3561 .client_order_id(child_id)
3562 .side(OrderSide::Sell)
3563 .trigger_price(Price::from("49000.00"))
3564 .quantity(Quantity::from("0.010000"))
3565 .reduce_only(true)
3566 .submit(!active_local)
3567 .build();
3568
3569 cache
3570 .borrow_mut()
3571 .add_instrument(instrument.clone())
3572 .unwrap();
3573
3574 for order in [&parent, &child] {
3575 cache
3576 .borrow_mut()
3577 .add_order(order.clone(), None, None, false)
3578 .unwrap();
3579 }
3580
3581 if active_local {
3582 let event = OrderEventAny::Emulated(
3583 OrderEmulatedSpec::builder()
3584 .trader_id(child.trader_id())
3585 .strategy_id(child.strategy_id())
3586 .instrument_id(child.instrument_id())
3587 .client_order_id(child_id)
3588 .build(),
3589 );
3590 cache.borrow_mut().update_order(&event).unwrap();
3591 } else {
3592 apply_accepted(&cache, &child, "V-CHILD");
3593 }
3594 let first = apply_position_fill(
3595 &cache,
3596 &parent,
3597 &instrument,
3598 position_id,
3599 "T-PARENT-1",
3600 Quantity::from("0.003000"),
3601 Some(Money::from("0.00040000 BTC")),
3602 );
3603
3604 let first_actions = manager.handle_event(&first);
3605 let cached_child = cache.borrow().order_owned(&child_id).unwrap();
3606
3607 assert!(first_actions.is_empty());
3608 assert_eq!(
3609 cached_child.status(),
3610 if active_local {
3611 OrderStatus::Emulated
3612 } else {
3613 OrderStatus::Accepted
3614 }
3615 );
3616
3617 let second = apply_position_fill(
3618 &cache,
3619 &parent,
3620 &instrument,
3621 position_id,
3622 "T-PARENT-2",
3623 Quantity::from("0.007000"),
3624 Some(Money::from("0.00040000 BTC")),
3625 );
3626 let second_actions = manager.handle_event(&second);
3627
3628 assert_eq!(second_actions.len(), if active_local { 2 } else { 1 });
3629 assert!(matches!(
3630 second_actions.first(),
3631 Some(OrderManagerAction::ModifyLocalQuantity { order, quantity })
3632 if order.client_order_id() == child_id
3633 && *quantity == Quantity::from("0.005000")
3634 ));
3635 }
3636
3637 #[rstest]
3638 fn test_handle_oto_closed_spawn_slice_waits_while_spawn_remains_active() {
3639 let (clock, cache) = create_test_components();
3640 let mut manager = OrderManager::new(clock, cache.clone(), true);
3641 let mut instrument = currency_pair_btcusdt();
3642 instrument.size_increment = Quantity::from("0.005000");
3643 let instrument = InstrumentAny::CurrencyPair(instrument);
3644 let position_id = PositionId::from("P-SPAWN-WAIT");
3645 let spawn_id = ClientOrderId::from("O-SPAWN");
3646 let first_id = ClientOrderId::from("O-SPAWN-1");
3647 let second_id = ClientOrderId::from("O-SPAWN-2");
3648 let child_id = ClientOrderId::from("O-CHILD");
3649 let exec_algorithm_id = ExecAlgorithmId::from("TWAP");
3650 let first = OrderTestBuilder::new(OrderType::Limit)
3651 .instrument_id(instrument.id())
3652 .client_order_id(first_id)
3653 .side(OrderSide::Buy)
3654 .price(Price::from("50000.00"))
3655 .quantity(Quantity::from("0.003000"))
3656 .contingency_type(ContingencyType::Oto)
3657 .linked_order_ids(vec![child_id])
3658 .exec_algorithm_id(exec_algorithm_id)
3659 .exec_spawn_id(spawn_id)
3660 .submit(true)
3661 .build();
3662 let second = OrderTestBuilder::new(OrderType::Limit)
3663 .instrument_id(instrument.id())
3664 .client_order_id(second_id)
3665 .side(OrderSide::Buy)
3666 .price(Price::from("50000.00"))
3667 .quantity(Quantity::from("0.007000"))
3668 .contingency_type(ContingencyType::Oto)
3669 .linked_order_ids(vec![child_id])
3670 .exec_algorithm_id(exec_algorithm_id)
3671 .exec_spawn_id(spawn_id)
3672 .submit(true)
3673 .build();
3674 let child = OrderTestBuilder::new(OrderType::StopMarket)
3675 .instrument_id(instrument.id())
3676 .client_order_id(child_id)
3677 .side(OrderSide::Sell)
3678 .trigger_price(Price::from("49000.00"))
3679 .quantity(Quantity::from("0.010000"))
3680 .reduce_only(true)
3681 .build();
3682
3683 cache
3684 .borrow_mut()
3685 .add_instrument(instrument.clone())
3686 .unwrap();
3687
3688 for order in [&first, &second, &child] {
3689 cache
3690 .borrow_mut()
3691 .add_order(order.clone(), None, None, false)
3692 .unwrap();
3693 }
3694 apply_accepted(&cache, &first, "V-SPAWN-1");
3695 apply_accepted(&cache, &second, "V-SPAWN-2");
3696 let first = cache.borrow().order_owned(&first_id).unwrap();
3697 let event = apply_position_fill(
3698 &cache,
3699 &first,
3700 &instrument,
3701 position_id,
3702 "T-SPAWN-1",
3703 Quantity::from("0.003000"),
3704 Some(Money::from("0.00040000 BTC")),
3705 );
3706
3707 let first_actions = manager.handle_event(&event);
3708
3709 assert!(first_actions.is_empty());
3710
3711 let second = cache.borrow().order_owned(&second_id).unwrap();
3712 let event = apply_position_fill(
3713 &cache,
3714 &second,
3715 &instrument,
3716 position_id,
3717 "T-SPAWN-2",
3718 Quantity::from("0.007000"),
3719 Some(Money::from("0.00040000 BTC")),
3720 );
3721 let second_actions = manager.handle_event(&event);
3722
3723 assert!(matches!(
3724 second_actions.as_slice(),
3725 [
3726 OrderManagerAction::ModifyLocalQuantity { order, quantity },
3727 OrderManagerAction::SubmitToRisk(command),
3728 ] if order.client_order_id() == child_id
3729 && *quantity == Quantity::from("0.005000")
3730 && command.client_order_id == child_id
3731 ));
3732 }
3733
3734 #[rstest]
3735 #[case::missing_primary(None)]
3736 #[case::active_primary(Some(false))]
3737 #[case::closed_primary(Some(true))]
3738 fn test_handle_oto_last_spawn_cancellation_respects_primary_state(
3739 #[case] primary_closed: Option<bool>,
3740 ) {
3741 let (clock, cache) = create_test_components();
3742 let mut manager = OrderManager::new(clock, cache.clone(), true);
3743 let mut instrument = currency_pair_btcusdt();
3744 instrument.size_increment = Quantity::from("0.005000");
3745 let instrument = InstrumentAny::CurrencyPair(instrument);
3746 let position_id = PositionId::from("P-SPAWN-CANCEL");
3747 let spawn_id = ClientOrderId::from("O-SPAWN");
3748 let first_id = ClientOrderId::from("O-SPAWN-1");
3749 let second_id = ClientOrderId::from("O-SPAWN-2");
3750 let child_id = ClientOrderId::from("O-CHILD");
3751 let exec_algorithm_id = ExecAlgorithmId::from("TWAP");
3752
3753 if let Some(closed) = primary_closed {
3754 let primary = OrderTestBuilder::new(OrderType::Limit)
3755 .instrument_id(instrument.id())
3756 .client_order_id(spawn_id)
3757 .side(OrderSide::Buy)
3758 .price(Price::from("50000.00"))
3759 .quantity(Quantity::from("0.010000"))
3760 .contingency_type(ContingencyType::Oto)
3761 .linked_order_ids(vec![child_id])
3762 .exec_algorithm_id(exec_algorithm_id)
3763 .exec_spawn_id(spawn_id)
3764 .build();
3765 cache
3766 .borrow_mut()
3767 .add_order(primary.clone(), None, None, false)
3768 .unwrap();
3769
3770 if closed {
3771 apply_terminal(&cache, &primary, TerminalEvent::Canceled);
3772 }
3773 }
3774 let first = OrderTestBuilder::new(OrderType::Limit)
3775 .instrument_id(instrument.id())
3776 .client_order_id(first_id)
3777 .side(OrderSide::Buy)
3778 .price(Price::from("50000.00"))
3779 .quantity(Quantity::from("0.003000"))
3780 .contingency_type(ContingencyType::Oto)
3781 .linked_order_ids(vec![child_id])
3782 .exec_algorithm_id(exec_algorithm_id)
3783 .exec_spawn_id(spawn_id)
3784 .submit(true)
3785 .build();
3786 let second = OrderTestBuilder::new(OrderType::Limit)
3787 .instrument_id(instrument.id())
3788 .client_order_id(second_id)
3789 .side(OrderSide::Buy)
3790 .price(Price::from("50000.00"))
3791 .quantity(Quantity::from("0.007000"))
3792 .contingency_type(ContingencyType::Oto)
3793 .linked_order_ids(vec![child_id])
3794 .exec_algorithm_id(exec_algorithm_id)
3795 .exec_spawn_id(spawn_id)
3796 .submit(true)
3797 .build();
3798 let child = OrderTestBuilder::new(OrderType::StopMarket)
3799 .instrument_id(instrument.id())
3800 .client_order_id(child_id)
3801 .side(OrderSide::Sell)
3802 .trigger_price(Price::from("49000.00"))
3803 .quantity(Quantity::from("0.010000"))
3804 .reduce_only(true)
3805 .build();
3806
3807 cache
3808 .borrow_mut()
3809 .add_instrument(instrument.clone())
3810 .unwrap();
3811
3812 for order in [&first, &second, &child] {
3813 cache
3814 .borrow_mut()
3815 .add_order(order.clone(), None, None, false)
3816 .unwrap();
3817 }
3818 apply_accepted(&cache, &first, "V-SPAWN-1");
3819 apply_accepted(&cache, &second, "V-SPAWN-2");
3820 let first = cache.borrow().order_owned(&first_id).unwrap();
3821 let first_fill = apply_position_fill(
3822 &cache,
3823 &first,
3824 &instrument,
3825 position_id,
3826 "T-SPAWN-1",
3827 Quantity::from("0.003000"),
3828 Some(Money::from("0.00040000 BTC")),
3829 );
3830
3831 assert!(manager.handle_event(&first_fill).is_empty());
3832
3833 let second = cache.borrow().order_owned(&second_id).unwrap();
3834 let canceled = apply_terminal(&cache, &second, TerminalEvent::Canceled);
3835 let actions = manager.handle_event(&canceled);
3836
3837 if primary_closed == Some(false) {
3838 assert!(actions.is_empty());
3839 } else {
3840 assert!(matches!(
3841 actions.as_slice(),
3842 [OrderManagerAction::CancelLocal(order)]
3843 if order.client_order_id() == child_id
3844 ));
3845 }
3846 }
3847
3848 #[rstest]
3849 fn test_handle_oto_missing_child_instrument_does_not_block_siblings() {
3850 let (clock, cache) = create_test_components();
3851 let mut manager = OrderManager::new(clock, cache.clone(), false);
3852 let mut instrument = currency_pair_btcusdt();
3853 instrument.size_increment = Quantity::from("0.005000");
3854 let instrument = InstrumentAny::CurrencyPair(instrument);
3855 let position_id = PositionId::from("P-SIBLINGS");
3856 let missing_id = ClientOrderId::from("O-MISSING");
3857 let valid_id = ClientOrderId::from("O-VALID");
3858 let parent = OrderTestBuilder::new(OrderType::Limit)
3859 .instrument_id(instrument.id())
3860 .client_order_id(ClientOrderId::from("O-PARENT"))
3861 .side(OrderSide::Buy)
3862 .price(Price::from("50000.00"))
3863 .quantity(Quantity::from("0.010000"))
3864 .contingency_type(ContingencyType::Oto)
3865 .linked_order_ids(vec![missing_id, valid_id])
3866 .submit(true)
3867 .build();
3868 let missing = OrderTestBuilder::new(OrderType::StopMarket)
3869 .instrument_id(InstrumentId::from("BTC-USDT-MISSING.OKX"))
3870 .client_order_id(missing_id)
3871 .side(OrderSide::Sell)
3872 .trigger_price(Price::from("49000.00"))
3873 .quantity(Quantity::from("0.010000"))
3874 .reduce_only(true)
3875 .submit(true)
3876 .build();
3877 let valid = OrderTestBuilder::new(OrderType::StopMarket)
3878 .instrument_id(instrument.id())
3879 .client_order_id(valid_id)
3880 .side(OrderSide::Sell)
3881 .trigger_price(Price::from("49000.00"))
3882 .quantity(Quantity::from("0.010000"))
3883 .reduce_only(true)
3884 .submit(true)
3885 .build();
3886
3887 cache
3888 .borrow_mut()
3889 .add_instrument(instrument.clone())
3890 .unwrap();
3891
3892 for order in [&parent, &missing, &valid] {
3893 cache
3894 .borrow_mut()
3895 .add_order(order.clone(), None, None, false)
3896 .unwrap();
3897 }
3898 let event = apply_position_fill(
3899 &cache,
3900 &parent,
3901 &instrument,
3902 position_id,
3903 "T-PARENT",
3904 Quantity::from("0.010000"),
3905 Some(Money::from("0.00040000 BTC")),
3906 );
3907
3908 let actions = manager.handle_event(&event);
3909
3910 assert!(matches!(
3911 actions.as_slice(),
3912 [OrderManagerAction::ModifyLocalQuantity { order, quantity }]
3913 if order.client_order_id() == valid_id
3914 && *quantity == Quantity::from("0.005000")
3915 ));
3916 }
3917
3918 #[rstest]
3919 fn test_handle_oto_parent_cancel_cancels_child_with_zero_target() {
3920 let (clock, cache) = create_test_components();
3921 let mut manager = OrderManager::new(clock, cache.clone(), true);
3922 let mut instrument = currency_pair_btcusdt();
3923 instrument.size_increment = Quantity::from("0.005000");
3924 let instrument = InstrumentAny::CurrencyPair(instrument);
3925 let position_id = PositionId::from("P-CANCEL");
3926 let parent_id = ClientOrderId::from("O-PARENT");
3927 let child_id = ClientOrderId::from("O-CHILD");
3928 let parent = OrderTestBuilder::new(OrderType::Limit)
3929 .instrument_id(instrument.id())
3930 .client_order_id(parent_id)
3931 .side(OrderSide::Buy)
3932 .price(Price::from("50000.00"))
3933 .quantity(Quantity::from("0.010000"))
3934 .contingency_type(ContingencyType::Oto)
3935 .linked_order_ids(vec![child_id])
3936 .submit(true)
3937 .build();
3938 let child = OrderTestBuilder::new(OrderType::StopMarket)
3939 .instrument_id(instrument.id())
3940 .client_order_id(child_id)
3941 .side(OrderSide::Sell)
3942 .trigger_price(Price::from("49000.00"))
3943 .quantity(Quantity::from("0.010000"))
3944 .reduce_only(true)
3945 .build();
3946
3947 cache
3948 .borrow_mut()
3949 .add_instrument(instrument.clone())
3950 .unwrap();
3951
3952 for order in [&parent, &child] {
3953 cache
3954 .borrow_mut()
3955 .add_order(order.clone(), None, None, false)
3956 .unwrap();
3957 }
3958 let fill = apply_position_fill(
3959 &cache,
3960 &parent,
3961 &instrument,
3962 position_id,
3963 "T-PARENT",
3964 Quantity::from("0.003000"),
3965 Some(Money::from("0.00040000 BTC")),
3966 );
3967 assert!(manager.handle_event(&fill).is_empty());
3968
3969 let parent = cache.borrow().order_owned(&parent_id).unwrap();
3970 let canceled = apply_terminal(&cache, &parent, TerminalEvent::Canceled);
3971 let actions = manager.handle_event(&canceled);
3972
3973 assert_eq!(
3974 cache.borrow().position(&position_id).unwrap().quantity,
3975 Quantity::from("0.002600")
3976 );
3977 assert!(matches!(
3978 actions.as_slice(),
3979 [OrderManagerAction::CancelLocal(order)]
3980 if order.client_order_id() == child_id
3981 ));
3982 }
3983
3984 #[rstest]
3985 fn test_handle_oto_unfilled_terminal_parent_cancels_child_without_cached_position() {
3986 let (clock, cache) = create_test_components();
3987 let mut manager = OrderManager::new(clock, cache.clone(), true);
3988 let instrument = InstrumentAny::CurrencyPair(currency_pair_btcusdt());
3989 let position_id = PositionId::from("P-NOT-OPENED");
3990 let parent_id = ClientOrderId::from("O-PARENT");
3991 let child_id = ClientOrderId::from("O-CHILD");
3992 let parent = OrderTestBuilder::new(OrderType::Limit)
3993 .instrument_id(instrument.id())
3994 .client_order_id(parent_id)
3995 .side(OrderSide::Buy)
3996 .price(Price::from("50000.00"))
3997 .quantity(Quantity::from("1.000000"))
3998 .contingency_type(ContingencyType::Oto)
3999 .linked_order_ids(vec![child_id])
4000 .submit(true)
4001 .build();
4002 let child = OrderTestBuilder::new(OrderType::StopMarket)
4003 .instrument_id(instrument.id())
4004 .client_order_id(child_id)
4005 .side(OrderSide::Sell)
4006 .trigger_price(Price::from("49000.00"))
4007 .quantity(Quantity::from("1.000000"))
4008 .reduce_only(true)
4009 .build();
4010
4011 cache.borrow_mut().add_instrument(instrument).unwrap();
4012 cache
4013 .borrow_mut()
4014 .add_order(parent.clone(), Some(position_id), None, false)
4015 .unwrap();
4016 cache
4017 .borrow_mut()
4018 .add_order(child, Some(position_id), None, false)
4019 .unwrap();
4020 let canceled = apply_terminal(&cache, &parent, TerminalEvent::Canceled);
4021
4022 let actions = manager.handle_event(&canceled);
4023
4024 assert!(!cache.borrow().position_exists(&position_id));
4025 assert!(matches!(
4026 actions.as_slice(),
4027 [OrderManagerAction::CancelLocal(order)]
4028 if order.client_order_id() == child_id
4029 ));
4030 }
4031
4032 #[rstest]
4033 fn test_handle_oto_terminal_fill_cancels_child_when_position_is_below_increment() {
4034 let (clock, cache) = create_test_components();
4035 let mut manager = OrderManager::new(clock, cache.clone(), true);
4036 let mut instrument = currency_pair_btcusdt();
4037 instrument.size_increment = Quantity::from("0.005000");
4038 let instrument = InstrumentAny::CurrencyPair(instrument);
4039 let position_id = PositionId::from("P-ZERO");
4040 let parent_id = ClientOrderId::from("O-PARENT");
4041 let child_id = ClientOrderId::from("O-CHILD");
4042 let parent = OrderTestBuilder::new(OrderType::Limit)
4043 .instrument_id(instrument.id())
4044 .client_order_id(parent_id)
4045 .side(OrderSide::Buy)
4046 .price(Price::from("50000.00"))
4047 .quantity(Quantity::from("0.004000"))
4048 .contingency_type(ContingencyType::Oto)
4049 .linked_order_ids(vec![child_id])
4050 .submit(true)
4051 .build();
4052 let child = OrderTestBuilder::new(OrderType::StopMarket)
4053 .instrument_id(instrument.id())
4054 .client_order_id(child_id)
4055 .side(OrderSide::Sell)
4056 .trigger_price(Price::from("49000.00"))
4057 .quantity(Quantity::from("0.005000"))
4058 .reduce_only(true)
4059 .build();
4060
4061 cache
4062 .borrow_mut()
4063 .add_instrument(instrument.clone())
4064 .unwrap();
4065
4066 for order in [&parent, &child] {
4067 cache
4068 .borrow_mut()
4069 .add_order(order.clone(), None, None, false)
4070 .unwrap();
4071 }
4072 let event = apply_position_fill(
4073 &cache,
4074 &parent,
4075 &instrument,
4076 position_id,
4077 "T-PARENT",
4078 Quantity::from("0.004000"),
4079 Some(Money::from("0.00400000 BTC")),
4080 );
4081
4082 let actions = manager.handle_event(&event);
4083
4084 assert!(cache.borrow().position(&position_id).unwrap().is_closed());
4085 assert!(matches!(
4086 actions.as_slice(),
4087 [OrderManagerAction::CancelLocal(order)]
4088 if order.client_order_id() == child_id
4089 ));
4090 assert!(manager.submit_order_commands.is_empty());
4091 }
4092
4093 #[rstest]
4094 fn test_handle_oto_fill_rejects_conflicting_position_ids() {
4095 let (clock, cache) = create_test_components();
4096 let mut manager = OrderManager::new(clock, cache.clone(), true);
4097 let instrument = InstrumentAny::CurrencyPair(currency_pair_btcusdt());
4098 let cached_position_id = PositionId::from("P-CACHED");
4099 let event_position_id = PositionId::from("P-EVENT");
4100 let parent_id = ClientOrderId::from("O-PARENT");
4101 let child_id = ClientOrderId::from("O-CHILD");
4102 let parent = OrderTestBuilder::new(OrderType::Limit)
4103 .instrument_id(instrument.id())
4104 .client_order_id(parent_id)
4105 .side(OrderSide::Buy)
4106 .price(Price::from("50000.00"))
4107 .quantity(Quantity::from("1.000000"))
4108 .contingency_type(ContingencyType::Oto)
4109 .linked_order_ids(vec![child_id])
4110 .submit(true)
4111 .build();
4112 let event = TestOrderEventStubs::filled(
4113 &parent,
4114 &instrument,
4115 Some(TradeId::from("T-PARENT")),
4116 Some(event_position_id),
4117 None,
4118 None,
4119 None,
4120 None,
4121 None,
4122 Some(AccountId::from("ACCOUNT-001")),
4123 );
4124 cache
4125 .borrow_mut()
4126 .add_order(parent, Some(cached_position_id), None, false)
4127 .unwrap();
4128
4129 let actions = manager.handle_event(&event);
4130
4131 assert!(actions.is_empty());
4132 assert!(manager.submit_order_commands.is_empty());
4133 }
4134
4135 #[rstest]
4136 fn test_handle_oto_chained_child_releases_its_child() {
4137 let (clock, cache) = create_test_components();
4138 let mut manager = OrderManager::new(clock, cache.clone(), true);
4139 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4140 let root_id = ClientOrderId::from("O-ROOT");
4141 let parent_id = ClientOrderId::from("O-PARENT");
4142 let child_id = ClientOrderId::from("O-CHILD");
4143 let parent = OrderTestBuilder::new(OrderType::Limit)
4144 .instrument_id(instrument.id())
4145 .client_order_id(parent_id)
4146 .side(OrderSide::Buy)
4147 .price(Price::from("1.00000"))
4148 .quantity(Quantity::from(100_000))
4149 .contingency_type(ContingencyType::Oto)
4150 .linked_order_ids(vec![child_id])
4151 .parent_order_id(root_id)
4152 .submit(true)
4153 .build();
4154 let child = OrderTestBuilder::new(OrderType::Limit)
4155 .instrument_id(instrument.id())
4156 .client_order_id(child_id)
4157 .side(OrderSide::Sell)
4158 .price(Price::from("1.00100"))
4159 .quantity(Quantity::from(100_000))
4160 .parent_order_id(parent_id)
4161 .emulation_trigger(TriggerType::BidAsk)
4162 .build();
4163 cache
4164 .borrow_mut()
4165 .add_order(parent.clone(), None, None, false)
4166 .unwrap();
4167 cache
4168 .borrow_mut()
4169 .add_order(child, None, None, false)
4170 .unwrap();
4171 apply_accepted(&cache, &parent, "V-PARENT");
4172 let fill = apply_fill(
4173 &cache,
4174 &parent,
4175 &instrument,
4176 "T-PARENT",
4177 Quantity::from(100_000),
4178 );
4179
4180 let actions = manager.handle_event(&fill);
4181
4182 assert!(matches!(
4183 actions.as_slice(),
4184 [OrderManagerAction::SubmitToEmulator(command)]
4185 if command.client_order_id == child_id
4186 ));
4187 }
4188
4189 #[rstest]
4190 fn test_handle_oto_cancels_child_filled_to_target() {
4191 let (clock, cache) = create_test_components();
4192 let mut manager = OrderManager::new(clock, cache.clone(), false);
4193 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4194 let child_id = ClientOrderId::from("O-CHILD");
4195 let parent = OrderTestBuilder::new(OrderType::Limit)
4196 .instrument_id(instrument.id())
4197 .client_order_id(ClientOrderId::from("O-PARENT"))
4198 .side(OrderSide::Buy)
4199 .price(Price::from("1.00000"))
4200 .quantity(Quantity::from(100_000))
4201 .contingency_type(ContingencyType::Oto)
4202 .linked_order_ids(vec![child_id])
4203 .submit(true)
4204 .build();
4205 let child = OrderTestBuilder::new(OrderType::Limit)
4206 .instrument_id(instrument.id())
4207 .client_order_id(child_id)
4208 .side(OrderSide::Sell)
4209 .price(Price::from("1.00100"))
4210 .quantity(Quantity::from(100_000))
4211 .submit(true)
4212 .build();
4213 cache
4214 .borrow_mut()
4215 .add_order(parent.clone(), None, None, false)
4216 .unwrap();
4217 cache
4218 .borrow_mut()
4219 .add_order(child.clone(), None, None, false)
4220 .unwrap();
4221 apply_accepted(&cache, &parent, "V-PARENT");
4222 apply_accepted(&cache, &child, "V-CHILD");
4223 apply_fill(
4224 &cache,
4225 &child,
4226 &instrument,
4227 "T-CHILD",
4228 Quantity::from(60_000),
4229 );
4230 let parent_fill = apply_fill(
4231 &cache,
4232 &parent,
4233 &instrument,
4234 "T-PARENT",
4235 Quantity::from(40_000),
4236 );
4237
4238 let actions = manager.handle_event(&parent_fill);
4239
4240 assert!(matches!(
4241 actions.as_slice(),
4242 [OrderManagerAction::CancelLocal(order)]
4243 if order.client_order_id() == child_id
4244 ));
4245 assert!(!manager.oto_target_quantities.contains_key(&child_id));
4246 }
4247
4248 #[rstest]
4249 fn test_handle_oto_clears_target_when_child_leaves_active_local() {
4250 let (clock, cache) = create_test_components();
4251 let mut terminal_manager = OrderManager::new(clock.clone(), cache.clone(), true);
4252 let mut update_manager = OrderManager::new(clock.clone(), cache.clone(), true);
4253 let mut contingency_update_manager = OrderManager::new(clock.clone(), cache.clone(), true);
4254 let mut fill_manager = OrderManager::new(clock, cache.clone(), true);
4255 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4256 let parent_id = ClientOrderId::from("O-PARENT");
4257 let child_id = ClientOrderId::from("O-CHILD");
4258 let parent = OrderTestBuilder::new(OrderType::Limit)
4259 .instrument_id(instrument.id())
4260 .client_order_id(parent_id)
4261 .side(OrderSide::Buy)
4262 .price(Price::from("1.00000"))
4263 .quantity(Quantity::from(100_000))
4264 .contingency_type(ContingencyType::Oto)
4265 .linked_order_ids(vec![child_id])
4266 .submit(true)
4267 .build();
4268 let child = OrderTestBuilder::new(OrderType::Limit)
4269 .instrument_id(instrument.id())
4270 .client_order_id(child_id)
4271 .side(OrderSide::Sell)
4272 .price(Price::from("1.00100"))
4273 .quantity(Quantity::from(100_000))
4274 .build();
4275 cache
4276 .borrow_mut()
4277 .add_order(parent.clone(), None, None, false)
4278 .unwrap();
4279 cache
4280 .borrow_mut()
4281 .add_order(child.clone(), None, None, false)
4282 .unwrap();
4283 let fill = apply_fill(
4284 &cache,
4285 &parent,
4286 &instrument,
4287 "T-PARTIAL",
4288 Quantity::from(40_000),
4289 );
4290 terminal_manager.handle_event(&fill);
4291 update_manager.handle_event(&fill);
4292 contingency_update_manager.handle_event(&fill);
4293 fill_manager.handle_event(&fill);
4294
4295 let submitted = TestOrderEventStubs::submitted(&child, AccountId::from("ACCOUNT-001"));
4296 cache.borrow_mut().update_order(&submitted).unwrap();
4297 apply_accepted(&cache, &child, "V-CHILD");
4298
4299 let cached_child = cache.borrow().order_owned(&child_id).unwrap();
4300 let child_fill = apply_fill(
4301 &cache,
4302 &cached_child,
4303 &instrument,
4304 "T-CHILD",
4305 Quantity::from(60_000),
4306 );
4307 assert!(fill_manager.handle_event(&child_fill).is_empty());
4308 assert!(!fill_manager.oto_target_quantities.contains_key(&child_id));
4309
4310 let cached_parent = cache.borrow().order_owned(&parent_id).unwrap();
4311 assert!(
4312 contingency_update_manager
4313 .handle_contingencies_update(&cached_parent)
4314 .is_empty()
4315 );
4316 assert!(
4317 !contingency_update_manager
4318 .oto_target_quantities
4319 .contains_key(&child_id)
4320 );
4321
4322 let terminal = apply_terminal(&cache, &parent, TerminalEvent::Canceled);
4323 assert!(terminal_manager.handle_event(&terminal).is_empty());
4324 assert!(
4325 !terminal_manager
4326 .oto_target_quantities
4327 .contains_key(&child_id)
4328 );
4329
4330 let update = apply_update(&cache, &child, Quantity::from(80_000));
4331 assert!(update_manager.handle_event(&update).is_empty());
4332 assert!(!update_manager.oto_target_quantities.contains_key(&child_id));
4333 }
4334
4335 #[rstest]
4336 fn test_handle_oto_out_of_order_updates_reassert_latest_target() {
4337 let (clock, cache) = create_test_components();
4338 let mut manager = OrderManager::new(clock, cache.clone(), false);
4339 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4340 let child_id = ClientOrderId::from("O-CHILD");
4341 let parent = OrderTestBuilder::new(OrderType::Limit)
4342 .instrument_id(instrument.id())
4343 .client_order_id(ClientOrderId::from("O-PARENT"))
4344 .side(OrderSide::Buy)
4345 .price(Price::from("1.00000"))
4346 .quantity(Quantity::from(100_000))
4347 .contingency_type(ContingencyType::Oto)
4348 .linked_order_ids(vec![child_id])
4349 .submit(true)
4350 .build();
4351 let child = OrderTestBuilder::new(OrderType::Limit)
4352 .instrument_id(instrument.id())
4353 .client_order_id(child_id)
4354 .side(OrderSide::Sell)
4355 .price(Price::from("1.00100"))
4356 .quantity(Quantity::from(100_000))
4357 .submit(true)
4358 .build();
4359 cache
4360 .borrow_mut()
4361 .add_order(parent.clone(), None, None, false)
4362 .unwrap();
4363 cache
4364 .borrow_mut()
4365 .add_order(child.clone(), None, None, false)
4366 .unwrap();
4367 apply_accepted(&cache, &child, "V-CHILD");
4368
4369 let first_fill = apply_fill(
4370 &cache,
4371 &parent,
4372 &instrument,
4373 "T-OUT-OF-ORDER-1",
4374 Quantity::from(40_000),
4375 );
4376 manager.handle_event(&first_fill);
4377 let second_fill = apply_fill(
4378 &cache,
4379 &parent,
4380 &instrument,
4381 "T-OUT-OF-ORDER-2",
4382 Quantity::from(60_000),
4383 );
4384 manager.handle_event(&second_fill);
4385
4386 let latest_update = apply_update(&cache, &child, Quantity::from(100_000));
4387 let latest_actions = manager.handle_event(&latest_update);
4388 let stale_update = apply_update(&cache, &child, Quantity::from(40_000));
4389 let corrective_actions = manager.handle_event(&stale_update);
4390
4391 assert!(latest_actions.is_empty());
4392 assert!(matches!(
4393 corrective_actions.as_slice(),
4394 [OrderManagerAction::ModifyLocalQuantity { order, quantity }]
4395 if order.client_order_id() == child_id
4396 && *quantity == Quantity::from(100_000)
4397 ));
4398 }
4399
4400 #[rstest]
4401 fn test_handle_oto_child_fill_waits_for_parent_target_refresh() {
4402 let (clock, cache) = create_test_components();
4403 let mut manager = OrderManager::new(clock, cache.clone(), false);
4404 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4405 let parent_id = ClientOrderId::from("O-PARENT");
4406 let child_id = ClientOrderId::from("O-CHILD");
4407 let parent = OrderTestBuilder::new(OrderType::Limit)
4408 .instrument_id(instrument.id())
4409 .client_order_id(parent_id)
4410 .side(OrderSide::Buy)
4411 .price(Price::from("1.00000"))
4412 .quantity(Quantity::from(100_000))
4413 .contingency_type(ContingencyType::Oto)
4414 .linked_order_ids(vec![child_id])
4415 .submit(true)
4416 .build();
4417 let child = OrderTestBuilder::new(OrderType::Limit)
4418 .instrument_id(instrument.id())
4419 .client_order_id(child_id)
4420 .side(OrderSide::Sell)
4421 .price(Price::from("1.00100"))
4422 .quantity(Quantity::from(100_000))
4423 .contingency_type(ContingencyType::Oto)
4424 .linked_order_ids(vec![parent_id])
4425 .parent_order_id(parent_id)
4426 .submit(true)
4427 .build();
4428 cache
4429 .borrow_mut()
4430 .add_order(parent.clone(), None, None, false)
4431 .unwrap();
4432 cache
4433 .borrow_mut()
4434 .add_order(child.clone(), None, None, false)
4435 .unwrap();
4436 apply_accepted(&cache, &parent, "V-PARENT");
4437 apply_accepted(&cache, &child, "V-CHILD");
4438 let parent_fill = apply_fill(
4439 &cache,
4440 &parent,
4441 &instrument,
4442 "T-PARENT",
4443 Quantity::from(40_000),
4444 );
4445 manager.handle_event(&parent_fill);
4446 let child_fill = apply_fill(
4447 &cache,
4448 &child,
4449 &instrument,
4450 "T-CHILD",
4451 Quantity::from(60_000),
4452 );
4453
4454 let actions = manager.handle_event(&child_fill);
4455
4456 assert!(actions.is_empty());
4457 assert_eq!(
4458 manager.oto_target_quantities.get(&child_id),
4459 Some(&Quantity::from(40_000)),
4460 );
4461
4462 let cached_parent = cache.borrow().order_owned(&parent_id).unwrap();
4463 let final_parent_fill = apply_fill(
4464 &cache,
4465 &cached_parent,
4466 &instrument,
4467 "T-PARENT-FINAL",
4468 Quantity::from(60_000),
4469 );
4470 let final_actions = manager.handle_event(&final_parent_fill);
4471
4472 assert!(final_actions.is_empty());
4473 assert_eq!(
4474 manager.oto_target_quantities.get(&child_id),
4475 Some(&Quantity::from(100_000)),
4476 );
4477 }
4478
4479 #[rstest]
4480 fn test_handle_oto_child_update_waits_when_filled_past_stored_target() {
4481 let (clock, cache) = create_test_components();
4482 let mut manager = OrderManager::new(clock, cache.clone(), false);
4483 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4484 let child_id = ClientOrderId::from("O-CHILD");
4485 let child = OrderTestBuilder::new(OrderType::Limit)
4486 .instrument_id(instrument.id())
4487 .client_order_id(child_id)
4488 .side(OrderSide::Sell)
4489 .price(Price::from("1.00100"))
4490 .quantity(Quantity::from(100_000))
4491 .submit(true)
4492 .build();
4493 cache
4494 .borrow_mut()
4495 .add_order(child.clone(), None, None, false)
4496 .unwrap();
4497 apply_accepted(&cache, &child, "V-CHILD");
4498 apply_fill(
4499 &cache,
4500 &child,
4501 &instrument,
4502 "T-CHILD",
4503 Quantity::from(60_000),
4504 );
4505 manager
4506 .oto_target_quantities
4507 .insert(child_id, Quantity::from(40_000));
4508 let update = apply_update(&cache, &child, Quantity::from(90_000));
4509
4510 let actions = manager.handle_event(&update);
4511
4512 assert!(actions.is_empty());
4513 assert_eq!(
4514 manager.oto_target_quantities.get(&child_id),
4515 Some(&Quantity::from(40_000)),
4516 );
4517 }
4518
4519 #[rstest]
4520 fn test_handle_oto_parent_update_preserves_partial_fill_target() {
4521 let (clock, cache) = create_test_components();
4522 let mut manager = OrderManager::new(clock, cache.clone(), false);
4523 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4524 let child_id = ClientOrderId::from("O-CHILD");
4525 let parent = OrderTestBuilder::new(OrderType::Limit)
4526 .instrument_id(instrument.id())
4527 .client_order_id(ClientOrderId::from("O-PARENT"))
4528 .side(OrderSide::Buy)
4529 .price(Price::from("1.00000"))
4530 .quantity(Quantity::from(100_000))
4531 .contingency_type(ContingencyType::Oto)
4532 .linked_order_ids(vec![child_id])
4533 .submit(true)
4534 .build();
4535 let child = OrderTestBuilder::new(OrderType::Limit)
4536 .instrument_id(instrument.id())
4537 .client_order_id(child_id)
4538 .side(OrderSide::Sell)
4539 .price(Price::from("1.00100"))
4540 .quantity(Quantity::from(100_000))
4541 .submit(true)
4542 .build();
4543 cache
4544 .borrow_mut()
4545 .add_order(parent.clone(), None, None, false)
4546 .unwrap();
4547 cache
4548 .borrow_mut()
4549 .add_order(child, None, None, false)
4550 .unwrap();
4551 let fill = apply_fill(
4552 &cache,
4553 &parent,
4554 &instrument,
4555 "T-PARTIAL",
4556 Quantity::from(40_000),
4557 );
4558 manager.handle_event(&fill);
4559 let parent_update = apply_update(&cache, &parent, Quantity::from(120_000));
4560
4561 let actions = manager.handle_event(&parent_update);
4562
4563 assert!(actions.is_empty());
4564 assert_eq!(
4565 manager.oto_target_quantities.get(&child_id),
4566 Some(&Quantity::from(40_000)),
4567 );
4568
4569 let terminal = apply_terminal(&cache, &parent, TerminalEvent::Canceled);
4570 assert!(manager.handle_event(&terminal).is_empty());
4571 assert_eq!(
4572 manager.oto_target_quantities.get(&child_id),
4573 Some(&Quantity::from(40_000)),
4574 );
4575 }
4576
4577 #[rstest]
4578 fn test_handle_oto_exec_spawn_fill_uses_commission_adjusted_position() {
4579 let (clock, cache) = create_test_components();
4580 let mut manager = OrderManager::new(clock, cache.clone(), false);
4581 let mut instrument = currency_pair_btcusdt();
4582 instrument.size_increment = Quantity::from("0.005000");
4583 let instrument = InstrumentAny::CurrencyPair(instrument);
4584 let position_id = PositionId::from("P-SPAWN");
4585 let spawn_id = ClientOrderId::from("O-SPAWN");
4586 let first_id = ClientOrderId::from("O-SPAWN-1");
4587 let second_id = ClientOrderId::from("O-SPAWN-2");
4588 let child_id = ClientOrderId::from("O-CHILD");
4589 let exec_algorithm_id = ExecAlgorithmId::from("TWAP");
4590 let first = OrderTestBuilder::new(OrderType::Limit)
4591 .instrument_id(instrument.id())
4592 .client_order_id(first_id)
4593 .side(OrderSide::Buy)
4594 .price(Price::from("50000.00"))
4595 .quantity(Quantity::from("0.600000"))
4596 .contingency_type(ContingencyType::Oto)
4597 .linked_order_ids(vec![child_id])
4598 .exec_algorithm_id(exec_algorithm_id)
4599 .exec_spawn_id(spawn_id)
4600 .submit(true)
4601 .build();
4602 let second = OrderTestBuilder::new(OrderType::Limit)
4603 .instrument_id(instrument.id())
4604 .client_order_id(second_id)
4605 .side(OrderSide::Buy)
4606 .price(Price::from("50000.00"))
4607 .quantity(Quantity::from("0.400000"))
4608 .contingency_type(ContingencyType::Oto)
4609 .linked_order_ids(vec![child_id])
4610 .exec_algorithm_id(exec_algorithm_id)
4611 .exec_spawn_id(spawn_id)
4612 .submit(true)
4613 .build();
4614 let child = OrderTestBuilder::new(OrderType::StopMarket)
4615 .instrument_id(instrument.id())
4616 .client_order_id(child_id)
4617 .side(OrderSide::Sell)
4618 .trigger_price(Price::from("49000.00"))
4619 .quantity(Quantity::from("1.000000"))
4620 .reduce_only(true)
4621 .submit(true)
4622 .build();
4623
4624 cache
4625 .borrow_mut()
4626 .add_instrument(instrument.clone())
4627 .unwrap();
4628
4629 for order in [&first, &second, &child] {
4630 cache
4631 .borrow_mut()
4632 .add_order(order.clone(), None, None, false)
4633 .unwrap();
4634 }
4635 apply_accepted(&cache, &first, "V-SPAWN-1");
4636 apply_accepted(&cache, &second, "V-SPAWN-2");
4637 let first = cache.borrow().order_owned(&first_id).unwrap();
4638 let second = cache.borrow().order_owned(&second_id).unwrap();
4639 apply_position_fill(
4640 &cache,
4641 &first,
4642 &instrument,
4643 position_id,
4644 "T-SPAWN-1",
4645 Quantity::from("0.600000"),
4646 Some(Money::from("0.00040000 BTC")),
4647 );
4648 let event = apply_position_fill(
4649 &cache,
4650 &second,
4651 &instrument,
4652 position_id,
4653 "T-SPAWN-2",
4654 Quantity::from("0.100000"),
4655 Some(Money::from("0.00040000 BTC")),
4656 );
4657
4658 let actions = manager.handle_event(&event);
4659 let repeated_actions = manager.handle_event(&event);
4660 let position = cache.borrow().position_owned(&position_id).unwrap();
4661
4662 assert_eq!(position.quantity, Quantity::from("0.699200"));
4663 assert!(matches!(
4664 actions.as_slice(),
4665 [OrderManagerAction::ModifyLocalQuantity { order, quantity }]
4666 if order.client_order_id() == child_id
4667 && *quantity == Quantity::from("0.695000")
4668 ));
4669 let OrderManagerAction::ModifyLocalQuantity { order, quantity } = &actions[0] else {
4670 unreachable!();
4671 };
4672 let mut resized = order.clone();
4673 resized.set_quantity(*quantity);
4674 resized.set_leaves_qty(*quantity);
4675
4676 assert!(resized.would_reduce_only(position.side, position.quantity));
4677 assert!(repeated_actions.is_empty());
4678
4679 let second = cache.borrow().order_owned(&second_id).unwrap();
4680 assert_eq!(second.position_id(), Some(position_id));
4681 assert!(manager.handle_contingencies(&second).is_empty());
4682 assert!(manager.handle_contingencies_update(&second).is_empty());
4683 }
4684
4685 #[rstest]
4686 fn test_handle_oto_exec_spawn_update_uses_filled_sibling_position() {
4687 let (clock, cache) = create_test_components();
4688 let mut manager = OrderManager::new(clock, cache.clone(), false);
4689 let mut instrument = currency_pair_btcusdt();
4690 instrument.size_increment = Quantity::from("0.005000");
4691 let instrument = InstrumentAny::CurrencyPair(instrument);
4692 let position_id = PositionId::from("P-SPAWN");
4693 let spawn_id = ClientOrderId::from("O-SPAWN");
4694 let first_id = ClientOrderId::from("O-SPAWN-1");
4695 let second_id = ClientOrderId::from("O-SPAWN-2");
4696 let child_id = ClientOrderId::from("O-CHILD");
4697 let exec_algorithm_id = ExecAlgorithmId::from("TWAP");
4698 let first = OrderTestBuilder::new(OrderType::Limit)
4699 .instrument_id(instrument.id())
4700 .client_order_id(first_id)
4701 .side(OrderSide::Buy)
4702 .price(Price::from("50000.00"))
4703 .quantity(Quantity::from("0.600000"))
4704 .contingency_type(ContingencyType::Oto)
4705 .linked_order_ids(vec![child_id])
4706 .exec_algorithm_id(exec_algorithm_id)
4707 .exec_spawn_id(spawn_id)
4708 .submit(true)
4709 .build();
4710 let second = OrderTestBuilder::new(OrderType::Limit)
4711 .instrument_id(instrument.id())
4712 .client_order_id(second_id)
4713 .side(OrderSide::Buy)
4714 .price(Price::from("50000.00"))
4715 .quantity(Quantity::from("0.400000"))
4716 .contingency_type(ContingencyType::Oto)
4717 .linked_order_ids(vec![child_id])
4718 .exec_algorithm_id(exec_algorithm_id)
4719 .exec_spawn_id(spawn_id)
4720 .submit(true)
4721 .build();
4722 let child = OrderTestBuilder::new(OrderType::StopMarket)
4723 .instrument_id(instrument.id())
4724 .client_order_id(child_id)
4725 .side(OrderSide::Sell)
4726 .trigger_price(Price::from("49000.00"))
4727 .quantity(Quantity::from("1.000000"))
4728 .reduce_only(true)
4729 .submit(true)
4730 .build();
4731
4732 cache
4733 .borrow_mut()
4734 .add_instrument(instrument.clone())
4735 .unwrap();
4736
4737 for order in [&first, &second, &child] {
4738 cache
4739 .borrow_mut()
4740 .add_order(order.clone(), None, None, false)
4741 .unwrap();
4742 }
4743 apply_accepted(&cache, &first, "V-SPAWN-1");
4744 apply_accepted(&cache, &second, "V-SPAWN-2");
4745 let first = cache.borrow().order_owned(&first_id).unwrap();
4746 let first_fill = apply_position_fill(
4747 &cache,
4748 &first,
4749 &instrument,
4750 position_id,
4751 "T-SPAWN-1",
4752 Quantity::from("0.600000"),
4753 Some(Money::from("0.00040000 BTC")),
4754 );
4755 let first_actions = manager.handle_event(&first_fill);
4756 assert!(matches!(
4757 first_actions.as_slice(),
4758 [OrderManagerAction::ModifyLocalQuantity { quantity, .. }]
4759 if *quantity == Quantity::from("0.595000")
4760 ));
4761 apply_update(&cache, &child, Quantity::from("0.595000"));
4762
4763 let second = cache.borrow().order_owned(&second_id).unwrap();
4764 assert_eq!(second.position_id(), None);
4765 let update = OrderEventAny::Updated(
4766 OrderUpdatedSpec::builder()
4767 .trader_id(second.trader_id())
4768 .strategy_id(second.strategy_id())
4769 .instrument_id(second.instrument_id())
4770 .client_order_id(second.client_order_id())
4771 .quantity(Quantity::from("0.500000"))
4772 .venue_order_id(VenueOrderId::from("V-SPAWN-2"))
4773 .account_id(AccountId::from("ACCOUNT-001"))
4774 .build(),
4775 );
4776 cache.borrow_mut().update_order(&update).unwrap();
4777 let actions = manager.handle_event(&update);
4778
4779 assert!(actions.is_empty());
4780 assert_eq!(
4781 manager.oto_target_quantities.get(&child_id),
4782 Some(&Quantity::from("0.595000")),
4783 );
4784 }
4785
4786 #[rstest]
4787 fn test_handle_oto_fill_uses_completed_and_active_spawn_totals() {
4788 let (clock, cache) = create_test_components();
4789 let mut manager = OrderManager::new(clock, cache.clone(), false);
4790 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4791 let spawn_id = ClientOrderId::from("O-SPAWN");
4792 let first_id = ClientOrderId::from("O-SPAWN-1");
4793 let second_id = ClientOrderId::from("O-SPAWN-2");
4794 let child_id = ClientOrderId::from("O-CHILD");
4795 let exec_algorithm_id = ExecAlgorithmId::from("TWAP");
4796 let first = OrderTestBuilder::new(OrderType::Limit)
4797 .instrument_id(instrument.id())
4798 .client_order_id(first_id)
4799 .side(OrderSide::Buy)
4800 .price(Price::from("1.00000"))
4801 .quantity(Quantity::from(60_000))
4802 .contingency_type(ContingencyType::Oto)
4803 .linked_order_ids(vec![child_id])
4804 .exec_algorithm_id(exec_algorithm_id)
4805 .exec_spawn_id(spawn_id)
4806 .submit(true)
4807 .build();
4808 let second = OrderTestBuilder::new(OrderType::Limit)
4809 .instrument_id(instrument.id())
4810 .client_order_id(second_id)
4811 .side(OrderSide::Buy)
4812 .price(Price::from("1.00000"))
4813 .quantity(Quantity::from(40_000))
4814 .contingency_type(ContingencyType::Oto)
4815 .linked_order_ids(vec![child_id])
4816 .exec_algorithm_id(exec_algorithm_id)
4817 .exec_spawn_id(spawn_id)
4818 .submit(true)
4819 .build();
4820 let child = OrderTestBuilder::new(OrderType::Limit)
4821 .instrument_id(instrument.id())
4822 .client_order_id(child_id)
4823 .side(OrderSide::Sell)
4824 .price(Price::from("1.00100"))
4825 .quantity(Quantity::from(100_000))
4826 .submit(true)
4827 .build();
4828
4829 for order in [&first, &second, &child] {
4830 cache
4831 .borrow_mut()
4832 .add_order(order.clone(), None, None, false)
4833 .unwrap();
4834 }
4835 apply_accepted(&cache, &first, "V-SPAWN-1");
4836 apply_accepted(&cache, &second, "V-SPAWN-2");
4837 apply_fill(
4838 &cache,
4839 &first,
4840 &instrument,
4841 "T-SPAWN-1",
4842 Quantity::from(60_000),
4843 );
4844 let event = apply_fill(
4845 &cache,
4846 &second,
4847 &instrument,
4848 "T-SPAWN-2",
4849 Quantity::from(10_000),
4850 );
4851
4852 let actions = manager.handle_event(&event);
4853
4854 assert!(matches!(
4855 actions.first(),
4856 Some(OrderManagerAction::ModifyLocalQuantity { order, quantity })
4857 if order.client_order_id() == child_id
4858 && *quantity == Quantity::from(70_000)
4859 ));
4860
4861 let update = apply_update(&cache, &second, Quantity::from(50_000));
4862 assert!(manager.handle_event(&update).is_empty());
4863 assert_eq!(
4864 manager.oto_target_quantities.get(&child_id),
4865 Some(&Quantity::from(70_000)),
4866 );
4867
4868 let cached_second = cache.borrow().order_owned(&second_id).unwrap();
4869 assert!(manager.handle_contingencies(&cached_second).is_empty());
4870 }
4871
4872 #[rstest]
4873 fn test_handle_ouo_partial_fill_resizes_open_sibling() {
4874 let (clock, cache) = create_test_components();
4875 let mut manager = OrderManager::new(clock, cache.clone(), false);
4876 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4877 let parent_id = ClientOrderId::from("O-PARENT");
4878 let sibling_id = ClientOrderId::from("O-SIBLING");
4879 let parent = OrderTestBuilder::new(OrderType::Limit)
4880 .instrument_id(instrument.id())
4881 .client_order_id(parent_id)
4882 .side(OrderSide::Buy)
4883 .price(Price::from("1.00000"))
4884 .quantity(Quantity::from(100_000))
4885 .contingency_type(ContingencyType::Ouo)
4886 .linked_order_ids(vec![parent_id, sibling_id])
4887 .submit(true)
4888 .build();
4889 let sibling = OrderTestBuilder::new(OrderType::Limit)
4890 .instrument_id(instrument.id())
4891 .client_order_id(sibling_id)
4892 .side(OrderSide::Sell)
4893 .price(Price::from("1.00100"))
4894 .quantity(Quantity::from(100_000))
4895 .submit(true)
4896 .build();
4897 cache
4898 .borrow_mut()
4899 .add_order(parent.clone(), None, None, false)
4900 .unwrap();
4901 cache
4902 .borrow_mut()
4903 .add_order(sibling, None, None, false)
4904 .unwrap();
4905 let event = apply_fill(
4906 &cache,
4907 &parent,
4908 &instrument,
4909 "T-OUO",
4910 Quantity::from(25_000),
4911 );
4912
4913 let actions = manager.handle_event(&event);
4914
4915 assert!(matches!(
4916 actions.as_slice(),
4917 [OrderManagerAction::ModifyLocalQuantity { order, quantity }]
4918 if order.client_order_id() == sibling_id
4919 && *quantity == Quantity::from(75_000)
4920 ));
4921 }
4922
4923 #[rstest]
4924 fn test_handle_ouo_partial_fill_cancels_sibling_filled_past_target() {
4925 let (clock, cache) = create_test_components();
4926 let mut manager = OrderManager::new(clock, cache.clone(), false);
4927 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4928 let parent_id = ClientOrderId::from("O-PARENT");
4929 let sibling_id = ClientOrderId::from("O-SIBLING");
4930 let parent = OrderTestBuilder::new(OrderType::Limit)
4931 .instrument_id(instrument.id())
4932 .client_order_id(parent_id)
4933 .side(OrderSide::Buy)
4934 .price(Price::from("1.00000"))
4935 .quantity(Quantity::from(100_000))
4936 .contingency_type(ContingencyType::Ouo)
4937 .linked_order_ids(vec![parent_id, sibling_id])
4938 .submit(true)
4939 .build();
4940 let sibling = OrderTestBuilder::new(OrderType::Limit)
4941 .instrument_id(instrument.id())
4942 .client_order_id(sibling_id)
4943 .side(OrderSide::Sell)
4944 .price(Price::from("1.00100"))
4945 .quantity(Quantity::from(100_000))
4946 .submit(true)
4947 .build();
4948 cache
4949 .borrow_mut()
4950 .add_order(parent.clone(), None, None, false)
4951 .unwrap();
4952 cache
4953 .borrow_mut()
4954 .add_order(sibling.clone(), None, None, false)
4955 .unwrap();
4956 apply_accepted(&cache, &parent, "V-PARENT");
4957 apply_accepted(&cache, &sibling, "V-SIBLING");
4958 apply_fill(
4959 &cache,
4960 &sibling,
4961 &instrument,
4962 "T-SIBLING",
4963 Quantity::from(30_000),
4964 );
4965 let parent_fill = apply_fill(
4966 &cache,
4967 &parent,
4968 &instrument,
4969 "T-PARENT",
4970 Quantity::from(90_000),
4971 );
4972
4973 let actions = manager.handle_event(&parent_fill);
4974
4975 assert!(matches!(
4976 actions.as_slice(),
4977 [OrderManagerAction::CancelLocal(order)]
4978 if order.client_order_id() == sibling_id
4979 ));
4980 }
4981
4982 #[rstest]
4983 #[case(TerminalEvent::Canceled)]
4984 #[case(TerminalEvent::Expired)]
4985 #[case(TerminalEvent::Rejected)]
4986 fn test_terminal_non_contingent_event_clears_oto_target(#[case] terminal: TerminalEvent) {
4987 let (clock, cache) = create_test_components();
4988 let mut manager = OrderManager::new(clock, cache.clone(), false);
4989 let order = OrderTestBuilder::new(OrderType::Limit)
4990 .instrument_id(audusd_sim().id())
4991 .client_order_id(ClientOrderId::from("O-001"))
4992 .side(OrderSide::Buy)
4993 .price(Price::from("1.00000"))
4994 .quantity(Quantity::from(100_000))
4995 .submit(true)
4996 .build();
4997 cache
4998 .borrow_mut()
4999 .add_order(order.clone(), None, None, false)
5000 .unwrap();
5001
5002 if matches!(terminal, TerminalEvent::Expired) {
5003 apply_accepted(&cache, &order, "V-001");
5004 }
5005 manager
5006 .oto_target_quantities
5007 .insert(order.client_order_id(), Quantity::from(40_000));
5008 let event = apply_terminal(&cache, &order, terminal);
5009
5010 let actions = manager.handle_event(&event);
5011
5012 assert!(actions.is_empty());
5013 assert!(manager.oto_target_quantities.is_empty());
5014 }
5015
5016 #[rstest]
5017 #[case(ContingencyType::Oto, TerminalEvent::Rejected)]
5018 #[case(ContingencyType::Oco, TerminalEvent::Canceled)]
5019 #[case(ContingencyType::Oco, TerminalEvent::Expired)]
5020 #[case(ContingencyType::Oco, TerminalEvent::Rejected)]
5021 #[case(ContingencyType::Ouo, TerminalEvent::Canceled)]
5022 fn test_terminal_contingent_event_cancels_open_sibling(
5023 #[case] contingency_type: ContingencyType,
5024 #[case] terminal: TerminalEvent,
5025 ) {
5026 let (clock, cache) = create_test_components();
5027 let mut manager = OrderManager::new(clock, cache.clone(), false);
5028 let parent_id = ClientOrderId::from("O-PARENT");
5029 let sibling_id = ClientOrderId::from("O-SIBLING");
5030 let linked_order_ids = if contingency_type == ContingencyType::Oto {
5031 vec![sibling_id]
5032 } else {
5033 vec![parent_id, sibling_id]
5034 };
5035 let parent = OrderTestBuilder::new(OrderType::Limit)
5036 .instrument_id(audusd_sim().id())
5037 .client_order_id(parent_id)
5038 .side(OrderSide::Buy)
5039 .price(Price::from("1.00000"))
5040 .quantity(Quantity::from(100_000))
5041 .contingency_type(contingency_type)
5042 .linked_order_ids(linked_order_ids)
5043 .submit(true)
5044 .build();
5045 let sibling = OrderTestBuilder::new(OrderType::Limit)
5046 .instrument_id(audusd_sim().id())
5047 .client_order_id(sibling_id)
5048 .side(OrderSide::Sell)
5049 .price(Price::from("1.00100"))
5050 .quantity(Quantity::from(100_000))
5051 .submit(true)
5052 .build();
5053 cache
5054 .borrow_mut()
5055 .add_order(parent.clone(), None, None, false)
5056 .unwrap();
5057 cache
5058 .borrow_mut()
5059 .add_order(sibling, None, None, false)
5060 .unwrap();
5061
5062 if matches!(terminal, TerminalEvent::Expired) {
5063 apply_accepted(&cache, &parent, "V-PARENT");
5064 }
5065 let event = apply_terminal(&cache, &parent, terminal);
5066
5067 let actions = manager.handle_event(&event);
5068
5069 assert!(matches!(
5070 actions.as_slice(),
5071 [OrderManagerAction::CancelLocal(order)]
5072 if order.client_order_id() == sibling_id
5073 ));
5074 }
5075
5076 #[rstest]
5077 #[case(ContingencyType::Oto)]
5078 #[case(ContingencyType::Ouo)]
5079 fn test_strategy_manager_excludes_active_local_and_closed_siblings(
5080 #[case] contingency_type: ContingencyType,
5081 ) {
5082 let (clock, cache) = create_test_components();
5083 let mut manager = OrderManager::new(clock, cache.clone(), false);
5084 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
5085 let parent_id = ClientOrderId::from("O-PARENT");
5086 let active_local_id = ClientOrderId::from("O-ACTIVE-LOCAL");
5087 let closed_id = ClientOrderId::from("O-CLOSED");
5088 let parent = OrderTestBuilder::new(OrderType::Limit)
5089 .instrument_id(instrument.id())
5090 .client_order_id(parent_id)
5091 .side(OrderSide::Buy)
5092 .price(Price::from("1.00000"))
5093 .quantity(Quantity::from(100_000))
5094 .contingency_type(contingency_type)
5095 .linked_order_ids(vec![active_local_id, closed_id])
5096 .submit(true)
5097 .build();
5098 let active_local = OrderTestBuilder::new(OrderType::Limit)
5099 .instrument_id(instrument.id())
5100 .client_order_id(active_local_id)
5101 .side(OrderSide::Sell)
5102 .price(Price::from("1.00100"))
5103 .quantity(Quantity::from(100_000))
5104 .build();
5105 let closed = OrderTestBuilder::new(OrderType::Limit)
5106 .instrument_id(instrument.id())
5107 .client_order_id(closed_id)
5108 .side(OrderSide::Sell)
5109 .price(Price::from("1.00100"))
5110 .quantity(Quantity::from(100_000))
5111 .submit(true)
5112 .build();
5113 cache
5114 .borrow_mut()
5115 .add_order(parent.clone(), None, None, false)
5116 .unwrap();
5117 cache
5118 .borrow_mut()
5119 .add_order(active_local, None, None, false)
5120 .unwrap();
5121 cache
5122 .borrow_mut()
5123 .add_order(closed.clone(), None, None, false)
5124 .unwrap();
5125 apply_terminal(&cache, &closed, TerminalEvent::Canceled);
5126 let event = apply_fill(
5127 &cache,
5128 &parent,
5129 &instrument,
5130 "T-EXCLUSION",
5131 Quantity::from(100_000),
5132 );
5133
5134 let actions = manager.handle_event(&event);
5135
5136 assert!(actions.is_empty());
5137 }
5138}