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},
34 types::Price,
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_all_positions(
153 instrument_id,
154 None,
155 client_id,
156 None,
157 time_in_force,
158 Some(self.config.reduce_only_on_stop),
159 None,
160 ) {
161 log::error!("Failed to close all positions: {e}");
162 }
163 }
164
165 if self.config.can_unsubscribe && self.instrument.is_some() {
166 if self.config.subscribe_quotes {
167 self.unsubscribe_quotes(instrument_id, client_id, None);
168 }
169
170 if self.config.subscribe_trades {
171 self.unsubscribe_trades(instrument_id, client_id, None);
172 }
173
174 if self.config.subscribe_book {
175 self.unsubscribe_book_at_interval(
176 instrument_id,
177 NonZeroUsize::new(self.config.book_interval_ms).ok_or_else(|| {
178 ConfigError::range("book_interval_ms", "must be positive, was 0")
179 })?,
180 client_id,
181 None,
182 );
183 }
184 }
185
186 Ok(())
187 }
188
189 fn on_quote(&mut self, quote: &QuoteTick) -> anyhow::Result<()> {
190 if self.config.log_data {
191 log_info!("{quote:?}", color = LogColor::Cyan);
192 }
193
194 if quote.instrument_id == self.config.instrument_id
195 && self.config.open_position_on_first_quote
196 {
197 self.submit_pending_open_position();
198 }
199
200 self.maintain_orders(quote.bid_price, quote.ask_price);
201 Ok(())
202 }
203
204 fn on_trade(&mut self, trade: &TradeTick) -> anyhow::Result<()> {
205 if self.config.log_data {
206 log_info!("{trade:?}", color = LogColor::Cyan);
207 }
208 Ok(())
209 }
210
211 fn on_book(&mut self, book: &OrderBook) -> anyhow::Result<()> {
212 if self.config.log_data {
213 let num_levels = self.config.book_levels_to_print;
214 let instrument_id = book.instrument_id;
215 let book_str = book.pprint(num_levels, None);
216 log_info!("\n{instrument_id}\n{book_str}", color = LogColor::Cyan);
217
218 if DataActorNative::core(&self.core).is_registered() {
220 let cache = self.cache();
221 if let Some(own_book) = cache.own_order_book(&instrument_id) {
222 let own_book_str = own_book.pprint(num_levels, None);
223 log_info!(
224 "\n{instrument_id} (own)\n{own_book_str}",
225 color = LogColor::Magenta
226 );
227 }
228 }
229 }
230
231 let Some(best_bid) = book.best_bid_price() else {
232 return Ok(()); };
234 let Some(best_ask) = book.best_ask_price() else {
235 return Ok(()); };
237
238 self.maintain_orders(best_bid, best_ask);
239 Ok(())
240 }
241
242 fn on_book_deltas(&mut self, deltas: &OrderBookDeltas) -> anyhow::Result<()> {
243 if self.config.log_data {
244 log_info!("{deltas:?}", color = LogColor::Cyan);
245 }
246 Ok(())
247 }
248
249 fn on_bar(&mut self, bar: &Bar) -> anyhow::Result<()> {
250 if self.config.log_data {
251 log_info!("{bar:?}", color = LogColor::Cyan);
252 }
253 Ok(())
254 }
255
256 fn on_mark_price(&mut self, mark_price: &MarkPriceUpdate) -> anyhow::Result<()> {
257 if self.config.log_data {
258 log_info!("{mark_price:?}", color = LogColor::Cyan);
259 }
260 Ok(())
261 }
262
263 fn on_index_price(&mut self, index_price: &IndexPriceUpdate) -> anyhow::Result<()> {
264 if self.config.log_data {
265 log_info!("{index_price:?}", color = LogColor::Cyan);
266 }
267 Ok(())
268 }
269
270 fn on_time_event(&mut self, event: &TimeEvent) -> anyhow::Result<()> {
271 Strategy::on_time_event(self, event)
272 }
273}
274
275impl ExecTester {
276 #[must_use]
278 pub fn new(config: ExecTesterConfig) -> Self {
279 let pending_open_position_qty = config.open_position_on_start_qty;
280
281 Self {
282 core: StrategyCore::new(config.base.clone()),
283 config,
284 instrument: None,
285 price_offset: None,
286 preinitialized_market_data: false,
287 buy_order: None,
288 sell_order: None,
289 buy_stop_order: None,
290 sell_stop_order: None,
291 open_position_submitted: false,
292 modify_rejected_attempted: false,
293 pending_open_position_qty,
294 buy_cancel_replace_attempted: false,
295 sell_cancel_replace_attempted: false,
296 buy_stop_cancel_replace_attempted: false,
297 sell_stop_cancel_replace_attempted: false,
298 }
299 }
300
301 fn initialize_with_instrument(
302 &mut self,
303 instrument: InstrumentAny,
304 subscribe_market_data: bool,
305 ) -> anyhow::Result<()> {
306 let instrument_id = self.config.instrument_id;
307 let client_id = self.config.client_id;
308
309 self.price_offset = Some(self.get_price_offset(&instrument));
310 self.instrument = Some(instrument);
311
312 if subscribe_market_data && self.config.subscribe_quotes {
313 self.subscribe_quotes(instrument_id, client_id, None);
314 }
315
316 if subscribe_market_data && self.config.subscribe_trades {
317 self.subscribe_trades(instrument_id, client_id, None);
318 }
319
320 if self.config.subscribe_book {
321 self.subscribe_book_at_interval(
322 instrument_id,
323 self.config.book_type,
324 self.config
325 .book_depth
326 .map(|depth| {
327 NonZeroUsize::new(depth).ok_or_else(|| {
328 ConfigError::range("book_depth", "must be positive, was 0")
329 })
330 })
331 .transpose()?,
332 NonZeroUsize::new(self.config.book_interval_ms).ok_or_else(|| {
333 ConfigError::range("book_interval_ms", "must be positive, was 0")
334 })?,
335 client_id,
336 None,
337 );
338 }
339
340 if let Some(qty) = self.pending_open_position_qty {
341 let quote_ready = {
342 let cache = self.cache();
343 cache.quote(&instrument_id).is_some()
344 };
345
346 if self.config.open_position_on_first_quote
347 && self.config.subscribe_quotes
348 && !quote_ready
349 {
350 log::info!("Waiting for first quote before opening {instrument_id} position");
351 } else {
352 self.pending_open_position_qty = None;
353 self.open_position(qty)?;
354 self.open_position_submitted = true;
355 }
356 }
357
358 Ok(())
359 }
360
361 pub(super) fn get_price_offset(&self, _instrument: &InstrumentAny) -> u64 {
362 self.config.tob_offset_ticks
363 }
364
365 fn expire_time_from_delta(&self, mins: u64) -> UnixNanos {
366 let current_ns = DataActorNative::core(&self.core).timestamp_ns();
367 let delta_ns = mins.saturating_mul(60).saturating_mul(1_000_000_000);
368 UnixNanos::from(current_ns.as_u64() + delta_ns)
369 }
370
371 fn resolve_time_in_force(
372 &self,
373 tif_override: Option<TimeInForce>,
374 ) -> (TimeInForce, Option<UnixNanos>) {
375 match (tif_override, self.config.order_expire_time_delta_mins) {
376 (Some(TimeInForce::Gtd), Some(mins)) => {
377 (TimeInForce::Gtd, Some(self.expire_time_from_delta(mins)))
378 }
379 (Some(TimeInForce::Gtd), None) => {
380 log_warn!(
381 "GTD time in force requires order_expire_time_delta_mins, falling back to GTC"
382 );
383 (TimeInForce::Gtc, None)
384 }
385 (Some(tif), _) => (tif, None),
386 (None, Some(mins)) => (TimeInForce::Gtd, Some(self.expire_time_from_delta(mins))),
387 (None, None) => (TimeInForce::Gtc, None),
388 }
389 }
390
391 fn submit_pending_open_position(&mut self) {
392 if self.instrument.is_none() {
393 return;
394 }
395
396 let Some(qty) = self.pending_open_position_qty.take() else {
397 return;
398 };
399
400 if let Err(e) = self.open_position(qty) {
401 log::error!("Failed to submit pending open position: {e}");
402 } else {
403 self.open_position_submitted = true;
404 }
405 }
406
407 pub(super) fn is_order_active(order: &OrderAny) -> bool {
408 order.is_active_local() || order.is_inflight() || order.is_open()
409 }
410
411 pub(super) fn limit_order_is_one_shot(&self) -> bool {
412 self.config.test_reject_post_only
413 || self.config.limit_aggressive
414 || self.config.order_expire_time_delta_mins.is_some()
415 || matches!(
416 self.config.limit_time_in_force,
417 Some(TimeInForce::Ioc | TimeInForce::Fok)
418 )
419 }
420
421 pub(super) fn stop_order_is_one_shot(&self) -> bool {
422 self.config.order_expire_time_delta_mins.is_some()
423 || matches!(
424 self.config.stop_time_in_force,
425 Some(TimeInForce::Ioc | TimeInForce::Fok)
426 )
427 || matches!(self.config.stop_order_type, OrderType::TrailingStopMarket)
428 }
429
430 pub(super) fn get_order_trigger_price(order: &OrderAny) -> Option<Price> {
431 order.trigger_price()
432 }
433
434 fn modify_stop_order(
435 &mut self,
436 order: &OrderAny,
437 trigger_price: Price,
438 limit_price: Option<Price>,
439 ) -> anyhow::Result<()> {
440 let client_id = self.config.client_id;
441
442 match order {
443 OrderAny::StopMarket(_)
444 | OrderAny::MarketIfTouched(_)
445 | OrderAny::TrailingStopMarket(_) => self.modify_order(
446 order.client_order_id(),
447 None,
448 None,
449 Some(trigger_price),
450 client_id,
451 None,
452 ),
453 OrderAny::StopLimit(_) | OrderAny::LimitIfTouched(_) => self.modify_order(
454 order.client_order_id(),
455 None,
456 limit_price,
457 Some(trigger_price),
458 client_id,
459 None,
460 ),
461 _ => {
462 log_warn!("Cannot modify order of type {:?}", order.order_type());
463 Ok(())
464 }
465 }
466 }
467
468 fn submit_order_apply_params(&mut self, order: OrderAny) -> anyhow::Result<()> {
470 let client_id = self.config.client_id;
471 if let Some(params) = &self.config.order_params {
472 self.submit_order(order, None, client_id, Some(params.clone()))
473 } else {
474 self.submit_order(order, None, client_id, None)
475 }
476 }
477
478 pub(super) fn maintain_orders(&mut self, best_bid: Price, best_ask: Price) {
480 if self.instrument.is_none() || self.config.dry_run {
481 return;
482 }
483
484 if self.config.batch_submit_limit_pair
485 && self.config.enable_limit_buys
486 && self.config.enable_limit_sells
487 {
488 self.maintain_batch_limit_pair(best_bid, best_ask);
489 return;
490 }
491
492 if self.config.enable_limit_buys {
493 self.maintain_buy_orders(best_bid, best_ask);
494 }
495
496 if self.config.enable_limit_sells {
497 self.maintain_sell_orders(best_bid, best_ask);
498 }
499
500 if self.config.enable_stop_buys {
501 self.maintain_stop_buy_orders(best_bid, best_ask);
502 }
503
504 if self.config.enable_stop_sells {
505 self.maintain_stop_sell_orders(best_bid, best_ask);
506 }
507 }
508
509 fn refresh_tracked_order(&mut self, side: OrderSide) {
513 let cid = match side {
514 OrderSide::Buy => self.buy_order.as_ref().map(OrderAny::client_order_id),
515 OrderSide::Sell => self.sell_order.as_ref().map(OrderAny::client_order_id),
516 OrderSide::NoOrderSide => None,
517 };
518 let Some(cid) = cid else {
519 return;
520 };
521 let latest = self.cache().order(&cid);
522 if let Some(latest) = latest {
523 match side {
524 OrderSide::Buy => self.buy_order = Some(latest),
525 OrderSide::Sell => self.sell_order = Some(latest),
526 OrderSide::NoOrderSide => {}
527 }
528 }
529 }
530
531 fn refresh_tracked_stop_order(&mut self, side: OrderSide) {
532 let cid = match side {
533 OrderSide::Buy => self.buy_stop_order.as_ref().map(OrderAny::client_order_id),
534 OrderSide::Sell => self.sell_stop_order.as_ref().map(OrderAny::client_order_id),
535 OrderSide::NoOrderSide => None,
536 };
537 let Some(cid) = cid else {
538 return;
539 };
540 let latest = self.cache().order(&cid);
541 if let Some(latest) = latest {
542 match side {
543 OrderSide::Buy => self.buy_stop_order = Some(latest),
544 OrderSide::Sell => self.sell_stop_order = Some(latest),
545 OrderSide::NoOrderSide => {}
546 }
547 }
548 }
549
550 fn maintain_buy_orders(&mut self, best_bid: Price, best_ask: Price) {
552 self.refresh_tracked_order(OrderSide::Buy);
556
557 let Some(instrument) = &self.instrument else {
558 return;
559 };
560 let Some(price_offset_ticks) = self.price_offset else {
561 return;
562 };
563
564 let increment = instrument.price_increment();
565 let precision = instrument.price_precision();
566
567 let cross_spread = self.config.test_reject_post_only || self.config.limit_aggressive;
572 let unclamped_price = if cross_spread {
573 add_price_ticks(best_ask, increment, price_offset_ticks, precision)
574 } else {
575 sub_price_ticks(best_bid, increment, price_offset_ticks, precision)
576 };
577 let price = clamp_price_to_range(
578 unclamped_price,
579 instrument,
580 self.config.clamp_to_instrument_price_range,
581 );
582
583 let needs_new_order = match &self.buy_order {
584 None => true,
585 Some(order) => !Self::is_order_active(order) && !self.limit_order_is_one_shot(),
586 };
587
588 if needs_new_order {
589 let result = if self.config.enable_brackets {
590 self.submit_bracket_order(OrderSide::Buy, price)
591 } else {
592 self.submit_limit_order(OrderSide::Buy, price)
593 };
594
595 if let Err(e) = result {
596 log::error!("Failed to submit buy order: {e}");
597 }
598 } else if let Some(order) = &self.buy_order
599 && order.venue_order_id().is_some()
600 && !order.is_pending_update()
601 && !order.is_pending_cancel()
602 {
603 let client_id = self.config.client_id;
604
605 if self.config.test_modify_rejected && !self.modify_rejected_attempted {
608 self.modify_rejected_attempted = true;
609 let order_clone = order.clone();
610 let bumped = clamp_price_to_range(
611 add_price_ticks(price, increment, 1, precision),
612 instrument,
613 self.config.clamp_to_instrument_price_range,
614 );
615
616 if let Err(e) = self.modify_order(
617 order_clone.client_order_id(),
618 None,
619 Some(bumped),
620 None,
621 client_id,
622 None,
623 ) {
624 log::error!("Failed to submit test modify on buy order: {e}");
625 }
626 return;
627 }
628
629 if let Some(order_price) = order.price()
630 && order_price < price
631 {
632 if self.config.modify_orders_to_maintain_tob_offset {
633 let order_clone = order.clone();
634 if let Err(e) = self.modify_order(
635 order_clone.client_order_id(),
636 None,
637 Some(price),
638 None,
639 client_id,
640 None,
641 ) {
642 log::error!("Failed to modify buy order: {e}");
643 }
644 } else if self.config.cancel_replace_orders_to_maintain_tob_offset
645 && !self.buy_cancel_replace_attempted
646 {
647 self.buy_cancel_replace_attempted = true;
648 let order_clone = order.clone();
649 let _ = self.cancel_order(order_clone.client_order_id(), client_id, None);
650
651 if let Err(e) = self.submit_limit_order(OrderSide::Buy, price) {
652 log::error!("Failed to submit replacement buy order: {e}");
653 }
654 }
655 }
656 }
657 }
658
659 fn maintain_sell_orders(&mut self, best_bid: Price, best_ask: Price) {
661 self.refresh_tracked_order(OrderSide::Sell);
664
665 let Some(instrument) = &self.instrument else {
666 return;
667 };
668 let Some(price_offset_ticks) = self.price_offset else {
669 return;
670 };
671
672 let increment = instrument.price_increment();
673 let precision = instrument.price_precision();
674
675 let cross_spread = self.config.test_reject_post_only || self.config.limit_aggressive;
677 let unclamped_price = if cross_spread {
678 sub_price_ticks(best_bid, increment, price_offset_ticks, precision)
679 } else {
680 add_price_ticks(best_ask, increment, price_offset_ticks, precision)
681 };
682 let price = clamp_price_to_range(
683 unclamped_price,
684 instrument,
685 self.config.clamp_to_instrument_price_range,
686 );
687
688 let needs_new_order = match &self.sell_order {
689 None => true,
690 Some(order) => !Self::is_order_active(order) && !self.limit_order_is_one_shot(),
691 };
692
693 if needs_new_order {
694 let result = if self.config.enable_brackets {
695 self.submit_bracket_order(OrderSide::Sell, price)
696 } else {
697 self.submit_limit_order(OrderSide::Sell, price)
698 };
699
700 if let Err(e) = result {
701 log::error!("Failed to submit sell order: {e}");
702 }
703 } else if let Some(order) = &self.sell_order
704 && order.venue_order_id().is_some()
705 && !order.is_pending_update()
706 && !order.is_pending_cancel()
707 {
708 let client_id = self.config.client_id;
709
710 if self.config.test_modify_rejected && !self.modify_rejected_attempted {
712 self.modify_rejected_attempted = true;
713 let order_clone = order.clone();
714 let bumped = clamp_price_to_range(
715 sub_price_ticks(price, increment, 1, precision),
716 instrument,
717 self.config.clamp_to_instrument_price_range,
718 );
719
720 if let Err(e) = self.modify_order(
721 order_clone.client_order_id(),
722 None,
723 Some(bumped),
724 None,
725 client_id,
726 None,
727 ) {
728 log::error!("Failed to submit test modify on sell order: {e}");
729 }
730 return;
731 }
732
733 if let Some(order_price) = order.price()
734 && order_price > price
735 {
736 if self.config.modify_orders_to_maintain_tob_offset {
737 let order_clone = order.clone();
738 if let Err(e) = self.modify_order(
739 order_clone.client_order_id(),
740 None,
741 Some(price),
742 None,
743 client_id,
744 None,
745 ) {
746 log::error!("Failed to modify sell order: {e}");
747 }
748 } else if self.config.cancel_replace_orders_to_maintain_tob_offset
749 && !self.sell_cancel_replace_attempted
750 {
751 self.sell_cancel_replace_attempted = true;
752 let order_clone = order.clone();
753 let _ = self.cancel_order(order_clone.client_order_id(), client_id, None);
754
755 if let Err(e) = self.submit_limit_order(OrderSide::Sell, price) {
756 log::error!("Failed to submit replacement sell order: {e}");
757 }
758 }
759 }
760 }
761 }
762
763 fn maintain_batch_limit_pair(&mut self, best_bid: Price, best_ask: Price) {
765 self.refresh_tracked_order(OrderSide::Buy);
769 self.refresh_tracked_order(OrderSide::Sell);
770
771 let Some(instrument) = &self.instrument else {
772 return;
773 };
774 let Some(price_offset_ticks) = self.price_offset else {
775 return;
776 };
777
778 let buy_needs = match &self.buy_order {
779 None => true,
780 Some(order) => !Self::is_order_active(order) && !self.limit_order_is_one_shot(),
781 };
782 let sell_needs = match &self.sell_order {
783 None => true,
784 Some(order) => !Self::is_order_active(order) && !self.limit_order_is_one_shot(),
785 };
786
787 if !buy_needs || !sell_needs {
788 return;
789 }
790
791 let increment = instrument.price_increment();
792 let precision = instrument.price_precision();
793
794 let cross_spread = self.config.test_reject_post_only || self.config.limit_aggressive;
798 let (unclamped_buy_price, unclamped_sell_price) = if cross_spread {
799 (
800 add_price_ticks(best_ask, increment, price_offset_ticks, precision),
801 sub_price_ticks(best_bid, increment, price_offset_ticks, precision),
802 )
803 } else {
804 (
805 sub_price_ticks(best_bid, increment, price_offset_ticks, precision),
806 add_price_ticks(best_ask, increment, price_offset_ticks, precision),
807 )
808 };
809 let clamp = self.config.clamp_to_instrument_price_range;
810 let buy_price = clamp_price_to_range(unclamped_buy_price, instrument, clamp);
811 let sell_price = clamp_price_to_range(unclamped_sell_price, instrument, clamp);
812 let quantity = instrument.make_qty(self.config.order_qty.as_f64(), None);
813 let (time_in_force, expire_time) =
814 self.resolve_time_in_force(self.config.limit_time_in_force);
815 let instrument_id = self.config.instrument_id;
816 let post_only = self.config.use_post_only || self.config.test_reject_post_only;
817 let quote_quantity = self.config.use_quote_quantity;
818 let display_qty = self.config.order_display_qty;
819 let emulation_trigger = self.config.emulation_trigger;
820
821 let buy_order = self.order_factory().limit(
822 instrument_id,
823 OrderSide::Buy,
824 quantity,
825 buy_price,
826 Some(time_in_force),
827 expire_time,
828 Some(post_only),
829 None,
830 Some(quote_quantity),
831 display_qty,
832 emulation_trigger,
833 None,
834 None,
835 None,
836 None,
837 None,
838 );
839
840 let sell_order = self.order_factory().limit(
841 instrument_id,
842 OrderSide::Sell,
843 quantity,
844 sell_price,
845 Some(time_in_force),
846 expire_time,
847 Some(post_only),
848 None,
849 Some(quote_quantity),
850 display_qty,
851 emulation_trigger,
852 None,
853 None,
854 None,
855 None,
856 None,
857 );
858
859 self.buy_order = Some(buy_order.clone());
860 self.sell_order = Some(sell_order.clone());
861
862 let client_id = self.config.client_id;
863 if let Err(e) = self.submit_order_list(vec![buy_order, sell_order], None, client_id, None) {
864 log::error!("Failed to submit batch limit pair: {e}");
865 }
866 }
867
868 fn maintain_stop_buy_orders(&mut self, best_bid: Price, best_ask: Price) {
870 self.refresh_tracked_stop_order(OrderSide::Buy);
871
872 if let Some(order) = self.buy_stop_order.as_ref()
874 && matches!(order.status(), OrderStatus::Rejected | OrderStatus::Denied)
875 {
876 return;
877 }
878
879 let Some(instrument) = &self.instrument else {
880 return;
881 };
882
883 let increment = instrument.price_increment();
884 let precision = instrument.price_precision();
885 let stop_offset_ticks = self.config.stop_offset_ticks;
886
887 let unclamped_trigger_price = if matches!(
889 self.config.stop_order_type,
890 OrderType::LimitIfTouched | OrderType::MarketIfTouched | OrderType::TrailingStopMarket
891 ) {
892 sub_price_ticks(best_bid, increment, stop_offset_ticks, precision)
894 } else {
895 add_price_ticks(best_ask, increment, stop_offset_ticks, precision)
897 };
898 let clamp = self.config.clamp_to_instrument_price_range;
899 let trigger_price = clamp_price_to_range(unclamped_trigger_price, instrument, clamp);
900
901 let limit_price = if matches!(
903 self.config.stop_order_type,
904 OrderType::StopLimit | OrderType::LimitIfTouched
905 ) {
906 let unclamped_limit_price =
907 if let Some(limit_offset_ticks) = self.config.stop_limit_offset_ticks {
908 add_price_ticks(trigger_price, increment, limit_offset_ticks, precision)
910 } else {
911 trigger_price
912 };
913 Some(clamp_price_to_range(
914 unclamped_limit_price,
915 instrument,
916 clamp,
917 ))
918 } else {
919 None
920 };
921
922 let needs_new_order = match &self.buy_stop_order {
923 None => true,
924 Some(order) => !Self::is_order_active(order) && !self.stop_order_is_one_shot(),
925 };
926
927 if needs_new_order {
928 if let Err(e) = self.submit_stop_order(OrderSide::Buy, trigger_price, limit_price) {
929 log::error!("Failed to submit buy stop order: {e}");
930 }
931 } else if let Some(order) = &self.buy_stop_order
932 && order.venue_order_id().is_some()
933 && !order.is_pending_update()
934 && !order.is_pending_cancel()
935 {
936 let current_trigger = Self::get_order_trigger_price(order);
937 if current_trigger.is_some() && current_trigger != Some(trigger_price) {
938 if self.config.modify_stop_orders_to_maintain_offset {
939 let order_clone = order.clone();
940 if let Err(e) = self.modify_stop_order(&order_clone, trigger_price, limit_price)
941 {
942 log::error!("Failed to modify buy stop order: {e}");
943 }
944 } else if self.config.cancel_replace_stop_orders_to_maintain_offset
945 && !self.buy_stop_cancel_replace_attempted
946 {
947 self.buy_stop_cancel_replace_attempted = true;
948 let order_clone = order.clone();
949 let _ = self.cancel_order(
950 order_clone.client_order_id(),
951 self.config.client_id,
952 None,
953 );
954
955 if let Err(e) =
956 self.submit_stop_order(OrderSide::Buy, trigger_price, limit_price)
957 {
958 log::error!("Failed to submit replacement buy stop order: {e}");
959 }
960 }
961 }
962 }
963 }
964
965 fn maintain_stop_sell_orders(&mut self, best_bid: Price, best_ask: Price) {
967 self.refresh_tracked_stop_order(OrderSide::Sell);
968
969 if let Some(order) = self.sell_stop_order.as_ref()
971 && matches!(order.status(), OrderStatus::Rejected | OrderStatus::Denied)
972 {
973 return;
974 }
975
976 let Some(instrument) = &self.instrument else {
977 return;
978 };
979
980 let increment = instrument.price_increment();
981 let precision = instrument.price_precision();
982 let stop_offset_ticks = self.config.stop_offset_ticks;
983
984 let unclamped_trigger_price = if matches!(
986 self.config.stop_order_type,
987 OrderType::LimitIfTouched | OrderType::MarketIfTouched | OrderType::TrailingStopMarket
988 ) {
989 add_price_ticks(best_ask, increment, stop_offset_ticks, precision)
991 } else {
992 sub_price_ticks(best_bid, increment, stop_offset_ticks, precision)
994 };
995 let clamp = self.config.clamp_to_instrument_price_range;
996 let trigger_price = clamp_price_to_range(unclamped_trigger_price, instrument, clamp);
997
998 let limit_price = if matches!(
1000 self.config.stop_order_type,
1001 OrderType::StopLimit | OrderType::LimitIfTouched
1002 ) {
1003 let unclamped_limit_price =
1004 if let Some(limit_offset_ticks) = self.config.stop_limit_offset_ticks {
1005 sub_price_ticks(trigger_price, increment, limit_offset_ticks, precision)
1007 } else {
1008 trigger_price
1009 };
1010 Some(clamp_price_to_range(
1011 unclamped_limit_price,
1012 instrument,
1013 clamp,
1014 ))
1015 } else {
1016 None
1017 };
1018
1019 let needs_new_order = match &self.sell_stop_order {
1020 None => true,
1021 Some(order) => !Self::is_order_active(order) && !self.stop_order_is_one_shot(),
1022 };
1023
1024 if needs_new_order {
1025 if let Err(e) = self.submit_stop_order(OrderSide::Sell, trigger_price, limit_price) {
1026 log::error!("Failed to submit sell stop order: {e}");
1027 }
1028 } else if let Some(order) = &self.sell_stop_order
1029 && order.venue_order_id().is_some()
1030 && !order.is_pending_update()
1031 && !order.is_pending_cancel()
1032 {
1033 let current_trigger = Self::get_order_trigger_price(order);
1034 if current_trigger.is_some() && current_trigger != Some(trigger_price) {
1035 if self.config.modify_stop_orders_to_maintain_offset {
1036 let order_clone = order.clone();
1037 if let Err(e) = self.modify_stop_order(&order_clone, trigger_price, limit_price)
1038 {
1039 log::error!("Failed to modify sell stop order: {e}");
1040 }
1041 } else if self.config.cancel_replace_stop_orders_to_maintain_offset
1042 && !self.sell_stop_cancel_replace_attempted
1043 {
1044 self.sell_stop_cancel_replace_attempted = true;
1045 let order_clone = order.clone();
1046 let _ = self.cancel_order(
1047 order_clone.client_order_id(),
1048 self.config.client_id,
1049 None,
1050 );
1051
1052 if let Err(e) =
1053 self.submit_stop_order(OrderSide::Sell, trigger_price, limit_price)
1054 {
1055 log::error!("Failed to submit replacement sell stop order: {e}");
1056 }
1057 }
1058 }
1059 }
1060 }
1061
1062 pub(super) fn submit_limit_order(
1068 &mut self,
1069 order_side: OrderSide,
1070 price: Price,
1071 ) -> anyhow::Result<()> {
1072 let Some(instrument) = &self.instrument else {
1073 anyhow::bail!("No instrument loaded");
1074 };
1075
1076 if self.config.dry_run {
1077 log_warn!("Dry run, skipping create {order_side:?} order");
1078 return Ok(());
1079 }
1080
1081 if order_side == OrderSide::Buy && !self.config.enable_limit_buys {
1082 log_warn!("BUY orders not enabled, skipping");
1083 return Ok(());
1084 } else if order_side == OrderSide::Sell && !self.config.enable_limit_sells {
1085 log_warn!("SELL orders not enabled, skipping");
1086 return Ok(());
1087 }
1088
1089 let (time_in_force, expire_time) =
1090 self.resolve_time_in_force(self.config.limit_time_in_force);
1091
1092 let quantity = instrument.make_qty(self.config.order_qty.as_f64(), None);
1093 let instrument_id = self.config.instrument_id;
1094 let post_only = self.config.use_post_only || self.config.test_reject_post_only;
1095 let quote_quantity = self.config.use_quote_quantity;
1096 let display_qty = self.config.order_display_qty;
1097 let emulation_trigger = self.config.emulation_trigger;
1098
1099 let order = self.order_factory().limit(
1100 instrument_id,
1101 order_side,
1102 quantity,
1103 price,
1104 Some(time_in_force),
1105 expire_time,
1106 Some(post_only),
1107 None, Some(quote_quantity),
1109 display_qty,
1110 emulation_trigger,
1111 None, None, None, None, None, );
1117
1118 if order_side == OrderSide::Buy {
1119 self.buy_order = Some(order.clone());
1120 } else {
1121 self.sell_order = Some(order.clone());
1122 }
1123
1124 self.submit_order_apply_params(order)
1125 }
1126
1127 #[expect(
1133 clippy::too_many_lines,
1134 reason = "stop order submission covers all supported stop order scenarios"
1135 )]
1136 pub(super) fn submit_stop_order(
1137 &mut self,
1138 order_side: OrderSide,
1139 trigger_price: Price,
1140 limit_price: Option<Price>,
1141 ) -> anyhow::Result<()> {
1142 let Some(instrument) = &self.instrument else {
1143 anyhow::bail!("No instrument loaded");
1144 };
1145
1146 if self.config.dry_run {
1147 log_warn!("Dry run, skipping create {order_side:?} stop order");
1148 return Ok(());
1149 }
1150
1151 if order_side == OrderSide::Buy && !self.config.enable_stop_buys {
1152 log_warn!("BUY stop orders not enabled, skipping");
1153 return Ok(());
1154 } else if order_side == OrderSide::Sell && !self.config.enable_stop_sells {
1155 log_warn!("SELL stop orders not enabled, skipping");
1156 return Ok(());
1157 }
1158
1159 let (time_in_force, expire_time) =
1160 self.resolve_time_in_force(self.config.stop_time_in_force);
1161
1162 let quantity = instrument.make_qty(self.config.order_qty.as_f64(), None);
1164 let instrument_id = self.config.instrument_id;
1165 let trigger_type = self.config.stop_trigger_type;
1166 let quote_quantity = self.config.use_quote_quantity;
1167 let display_qty = self.config.order_display_qty;
1168 let emulation_trigger = self.config.emulation_trigger;
1169 let stop_order_type = self.config.stop_order_type;
1170 let trailing_offset = self.config.trailing_offset;
1171 let trailing_offset_type = self.config.trailing_offset_type;
1172
1173 let mut factory = self.order_factory();
1174
1175 let mut order: OrderAny = match stop_order_type {
1176 OrderType::StopMarket => factory.stop_market(
1177 instrument_id,
1178 order_side,
1179 quantity,
1180 trigger_price,
1181 Some(trigger_type),
1182 Some(time_in_force),
1183 expire_time,
1184 None, Some(quote_quantity),
1186 None, emulation_trigger,
1188 None, None, None, None, None, ),
1194 OrderType::StopLimit => {
1195 let Some(limit_price) = limit_price else {
1196 anyhow::bail!("STOP_LIMIT order requires limit_price");
1197 };
1198 factory.stop_limit(
1199 instrument_id,
1200 order_side,
1201 quantity,
1202 limit_price,
1203 trigger_price,
1204 Some(trigger_type),
1205 Some(time_in_force),
1206 expire_time,
1207 None, None, Some(quote_quantity),
1210 display_qty,
1211 emulation_trigger,
1212 None, None, None, None, None, )
1218 }
1219 OrderType::MarketIfTouched => factory.market_if_touched(
1220 instrument_id,
1221 order_side,
1222 quantity,
1223 trigger_price,
1224 Some(trigger_type),
1225 Some(time_in_force),
1226 expire_time,
1227 None, Some(quote_quantity),
1229 emulation_trigger,
1230 None, None, None, None, None, ),
1236 OrderType::LimitIfTouched => {
1237 let Some(limit_price) = limit_price else {
1238 anyhow::bail!("LIMIT_IF_TOUCHED order requires limit_price");
1239 };
1240 factory.limit_if_touched(
1241 instrument_id,
1242 order_side,
1243 quantity,
1244 limit_price,
1245 trigger_price,
1246 Some(trigger_type),
1247 Some(time_in_force),
1248 expire_time,
1249 None, None, Some(quote_quantity),
1252 display_qty,
1253 emulation_trigger,
1254 None, None, None, None, None, )
1260 }
1261 OrderType::TrailingStopMarket => {
1262 let Some(trailing_offset) = trailing_offset else {
1263 anyhow::bail!("TRAILING_STOP_MARKET order requires trailing_offset config");
1264 };
1265 factory.trailing_stop_market(
1266 instrument_id,
1267 order_side,
1268 quantity,
1269 trailing_offset,
1270 Some(trailing_offset_type),
1271 None,
1272 Some(trigger_price),
1273 Some(trigger_type),
1274 Some(time_in_force),
1275 expire_time,
1276 None, Some(quote_quantity),
1278 None, emulation_trigger,
1280 None, None, None, None, None, )
1286 }
1287 _ => {
1288 anyhow::bail!("Unknown stop order type: {stop_order_type:?}");
1289 }
1290 };
1291 drop(factory);
1292
1293 if let OrderAny::TrailingStopMarket(order) = &mut order {
1294 order.activation_price = Some(trigger_price);
1295 }
1296
1297 if order_side == OrderSide::Buy {
1298 self.buy_stop_order = Some(order.clone());
1299 } else {
1300 self.sell_stop_order = Some(order.clone());
1301 }
1302
1303 self.submit_order_apply_params(order)
1304 }
1305
1306 pub(super) fn submit_bracket_order(
1312 &mut self,
1313 order_side: OrderSide,
1314 entry_price: Price,
1315 ) -> anyhow::Result<()> {
1316 let Some(instrument) = &self.instrument else {
1317 anyhow::bail!("No instrument loaded");
1318 };
1319
1320 if self.config.dry_run {
1321 log_warn!("Dry run, skipping create {order_side:?} bracket order");
1322 return Ok(());
1323 }
1324
1325 if self.config.bracket_entry_order_type != OrderType::Limit {
1326 anyhow::bail!(
1327 "Only Limit entry orders are supported for brackets, was {:?}",
1328 self.config.bracket_entry_order_type
1329 );
1330 }
1331
1332 if order_side == OrderSide::Buy && !self.config.enable_limit_buys {
1333 log_warn!("BUY orders not enabled, skipping bracket");
1334 return Ok(());
1335 } else if order_side == OrderSide::Sell && !self.config.enable_limit_sells {
1336 log_warn!("SELL orders not enabled, skipping bracket");
1337 return Ok(());
1338 }
1339
1340 let (time_in_force, expire_time) =
1341 self.resolve_time_in_force(self.config.limit_time_in_force);
1342 let sl_time_in_force = self.config.stop_time_in_force.unwrap_or(TimeInForce::Gtc);
1343 if sl_time_in_force == TimeInForce::Gtd {
1344 anyhow::bail!("GTD time in force not supported for bracket stop-loss legs");
1345 }
1346
1347 let quantity = instrument.make_qty(self.config.order_qty.as_f64(), None);
1348 let increment = instrument.price_increment();
1349 let precision = instrument.price_precision();
1350 let bracket_offset_ticks = self.config.bracket_offset_ticks;
1351
1352 let (unclamped_tp_price, unclamped_sl_trigger_price) = match order_side {
1353 OrderSide::Buy => {
1354 let tp = add_price_ticks(entry_price, increment, bracket_offset_ticks, precision);
1355 let sl = sub_price_ticks(entry_price, increment, bracket_offset_ticks, precision);
1356 (tp, sl)
1357 }
1358 OrderSide::Sell => {
1359 let tp = sub_price_ticks(entry_price, increment, bracket_offset_ticks, precision);
1360 let sl = add_price_ticks(entry_price, increment, bracket_offset_ticks, precision);
1361 (tp, sl)
1362 }
1363 OrderSide::NoOrderSide => {
1364 anyhow::bail!("Invalid order side for bracket: {order_side:?}")
1365 }
1366 };
1367 let clamp = self.config.clamp_to_instrument_price_range;
1368 let tp_price = clamp_price_to_range(unclamped_tp_price, instrument, clamp);
1369 let sl_trigger_price = clamp_price_to_range(unclamped_sl_trigger_price, instrument, clamp);
1370
1371 let entry_post_only = self.config.use_post_only || self.config.test_reject_post_only;
1372 let instrument_id = self.config.instrument_id;
1373 let quote_quantity = self.config.use_quote_quantity;
1374 let emulation_trigger = self.config.emulation_trigger;
1375 let stop_trigger_type = self.config.stop_trigger_type;
1376 let orders = self
1377 .order_factory()
1378 .bracket()
1379 .instrument_id(instrument_id)
1380 .order_side(order_side)
1381 .quantity(quantity)
1382 .quote_quantity(quote_quantity)
1383 .entry_order_type(OrderType::Limit)
1384 .entry_price(entry_price)
1385 .time_in_force(time_in_force)
1386 .entry_post_only(entry_post_only)
1387 .maybe_emulation_trigger(emulation_trigger)
1388 .maybe_expire_time(expire_time)
1389 .tp_price(tp_price)
1390 .tp_post_only(entry_post_only)
1391 .tp_time_in_force(time_in_force)
1392 .sl_trigger_price(sl_trigger_price)
1393 .sl_trigger_type(stop_trigger_type)
1394 .sl_time_in_force(sl_time_in_force)
1395 .call();
1396
1397 if let Some(entry_order) = orders.first() {
1398 if order_side == OrderSide::Buy {
1399 self.buy_order = Some(entry_order.clone());
1400 } else {
1401 self.sell_order = Some(entry_order.clone());
1402 }
1403 }
1404
1405 let client_id = self.config.client_id;
1406 if let Some(params) = &self.config.order_params {
1407 self.submit_order_list(orders, None, client_id, Some(params.clone()))
1408 } else {
1409 self.submit_order_list(orders, None, client_id, None)
1410 }
1411 }
1412
1413 pub(super) fn open_position(&mut self, net_qty: Decimal) -> anyhow::Result<()> {
1419 let Some(instrument) = &self.instrument else {
1420 anyhow::bail!("No instrument loaded");
1421 };
1422
1423 if net_qty == Decimal::ZERO {
1424 log_warn!("Open position with zero quantity, skipping");
1425 return Ok(());
1426 }
1427
1428 let order_side = if net_qty > Decimal::ZERO {
1429 OrderSide::Buy
1430 } else {
1431 OrderSide::Sell
1432 };
1433
1434 let quantity = instrument.make_qty(net_qty.abs().to_f64().unwrap_or(0.0), None);
1435
1436 let reduce_only = if self.config.test_reject_reduce_only {
1438 Some(true)
1439 } else {
1440 None
1441 };
1442 let instrument_id = self.config.instrument_id;
1443 let time_in_force = self.config.open_position_time_in_force;
1444 let quote_quantity = self.config.use_quote_quantity;
1445
1446 let order = self.order_factory().market(
1447 instrument_id,
1448 order_side,
1449 quantity,
1450 Some(time_in_force),
1451 reduce_only,
1452 Some(quote_quantity),
1453 None, None, None, None, );
1458
1459 self.submit_order_apply_params(order)
1460 }
1461
1462 pub(super) fn cancel_active_orders(
1463 &mut self,
1464 instrument_id: InstrumentId,
1465 strategy_id: StrategyId,
1466 client_id: Option<ClientId>,
1467 ) {
1468 let bracket_targets: Vec<ClientOrderId> = {
1471 let cache = self.cache();
1472 let mut targets = Vec::new();
1473
1474 for order_list in
1475 cache.order_lists(None, Some(&instrument_id), Some(&strategy_id), None)
1476 {
1477 let is_bracket = order_list.client_order_ids.iter().any(|cid| {
1478 cache
1479 .order(cid)
1480 .is_some_and(|o| is_in_contingency_group(&o))
1481 });
1482
1483 if !is_bracket {
1484 continue;
1485 }
1486
1487 for cid in &order_list.client_order_ids {
1488 if let Some(order) = cache.order(cid)
1489 && !order.is_closed()
1490 && !order.is_pending_cancel()
1491 {
1492 targets.push(*cid);
1493 }
1494 }
1495 }
1496 targets
1497 };
1498
1499 for cid in bracket_targets {
1500 if let Err(e) = self.cancel_order(cid, client_id, None) {
1501 log::error!("Failed to cancel bracket leg {cid}: {e}");
1502 }
1503 }
1504
1505 if self.config.use_individual_cancels_on_stop {
1506 for cid in self.collect_cancellable_order_ids(instrument_id, strategy_id) {
1507 if let Err(e) = self.cancel_order(cid, client_id, None) {
1508 log::error!("Failed to cancel order {cid}: {e}");
1509 }
1510 }
1511 } else if self.config.use_batch_cancel_on_stop {
1512 let candidates = self.collect_cancellable_orders(instrument_id, strategy_id);
1513 let mut batchable: Vec<ClientOrderId> = Vec::new();
1514
1515 for order in candidates {
1516 let cid = order.client_order_id();
1517 if order.is_emulated() || order.is_active_local() {
1518 if let Err(e) = self.cancel_order(cid, client_id, None) {
1519 log::error!("Failed to cancel local order {cid}: {e}");
1520 }
1521 } else {
1522 batchable.push(cid);
1523 }
1524 }
1525
1526 if !batchable.is_empty()
1527 && let Err(e) = self.cancel_orders(batchable, client_id, None)
1528 {
1529 log::error!("Failed to batch cancel orders: {e}");
1530 }
1531 } else {
1532 let local_ids: Vec<ClientOrderId> = {
1535 let cache = self.cache();
1536 cache
1537 .orders_active_local(None, Some(&instrument_id), Some(&strategy_id), None, None)
1538 .into_iter()
1539 .filter(|o| {
1540 !o.is_closed() && !o.is_pending_cancel() && !is_in_contingency_group(o)
1541 })
1542 .map(|o| o.client_order_id())
1543 .collect()
1544 };
1545
1546 for cid in local_ids {
1547 if let Err(e) = self.cancel_order(cid, client_id, None) {
1548 log::error!("Failed to cancel active-local order {cid}: {e}");
1549 }
1550 }
1551
1552 if let Err(e) = self.cancel_all_orders(instrument_id, None, client_id, None) {
1553 log::error!("Failed to cancel all orders: {e}");
1554 }
1555 }
1556 }
1557
1558 pub(super) fn collect_cancellable_orders(
1559 &self,
1560 instrument_id: InstrumentId,
1561 strategy_id: StrategyId,
1562 ) -> Vec<OrderAny> {
1563 let cache = self.cache();
1564 let mut seen: AHashSet<ClientOrderId> = AHashSet::new();
1565 let mut candidates: Vec<OrderAny> = Vec::new();
1566 let sources = [
1569 cache.orders_active_local(None, Some(&instrument_id), Some(&strategy_id), None, None),
1570 cache.orders_emulated(None, Some(&instrument_id), Some(&strategy_id), None, None),
1571 cache.orders_inflight(None, Some(&instrument_id), Some(&strategy_id), None, None),
1572 cache.orders_open(None, Some(&instrument_id), Some(&strategy_id), None, None),
1573 ];
1574
1575 for orders in sources {
1576 for order in orders {
1577 if order.is_closed() || order.is_pending_cancel() || is_in_contingency_group(&order)
1578 {
1579 continue;
1580 }
1581 let cid = order.client_order_id();
1582 if seen.insert(cid) {
1583 candidates.push(order);
1584 }
1585 }
1586 }
1587 candidates
1588 }
1589
1590 pub(super) fn collect_cancellable_order_ids(
1591 &self,
1592 instrument_id: InstrumentId,
1593 strategy_id: StrategyId,
1594 ) -> Vec<ClientOrderId> {
1595 self.collect_cancellable_orders(instrument_id, strategy_id)
1596 .into_iter()
1597 .map(|o| o.client_order_id())
1598 .collect()
1599 }
1600}
1601
1602fn add_price_ticks(base: Price, increment: Price, ticks: u64, precision: u8) -> Price {
1603 let offset = price_tick_offset(increment, ticks, precision);
1604 base + offset
1605}
1606
1607fn sub_price_ticks(base: Price, increment: Price, ticks: u64, precision: u8) -> Price {
1608 let offset = price_tick_offset(increment, ticks, precision);
1609 base - offset
1610}
1611
1612fn price_tick_offset(increment: Price, ticks: u64, precision: u8) -> Price {
1613 let offset = increment * Decimal::from(ticks);
1614 Price::from_decimal_dp(offset, precision)
1615 .unwrap_or_else(|e| panic!("Failed to calculate price tick offset: {e}"))
1616}
1617
1618fn is_in_contingency_group(order: &OrderAny) -> bool {
1621 matches!(
1622 order.contingency_type(),
1623 Some(ContingencyType::Oto | ContingencyType::Oco | ContingencyType::Ouo)
1624 )
1625}
1626
1627fn clamp_price_to_range(price: Price, instrument: &InstrumentAny, enabled: bool) -> Price {
1628 if !enabled {
1629 return price;
1630 }
1631 let mut clamped = price;
1632 if let Some(min) = instrument.min_price()
1633 && clamped < min
1634 {
1635 clamped = min;
1636 }
1637
1638 if let Some(max) = instrument.max_price()
1639 && clamped > max
1640 {
1641 clamped = max;
1642 }
1643
1644 clamped
1645}