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, 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 let Some(instrument) = &self.instrument else {
873 return;
874 };
875
876 let increment = instrument.price_increment();
877 let precision = instrument.price_precision();
878 let stop_offset_ticks = self.config.stop_offset_ticks;
879
880 let unclamped_trigger_price = if matches!(
882 self.config.stop_order_type,
883 OrderType::LimitIfTouched | OrderType::MarketIfTouched | OrderType::TrailingStopMarket
884 ) {
885 sub_price_ticks(best_bid, increment, stop_offset_ticks, precision)
887 } else {
888 add_price_ticks(best_ask, increment, stop_offset_ticks, precision)
890 };
891 let clamp = self.config.clamp_to_instrument_price_range;
892 let trigger_price = clamp_price_to_range(unclamped_trigger_price, instrument, clamp);
893
894 let limit_price = if matches!(
896 self.config.stop_order_type,
897 OrderType::StopLimit | OrderType::LimitIfTouched
898 ) {
899 let unclamped_limit_price =
900 if let Some(limit_offset_ticks) = self.config.stop_limit_offset_ticks {
901 add_price_ticks(trigger_price, increment, limit_offset_ticks, precision)
903 } else {
904 trigger_price
905 };
906 Some(clamp_price_to_range(
907 unclamped_limit_price,
908 instrument,
909 clamp,
910 ))
911 } else {
912 None
913 };
914
915 let needs_new_order = match &self.buy_stop_order {
916 None => true,
917 Some(order) => !Self::is_order_active(order) && !self.stop_order_is_one_shot(),
918 };
919
920 if needs_new_order {
921 if let Err(e) = self.submit_stop_order(OrderSide::Buy, trigger_price, limit_price) {
922 log::error!("Failed to submit buy stop order: {e}");
923 }
924 } else if let Some(order) = &self.buy_stop_order
925 && order.venue_order_id().is_some()
926 && !order.is_pending_update()
927 && !order.is_pending_cancel()
928 {
929 let current_trigger = Self::get_order_trigger_price(order);
930 if current_trigger.is_some() && current_trigger != Some(trigger_price) {
931 if self.config.modify_stop_orders_to_maintain_offset {
932 let order_clone = order.clone();
933 if let Err(e) = self.modify_stop_order(&order_clone, trigger_price, limit_price)
934 {
935 log::error!("Failed to modify buy stop order: {e}");
936 }
937 } else if self.config.cancel_replace_stop_orders_to_maintain_offset
938 && !self.buy_stop_cancel_replace_attempted
939 {
940 self.buy_stop_cancel_replace_attempted = true;
941 let order_clone = order.clone();
942 let _ = self.cancel_order(
943 order_clone.client_order_id(),
944 self.config.client_id,
945 None,
946 );
947
948 if let Err(e) =
949 self.submit_stop_order(OrderSide::Buy, trigger_price, limit_price)
950 {
951 log::error!("Failed to submit replacement buy stop order: {e}");
952 }
953 }
954 }
955 }
956 }
957
958 fn maintain_stop_sell_orders(&mut self, best_bid: Price, best_ask: Price) {
960 self.refresh_tracked_stop_order(OrderSide::Sell);
961
962 let Some(instrument) = &self.instrument else {
963 return;
964 };
965
966 let increment = instrument.price_increment();
967 let precision = instrument.price_precision();
968 let stop_offset_ticks = self.config.stop_offset_ticks;
969
970 let unclamped_trigger_price = if matches!(
972 self.config.stop_order_type,
973 OrderType::LimitIfTouched | OrderType::MarketIfTouched | OrderType::TrailingStopMarket
974 ) {
975 add_price_ticks(best_ask, increment, stop_offset_ticks, precision)
977 } else {
978 sub_price_ticks(best_bid, increment, stop_offset_ticks, precision)
980 };
981 let clamp = self.config.clamp_to_instrument_price_range;
982 let trigger_price = clamp_price_to_range(unclamped_trigger_price, instrument, clamp);
983
984 let limit_price = if matches!(
986 self.config.stop_order_type,
987 OrderType::StopLimit | OrderType::LimitIfTouched
988 ) {
989 let unclamped_limit_price =
990 if let Some(limit_offset_ticks) = self.config.stop_limit_offset_ticks {
991 sub_price_ticks(trigger_price, increment, limit_offset_ticks, precision)
993 } else {
994 trigger_price
995 };
996 Some(clamp_price_to_range(
997 unclamped_limit_price,
998 instrument,
999 clamp,
1000 ))
1001 } else {
1002 None
1003 };
1004
1005 let needs_new_order = match &self.sell_stop_order {
1006 None => true,
1007 Some(order) => !Self::is_order_active(order) && !self.stop_order_is_one_shot(),
1008 };
1009
1010 if needs_new_order {
1011 if let Err(e) = self.submit_stop_order(OrderSide::Sell, trigger_price, limit_price) {
1012 log::error!("Failed to submit sell stop order: {e}");
1013 }
1014 } else if let Some(order) = &self.sell_stop_order
1015 && order.venue_order_id().is_some()
1016 && !order.is_pending_update()
1017 && !order.is_pending_cancel()
1018 {
1019 let current_trigger = Self::get_order_trigger_price(order);
1020 if current_trigger.is_some() && current_trigger != Some(trigger_price) {
1021 if self.config.modify_stop_orders_to_maintain_offset {
1022 let order_clone = order.clone();
1023 if let Err(e) = self.modify_stop_order(&order_clone, trigger_price, limit_price)
1024 {
1025 log::error!("Failed to modify sell stop order: {e}");
1026 }
1027 } else if self.config.cancel_replace_stop_orders_to_maintain_offset
1028 && !self.sell_stop_cancel_replace_attempted
1029 {
1030 self.sell_stop_cancel_replace_attempted = true;
1031 let order_clone = order.clone();
1032 let _ = self.cancel_order(
1033 order_clone.client_order_id(),
1034 self.config.client_id,
1035 None,
1036 );
1037
1038 if let Err(e) =
1039 self.submit_stop_order(OrderSide::Sell, trigger_price, limit_price)
1040 {
1041 log::error!("Failed to submit replacement sell stop order: {e}");
1042 }
1043 }
1044 }
1045 }
1046 }
1047
1048 pub(super) fn submit_limit_order(
1054 &mut self,
1055 order_side: OrderSide,
1056 price: Price,
1057 ) -> anyhow::Result<()> {
1058 let Some(instrument) = &self.instrument else {
1059 anyhow::bail!("No instrument loaded");
1060 };
1061
1062 if self.config.dry_run {
1063 log_warn!("Dry run, skipping create {order_side:?} order");
1064 return Ok(());
1065 }
1066
1067 if order_side == OrderSide::Buy && !self.config.enable_limit_buys {
1068 log_warn!("BUY orders not enabled, skipping");
1069 return Ok(());
1070 } else if order_side == OrderSide::Sell && !self.config.enable_limit_sells {
1071 log_warn!("SELL orders not enabled, skipping");
1072 return Ok(());
1073 }
1074
1075 let (time_in_force, expire_time) =
1076 self.resolve_time_in_force(self.config.limit_time_in_force);
1077
1078 let quantity = instrument.make_qty(self.config.order_qty.as_f64(), None);
1079 let instrument_id = self.config.instrument_id;
1080 let post_only = self.config.use_post_only || self.config.test_reject_post_only;
1081 let quote_quantity = self.config.use_quote_quantity;
1082 let display_qty = self.config.order_display_qty;
1083 let emulation_trigger = self.config.emulation_trigger;
1084
1085 let order = self.order_factory().limit(
1086 instrument_id,
1087 order_side,
1088 quantity,
1089 price,
1090 Some(time_in_force),
1091 expire_time,
1092 Some(post_only),
1093 None, Some(quote_quantity),
1095 display_qty,
1096 emulation_trigger,
1097 None, None, None, None, None, );
1103
1104 if order_side == OrderSide::Buy {
1105 self.buy_order = Some(order.clone());
1106 } else {
1107 self.sell_order = Some(order.clone());
1108 }
1109
1110 self.submit_order_apply_params(order)
1111 }
1112
1113 #[expect(
1119 clippy::too_many_lines,
1120 reason = "stop order submission covers all supported stop order scenarios"
1121 )]
1122 pub(super) fn submit_stop_order(
1123 &mut self,
1124 order_side: OrderSide,
1125 trigger_price: Price,
1126 limit_price: Option<Price>,
1127 ) -> anyhow::Result<()> {
1128 let Some(instrument) = &self.instrument else {
1129 anyhow::bail!("No instrument loaded");
1130 };
1131
1132 if self.config.dry_run {
1133 log_warn!("Dry run, skipping create {order_side:?} stop order");
1134 return Ok(());
1135 }
1136
1137 if order_side == OrderSide::Buy && !self.config.enable_stop_buys {
1138 log_warn!("BUY stop orders not enabled, skipping");
1139 return Ok(());
1140 } else if order_side == OrderSide::Sell && !self.config.enable_stop_sells {
1141 log_warn!("SELL stop orders not enabled, skipping");
1142 return Ok(());
1143 }
1144
1145 let (time_in_force, expire_time) =
1146 self.resolve_time_in_force(self.config.stop_time_in_force);
1147
1148 let quantity = instrument.make_qty(self.config.order_qty.as_f64(), None);
1150 let instrument_id = self.config.instrument_id;
1151 let trigger_type = self.config.stop_trigger_type;
1152 let quote_quantity = self.config.use_quote_quantity;
1153 let display_qty = self.config.order_display_qty;
1154 let emulation_trigger = self.config.emulation_trigger;
1155 let stop_order_type = self.config.stop_order_type;
1156 let trailing_offset = self.config.trailing_offset;
1157 let trailing_offset_type = self.config.trailing_offset_type;
1158
1159 let mut factory = self.order_factory();
1160
1161 let mut order: OrderAny = match stop_order_type {
1162 OrderType::StopMarket => factory.stop_market(
1163 instrument_id,
1164 order_side,
1165 quantity,
1166 trigger_price,
1167 Some(trigger_type),
1168 Some(time_in_force),
1169 expire_time,
1170 None, Some(quote_quantity),
1172 None, emulation_trigger,
1174 None, None, None, None, None, ),
1180 OrderType::StopLimit => {
1181 let Some(limit_price) = limit_price else {
1182 anyhow::bail!("STOP_LIMIT order requires limit_price");
1183 };
1184 factory.stop_limit(
1185 instrument_id,
1186 order_side,
1187 quantity,
1188 limit_price,
1189 trigger_price,
1190 Some(trigger_type),
1191 Some(time_in_force),
1192 expire_time,
1193 None, None, Some(quote_quantity),
1196 display_qty,
1197 emulation_trigger,
1198 None, None, None, None, None, )
1204 }
1205 OrderType::MarketIfTouched => factory.market_if_touched(
1206 instrument_id,
1207 order_side,
1208 quantity,
1209 trigger_price,
1210 Some(trigger_type),
1211 Some(time_in_force),
1212 expire_time,
1213 None, Some(quote_quantity),
1215 emulation_trigger,
1216 None, None, None, None, None, ),
1222 OrderType::LimitIfTouched => {
1223 let Some(limit_price) = limit_price else {
1224 anyhow::bail!("LIMIT_IF_TOUCHED order requires limit_price");
1225 };
1226 factory.limit_if_touched(
1227 instrument_id,
1228 order_side,
1229 quantity,
1230 limit_price,
1231 trigger_price,
1232 Some(trigger_type),
1233 Some(time_in_force),
1234 expire_time,
1235 None, None, Some(quote_quantity),
1238 display_qty,
1239 emulation_trigger,
1240 None, None, None, None, None, )
1246 }
1247 OrderType::TrailingStopMarket => {
1248 let Some(trailing_offset) = trailing_offset else {
1249 anyhow::bail!("TRAILING_STOP_MARKET order requires trailing_offset config");
1250 };
1251 factory.trailing_stop_market(
1252 instrument_id,
1253 order_side,
1254 quantity,
1255 trailing_offset,
1256 Some(trailing_offset_type),
1257 None,
1258 Some(trigger_price),
1259 Some(trigger_type),
1260 Some(time_in_force),
1261 expire_time,
1262 None, Some(quote_quantity),
1264 None, emulation_trigger,
1266 None, None, None, None, None, )
1272 }
1273 _ => {
1274 anyhow::bail!("Unknown stop order type: {stop_order_type:?}");
1275 }
1276 };
1277 drop(factory);
1278
1279 if let OrderAny::TrailingStopMarket(order) = &mut order {
1280 order.activation_price = Some(trigger_price);
1281 }
1282
1283 if order_side == OrderSide::Buy {
1284 self.buy_stop_order = Some(order.clone());
1285 } else {
1286 self.sell_stop_order = Some(order.clone());
1287 }
1288
1289 self.submit_order_apply_params(order)
1290 }
1291
1292 pub(super) fn submit_bracket_order(
1298 &mut self,
1299 order_side: OrderSide,
1300 entry_price: Price,
1301 ) -> anyhow::Result<()> {
1302 let Some(instrument) = &self.instrument else {
1303 anyhow::bail!("No instrument loaded");
1304 };
1305
1306 if self.config.dry_run {
1307 log_warn!("Dry run, skipping create {order_side:?} bracket order");
1308 return Ok(());
1309 }
1310
1311 if self.config.bracket_entry_order_type != OrderType::Limit {
1312 anyhow::bail!(
1313 "Only Limit entry orders are supported for brackets, was {:?}",
1314 self.config.bracket_entry_order_type
1315 );
1316 }
1317
1318 if order_side == OrderSide::Buy && !self.config.enable_limit_buys {
1319 log_warn!("BUY orders not enabled, skipping bracket");
1320 return Ok(());
1321 } else if order_side == OrderSide::Sell && !self.config.enable_limit_sells {
1322 log_warn!("SELL orders not enabled, skipping bracket");
1323 return Ok(());
1324 }
1325
1326 let (time_in_force, expire_time) =
1327 self.resolve_time_in_force(self.config.limit_time_in_force);
1328 let sl_time_in_force = self.config.stop_time_in_force.unwrap_or(TimeInForce::Gtc);
1329 if sl_time_in_force == TimeInForce::Gtd {
1330 anyhow::bail!("GTD time in force not supported for bracket stop-loss legs");
1331 }
1332
1333 let quantity = instrument.make_qty(self.config.order_qty.as_f64(), None);
1334 let increment = instrument.price_increment();
1335 let precision = instrument.price_precision();
1336 let bracket_offset_ticks = self.config.bracket_offset_ticks;
1337
1338 let (unclamped_tp_price, unclamped_sl_trigger_price) = match order_side {
1339 OrderSide::Buy => {
1340 let tp = add_price_ticks(entry_price, increment, bracket_offset_ticks, precision);
1341 let sl = sub_price_ticks(entry_price, increment, bracket_offset_ticks, precision);
1342 (tp, sl)
1343 }
1344 OrderSide::Sell => {
1345 let tp = sub_price_ticks(entry_price, increment, bracket_offset_ticks, precision);
1346 let sl = add_price_ticks(entry_price, increment, bracket_offset_ticks, precision);
1347 (tp, sl)
1348 }
1349 OrderSide::NoOrderSide => {
1350 anyhow::bail!("Invalid order side for bracket: {order_side:?}")
1351 }
1352 };
1353 let clamp = self.config.clamp_to_instrument_price_range;
1354 let tp_price = clamp_price_to_range(unclamped_tp_price, instrument, clamp);
1355 let sl_trigger_price = clamp_price_to_range(unclamped_sl_trigger_price, instrument, clamp);
1356
1357 let entry_post_only = self.config.use_post_only || self.config.test_reject_post_only;
1358 let instrument_id = self.config.instrument_id;
1359 let quote_quantity = self.config.use_quote_quantity;
1360 let emulation_trigger = self.config.emulation_trigger;
1361 let stop_trigger_type = self.config.stop_trigger_type;
1362 let orders = self
1363 .order_factory()
1364 .bracket()
1365 .instrument_id(instrument_id)
1366 .order_side(order_side)
1367 .quantity(quantity)
1368 .quote_quantity(quote_quantity)
1369 .entry_order_type(OrderType::Limit)
1370 .entry_price(entry_price)
1371 .time_in_force(time_in_force)
1372 .entry_post_only(entry_post_only)
1373 .maybe_emulation_trigger(emulation_trigger)
1374 .maybe_expire_time(expire_time)
1375 .tp_price(tp_price)
1376 .tp_post_only(entry_post_only)
1377 .tp_time_in_force(time_in_force)
1378 .sl_trigger_price(sl_trigger_price)
1379 .sl_trigger_type(stop_trigger_type)
1380 .sl_time_in_force(sl_time_in_force)
1381 .call();
1382
1383 if let Some(entry_order) = orders.first() {
1384 if order_side == OrderSide::Buy {
1385 self.buy_order = Some(entry_order.clone());
1386 } else {
1387 self.sell_order = Some(entry_order.clone());
1388 }
1389 }
1390
1391 let client_id = self.config.client_id;
1392 if let Some(params) = &self.config.order_params {
1393 self.submit_order_list(orders, None, client_id, Some(params.clone()))
1394 } else {
1395 self.submit_order_list(orders, None, client_id, None)
1396 }
1397 }
1398
1399 pub(super) fn open_position(&mut self, net_qty: Decimal) -> anyhow::Result<()> {
1405 let Some(instrument) = &self.instrument else {
1406 anyhow::bail!("No instrument loaded");
1407 };
1408
1409 if net_qty == Decimal::ZERO {
1410 log_warn!("Open position with zero quantity, skipping");
1411 return Ok(());
1412 }
1413
1414 let order_side = if net_qty > Decimal::ZERO {
1415 OrderSide::Buy
1416 } else {
1417 OrderSide::Sell
1418 };
1419
1420 let quantity = instrument.make_qty(net_qty.abs().to_f64().unwrap_or(0.0), None);
1421
1422 let reduce_only = if self.config.test_reject_reduce_only {
1424 Some(true)
1425 } else {
1426 None
1427 };
1428 let instrument_id = self.config.instrument_id;
1429 let time_in_force = self.config.open_position_time_in_force;
1430 let quote_quantity = self.config.use_quote_quantity;
1431
1432 let order = self.order_factory().market(
1433 instrument_id,
1434 order_side,
1435 quantity,
1436 Some(time_in_force),
1437 reduce_only,
1438 Some(quote_quantity),
1439 None, None, None, None, );
1444
1445 self.submit_order_apply_params(order)
1446 }
1447
1448 pub(super) fn cancel_active_orders(
1449 &mut self,
1450 instrument_id: InstrumentId,
1451 strategy_id: StrategyId,
1452 client_id: Option<ClientId>,
1453 ) {
1454 let bracket_targets: Vec<ClientOrderId> = {
1457 let cache = self.cache();
1458 let mut targets = Vec::new();
1459
1460 for order_list in
1461 cache.order_lists(None, Some(&instrument_id), Some(&strategy_id), None)
1462 {
1463 let is_bracket = order_list.client_order_ids.iter().any(|cid| {
1464 cache
1465 .order(cid)
1466 .is_some_and(|o| is_in_contingency_group(&o))
1467 });
1468
1469 if !is_bracket {
1470 continue;
1471 }
1472
1473 for cid in &order_list.client_order_ids {
1474 if let Some(order) = cache.order(cid)
1475 && !order.is_closed()
1476 && !order.is_pending_cancel()
1477 {
1478 targets.push(*cid);
1479 }
1480 }
1481 }
1482 targets
1483 };
1484
1485 for cid in bracket_targets {
1486 if let Err(e) = self.cancel_order(cid, client_id, None) {
1487 log::error!("Failed to cancel bracket leg {cid}: {e}");
1488 }
1489 }
1490
1491 if self.config.use_individual_cancels_on_stop {
1492 for cid in self.collect_cancellable_order_ids(instrument_id, strategy_id) {
1493 if let Err(e) = self.cancel_order(cid, client_id, None) {
1494 log::error!("Failed to cancel order {cid}: {e}");
1495 }
1496 }
1497 } else if self.config.use_batch_cancel_on_stop {
1498 let candidates = self.collect_cancellable_orders(instrument_id, strategy_id);
1499 let mut batchable: Vec<ClientOrderId> = Vec::new();
1500
1501 for order in candidates {
1502 let cid = order.client_order_id();
1503 if order.is_emulated() || order.is_active_local() {
1504 if let Err(e) = self.cancel_order(cid, client_id, None) {
1505 log::error!("Failed to cancel local order {cid}: {e}");
1506 }
1507 } else {
1508 batchable.push(cid);
1509 }
1510 }
1511
1512 if !batchable.is_empty()
1513 && let Err(e) = self.cancel_orders(batchable, client_id, None)
1514 {
1515 log::error!("Failed to batch cancel orders: {e}");
1516 }
1517 } else {
1518 let local_ids: Vec<ClientOrderId> = {
1521 let cache = self.cache();
1522 cache
1523 .orders_active_local(None, Some(&instrument_id), Some(&strategy_id), None, None)
1524 .into_iter()
1525 .filter(|o| {
1526 !o.is_closed() && !o.is_pending_cancel() && !is_in_contingency_group(o)
1527 })
1528 .map(|o| o.client_order_id())
1529 .collect()
1530 };
1531
1532 for cid in local_ids {
1533 if let Err(e) = self.cancel_order(cid, client_id, None) {
1534 log::error!("Failed to cancel active-local order {cid}: {e}");
1535 }
1536 }
1537
1538 if let Err(e) = self.cancel_all_orders(instrument_id, None, client_id, None) {
1539 log::error!("Failed to cancel all orders: {e}");
1540 }
1541 }
1542 }
1543
1544 pub(super) fn collect_cancellable_orders(
1545 &self,
1546 instrument_id: InstrumentId,
1547 strategy_id: StrategyId,
1548 ) -> Vec<OrderAny> {
1549 let cache = self.cache();
1550 let mut seen: AHashSet<ClientOrderId> = AHashSet::new();
1551 let mut candidates: Vec<OrderAny> = Vec::new();
1552 let sources = [
1555 cache.orders_active_local(None, Some(&instrument_id), Some(&strategy_id), None, None),
1556 cache.orders_emulated(None, Some(&instrument_id), Some(&strategy_id), None, None),
1557 cache.orders_inflight(None, Some(&instrument_id), Some(&strategy_id), None, None),
1558 cache.orders_open(None, Some(&instrument_id), Some(&strategy_id), None, None),
1559 ];
1560
1561 for orders in sources {
1562 for order in orders {
1563 if order.is_closed() || order.is_pending_cancel() || is_in_contingency_group(&order)
1564 {
1565 continue;
1566 }
1567 let cid = order.client_order_id();
1568 if seen.insert(cid) {
1569 candidates.push(order);
1570 }
1571 }
1572 }
1573 candidates
1574 }
1575
1576 pub(super) fn collect_cancellable_order_ids(
1577 &self,
1578 instrument_id: InstrumentId,
1579 strategy_id: StrategyId,
1580 ) -> Vec<ClientOrderId> {
1581 self.collect_cancellable_orders(instrument_id, strategy_id)
1582 .into_iter()
1583 .map(|o| o.client_order_id())
1584 .collect()
1585 }
1586}
1587
1588fn add_price_ticks(base: Price, increment: Price, ticks: u64, precision: u8) -> Price {
1589 let offset = price_tick_offset(increment, ticks, precision);
1590 base + offset
1591}
1592
1593fn sub_price_ticks(base: Price, increment: Price, ticks: u64, precision: u8) -> Price {
1594 let offset = price_tick_offset(increment, ticks, precision);
1595 base - offset
1596}
1597
1598fn price_tick_offset(increment: Price, ticks: u64, precision: u8) -> Price {
1599 let offset = increment * Decimal::from(ticks);
1600 Price::from_decimal_dp(offset, precision)
1601 .unwrap_or_else(|e| panic!("Failed to calculate price tick offset: {e}"))
1602}
1603
1604fn is_in_contingency_group(order: &OrderAny) -> bool {
1607 matches!(
1608 order.contingency_type(),
1609 Some(ContingencyType::Oto | ContingencyType::Oco | ContingencyType::Ouo)
1610 )
1611}
1612
1613fn clamp_price_to_range(price: Price, instrument: &InstrumentAny, enabled: bool) -> Price {
1614 if !enabled {
1615 return price;
1616 }
1617 let mut clamped = price;
1618 if let Some(min) = instrument.min_price()
1619 && clamped < min
1620 {
1621 clamped = min;
1622 }
1623
1624 if let Some(max) = instrument.max_price()
1625 && clamped > max
1626 {
1627 clamped = max;
1628 }
1629
1630 clamped
1631}