1use nautilus_common::{actor::DataActor, enums::LogColor, log_info, log_warn, timer::TimeEvent};
17use nautilus_core::{UnixNanos, datetime::secs_to_nanos_unchecked};
18use nautilus_model::{
19 data::{Bar, IndexPriceUpdate, MarkPriceUpdate, OrderBookDeltas, QuoteTick, TradeTick},
20 enums::{OrderSide, OrderType, TimeInForce},
21 identifiers::{ClientOrderId, InstrumentId, StrategyId},
22 instruments::{Instrument, InstrumentAny},
23 orderbook::OrderBook,
24 orders::{Order, OrderAny},
25 types::{Price, price::PriceRaw},
26};
27use nautilus_trading::{
28 nautilus_strategy,
29 strategy::{Strategy, StrategyCore},
30};
31use rust_decimal::{Decimal, prelude::ToPrimitive};
32
33use super::config::ExecTesterConfig;
34
35#[derive(Debug)]
44pub struct ExecTester {
45 pub(super) core: StrategyCore,
46 pub(super) config: ExecTesterConfig,
47 pub(super) instrument: Option<InstrumentAny>,
48 pub(super) price_offset: Option<u64>,
49 pub(super) preinitialized_market_data: bool,
50
51 pub(super) buy_order: Option<OrderAny>,
53 pub(super) sell_order: Option<OrderAny>,
54 pub(super) buy_stop_order: Option<OrderAny>,
55 pub(super) sell_stop_order: Option<OrderAny>,
56
57 pub(super) modify_rejected_attempted: bool,
60}
61
62nautilus_strategy!(ExecTester, {
63 fn external_order_claims(&self) -> Option<Vec<InstrumentId>> {
64 self.config.base.external_order_claims.clone()
65 }
66});
67
68impl DataActor for ExecTester {
69 fn on_start(&mut self) -> anyhow::Result<()> {
70 Strategy::on_start(self)?;
71
72 let instrument_id = self.config.instrument_id;
73 let client_id = self.config.client_id;
74
75 let instrument = {
76 let cache = self.cache();
77 cache.instrument(&instrument_id).cloned()
78 };
79
80 if let Some(inst) = instrument {
81 self.initialize_with_instrument(inst, true)?;
82 } else {
83 log::info!("Instrument {instrument_id} not in cache, subscribing...");
84 self.subscribe_instrument(instrument_id, client_id, None);
85
86 if self.config.subscribe_quotes {
89 self.subscribe_quotes(instrument_id, client_id, None);
90 }
91
92 if self.config.subscribe_trades {
93 self.subscribe_trades(instrument_id, client_id, None);
94 }
95 self.preinitialized_market_data =
96 self.config.subscribe_quotes || self.config.subscribe_trades;
97 }
98
99 Ok(())
100 }
101
102 fn on_instrument(&mut self, instrument: &InstrumentAny) -> anyhow::Result<()> {
103 if instrument.id() == self.config.instrument_id && self.instrument.is_none() {
104 let id = instrument.id();
105 log::info!("Received instrument {id}, initializing...");
106 self.initialize_with_instrument(instrument.clone(), !self.preinitialized_market_data)?;
107 }
108 Ok(())
109 }
110
111 fn on_stop(&mut self) -> anyhow::Result<()> {
112 if self.config.dry_run {
113 log_warn!("Dry run mode, skipping cancel all orders and close all positions");
114 return Ok(());
115 }
116
117 let instrument_id = self.config.instrument_id;
118 let client_id = self.config.client_id;
119
120 if self.config.cancel_orders_on_stop {
121 let strategy_id = StrategyId::from(self.core.actor_id.inner().as_str());
122
123 if self.config.use_individual_cancels_on_stop {
124 let cache = self.cache();
125 let open_orders: Vec<OrderAny> = cache
126 .orders_open(None, Some(&instrument_id), Some(&strategy_id), None, None)
127 .iter()
128 .map(|o| (*o).clone())
129 .collect();
130 drop(cache);
131
132 for order in open_orders {
133 if let Err(e) = self.cancel_order(order.client_order_id(), client_id, None) {
134 log::error!("Failed to cancel order: {e}");
135 }
136 }
137 } else if self.config.use_batch_cancel_on_stop {
138 let cache = self.cache();
139 let open_order_ids: Vec<ClientOrderId> = cache
140 .orders_open(None, Some(&instrument_id), Some(&strategy_id), None, None)
141 .iter()
142 .map(|o| o.client_order_id())
143 .collect();
144 drop(cache);
145
146 if let Err(e) = self.cancel_orders(open_order_ids, client_id, None) {
147 log::error!("Failed to batch cancel orders: {e}");
148 }
149 } else if let Err(e) = self.cancel_all_orders(instrument_id, None, client_id, None) {
150 log::error!("Failed to cancel all orders: {e}");
151 }
152 }
153
154 if self.config.close_positions_on_stop {
155 let time_in_force = self
156 .config
157 .close_positions_time_in_force
158 .or(Some(TimeInForce::Gtc));
159
160 if let Err(e) = self.close_all_positions(
161 instrument_id,
162 None,
163 client_id,
164 None,
165 time_in_force,
166 Some(self.config.reduce_only_on_stop),
167 None,
168 ) {
169 log::error!("Failed to close all positions: {e}");
170 }
171 }
172
173 if self.config.can_unsubscribe && self.instrument.is_some() {
174 if self.config.subscribe_quotes {
175 self.unsubscribe_quotes(instrument_id, client_id, None);
176 }
177
178 if self.config.subscribe_trades {
179 self.unsubscribe_trades(instrument_id, client_id, None);
180 }
181
182 if self.config.subscribe_book {
183 self.unsubscribe_book_at_interval(
184 instrument_id,
185 self.config.book_interval_ms,
186 client_id,
187 None,
188 );
189 }
190 }
191
192 Ok(())
193 }
194
195 fn on_quote(&mut self, quote: &QuoteTick) -> anyhow::Result<()> {
196 if self.config.log_data {
197 log_info!("{quote:?}", color = LogColor::Cyan);
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 self.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 Self {
280 core: StrategyCore::new(config.base.clone()),
281 config,
282 instrument: None,
283 price_offset: None,
284 preinitialized_market_data: false,
285 buy_order: None,
286 sell_order: None,
287 buy_stop_order: None,
288 sell_stop_order: None,
289 modify_rejected_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.book_depth,
317 self.config.book_interval_ms,
318 client_id,
319 None,
320 );
321 }
322
323 if let Some(qty) = self.config.open_position_on_start_qty {
324 self.open_position(qty)?;
325 }
326
327 Ok(())
328 }
329
330 pub(super) fn get_price_offset(&self, _instrument: &InstrumentAny) -> u64 {
331 self.config.tob_offset_ticks
332 }
333
334 fn expire_time_from_delta(&self, mins: u64) -> UnixNanos {
335 let current_ns = self.timestamp_ns();
336 let delta_ns = secs_to_nanos_unchecked((mins * 60) as f64);
337 UnixNanos::from(current_ns.as_u64() + delta_ns)
338 }
339
340 fn resolve_time_in_force(
341 &self,
342 tif_override: Option<TimeInForce>,
343 ) -> (TimeInForce, Option<UnixNanos>) {
344 match (tif_override, self.config.order_expire_time_delta_mins) {
345 (Some(TimeInForce::Gtd), Some(mins)) => {
346 (TimeInForce::Gtd, Some(self.expire_time_from_delta(mins)))
347 }
348 (Some(TimeInForce::Gtd), None) => {
349 log_warn!(
350 "GTD time in force requires order_expire_time_delta_mins, falling back to GTC"
351 );
352 (TimeInForce::Gtc, None)
353 }
354 (Some(tif), _) => (tif, None),
355 (None, Some(mins)) => (TimeInForce::Gtd, Some(self.expire_time_from_delta(mins))),
356 (None, None) => (TimeInForce::Gtc, None),
357 }
358 }
359
360 pub(super) fn is_order_active(&self, order: &OrderAny) -> bool {
361 order.is_active_local() || order.is_inflight() || order.is_open()
362 }
363
364 pub(super) fn get_order_trigger_price(&self, order: &OrderAny) -> Option<Price> {
365 order.trigger_price()
366 }
367
368 fn modify_stop_order(
369 &mut self,
370 order: &OrderAny,
371 trigger_price: Price,
372 limit_price: Option<Price>,
373 ) -> anyhow::Result<()> {
374 let client_id = self.config.client_id;
375
376 match order {
377 OrderAny::StopMarket(_)
378 | OrderAny::MarketIfTouched(_)
379 | OrderAny::TrailingStopMarket(_) => self.modify_order(
380 order.client_order_id(),
381 None,
382 None,
383 Some(trigger_price),
384 client_id,
385 None,
386 ),
387 OrderAny::StopLimit(_) | OrderAny::LimitIfTouched(_) => self.modify_order(
388 order.client_order_id(),
389 None,
390 limit_price,
391 Some(trigger_price),
392 client_id,
393 None,
394 ),
395 _ => {
396 log_warn!("Cannot modify order of type {:?}", order.order_type());
397 Ok(())
398 }
399 }
400 }
401
402 fn submit_order_apply_params(&mut self, order: OrderAny) -> anyhow::Result<()> {
404 let client_id = self.config.client_id;
405 if let Some(params) = &self.config.order_params {
406 self.submit_order(order, None, client_id, Some(params.clone()))
407 } else {
408 self.submit_order(order, None, client_id, None)
409 }
410 }
411
412 pub(super) fn maintain_orders(&mut self, best_bid: Price, best_ask: Price) {
414 if self.instrument.is_none() || self.config.dry_run {
415 return;
416 }
417
418 if self.config.batch_submit_limit_pair
419 && self.config.enable_limit_buys
420 && self.config.enable_limit_sells
421 {
422 self.maintain_batch_limit_pair(best_bid, best_ask);
423 return;
424 }
425
426 if self.config.enable_limit_buys {
427 self.maintain_buy_orders(best_bid, best_ask);
428 }
429
430 if self.config.enable_limit_sells {
431 self.maintain_sell_orders(best_bid, best_ask);
432 }
433
434 if self.config.enable_stop_buys {
435 self.maintain_stop_buy_orders(best_bid, best_ask);
436 }
437
438 if self.config.enable_stop_sells {
439 self.maintain_stop_sell_orders(best_bid, best_ask);
440 }
441 }
442
443 fn refresh_tracked_order(&mut self, side: OrderSide) {
447 let cid = match side {
448 OrderSide::Buy => self.buy_order.as_ref().map(|o| o.client_order_id()),
449 OrderSide::Sell => self.sell_order.as_ref().map(|o| o.client_order_id()),
450 _ => None,
451 };
452 let Some(cid) = cid else {
453 return;
454 };
455 let latest = self.cache().order(&cid).map(|o| o.clone());
456 if let Some(latest) = latest {
457 match side {
458 OrderSide::Buy => self.buy_order = Some(latest),
459 OrderSide::Sell => self.sell_order = Some(latest),
460 _ => {}
461 }
462 }
463 }
464
465 fn maintain_buy_orders(&mut self, best_bid: Price, best_ask: Price) {
467 self.refresh_tracked_order(OrderSide::Buy);
471
472 let Some(instrument) = &self.instrument else {
473 return;
474 };
475 let Some(price_offset_ticks) = self.price_offset else {
476 return;
477 };
478
479 let increment = instrument.price_increment();
480 let precision = instrument.price_precision();
481
482 let cross_spread = self.config.test_reject_post_only || self.config.limit_aggressive;
487 let price = if cross_spread {
488 add_price_ticks(best_ask, increment, price_offset_ticks, precision)
489 } else {
490 sub_price_ticks(best_bid, increment, price_offset_ticks, precision)
491 };
492
493 let needs_new_order = match &self.buy_order {
494 None => true,
495 Some(order) => !self.is_order_active(order),
496 };
497
498 if needs_new_order {
499 let result = if self.config.enable_brackets {
500 self.submit_bracket_order(OrderSide::Buy, price)
501 } else {
502 self.submit_limit_order(OrderSide::Buy, price)
503 };
504
505 if let Err(e) = result {
506 log::error!("Failed to submit buy order: {e}");
507 }
508 } else if let Some(order) = &self.buy_order
509 && order.venue_order_id().is_some()
510 && !order.is_pending_update()
511 && !order.is_pending_cancel()
512 {
513 let client_id = self.config.client_id;
514
515 if self.config.test_modify_rejected && !self.modify_rejected_attempted {
518 self.modify_rejected_attempted = true;
519 let order_clone = order.clone();
520 let bumped = add_price_ticks(price, increment, 1, precision);
521
522 if let Err(e) = self.modify_order(
523 order_clone.client_order_id(),
524 None,
525 Some(bumped),
526 None,
527 client_id,
528 None,
529 ) {
530 log::error!("Failed to submit test modify on buy order: {e}");
531 }
532 return;
533 }
534
535 if let Some(order_price) = order.price()
536 && order_price < price
537 {
538 if self.config.modify_orders_to_maintain_tob_offset {
539 let order_clone = order.clone();
540 if let Err(e) = self.modify_order(
541 order_clone.client_order_id(),
542 None,
543 Some(price),
544 None,
545 client_id,
546 None,
547 ) {
548 log::error!("Failed to modify buy order: {e}");
549 }
550 } else if self.config.cancel_replace_orders_to_maintain_tob_offset {
551 let order_clone = order.clone();
552 let _ = self.cancel_order(order_clone.client_order_id(), client_id, None);
553
554 if let Err(e) = self.submit_limit_order(OrderSide::Buy, price) {
555 log::error!("Failed to submit replacement buy order: {e}");
556 }
557 }
558 }
559 }
560 }
561
562 fn maintain_sell_orders(&mut self, best_bid: Price, best_ask: Price) {
564 self.refresh_tracked_order(OrderSide::Sell);
567
568 let Some(instrument) = &self.instrument else {
569 return;
570 };
571 let Some(price_offset_ticks) = self.price_offset else {
572 return;
573 };
574
575 let increment = instrument.price_increment();
576 let precision = instrument.price_precision();
577
578 let cross_spread = self.config.test_reject_post_only || self.config.limit_aggressive;
580 let price = if cross_spread {
581 sub_price_ticks(best_bid, increment, price_offset_ticks, precision)
582 } else {
583 add_price_ticks(best_ask, increment, price_offset_ticks, precision)
584 };
585
586 let needs_new_order = match &self.sell_order {
587 None => true,
588 Some(order) => !self.is_order_active(order),
589 };
590
591 if needs_new_order {
592 let result = if self.config.enable_brackets {
593 self.submit_bracket_order(OrderSide::Sell, price)
594 } else {
595 self.submit_limit_order(OrderSide::Sell, price)
596 };
597
598 if let Err(e) = result {
599 log::error!("Failed to submit sell order: {e}");
600 }
601 } else if let Some(order) = &self.sell_order
602 && order.venue_order_id().is_some()
603 && !order.is_pending_update()
604 && !order.is_pending_cancel()
605 {
606 let client_id = self.config.client_id;
607
608 if self.config.test_modify_rejected && !self.modify_rejected_attempted {
610 self.modify_rejected_attempted = true;
611 let order_clone = order.clone();
612 let bumped = sub_price_ticks(price, increment, 1, precision);
613
614 if let Err(e) = self.modify_order(
615 order_clone.client_order_id(),
616 None,
617 Some(bumped),
618 None,
619 client_id,
620 None,
621 ) {
622 log::error!("Failed to submit test modify on sell order: {e}");
623 }
624 return;
625 }
626
627 if let Some(order_price) = order.price()
628 && order_price > price
629 {
630 if self.config.modify_orders_to_maintain_tob_offset {
631 let order_clone = order.clone();
632 if let Err(e) = self.modify_order(
633 order_clone.client_order_id(),
634 None,
635 Some(price),
636 None,
637 client_id,
638 None,
639 ) {
640 log::error!("Failed to modify sell order: {e}");
641 }
642 } else if self.config.cancel_replace_orders_to_maintain_tob_offset {
643 let order_clone = order.clone();
644 let _ = self.cancel_order(order_clone.client_order_id(), client_id, None);
645
646 if let Err(e) = self.submit_limit_order(OrderSide::Sell, price) {
647 log::error!("Failed to submit replacement sell order: {e}");
648 }
649 }
650 }
651 }
652 }
653
654 fn maintain_batch_limit_pair(&mut self, best_bid: Price, best_ask: Price) {
656 self.refresh_tracked_order(OrderSide::Buy);
660 self.refresh_tracked_order(OrderSide::Sell);
661
662 let Some(instrument) = &self.instrument else {
663 return;
664 };
665 let Some(price_offset_ticks) = self.price_offset else {
666 return;
667 };
668
669 let buy_needs = match &self.buy_order {
670 None => true,
671 Some(order) => !self.is_order_active(order),
672 };
673 let sell_needs = match &self.sell_order {
674 None => true,
675 Some(order) => !self.is_order_active(order),
676 };
677
678 if !buy_needs || !sell_needs {
679 return;
680 }
681
682 let increment = instrument.price_increment();
683 let precision = instrument.price_precision();
684
685 let cross_spread = self.config.test_reject_post_only || self.config.limit_aggressive;
689 let (buy_price, sell_price) = if cross_spread {
690 (
691 add_price_ticks(best_ask, increment, price_offset_ticks, precision),
692 sub_price_ticks(best_bid, increment, price_offset_ticks, precision),
693 )
694 } else {
695 (
696 sub_price_ticks(best_bid, increment, price_offset_ticks, precision),
697 add_price_ticks(best_ask, increment, price_offset_ticks, precision),
698 )
699 };
700 let quantity = instrument.make_qty(self.config.order_qty.as_f64(), None);
701 let (time_in_force, expire_time) =
702 self.resolve_time_in_force(self.config.limit_time_in_force);
703
704 let buy_order = self.core.order_factory().limit(
705 self.config.instrument_id,
706 OrderSide::Buy,
707 quantity,
708 buy_price,
709 Some(time_in_force),
710 expire_time,
711 Some(self.config.use_post_only || self.config.test_reject_post_only),
712 None,
713 Some(self.config.use_quote_quantity),
714 self.config.order_display_qty,
715 self.config.emulation_trigger,
716 None,
717 None,
718 None,
719 None,
720 None,
721 );
722
723 let sell_order = self.core.order_factory().limit(
724 self.config.instrument_id,
725 OrderSide::Sell,
726 quantity,
727 sell_price,
728 Some(time_in_force),
729 expire_time,
730 Some(self.config.use_post_only || self.config.test_reject_post_only),
731 None,
732 Some(self.config.use_quote_quantity),
733 self.config.order_display_qty,
734 self.config.emulation_trigger,
735 None,
736 None,
737 None,
738 None,
739 None,
740 );
741
742 self.buy_order = Some(buy_order.clone());
743 self.sell_order = Some(sell_order.clone());
744
745 let client_id = self.config.client_id;
746 if let Err(e) = self.submit_order_list(vec![buy_order, sell_order], None, client_id, None) {
747 log::error!("Failed to submit batch limit pair: {e}");
748 }
749 }
750
751 fn maintain_stop_buy_orders(&mut self, best_bid: Price, best_ask: Price) {
753 let Some(instrument) = &self.instrument else {
754 return;
755 };
756
757 let increment = instrument.price_increment();
758 let precision = instrument.price_precision();
759 let stop_offset_ticks = self.config.stop_offset_ticks;
760
761 let trigger_price = if matches!(
763 self.config.stop_order_type,
764 OrderType::LimitIfTouched | OrderType::MarketIfTouched | OrderType::TrailingStopMarket
765 ) {
766 sub_price_ticks(best_bid, increment, stop_offset_ticks, precision)
768 } else {
769 add_price_ticks(best_ask, increment, stop_offset_ticks, precision)
771 };
772
773 let limit_price = if matches!(
775 self.config.stop_order_type,
776 OrderType::StopLimit | OrderType::LimitIfTouched
777 ) {
778 if let Some(limit_offset_ticks) = self.config.stop_limit_offset_ticks {
779 if self.config.stop_order_type == OrderType::LimitIfTouched {
780 Some(add_price_ticks(
782 trigger_price,
783 increment,
784 limit_offset_ticks,
785 precision,
786 ))
787 } else {
788 Some(add_price_ticks(
790 trigger_price,
791 increment,
792 limit_offset_ticks,
793 precision,
794 ))
795 }
796 } else {
797 Some(trigger_price)
798 }
799 } else {
800 None
801 };
802
803 let needs_new_order = match &self.buy_stop_order {
804 None => true,
805 Some(order) => !self.is_order_active(order),
806 };
807
808 if needs_new_order {
809 if let Err(e) = self.submit_stop_order(OrderSide::Buy, trigger_price, limit_price) {
810 log::error!("Failed to submit buy stop order: {e}");
811 }
812 } else if let Some(order) = &self.buy_stop_order
813 && order.venue_order_id().is_some()
814 && !order.is_pending_update()
815 && !order.is_pending_cancel()
816 {
817 let current_trigger = self.get_order_trigger_price(order);
818 if current_trigger.is_some() && current_trigger != Some(trigger_price) {
819 if self.config.modify_stop_orders_to_maintain_offset {
820 let order_clone = order.clone();
821 if let Err(e) = self.modify_stop_order(&order_clone, trigger_price, limit_price)
822 {
823 log::error!("Failed to modify buy stop order: {e}");
824 }
825 } else if self.config.cancel_replace_stop_orders_to_maintain_offset {
826 let order_clone = order.clone();
827 let _ = self.cancel_order(
828 order_clone.client_order_id(),
829 self.config.client_id,
830 None,
831 );
832
833 if let Err(e) =
834 self.submit_stop_order(OrderSide::Buy, trigger_price, limit_price)
835 {
836 log::error!("Failed to submit replacement buy stop order: {e}");
837 }
838 }
839 }
840 }
841 }
842
843 fn maintain_stop_sell_orders(&mut self, best_bid: Price, best_ask: Price) {
845 let Some(instrument) = &self.instrument else {
846 return;
847 };
848
849 let increment = instrument.price_increment();
850 let precision = instrument.price_precision();
851 let stop_offset_ticks = self.config.stop_offset_ticks;
852
853 let trigger_price = if matches!(
855 self.config.stop_order_type,
856 OrderType::LimitIfTouched | OrderType::MarketIfTouched | OrderType::TrailingStopMarket
857 ) {
858 add_price_ticks(best_ask, increment, stop_offset_ticks, precision)
860 } else {
861 sub_price_ticks(best_bid, increment, stop_offset_ticks, precision)
863 };
864
865 let limit_price = if matches!(
867 self.config.stop_order_type,
868 OrderType::StopLimit | OrderType::LimitIfTouched
869 ) {
870 if let Some(limit_offset_ticks) = self.config.stop_limit_offset_ticks {
871 if self.config.stop_order_type == OrderType::LimitIfTouched {
872 Some(sub_price_ticks(
874 trigger_price,
875 increment,
876 limit_offset_ticks,
877 precision,
878 ))
879 } else {
880 Some(sub_price_ticks(
882 trigger_price,
883 increment,
884 limit_offset_ticks,
885 precision,
886 ))
887 }
888 } else {
889 Some(trigger_price)
890 }
891 } else {
892 None
893 };
894
895 let needs_new_order = match &self.sell_stop_order {
896 None => true,
897 Some(order) => !self.is_order_active(order),
898 };
899
900 if needs_new_order {
901 if let Err(e) = self.submit_stop_order(OrderSide::Sell, trigger_price, limit_price) {
902 log::error!("Failed to submit sell stop order: {e}");
903 }
904 } else if let Some(order) = &self.sell_stop_order
905 && order.venue_order_id().is_some()
906 && !order.is_pending_update()
907 && !order.is_pending_cancel()
908 {
909 let current_trigger = self.get_order_trigger_price(order);
910 if current_trigger.is_some() && current_trigger != Some(trigger_price) {
911 if self.config.modify_stop_orders_to_maintain_offset {
912 let order_clone = order.clone();
913 if let Err(e) = self.modify_stop_order(&order_clone, trigger_price, limit_price)
914 {
915 log::error!("Failed to modify sell stop order: {e}");
916 }
917 } else if self.config.cancel_replace_stop_orders_to_maintain_offset {
918 let order_clone = order.clone();
919 let _ = self.cancel_order(
920 order_clone.client_order_id(),
921 self.config.client_id,
922 None,
923 );
924
925 if let Err(e) =
926 self.submit_stop_order(OrderSide::Sell, trigger_price, limit_price)
927 {
928 log::error!("Failed to submit replacement sell stop order: {e}");
929 }
930 }
931 }
932 }
933 }
934
935 pub(super) fn submit_limit_order(
941 &mut self,
942 order_side: OrderSide,
943 price: Price,
944 ) -> anyhow::Result<()> {
945 let Some(instrument) = &self.instrument else {
946 anyhow::bail!("No instrument loaded");
947 };
948
949 if self.config.dry_run {
950 log_warn!("Dry run, skipping create {order_side:?} order");
951 return Ok(());
952 }
953
954 if order_side == OrderSide::Buy && !self.config.enable_limit_buys {
955 log_warn!("BUY orders not enabled, skipping");
956 return Ok(());
957 } else if order_side == OrderSide::Sell && !self.config.enable_limit_sells {
958 log_warn!("SELL orders not enabled, skipping");
959 return Ok(());
960 }
961
962 let (time_in_force, expire_time) =
963 self.resolve_time_in_force(self.config.limit_time_in_force);
964
965 let quantity = instrument.make_qty(self.config.order_qty.as_f64(), None);
966
967 let order = self.core.order_factory().limit(
968 self.config.instrument_id,
969 order_side,
970 quantity,
971 price,
972 Some(time_in_force),
973 expire_time,
974 Some(self.config.use_post_only || self.config.test_reject_post_only),
975 None, Some(self.config.use_quote_quantity),
977 self.config.order_display_qty,
978 self.config.emulation_trigger,
979 None, None, None, None, None, );
985
986 if order_side == OrderSide::Buy {
987 self.buy_order = Some(order.clone());
988 } else {
989 self.sell_order = Some(order.clone());
990 }
991
992 self.submit_order_apply_params(order)
993 }
994
995 pub(super) fn submit_stop_order(
1001 &mut self,
1002 order_side: OrderSide,
1003 trigger_price: Price,
1004 limit_price: Option<Price>,
1005 ) -> anyhow::Result<()> {
1006 let Some(instrument) = &self.instrument else {
1007 anyhow::bail!("No instrument loaded");
1008 };
1009
1010 if self.config.dry_run {
1011 log_warn!("Dry run, skipping create {order_side:?} stop order");
1012 return Ok(());
1013 }
1014
1015 if order_side == OrderSide::Buy && !self.config.enable_stop_buys {
1016 log_warn!("BUY stop orders not enabled, skipping");
1017 return Ok(());
1018 } else if order_side == OrderSide::Sell && !self.config.enable_stop_sells {
1019 log_warn!("SELL stop orders not enabled, skipping");
1020 return Ok(());
1021 }
1022
1023 let (time_in_force, expire_time) =
1024 self.resolve_time_in_force(self.config.stop_time_in_force);
1025
1026 let quantity = instrument.make_qty(self.config.order_qty.as_f64(), None);
1028
1029 let factory = self.core.order_factory();
1030
1031 let mut order: OrderAny = match self.config.stop_order_type {
1032 OrderType::StopMarket => factory.stop_market(
1033 self.config.instrument_id,
1034 order_side,
1035 quantity,
1036 trigger_price,
1037 Some(self.config.stop_trigger_type),
1038 Some(time_in_force),
1039 expire_time,
1040 None, Some(self.config.use_quote_quantity),
1042 None, self.config.emulation_trigger,
1044 None, None, None, None, None, ),
1050 OrderType::StopLimit => {
1051 let Some(limit_price) = limit_price else {
1052 anyhow::bail!("STOP_LIMIT order requires limit_price");
1053 };
1054 factory.stop_limit(
1055 self.config.instrument_id,
1056 order_side,
1057 quantity,
1058 limit_price,
1059 trigger_price,
1060 Some(self.config.stop_trigger_type),
1061 Some(time_in_force),
1062 expire_time,
1063 None, None, Some(self.config.use_quote_quantity),
1066 self.config.order_display_qty,
1067 self.config.emulation_trigger,
1068 None, None, None, None, None, )
1074 }
1075 OrderType::MarketIfTouched => factory.market_if_touched(
1076 self.config.instrument_id,
1077 order_side,
1078 quantity,
1079 trigger_price,
1080 Some(self.config.stop_trigger_type),
1081 Some(time_in_force),
1082 expire_time,
1083 None, Some(self.config.use_quote_quantity),
1085 self.config.emulation_trigger,
1086 None, None, None, None, None, ),
1092 OrderType::LimitIfTouched => {
1093 let Some(limit_price) = limit_price else {
1094 anyhow::bail!("LIMIT_IF_TOUCHED order requires limit_price");
1095 };
1096 factory.limit_if_touched(
1097 self.config.instrument_id,
1098 order_side,
1099 quantity,
1100 limit_price,
1101 trigger_price,
1102 Some(self.config.stop_trigger_type),
1103 Some(time_in_force),
1104 expire_time,
1105 None, None, Some(self.config.use_quote_quantity),
1108 self.config.order_display_qty,
1109 self.config.emulation_trigger,
1110 None, None, None, None, None, )
1116 }
1117 OrderType::TrailingStopMarket => {
1118 let Some(trailing_offset) = self.config.trailing_offset else {
1119 anyhow::bail!("TRAILING_STOP_MARKET order requires trailing_offset config");
1120 };
1121 factory.trailing_stop_market(
1122 self.config.instrument_id,
1123 order_side,
1124 quantity,
1125 trailing_offset,
1126 Some(self.config.trailing_offset_type),
1127 None,
1128 Some(trigger_price),
1129 Some(self.config.stop_trigger_type),
1130 Some(time_in_force),
1131 expire_time,
1132 None, Some(self.config.use_quote_quantity),
1134 None, self.config.emulation_trigger,
1136 None, None, None, None, None, )
1142 }
1143 _ => {
1144 anyhow::bail!("Unknown stop order type: {:?}", self.config.stop_order_type);
1145 }
1146 };
1147
1148 if let OrderAny::TrailingStopMarket(order) = &mut order {
1149 order.activation_price = Some(trigger_price);
1150 }
1151
1152 if order_side == OrderSide::Buy {
1153 self.buy_stop_order = Some(order.clone());
1154 } else {
1155 self.sell_stop_order = Some(order.clone());
1156 }
1157
1158 self.submit_order_apply_params(order)
1159 }
1160
1161 pub(super) fn submit_bracket_order(
1167 &mut self,
1168 order_side: OrderSide,
1169 entry_price: Price,
1170 ) -> anyhow::Result<()> {
1171 let Some(instrument) = &self.instrument else {
1172 anyhow::bail!("No instrument loaded");
1173 };
1174
1175 if self.config.dry_run {
1176 log_warn!("Dry run, skipping create {order_side:?} bracket order");
1177 return Ok(());
1178 }
1179
1180 if self.config.bracket_entry_order_type != OrderType::Limit {
1181 anyhow::bail!(
1182 "Only Limit entry orders are supported for brackets, was {:?}",
1183 self.config.bracket_entry_order_type
1184 );
1185 }
1186
1187 if order_side == OrderSide::Buy && !self.config.enable_limit_buys {
1188 log_warn!("BUY orders not enabled, skipping bracket");
1189 return Ok(());
1190 } else if order_side == OrderSide::Sell && !self.config.enable_limit_sells {
1191 log_warn!("SELL orders not enabled, skipping bracket");
1192 return Ok(());
1193 }
1194
1195 let (time_in_force, expire_time) =
1196 self.resolve_time_in_force(self.config.limit_time_in_force);
1197 let sl_time_in_force = self.config.stop_time_in_force.unwrap_or(TimeInForce::Gtc);
1198 if sl_time_in_force == TimeInForce::Gtd {
1199 anyhow::bail!("GTD time in force not supported for bracket stop-loss legs");
1200 }
1201
1202 let quantity = instrument.make_qty(self.config.order_qty.as_f64(), None);
1203 let increment = instrument.price_increment();
1204 let precision = instrument.price_precision();
1205 let bracket_offset_ticks = self.config.bracket_offset_ticks;
1206
1207 let (tp_price, sl_trigger_price) = match order_side {
1208 OrderSide::Buy => {
1209 let tp = add_price_ticks(entry_price, increment, bracket_offset_ticks, precision);
1210 let sl = sub_price_ticks(entry_price, increment, bracket_offset_ticks, precision);
1211 (tp, sl)
1212 }
1213 OrderSide::Sell => {
1214 let tp = sub_price_ticks(entry_price, increment, bracket_offset_ticks, precision);
1215 let sl = add_price_ticks(entry_price, increment, bracket_offset_ticks, precision);
1216 (tp, sl)
1217 }
1218 _ => anyhow::bail!("Invalid order side for bracket: {order_side:?}"),
1219 };
1220
1221 let entry_post_only = self.config.use_post_only || self.config.test_reject_post_only;
1222 let orders = self
1223 .core
1224 .order_factory()
1225 .bracket()
1226 .instrument_id(self.config.instrument_id)
1227 .order_side(order_side)
1228 .quantity(quantity)
1229 .quote_quantity(self.config.use_quote_quantity)
1230 .entry_order_type(OrderType::Limit)
1231 .entry_price(entry_price)
1232 .time_in_force(time_in_force)
1233 .entry_post_only(entry_post_only)
1234 .maybe_emulation_trigger(self.config.emulation_trigger)
1235 .maybe_expire_time(expire_time)
1236 .tp_price(tp_price)
1237 .tp_post_only(entry_post_only)
1238 .tp_time_in_force(time_in_force)
1239 .sl_trigger_price(sl_trigger_price)
1240 .sl_trigger_type(self.config.stop_trigger_type)
1241 .sl_time_in_force(sl_time_in_force)
1242 .call();
1243
1244 if let Some(entry_order) = orders.first() {
1245 if order_side == OrderSide::Buy {
1246 self.buy_order = Some(entry_order.clone());
1247 } else {
1248 self.sell_order = Some(entry_order.clone());
1249 }
1250 }
1251
1252 let client_id = self.config.client_id;
1253 if let Some(params) = &self.config.order_params {
1254 self.submit_order_list(orders, None, client_id, Some(params.clone()))
1255 } else {
1256 self.submit_order_list(orders, None, client_id, None)
1257 }
1258 }
1259
1260 pub(super) fn open_position(&mut self, net_qty: Decimal) -> anyhow::Result<()> {
1266 let Some(instrument) = &self.instrument else {
1267 anyhow::bail!("No instrument loaded");
1268 };
1269
1270 if net_qty == Decimal::ZERO {
1271 log_warn!("Open position with zero quantity, skipping");
1272 return Ok(());
1273 }
1274
1275 let order_side = if net_qty > Decimal::ZERO {
1276 OrderSide::Buy
1277 } else {
1278 OrderSide::Sell
1279 };
1280
1281 let quantity = instrument.make_qty(net_qty.abs().to_f64().unwrap_or(0.0), None);
1282
1283 let reduce_only = if self.config.test_reject_reduce_only {
1285 Some(true)
1286 } else {
1287 None
1288 };
1289
1290 let order = self.core.order_factory().market(
1291 self.config.instrument_id,
1292 order_side,
1293 quantity,
1294 Some(self.config.open_position_time_in_force),
1295 reduce_only,
1296 Some(self.config.use_quote_quantity),
1297 None, None, None, None, );
1302
1303 self.submit_order_apply_params(order)
1304 }
1305}
1306
1307fn add_price_ticks(base: Price, increment: Price, ticks: u64, precision: u8) -> Price {
1308 let offset_raw = increment.raw * ticks as PriceRaw;
1309 Price::from_raw(base.raw + offset_raw, precision)
1310}
1311
1312fn sub_price_ticks(base: Price, increment: Price, ticks: u64, precision: u8) -> Price {
1313 let offset_raw = increment.raw * ticks as PriceRaw;
1314 Price::from_raw(base.raw - offset_raw, precision)
1315}