1use std::num::NonZeroUsize;
17
18use ahash::AHashSet;
19use nautilus_common::{
20 actor::{DataActor, DataActorNative},
21 config::ConfigError,
22 enums::LogColor,
23 log_info, log_warn,
24 timer::TimeEvent,
25};
26use nautilus_core::UnixNanos;
27use nautilus_model::{
28 data::{Bar, IndexPriceUpdate, MarkPriceUpdate, OrderBookDeltas, QuoteTick, TradeTick},
29 enums::{ContingencyType, OrderSide, OrderStatus, OrderType, TimeInForce},
30 identifiers::{ClientId, ClientOrderId, InstrumentId, StrategyId},
31 instruments::{Instrument, InstrumentAny},
32 orderbook::OrderBook,
33 orders::{Order, OrderAny, OrderCore},
34 types::{Price, Quantity},
35};
36use nautilus_trading::{
37 nautilus_strategy,
38 strategy::{Strategy, StrategyCore, StrategyNative},
39};
40use rust_decimal::{Decimal, prelude::ToPrimitive};
41
42use super::config::ExecTesterConfig;
43
44#[derive(Debug)]
53#[expect(
54 clippy::struct_excessive_bools,
55 reason = "tester state tracks independent execution scenarios"
56)]
57pub struct ExecTester {
58 pub(super) core: StrategyCore,
59 pub(super) config: ExecTesterConfig,
60 pub(super) instrument: Option<InstrumentAny>,
61 pub(super) price_offset: Option<u64>,
62 pub(super) preinitialized_market_data: bool,
63
64 pub(super) buy_order: Option<OrderAny>,
66 pub(super) sell_order: Option<OrderAny>,
67 pub(super) buy_stop_order: Option<OrderAny>,
68 pub(super) sell_stop_order: Option<OrderAny>,
69 pub(super) open_position_submitted: bool,
70
71 pub(super) modify_rejected_attempted: bool,
74 pub(super) pending_open_position_qty: Option<Decimal>,
75 pub(super) buy_cancel_replace_attempted: bool,
76 pub(super) sell_cancel_replace_attempted: bool,
77 pub(super) buy_stop_cancel_replace_attempted: bool,
78 pub(super) sell_stop_cancel_replace_attempted: bool,
79}
80
81nautilus_strategy!(ExecTester, {
82 fn external_order_claims(&self) -> Option<Vec<InstrumentId>> {
83 self.config.base.external_order_claims.clone()
84 }
85});
86
87impl DataActor for ExecTester {
88 fn on_start(&mut self) -> anyhow::Result<()> {
89 Strategy::on_start(self)?;
90
91 let instrument_id = self.config.instrument_id;
92 let client_id = self.config.client_id;
93
94 let instrument = self.cache().instrument(&instrument_id);
95
96 if let Some(inst) = instrument {
97 self.initialize_with_instrument(inst, true)?;
98 } else {
99 log::info!("Instrument {instrument_id} not in cache, subscribing...");
100 self.subscribe_instrument(instrument_id, client_id, None);
101
102 if self.config.subscribe_quotes {
105 self.subscribe_quotes(instrument_id, client_id, None);
106 }
107
108 if self.config.subscribe_trades {
109 self.subscribe_trades(instrument_id, client_id, None);
110 }
111 self.preinitialized_market_data =
112 self.config.subscribe_quotes || self.config.subscribe_trades;
113 }
114
115 Ok(())
116 }
117
118 fn on_instrument(&mut self, instrument: &InstrumentAny) -> anyhow::Result<()> {
119 if instrument.id() == self.config.instrument_id && self.instrument.is_none() {
120 let id = instrument.id();
121 log::info!("Received instrument {id}, initializing...");
122 self.initialize_with_instrument(instrument.clone(), !self.preinitialized_market_data)?;
123 }
124 Ok(())
125 }
126
127 fn on_stop(&mut self) -> anyhow::Result<()> {
128 if self.config.dry_run {
129 log_warn!("Dry run mode, skipping cancel all orders and close all positions");
130 return Ok(());
131 }
132
133 let instrument_id = self.config.instrument_id;
134 let client_id = self.config.client_id;
135 let strategy_id = StrategyId::from(
136 DataActorNative::core(&self.core)
137 .actor_id()
138 .inner()
139 .as_str(),
140 );
141
142 if self.config.cancel_orders_on_stop {
143 self.cancel_active_orders(instrument_id, strategy_id, client_id);
144 }
145
146 if self.config.close_positions_on_stop {
147 let time_in_force = self
148 .config
149 .close_positions_time_in_force
150 .or(Some(TimeInForce::Gtc));
151
152 if let Err(e) = self.close_positions_on_stop(strategy_id, time_in_force) {
153 log::error!("Failed to close all positions: {e}");
154 }
155 }
156
157 if self.config.can_unsubscribe && self.instrument.is_some() {
158 if self.config.subscribe_quotes {
159 self.unsubscribe_quotes(instrument_id, client_id, None);
160 }
161
162 if self.config.subscribe_trades {
163 self.unsubscribe_trades(instrument_id, client_id, None);
164 }
165
166 if self.config.subscribe_book {
167 self.unsubscribe_book_at_interval(
168 instrument_id,
169 NonZeroUsize::new(self.config.book_interval_ms).ok_or_else(|| {
170 ConfigError::range("book_interval_ms", "must be positive, was 0")
171 })?,
172 client_id,
173 None,
174 );
175 }
176 }
177
178 Ok(())
179 }
180
181 fn on_quote(&mut self, quote: &QuoteTick) -> anyhow::Result<()> {
182 if self.config.log_data {
183 log_info!("{quote:?}", color = LogColor::Cyan);
184 }
185
186 if quote.instrument_id == self.config.instrument_id
187 && self.config.open_position_on_first_quote
188 {
189 self.submit_pending_open_position();
190 }
191
192 self.maintain_orders(quote.bid_price, quote.ask_price);
193 Ok(())
194 }
195
196 fn on_trade(&mut self, trade: &TradeTick) -> anyhow::Result<()> {
197 if self.config.log_data {
198 log_info!("{trade:?}", color = LogColor::Cyan);
199 }
200 Ok(())
201 }
202
203 fn on_book(&mut self, book: &OrderBook) -> anyhow::Result<()> {
204 if self.config.log_data {
205 let num_levels = self.config.book_levels_to_print;
206 let instrument_id = book.instrument_id;
207 let book_str = book.pprint(num_levels, None);
208 log_info!("\n{instrument_id}\n{book_str}", color = LogColor::Cyan);
209
210 if DataActorNative::core(&self.core).is_registered() {
212 let cache = self.cache();
213 if let Some(own_book) = cache.own_order_book(&instrument_id) {
214 let own_book_str = own_book.pprint(num_levels, None);
215 log_info!(
216 "\n{instrument_id} (own)\n{own_book_str}",
217 color = LogColor::Magenta
218 );
219 }
220 }
221 }
222
223 let Some(best_bid) = book.best_bid_price() else {
224 return Ok(()); };
226 let Some(best_ask) = book.best_ask_price() else {
227 return Ok(()); };
229
230 self.maintain_orders(best_bid, best_ask);
231 Ok(())
232 }
233
234 fn on_book_deltas(&mut self, deltas: &OrderBookDeltas) -> anyhow::Result<()> {
235 if self.config.log_data {
236 log_info!("{deltas:?}", color = LogColor::Cyan);
237 }
238 Ok(())
239 }
240
241 fn on_bar(&mut self, bar: &Bar) -> anyhow::Result<()> {
242 if self.config.log_data {
243 log_info!("{bar:?}", color = LogColor::Cyan);
244 }
245 Ok(())
246 }
247
248 fn on_mark_price(&mut self, mark_price: &MarkPriceUpdate) -> anyhow::Result<()> {
249 if self.config.log_data {
250 log_info!("{mark_price:?}", color = LogColor::Cyan);
251 }
252 Ok(())
253 }
254
255 fn on_index_price(&mut self, index_price: &IndexPriceUpdate) -> anyhow::Result<()> {
256 if self.config.log_data {
257 log_info!("{index_price:?}", color = LogColor::Cyan);
258 }
259 Ok(())
260 }
261
262 fn on_time_event(&mut self, event: &TimeEvent) -> anyhow::Result<()> {
263 Strategy::on_time_event(self, event)
264 }
265}
266
267impl ExecTester {
268 #[must_use]
270 pub fn new(config: ExecTesterConfig) -> Self {
271 let pending_open_position_qty = config.open_position_on_start_qty;
272
273 Self {
274 core: StrategyCore::new(config.base.clone()),
275 config,
276 instrument: None,
277 price_offset: None,
278 preinitialized_market_data: false,
279 buy_order: None,
280 sell_order: None,
281 buy_stop_order: None,
282 sell_stop_order: None,
283 open_position_submitted: false,
284 modify_rejected_attempted: false,
285 pending_open_position_qty,
286 buy_cancel_replace_attempted: false,
287 sell_cancel_replace_attempted: false,
288 buy_stop_cancel_replace_attempted: false,
289 sell_stop_cancel_replace_attempted: false,
290 }
291 }
292
293 fn initialize_with_instrument(
294 &mut self,
295 instrument: InstrumentAny,
296 subscribe_market_data: bool,
297 ) -> anyhow::Result<()> {
298 let instrument_id = self.config.instrument_id;
299 let client_id = self.config.client_id;
300
301 self.price_offset = Some(self.get_price_offset(&instrument));
302 self.instrument = Some(instrument);
303
304 if subscribe_market_data && self.config.subscribe_quotes {
305 self.subscribe_quotes(instrument_id, client_id, None);
306 }
307
308 if subscribe_market_data && self.config.subscribe_trades {
309 self.subscribe_trades(instrument_id, client_id, None);
310 }
311
312 if self.config.subscribe_book {
313 self.subscribe_book_at_interval(
314 instrument_id,
315 self.config.book_type,
316 self.config
317 .book_depth
318 .map(|depth| {
319 NonZeroUsize::new(depth).ok_or_else(|| {
320 ConfigError::range("book_depth", "must be positive, was 0")
321 })
322 })
323 .transpose()?,
324 NonZeroUsize::new(self.config.book_interval_ms).ok_or_else(|| {
325 ConfigError::range("book_interval_ms", "must be positive, was 0")
326 })?,
327 client_id,
328 None,
329 );
330 }
331
332 if let Some(qty) = self.pending_open_position_qty {
333 let quote_ready = {
334 let cache = self.cache();
335 cache.quote(&instrument_id).is_some()
336 };
337
338 if self.config.open_position_on_first_quote
339 && self.config.subscribe_quotes
340 && !quote_ready
341 {
342 log::info!("Waiting for first quote before opening {instrument_id} position");
343 } else {
344 self.pending_open_position_qty = None;
345 self.open_position(qty)?;
346 self.open_position_submitted = true;
347 }
348 }
349
350 Ok(())
351 }
352
353 pub(super) fn get_price_offset(&self, _instrument: &InstrumentAny) -> u64 {
354 self.config.tob_offset_ticks
355 }
356
357 fn expire_time_from_delta(&self, mins: u64) -> UnixNanos {
358 let current_ns = DataActorNative::core(&self.core).timestamp_ns();
359 let delta_ns = mins.saturating_mul(60).saturating_mul(1_000_000_000);
360 UnixNanos::from(current_ns.as_u64() + delta_ns)
361 }
362
363 fn resolve_time_in_force(
364 &self,
365 tif_override: Option<TimeInForce>,
366 ) -> (TimeInForce, Option<UnixNanos>) {
367 match (tif_override, self.config.order_expire_time_delta_mins) {
368 (Some(TimeInForce::Gtd), Some(mins)) => {
369 (TimeInForce::Gtd, Some(self.expire_time_from_delta(mins)))
370 }
371 (Some(TimeInForce::Gtd), None) => {
372 log_warn!(
373 "GTD time in force requires order_expire_time_delta_mins, falling back to GTC"
374 );
375 (TimeInForce::Gtc, None)
376 }
377 (Some(tif), _) => (tif, None),
378 (None, Some(mins)) => (TimeInForce::Gtd, Some(self.expire_time_from_delta(mins))),
379 (None, None) => (TimeInForce::Gtc, None),
380 }
381 }
382
383 fn submit_pending_open_position(&mut self) {
384 if self.instrument.is_none() {
385 return;
386 }
387
388 let Some(qty) = self.pending_open_position_qty.take() else {
389 return;
390 };
391
392 if let Err(e) = self.open_position(qty) {
393 log::error!("Failed to submit pending open position: {e}");
394 } else {
395 self.open_position_submitted = true;
396 }
397 }
398
399 pub(super) fn is_order_active(order: &OrderAny) -> bool {
400 order.is_active_local() || order.is_inflight() || order.is_open()
401 }
402
403 pub(super) fn limit_order_is_one_shot(&self) -> bool {
404 self.config.test_reject_post_only
405 || self.config.limit_aggressive
406 || self.config.order_expire_time_delta_mins.is_some()
407 || matches!(
408 self.config.limit_time_in_force,
409 Some(TimeInForce::Ioc | TimeInForce::Fok)
410 )
411 }
412
413 pub(super) fn stop_order_is_one_shot(&self) -> bool {
414 self.config.order_expire_time_delta_mins.is_some()
415 || matches!(
416 self.config.stop_time_in_force,
417 Some(TimeInForce::Ioc | TimeInForce::Fok)
418 )
419 || matches!(self.config.stop_order_type, OrderType::TrailingStopMarket)
420 }
421
422 pub(super) fn get_order_trigger_price(order: &OrderAny) -> Option<Price> {
423 order.trigger_price()
424 }
425
426 fn modify_stop_order(
427 &mut self,
428 order: &OrderAny,
429 trigger_price: Price,
430 limit_price: Option<Price>,
431 ) -> anyhow::Result<()> {
432 let client_id = self.config.client_id;
433
434 match order {
435 OrderAny::StopMarket(_)
436 | OrderAny::MarketIfTouched(_)
437 | OrderAny::TrailingStopMarket(_) => self.modify_order(
438 order.client_order_id(),
439 None,
440 None,
441 Some(trigger_price),
442 client_id,
443 None,
444 ),
445 OrderAny::StopLimit(_) | OrderAny::LimitIfTouched(_) => self.modify_order(
446 order.client_order_id(),
447 None,
448 limit_price,
449 Some(trigger_price),
450 client_id,
451 None,
452 ),
453 _ => {
454 log_warn!("Cannot modify order of type {:?}", order.order_type());
455 Ok(())
456 }
457 }
458 }
459
460 fn submit_order_apply_params(&mut self, order: OrderAny) -> anyhow::Result<()> {
462 let client_id = self.config.client_id;
463 if let Some(params) = &self.config.order_params {
464 self.submit_order(order, None, client_id, Some(params.clone()))
465 } else {
466 self.submit_order(order, None, client_id, None)
467 }
468 }
469
470 pub(super) fn maintain_orders(&mut self, best_bid: Price, best_ask: Price) {
472 if self.instrument.is_none() || self.config.dry_run {
473 return;
474 }
475
476 if self.config.batch_submit_limit_pair
477 && self.config.enable_limit_buys
478 && self.config.enable_limit_sells
479 {
480 self.maintain_batch_limit_pair(best_bid, best_ask);
481 return;
482 }
483
484 if self.config.enable_limit_buys {
485 self.maintain_buy_orders(best_bid, best_ask);
486 }
487
488 if self.config.enable_limit_sells {
489 self.maintain_sell_orders(best_bid, best_ask);
490 }
491
492 if self.config.enable_stop_buys {
493 self.maintain_stop_buy_orders(best_bid, best_ask);
494 }
495
496 if self.config.enable_stop_sells {
497 self.maintain_stop_sell_orders(best_bid, best_ask);
498 }
499 }
500
501 fn refresh_tracked_order(&mut self, side: OrderSide) {
505 let cid = match side {
506 OrderSide::Buy => self.buy_order.as_ref().map(OrderAny::client_order_id),
507 OrderSide::Sell => self.sell_order.as_ref().map(OrderAny::client_order_id),
508 OrderSide::NoOrderSide => None,
509 };
510 let Some(cid) = cid else {
511 return;
512 };
513 let latest = self.cache().order(&cid);
514 if let Some(latest) = latest {
515 match side {
516 OrderSide::Buy => self.buy_order = Some(latest),
517 OrderSide::Sell => self.sell_order = Some(latest),
518 OrderSide::NoOrderSide => {}
519 }
520 }
521 }
522
523 fn refresh_tracked_stop_order(&mut self, side: OrderSide) {
524 let cid = match side {
525 OrderSide::Buy => self.buy_stop_order.as_ref().map(OrderAny::client_order_id),
526 OrderSide::Sell => self.sell_stop_order.as_ref().map(OrderAny::client_order_id),
527 OrderSide::NoOrderSide => None,
528 };
529 let Some(cid) = cid else {
530 return;
531 };
532 let latest = self.cache().order(&cid);
533 if let Some(latest) = latest {
534 match side {
535 OrderSide::Buy => self.buy_stop_order = Some(latest),
536 OrderSide::Sell => self.sell_stop_order = Some(latest),
537 OrderSide::NoOrderSide => {}
538 }
539 }
540 }
541
542 fn maintain_buy_orders(&mut self, best_bid: Price, best_ask: Price) {
544 self.refresh_tracked_order(OrderSide::Buy);
548
549 let Some(instrument) = &self.instrument else {
550 return;
551 };
552 let Some(price_offset_ticks) = self.price_offset else {
553 return;
554 };
555
556 let increment = instrument.price_increment();
557 let precision = instrument.price_precision();
558
559 let cross_spread = self.config.test_reject_post_only || self.config.limit_aggressive;
564 let unclamped_price = if cross_spread {
565 add_price_ticks(best_ask, increment, price_offset_ticks, precision)
566 } else {
567 sub_price_ticks(best_bid, increment, price_offset_ticks, precision)
568 };
569 let price = clamp_price_to_range(
570 unclamped_price,
571 instrument,
572 self.config.clamp_to_instrument_price_range,
573 );
574
575 let needs_new_order = match &self.buy_order {
576 None => true,
577 Some(order) => !Self::is_order_active(order) && !self.limit_order_is_one_shot(),
578 };
579
580 if needs_new_order {
581 let result = if self.config.enable_brackets {
582 self.submit_bracket_order(OrderSide::Buy, price)
583 } else {
584 self.submit_limit_order(OrderSide::Buy, price)
585 };
586
587 if let Err(e) = result {
588 log::error!("Failed to submit buy order: {e}");
589 }
590 } else if let Some(order) = &self.buy_order
591 && order.venue_order_id().is_some()
592 && !order.is_pending_update()
593 && !order.is_pending_cancel()
594 {
595 let client_id = self.config.client_id;
596
597 if self.config.test_modify_rejected && !self.modify_rejected_attempted {
600 self.modify_rejected_attempted = true;
601 let order_clone = order.clone();
602 let bumped = clamp_price_to_range(
603 add_price_ticks(price, increment, 1, precision),
604 instrument,
605 self.config.clamp_to_instrument_price_range,
606 );
607
608 if let Err(e) = self.modify_order(
609 order_clone.client_order_id(),
610 None,
611 Some(bumped),
612 None,
613 client_id,
614 None,
615 ) {
616 log::error!("Failed to submit test modify on buy order: {e}");
617 }
618 return;
619 }
620
621 if let Some(order_price) = order.price()
622 && order_price < price
623 {
624 if self.config.modify_orders_to_maintain_tob_offset {
625 let order_clone = order.clone();
626 if let Err(e) = self.modify_order(
627 order_clone.client_order_id(),
628 None,
629 Some(price),
630 None,
631 client_id,
632 None,
633 ) {
634 log::error!("Failed to modify buy order: {e}");
635 }
636 } else if self.config.cancel_replace_orders_to_maintain_tob_offset
637 && !self.buy_cancel_replace_attempted
638 {
639 self.buy_cancel_replace_attempted = true;
640 let order_clone = order.clone();
641 let _ = self.cancel_order(order_clone.client_order_id(), client_id, None);
642
643 if let Err(e) = self.submit_limit_order(OrderSide::Buy, price) {
644 log::error!("Failed to submit replacement buy order: {e}");
645 }
646 }
647 }
648 }
649 }
650
651 fn maintain_sell_orders(&mut self, best_bid: Price, best_ask: Price) {
653 self.refresh_tracked_order(OrderSide::Sell);
656
657 let Some(instrument) = &self.instrument else {
658 return;
659 };
660 let Some(price_offset_ticks) = self.price_offset else {
661 return;
662 };
663
664 let increment = instrument.price_increment();
665 let precision = instrument.price_precision();
666
667 let cross_spread = self.config.test_reject_post_only || self.config.limit_aggressive;
669 let unclamped_price = if cross_spread {
670 sub_price_ticks(best_bid, increment, price_offset_ticks, precision)
671 } else {
672 add_price_ticks(best_ask, increment, price_offset_ticks, precision)
673 };
674 let price = clamp_price_to_range(
675 unclamped_price,
676 instrument,
677 self.config.clamp_to_instrument_price_range,
678 );
679
680 let needs_new_order = match &self.sell_order {
681 None => true,
682 Some(order) => !Self::is_order_active(order) && !self.limit_order_is_one_shot(),
683 };
684
685 if needs_new_order {
686 let result = if self.config.enable_brackets {
687 self.submit_bracket_order(OrderSide::Sell, price)
688 } else {
689 self.submit_limit_order(OrderSide::Sell, price)
690 };
691
692 if let Err(e) = result {
693 log::error!("Failed to submit sell order: {e}");
694 }
695 } else if let Some(order) = &self.sell_order
696 && order.venue_order_id().is_some()
697 && !order.is_pending_update()
698 && !order.is_pending_cancel()
699 {
700 let client_id = self.config.client_id;
701
702 if self.config.test_modify_rejected && !self.modify_rejected_attempted {
704 self.modify_rejected_attempted = true;
705 let order_clone = order.clone();
706 let bumped = clamp_price_to_range(
707 sub_price_ticks(price, increment, 1, precision),
708 instrument,
709 self.config.clamp_to_instrument_price_range,
710 );
711
712 if let Err(e) = self.modify_order(
713 order_clone.client_order_id(),
714 None,
715 Some(bumped),
716 None,
717 client_id,
718 None,
719 ) {
720 log::error!("Failed to submit test modify on sell order: {e}");
721 }
722 return;
723 }
724
725 if let Some(order_price) = order.price()
726 && order_price > price
727 {
728 if self.config.modify_orders_to_maintain_tob_offset {
729 let order_clone = order.clone();
730 if let Err(e) = self.modify_order(
731 order_clone.client_order_id(),
732 None,
733 Some(price),
734 None,
735 client_id,
736 None,
737 ) {
738 log::error!("Failed to modify sell order: {e}");
739 }
740 } else if self.config.cancel_replace_orders_to_maintain_tob_offset
741 && !self.sell_cancel_replace_attempted
742 {
743 self.sell_cancel_replace_attempted = true;
744 let order_clone = order.clone();
745 let _ = self.cancel_order(order_clone.client_order_id(), client_id, None);
746
747 if let Err(e) = self.submit_limit_order(OrderSide::Sell, price) {
748 log::error!("Failed to submit replacement sell order: {e}");
749 }
750 }
751 }
752 }
753 }
754
755 fn maintain_batch_limit_pair(&mut self, best_bid: Price, best_ask: Price) {
757 self.refresh_tracked_order(OrderSide::Buy);
761 self.refresh_tracked_order(OrderSide::Sell);
762
763 let Some(instrument) = &self.instrument else {
764 return;
765 };
766 let Some(price_offset_ticks) = self.price_offset else {
767 return;
768 };
769
770 let buy_needs = match &self.buy_order {
771 None => true,
772 Some(order) => !Self::is_order_active(order) && !self.limit_order_is_one_shot(),
773 };
774 let sell_needs = match &self.sell_order {
775 None => true,
776 Some(order) => !Self::is_order_active(order) && !self.limit_order_is_one_shot(),
777 };
778
779 if !buy_needs || !sell_needs {
780 return;
781 }
782
783 let increment = instrument.price_increment();
784 let precision = instrument.price_precision();
785
786 let cross_spread = self.config.test_reject_post_only || self.config.limit_aggressive;
790 let (unclamped_buy_price, unclamped_sell_price) = if cross_spread {
791 (
792 add_price_ticks(best_ask, increment, price_offset_ticks, precision),
793 sub_price_ticks(best_bid, increment, price_offset_ticks, precision),
794 )
795 } else {
796 (
797 sub_price_ticks(best_bid, increment, price_offset_ticks, precision),
798 add_price_ticks(best_ask, increment, price_offset_ticks, precision),
799 )
800 };
801 let clamp = self.config.clamp_to_instrument_price_range;
802 let buy_price = clamp_price_to_range(unclamped_buy_price, instrument, clamp);
803 let sell_price = clamp_price_to_range(unclamped_sell_price, instrument, clamp);
804 let quantity = instrument.make_qty(self.config.order_qty.as_f64(), None);
805 let (time_in_force, expire_time) =
806 self.resolve_time_in_force(self.config.limit_time_in_force);
807 let instrument_id = self.config.instrument_id;
808 let post_only = self.config.use_post_only || self.config.test_reject_post_only;
809 let quote_quantity = self.config.use_quote_quantity;
810 let display_qty = self.config.order_display_qty;
811 let emulation_trigger = self.config.emulation_trigger;
812
813 let buy_order = self.order_factory().limit(
814 instrument_id,
815 OrderSide::Buy,
816 quantity,
817 buy_price,
818 Some(time_in_force),
819 expire_time,
820 Some(post_only),
821 None,
822 Some(quote_quantity),
823 display_qty,
824 emulation_trigger,
825 None,
826 None,
827 None,
828 None,
829 None,
830 );
831
832 let sell_order = self.order_factory().limit(
833 instrument_id,
834 OrderSide::Sell,
835 quantity,
836 sell_price,
837 Some(time_in_force),
838 expire_time,
839 Some(post_only),
840 None,
841 Some(quote_quantity),
842 display_qty,
843 emulation_trigger,
844 None,
845 None,
846 None,
847 None,
848 None,
849 );
850
851 self.buy_order = Some(buy_order.clone());
852 self.sell_order = Some(sell_order.clone());
853
854 let client_id = self.config.client_id;
855 if let Err(e) = self.submit_order_list(vec![buy_order, sell_order], None, client_id, None) {
856 log::error!("Failed to submit batch limit pair: {e}");
857 }
858 }
859
860 fn maintain_stop_buy_orders(&mut self, best_bid: Price, best_ask: Price) {
862 self.refresh_tracked_stop_order(OrderSide::Buy);
863
864 if let Some(order) = self.buy_stop_order.as_ref()
866 && matches!(order.status(), OrderStatus::Rejected | OrderStatus::Denied)
867 {
868 return;
869 }
870
871 let Some(instrument) = &self.instrument else {
872 return;
873 };
874
875 let increment = instrument.price_increment();
876 let precision = instrument.price_precision();
877 let stop_offset_ticks = self.config.stop_offset_ticks;
878
879 let unclamped_trigger_price = if matches!(
881 self.config.stop_order_type,
882 OrderType::LimitIfTouched | OrderType::MarketIfTouched | OrderType::TrailingStopMarket
883 ) {
884 sub_price_ticks(best_bid, increment, stop_offset_ticks, precision)
886 } else {
887 add_price_ticks(best_ask, increment, stop_offset_ticks, precision)
889 };
890 let clamp = self.config.clamp_to_instrument_price_range;
891 let trigger_price = clamp_price_to_range(unclamped_trigger_price, instrument, clamp);
892
893 let limit_price = if matches!(
895 self.config.stop_order_type,
896 OrderType::StopLimit | OrderType::LimitIfTouched
897 ) {
898 let unclamped_limit_price =
899 if let Some(limit_offset_ticks) = self.config.stop_limit_offset_ticks {
900 add_price_ticks(trigger_price, increment, limit_offset_ticks, precision)
902 } else {
903 trigger_price
904 };
905 Some(clamp_price_to_range(
906 unclamped_limit_price,
907 instrument,
908 clamp,
909 ))
910 } else {
911 None
912 };
913
914 let needs_new_order = match &self.buy_stop_order {
915 None => true,
916 Some(order) => !Self::is_order_active(order) && !self.stop_order_is_one_shot(),
917 };
918
919 if needs_new_order {
920 if let Err(e) = self.submit_stop_order(OrderSide::Buy, trigger_price, limit_price) {
921 log::error!("Failed to submit buy stop order: {e}");
922 }
923 } else if let Some(order) = &self.buy_stop_order
924 && order.venue_order_id().is_some()
925 && !order.is_pending_update()
926 && !order.is_pending_cancel()
927 {
928 let current_trigger = Self::get_order_trigger_price(order);
929 if current_trigger.is_some() && current_trigger != Some(trigger_price) {
930 if self.config.modify_stop_orders_to_maintain_offset {
931 let order_clone = order.clone();
932 if let Err(e) = self.modify_stop_order(&order_clone, trigger_price, limit_price)
933 {
934 log::error!("Failed to modify buy stop order: {e}");
935 }
936 } else if self.config.cancel_replace_stop_orders_to_maintain_offset
937 && !self.buy_stop_cancel_replace_attempted
938 {
939 self.buy_stop_cancel_replace_attempted = true;
940 let order_clone = order.clone();
941 let _ = self.cancel_order(
942 order_clone.client_order_id(),
943 self.config.client_id,
944 None,
945 );
946
947 if let Err(e) =
948 self.submit_stop_order(OrderSide::Buy, trigger_price, limit_price)
949 {
950 log::error!("Failed to submit replacement buy stop order: {e}");
951 }
952 }
953 }
954 }
955 }
956
957 fn maintain_stop_sell_orders(&mut self, best_bid: Price, best_ask: Price) {
959 self.refresh_tracked_stop_order(OrderSide::Sell);
960
961 if let Some(order) = self.sell_stop_order.as_ref()
963 && matches!(order.status(), OrderStatus::Rejected | OrderStatus::Denied)
964 {
965 return;
966 }
967
968 let Some(instrument) = &self.instrument else {
969 return;
970 };
971
972 let increment = instrument.price_increment();
973 let precision = instrument.price_precision();
974 let stop_offset_ticks = self.config.stop_offset_ticks;
975
976 let unclamped_trigger_price = if matches!(
978 self.config.stop_order_type,
979 OrderType::LimitIfTouched | OrderType::MarketIfTouched | OrderType::TrailingStopMarket
980 ) {
981 add_price_ticks(best_ask, increment, stop_offset_ticks, precision)
983 } else {
984 sub_price_ticks(best_bid, increment, stop_offset_ticks, precision)
986 };
987 let clamp = self.config.clamp_to_instrument_price_range;
988 let trigger_price = clamp_price_to_range(unclamped_trigger_price, instrument, clamp);
989
990 let limit_price = if matches!(
992 self.config.stop_order_type,
993 OrderType::StopLimit | OrderType::LimitIfTouched
994 ) {
995 let unclamped_limit_price =
996 if let Some(limit_offset_ticks) = self.config.stop_limit_offset_ticks {
997 sub_price_ticks(trigger_price, increment, limit_offset_ticks, precision)
999 } else {
1000 trigger_price
1001 };
1002 Some(clamp_price_to_range(
1003 unclamped_limit_price,
1004 instrument,
1005 clamp,
1006 ))
1007 } else {
1008 None
1009 };
1010
1011 let needs_new_order = match &self.sell_stop_order {
1012 None => true,
1013 Some(order) => !Self::is_order_active(order) && !self.stop_order_is_one_shot(),
1014 };
1015
1016 if needs_new_order {
1017 if let Err(e) = self.submit_stop_order(OrderSide::Sell, trigger_price, limit_price) {
1018 log::error!("Failed to submit sell stop order: {e}");
1019 }
1020 } else if let Some(order) = &self.sell_stop_order
1021 && order.venue_order_id().is_some()
1022 && !order.is_pending_update()
1023 && !order.is_pending_cancel()
1024 {
1025 let current_trigger = Self::get_order_trigger_price(order);
1026 if current_trigger.is_some() && current_trigger != Some(trigger_price) {
1027 if self.config.modify_stop_orders_to_maintain_offset {
1028 let order_clone = order.clone();
1029 if let Err(e) = self.modify_stop_order(&order_clone, trigger_price, limit_price)
1030 {
1031 log::error!("Failed to modify sell stop order: {e}");
1032 }
1033 } else if self.config.cancel_replace_stop_orders_to_maintain_offset
1034 && !self.sell_stop_cancel_replace_attempted
1035 {
1036 self.sell_stop_cancel_replace_attempted = true;
1037 let order_clone = order.clone();
1038 let _ = self.cancel_order(
1039 order_clone.client_order_id(),
1040 self.config.client_id,
1041 None,
1042 );
1043
1044 if let Err(e) =
1045 self.submit_stop_order(OrderSide::Sell, trigger_price, limit_price)
1046 {
1047 log::error!("Failed to submit replacement sell stop order: {e}");
1048 }
1049 }
1050 }
1051 }
1052 }
1053
1054 pub(super) fn submit_limit_order(
1060 &mut self,
1061 order_side: OrderSide,
1062 price: Price,
1063 ) -> anyhow::Result<()> {
1064 let Some(instrument) = &self.instrument else {
1065 anyhow::bail!("No instrument loaded");
1066 };
1067
1068 if self.config.dry_run {
1069 log_warn!("Dry run, skipping create {order_side:?} order");
1070 return Ok(());
1071 }
1072
1073 if order_side == OrderSide::Buy && !self.config.enable_limit_buys {
1074 log_warn!("BUY orders not enabled, skipping");
1075 return Ok(());
1076 } else if order_side == OrderSide::Sell && !self.config.enable_limit_sells {
1077 log_warn!("SELL orders not enabled, skipping");
1078 return Ok(());
1079 }
1080
1081 let (time_in_force, expire_time) =
1082 self.resolve_time_in_force(self.config.limit_time_in_force);
1083
1084 let quantity = instrument.make_qty(self.config.order_qty.as_f64(), None);
1085 let instrument_id = self.config.instrument_id;
1086 let post_only = self.config.use_post_only || self.config.test_reject_post_only;
1087 let quote_quantity = self.config.use_quote_quantity;
1088 let display_qty = self.config.order_display_qty;
1089 let emulation_trigger = self.config.emulation_trigger;
1090
1091 let order = self.order_factory().limit(
1092 instrument_id,
1093 order_side,
1094 quantity,
1095 price,
1096 Some(time_in_force),
1097 expire_time,
1098 Some(post_only),
1099 None, Some(quote_quantity),
1101 display_qty,
1102 emulation_trigger,
1103 None, None, None, None, None, );
1109
1110 if order_side == OrderSide::Buy {
1111 self.buy_order = Some(order.clone());
1112 } else {
1113 self.sell_order = Some(order.clone());
1114 }
1115
1116 self.submit_order_apply_params(order)
1117 }
1118
1119 #[expect(
1125 clippy::too_many_lines,
1126 reason = "stop order submission covers all supported stop order scenarios"
1127 )]
1128 pub(super) fn submit_stop_order(
1129 &mut self,
1130 order_side: OrderSide,
1131 trigger_price: Price,
1132 limit_price: Option<Price>,
1133 ) -> anyhow::Result<()> {
1134 let Some(instrument) = &self.instrument else {
1135 anyhow::bail!("No instrument loaded");
1136 };
1137
1138 if self.config.dry_run {
1139 log_warn!("Dry run, skipping create {order_side:?} stop order");
1140 return Ok(());
1141 }
1142
1143 if order_side == OrderSide::Buy && !self.config.enable_stop_buys {
1144 log_warn!("BUY stop orders not enabled, skipping");
1145 return Ok(());
1146 } else if order_side == OrderSide::Sell && !self.config.enable_stop_sells {
1147 log_warn!("SELL stop orders not enabled, skipping");
1148 return Ok(());
1149 }
1150
1151 let (time_in_force, expire_time) =
1152 self.resolve_time_in_force(self.config.stop_time_in_force);
1153
1154 let quantity = instrument.make_qty(self.config.order_qty.as_f64(), None);
1156 let instrument_id = self.config.instrument_id;
1157 let trigger_type = self.config.stop_trigger_type;
1158 let quote_quantity = self.config.use_quote_quantity;
1159 let display_qty = self.config.order_display_qty;
1160 let emulation_trigger = self.config.emulation_trigger;
1161 let stop_order_type = self.config.stop_order_type;
1162 let trailing_offset = self.config.trailing_offset;
1163 let trailing_offset_type = self.config.trailing_offset_type;
1164
1165 let mut factory = self.order_factory();
1166
1167 let mut order: OrderAny = match stop_order_type {
1168 OrderType::StopMarket => factory.stop_market(
1169 instrument_id,
1170 order_side,
1171 quantity,
1172 trigger_price,
1173 Some(trigger_type),
1174 Some(time_in_force),
1175 expire_time,
1176 None, Some(quote_quantity),
1178 None, emulation_trigger,
1180 None, None, None, None, None, ),
1186 OrderType::StopLimit => {
1187 let Some(limit_price) = limit_price else {
1188 anyhow::bail!("STOP_LIMIT order requires limit_price");
1189 };
1190 factory.stop_limit(
1191 instrument_id,
1192 order_side,
1193 quantity,
1194 limit_price,
1195 trigger_price,
1196 Some(trigger_type),
1197 Some(time_in_force),
1198 expire_time,
1199 None, None, Some(quote_quantity),
1202 display_qty,
1203 emulation_trigger,
1204 None, None, None, None, None, )
1210 }
1211 OrderType::MarketIfTouched => factory.market_if_touched(
1212 instrument_id,
1213 order_side,
1214 quantity,
1215 trigger_price,
1216 Some(trigger_type),
1217 Some(time_in_force),
1218 expire_time,
1219 None, Some(quote_quantity),
1221 emulation_trigger,
1222 None, None, None, None, None, ),
1228 OrderType::LimitIfTouched => {
1229 let Some(limit_price) = limit_price else {
1230 anyhow::bail!("LIMIT_IF_TOUCHED order requires limit_price");
1231 };
1232 factory.limit_if_touched(
1233 instrument_id,
1234 order_side,
1235 quantity,
1236 limit_price,
1237 trigger_price,
1238 Some(trigger_type),
1239 Some(time_in_force),
1240 expire_time,
1241 None, None, Some(quote_quantity),
1244 display_qty,
1245 emulation_trigger,
1246 None, None, None, None, None, )
1252 }
1253 OrderType::TrailingStopMarket => {
1254 let Some(trailing_offset) = trailing_offset else {
1255 anyhow::bail!("TRAILING_STOP_MARKET order requires trailing_offset config");
1256 };
1257 factory.trailing_stop_market(
1258 instrument_id,
1259 order_side,
1260 quantity,
1261 trailing_offset,
1262 Some(trailing_offset_type),
1263 None,
1264 Some(trigger_price),
1265 Some(trigger_type),
1266 Some(time_in_force),
1267 expire_time,
1268 None, Some(quote_quantity),
1270 None, emulation_trigger,
1272 None, None, None, None, None, )
1278 }
1279 _ => {
1280 anyhow::bail!("Unknown stop order type: {stop_order_type:?}");
1281 }
1282 };
1283 drop(factory);
1284
1285 if let OrderAny::TrailingStopMarket(order) = &mut order {
1286 order.activation_price = Some(trigger_price);
1287 }
1288
1289 if order_side == OrderSide::Buy {
1290 self.buy_stop_order = Some(order.clone());
1291 } else {
1292 self.sell_stop_order = Some(order.clone());
1293 }
1294
1295 self.submit_order_apply_params(order)
1296 }
1297
1298 pub(super) fn submit_bracket_order(
1304 &mut self,
1305 order_side: OrderSide,
1306 entry_price: Price,
1307 ) -> anyhow::Result<()> {
1308 let Some(instrument) = &self.instrument else {
1309 anyhow::bail!("No instrument loaded");
1310 };
1311
1312 if self.config.dry_run {
1313 log_warn!("Dry run, skipping create {order_side:?} bracket order");
1314 return Ok(());
1315 }
1316
1317 if self.config.bracket_entry_order_type != OrderType::Limit {
1318 anyhow::bail!(
1319 "Only Limit entry orders are supported for brackets, was {:?}",
1320 self.config.bracket_entry_order_type
1321 );
1322 }
1323
1324 if order_side == OrderSide::Buy && !self.config.enable_limit_buys {
1325 log_warn!("BUY orders not enabled, skipping bracket");
1326 return Ok(());
1327 } else if order_side == OrderSide::Sell && !self.config.enable_limit_sells {
1328 log_warn!("SELL orders not enabled, skipping bracket");
1329 return Ok(());
1330 }
1331
1332 let (time_in_force, expire_time) =
1333 self.resolve_time_in_force(self.config.limit_time_in_force);
1334 let sl_time_in_force = self.config.stop_time_in_force.unwrap_or(TimeInForce::Gtc);
1335 if sl_time_in_force == TimeInForce::Gtd {
1336 anyhow::bail!("GTD time in force not supported for bracket stop-loss legs");
1337 }
1338
1339 let quantity = instrument.make_qty(self.config.order_qty.as_f64(), None);
1340 let increment = instrument.price_increment();
1341 let precision = instrument.price_precision();
1342 let bracket_offset_ticks = self.config.bracket_offset_ticks;
1343
1344 let (unclamped_tp_price, unclamped_sl_trigger_price) = match order_side {
1345 OrderSide::Buy => {
1346 let tp = add_price_ticks(entry_price, increment, bracket_offset_ticks, precision);
1347 let sl = sub_price_ticks(entry_price, increment, bracket_offset_ticks, precision);
1348 (tp, sl)
1349 }
1350 OrderSide::Sell => {
1351 let tp = sub_price_ticks(entry_price, increment, bracket_offset_ticks, precision);
1352 let sl = add_price_ticks(entry_price, increment, bracket_offset_ticks, precision);
1353 (tp, sl)
1354 }
1355 OrderSide::NoOrderSide => {
1356 anyhow::bail!("Invalid order side for bracket: {order_side:?}")
1357 }
1358 };
1359 let clamp = self.config.clamp_to_instrument_price_range;
1360 let tp_price = clamp_price_to_range(unclamped_tp_price, instrument, clamp);
1361 let sl_trigger_price = clamp_price_to_range(unclamped_sl_trigger_price, instrument, clamp);
1362
1363 let entry_post_only = self.config.use_post_only || self.config.test_reject_post_only;
1364 let instrument_id = self.config.instrument_id;
1365 let quote_quantity = self.config.use_quote_quantity;
1366 let emulation_trigger = self.config.emulation_trigger;
1367 let stop_trigger_type = self.config.stop_trigger_type;
1368 let orders = self
1369 .order_factory()
1370 .bracket()
1371 .instrument_id(instrument_id)
1372 .order_side(order_side)
1373 .quantity(quantity)
1374 .quote_quantity(quote_quantity)
1375 .entry_order_type(OrderType::Limit)
1376 .entry_price(entry_price)
1377 .time_in_force(time_in_force)
1378 .entry_post_only(entry_post_only)
1379 .maybe_emulation_trigger(emulation_trigger)
1380 .maybe_expire_time(expire_time)
1381 .tp_price(tp_price)
1382 .tp_post_only(entry_post_only)
1383 .tp_time_in_force(time_in_force)
1384 .sl_trigger_price(sl_trigger_price)
1385 .sl_trigger_type(stop_trigger_type)
1386 .sl_time_in_force(sl_time_in_force)
1387 .call();
1388
1389 if let Some(entry_order) = orders.first() {
1390 if order_side == OrderSide::Buy {
1391 self.buy_order = Some(entry_order.clone());
1392 } else {
1393 self.sell_order = Some(entry_order.clone());
1394 }
1395 }
1396
1397 let client_id = self.config.client_id;
1398 if let Some(params) = &self.config.order_params {
1399 self.submit_order_list(orders, None, client_id, Some(params.clone()))
1400 } else {
1401 self.submit_order_list(orders, None, client_id, None)
1402 }
1403 }
1404
1405 fn close_positions_on_stop(
1406 &mut self,
1407 strategy_id: StrategyId,
1408 time_in_force: Option<TimeInForce>,
1409 ) -> anyhow::Result<()> {
1410 let instrument_id = self.config.instrument_id;
1411 let client_id = self.config.client_id;
1412 let reduce_only = Some(self.config.reduce_only_on_stop);
1413 let Some(precision) = self.config.close_positions_qty_precision else {
1414 return self.close_all_positions(
1415 instrument_id,
1416 None,
1417 client_id,
1418 None,
1419 time_in_force,
1420 reduce_only,
1421 None,
1422 None,
1423 );
1424 };
1425
1426 let positions =
1427 self.cache()
1428 .positions_open(None, Some(&instrument_id), Some(&strategy_id), None, None);
1429
1430 if positions.is_empty() {
1431 log::info!("No {instrument_id} open positions to close");
1432 return Ok(());
1433 }
1434
1435 log::info!(
1436 "Closing {} open position{}",
1437 positions.len(),
1438 if positions.len() == 1 { "" } else { "s" },
1439 );
1440
1441 for position in positions {
1442 let position_id = position.id;
1443 let (close_quantity, residual) = split_position_quantity(position.quantity, precision)?;
1444
1445 if close_quantity.is_zero() {
1446 log_warn!(
1447 "Position {position_id} has no venue-fillable close quantity at {precision}-decimal precision; exact residual remains {residual}"
1448 );
1449 continue;
1450 }
1451
1452 if residual > Decimal::ZERO {
1453 log_warn!(
1454 "Position {position_id} close quantity {close_quantity} leaves exact residual {residual} after a full fill"
1455 );
1456 }
1457
1458 let closing_side = OrderCore::closing_side(position.side);
1459 let order = self.order_factory().market(
1460 position.instrument_id,
1461 closing_side,
1462 close_quantity,
1463 time_in_force,
1464 reduce_only,
1465 None,
1466 None,
1467 None,
1468 None,
1469 None,
1470 );
1471
1472 self.submit_order(order, Some(position_id), client_id, None)?;
1473 }
1474
1475 Ok(())
1476 }
1477
1478 pub(super) fn open_position(&mut self, net_qty: Decimal) -> anyhow::Result<()> {
1484 let Some(instrument) = &self.instrument else {
1485 anyhow::bail!("No instrument loaded");
1486 };
1487
1488 if net_qty == Decimal::ZERO {
1489 log_warn!("Open position with zero quantity, skipping");
1490 return Ok(());
1491 }
1492
1493 let order_side = if net_qty > Decimal::ZERO {
1494 OrderSide::Buy
1495 } else {
1496 OrderSide::Sell
1497 };
1498
1499 let quantity = instrument.make_qty(net_qty.abs().to_f64().unwrap_or(0.0), None);
1500
1501 let reduce_only = if self.config.test_reject_reduce_only {
1503 Some(true)
1504 } else {
1505 None
1506 };
1507 let instrument_id = self.config.instrument_id;
1508 let time_in_force = self.config.open_position_time_in_force;
1509 let quote_quantity = self.config.use_quote_quantity;
1510
1511 let order = self.order_factory().market(
1512 instrument_id,
1513 order_side,
1514 quantity,
1515 Some(time_in_force),
1516 reduce_only,
1517 Some(quote_quantity),
1518 None, None, None, None, );
1523
1524 self.submit_order_apply_params(order)
1525 }
1526
1527 pub(super) fn cancel_active_orders(
1528 &mut self,
1529 instrument_id: InstrumentId,
1530 strategy_id: StrategyId,
1531 client_id: Option<ClientId>,
1532 ) {
1533 let bracket_targets: Vec<ClientOrderId> = {
1536 let cache = self.cache();
1537 let mut targets = Vec::new();
1538
1539 for order_list in
1540 cache.order_lists(None, Some(&instrument_id), Some(&strategy_id), None)
1541 {
1542 let is_bracket = order_list.client_order_ids.iter().any(|cid| {
1543 cache
1544 .order(cid)
1545 .is_some_and(|o| is_in_contingency_group(&o))
1546 });
1547
1548 if !is_bracket {
1549 continue;
1550 }
1551
1552 for cid in &order_list.client_order_ids {
1553 if let Some(order) = cache.order(cid)
1554 && !order.is_closed()
1555 && !order.is_pending_cancel()
1556 {
1557 targets.push(*cid);
1558 }
1559 }
1560 }
1561 targets
1562 };
1563
1564 for cid in bracket_targets {
1565 if let Err(e) = self.cancel_order(cid, client_id, None) {
1566 log::error!("Failed to cancel bracket leg {cid}: {e}");
1567 }
1568 }
1569
1570 if self.config.use_individual_cancels_on_stop {
1571 for cid in self.collect_cancellable_order_ids(instrument_id, strategy_id) {
1572 if let Err(e) = self.cancel_order(cid, client_id, None) {
1573 log::error!("Failed to cancel order {cid}: {e}");
1574 }
1575 }
1576 } else if self.config.use_batch_cancel_on_stop {
1577 let candidates = self.collect_cancellable_orders(instrument_id, strategy_id);
1578 let mut batchable: Vec<ClientOrderId> = Vec::new();
1579
1580 for order in candidates {
1581 let cid = order.client_order_id();
1582 if order.is_emulated() || order.is_active_local() {
1583 if let Err(e) = self.cancel_order(cid, client_id, None) {
1584 log::error!("Failed to cancel local order {cid}: {e}");
1585 }
1586 } else {
1587 batchable.push(cid);
1588 }
1589 }
1590
1591 if !batchable.is_empty()
1592 && let Err(e) = self.cancel_orders(batchable, client_id, None)
1593 {
1594 log::error!("Failed to batch cancel orders: {e}");
1595 }
1596 } else {
1597 let local_ids: Vec<ClientOrderId> = {
1600 let cache = self.cache();
1601 cache
1602 .orders_active_local(None, Some(&instrument_id), Some(&strategy_id), None, None)
1603 .into_iter()
1604 .filter(|o| {
1605 !o.is_closed() && !o.is_pending_cancel() && !is_in_contingency_group(o)
1606 })
1607 .map(|o| o.client_order_id())
1608 .collect()
1609 };
1610
1611 for cid in local_ids {
1612 if let Err(e) = self.cancel_order(cid, client_id, None) {
1613 log::error!("Failed to cancel active-local order {cid}: {e}");
1614 }
1615 }
1616
1617 if let Err(e) = self.cancel_all_orders(instrument_id, None, client_id, None) {
1618 log::error!("Failed to cancel all orders: {e}");
1619 }
1620 }
1621 }
1622
1623 pub(super) fn collect_cancellable_orders(
1624 &self,
1625 instrument_id: InstrumentId,
1626 strategy_id: StrategyId,
1627 ) -> Vec<OrderAny> {
1628 let cache = self.cache();
1629 let mut seen: AHashSet<ClientOrderId> = AHashSet::new();
1630 let mut candidates: Vec<OrderAny> = Vec::new();
1631 let sources = [
1634 cache.orders_active_local(None, Some(&instrument_id), Some(&strategy_id), None, None),
1635 cache.orders_emulated(None, Some(&instrument_id), Some(&strategy_id), None, None),
1636 cache.orders_inflight(None, Some(&instrument_id), Some(&strategy_id), None, None),
1637 cache.orders_open(None, Some(&instrument_id), Some(&strategy_id), None, None),
1638 ];
1639
1640 for orders in sources {
1641 for order in orders {
1642 if order.is_closed() || order.is_pending_cancel() || is_in_contingency_group(&order)
1643 {
1644 continue;
1645 }
1646 let cid = order.client_order_id();
1647 if seen.insert(cid) {
1648 candidates.push(order);
1649 }
1650 }
1651 }
1652 candidates
1653 }
1654
1655 pub(super) fn collect_cancellable_order_ids(
1656 &self,
1657 instrument_id: InstrumentId,
1658 strategy_id: StrategyId,
1659 ) -> Vec<ClientOrderId> {
1660 self.collect_cancellable_orders(instrument_id, strategy_id)
1661 .into_iter()
1662 .map(|o| o.client_order_id())
1663 .collect()
1664 }
1665}
1666
1667fn split_position_quantity(
1668 quantity: Quantity,
1669 precision: u8,
1670) -> anyhow::Result<(Quantity, Decimal)> {
1671 let quantity_decimal = quantity.as_decimal();
1672 let close_decimal = quantity_decimal.trunc_with_scale(u32::from(precision));
1673 let close_quantity = Quantity::from_decimal_dp(close_decimal, quantity.precision)
1674 .map_err(|e| anyhow::anyhow!("Invalid position close quantity {close_decimal}: {e}"))?;
1675 let residual = quantity_decimal - close_quantity.as_decimal();
1676 Ok((close_quantity, residual))
1677}
1678
1679fn add_price_ticks(base: Price, increment: Price, ticks: u64, precision: u8) -> Price {
1680 let offset = price_tick_offset(increment, ticks, precision);
1681 base + offset
1682}
1683
1684fn sub_price_ticks(base: Price, increment: Price, ticks: u64, precision: u8) -> Price {
1685 let offset = price_tick_offset(increment, ticks, precision);
1686 base - offset
1687}
1688
1689fn price_tick_offset(increment: Price, ticks: u64, precision: u8) -> Price {
1690 let offset = increment * Decimal::from(ticks);
1691 Price::from_decimal_dp(offset, precision)
1692 .unwrap_or_else(|e| panic!("Failed to calculate price tick offset: {e}"))
1693}
1694
1695fn is_in_contingency_group(order: &OrderAny) -> bool {
1698 matches!(
1699 order.contingency_type(),
1700 Some(ContingencyType::Oto | ContingencyType::Oco | ContingencyType::Ouo)
1701 )
1702}
1703
1704fn clamp_price_to_range(price: Price, instrument: &InstrumentAny, enabled: bool) -> Price {
1705 if !enabled {
1706 return price;
1707 }
1708 let mut clamped = price;
1709 if let Some(min) = instrument.min_price()
1710 && clamped < min
1711 {
1712 clamped = min;
1713 }
1714
1715 if let Some(max) = instrument.max_price()
1716 && clamped > max
1717 {
1718 clamped = max;
1719 }
1720
1721 clamped
1722}