1use std::{
19 num::NonZeroUsize,
20 ops::{Deref, DerefMut},
21};
22
23use nautilus_common::{
24 actor::{DataActor, DataActorCore},
25 enums::LogColor,
26 log_info, log_warn,
27 timer::TimeEvent,
28};
29use nautilus_model::{
30 data::{Bar, IndexPriceUpdate, MarkPriceUpdate, OrderBookDeltas, QuoteTick, TradeTick},
31 enums::{BookType, OrderSide, OrderStatus, OrderType, TimeInForce, TriggerType},
32 identifiers::{ClientId, InstrumentId, StrategyId},
33 instruments::{Instrument, InstrumentAny},
34 orderbook::OrderBook,
35 orders::{Order, OrderAny},
36 types::{Price, Quantity},
37};
38use nautilus_trading::strategy::{Strategy, StrategyConfig, StrategyCore};
39use rust_decimal::{Decimal, prelude::ToPrimitive};
40
41#[derive(Debug, Clone)]
43pub struct ExecTesterConfig {
44 pub base: StrategyConfig,
46 pub instrument_id: InstrumentId,
48 pub client_id: Option<ClientId>,
50 pub order_qty: Quantity,
52 pub order_display_qty: Option<Quantity>,
54 pub order_expire_time_delta_mins: Option<u64>,
56 pub subscribe_quotes: bool,
58 pub subscribe_trades: bool,
60 pub subscribe_book: bool,
62 pub book_type: BookType,
64 pub book_depth: Option<NonZeroUsize>,
66 pub book_interval_ms: NonZeroUsize,
68 pub book_levels_to_print: usize,
70 pub open_position_on_start_qty: Option<Decimal>,
72 pub open_position_time_in_force: TimeInForce,
74 pub enable_limit_buys: bool,
76 pub enable_limit_sells: bool,
78 pub tob_offset_ticks: u64,
80 pub enable_stop_buys: bool,
82 pub enable_stop_sells: bool,
84 pub stop_order_type: OrderType,
86 pub stop_offset_ticks: u64,
88 pub stop_limit_offset_ticks: Option<u64>,
90 pub stop_trigger_type: TriggerType,
92 pub modify_orders_to_maintain_tob_offset: bool,
94 pub modify_stop_orders_to_maintain_offset: bool,
96 pub cancel_replace_orders_to_maintain_tob_offset: bool,
98 pub cancel_replace_stop_orders_to_maintain_offset: bool,
100 pub use_post_only: bool,
102 pub cancel_orders_on_stop: bool,
104 pub close_positions_on_stop: bool,
106 pub close_positions_time_in_force: Option<TimeInForce>,
108 pub reduce_only_on_stop: bool,
110 pub use_individual_cancels_on_stop: bool,
112 pub use_batch_cancel_on_stop: bool,
114 pub dry_run: bool,
116 pub log_data: bool,
118 pub can_unsubscribe: bool,
120}
121
122impl ExecTesterConfig {
123 #[must_use]
129 pub fn new(
130 strategy_id: StrategyId,
131 instrument_id: InstrumentId,
132 client_id: ClientId,
133 order_qty: Quantity,
134 ) -> Self {
135 Self {
136 base: StrategyConfig {
137 strategy_id: Some(strategy_id),
138 order_id_tag: None,
139 ..Default::default()
140 },
141 instrument_id,
142 client_id: Some(client_id),
143 order_qty,
144 order_display_qty: None,
145 order_expire_time_delta_mins: None,
146 subscribe_quotes: true,
147 subscribe_trades: true,
148 subscribe_book: false,
149 book_type: BookType::L2_MBP,
150 book_depth: None,
151 book_interval_ms: NonZeroUsize::new(1000).unwrap(),
152 book_levels_to_print: 10,
153 open_position_on_start_qty: None,
154 open_position_time_in_force: TimeInForce::Gtc,
155 enable_limit_buys: true,
156 enable_limit_sells: true,
157 tob_offset_ticks: 500,
158 enable_stop_buys: false,
159 enable_stop_sells: false,
160 stop_order_type: OrderType::StopMarket,
161 stop_offset_ticks: 100,
162 stop_limit_offset_ticks: None,
163 stop_trigger_type: TriggerType::Default,
164 modify_orders_to_maintain_tob_offset: false,
165 modify_stop_orders_to_maintain_offset: false,
166 cancel_replace_orders_to_maintain_tob_offset: false,
167 cancel_replace_stop_orders_to_maintain_offset: false,
168 use_post_only: false,
169 cancel_orders_on_stop: true,
170 close_positions_on_stop: true,
171 close_positions_time_in_force: None,
172 reduce_only_on_stop: true,
173 use_individual_cancels_on_stop: false,
174 use_batch_cancel_on_stop: false,
175 dry_run: false,
176 log_data: true,
177 can_unsubscribe: true,
178 }
179 }
180
181 #[must_use]
182 pub fn with_log_data(mut self, log_data: bool) -> Self {
183 self.log_data = log_data;
184 self
185 }
186
187 #[must_use]
188 pub fn with_dry_run(mut self, dry_run: bool) -> Self {
189 self.dry_run = dry_run;
190 self
191 }
192
193 #[must_use]
194 pub fn with_subscribe_quotes(mut self, subscribe: bool) -> Self {
195 self.subscribe_quotes = subscribe;
196 self
197 }
198
199 #[must_use]
200 pub fn with_subscribe_trades(mut self, subscribe: bool) -> Self {
201 self.subscribe_trades = subscribe;
202 self
203 }
204
205 #[must_use]
206 pub fn with_subscribe_book(mut self, subscribe: bool) -> Self {
207 self.subscribe_book = subscribe;
208 self
209 }
210
211 #[must_use]
212 pub fn with_book_type(mut self, book_type: BookType) -> Self {
213 self.book_type = book_type;
214 self
215 }
216
217 #[must_use]
218 pub fn with_book_depth(mut self, depth: Option<NonZeroUsize>) -> Self {
219 self.book_depth = depth;
220 self
221 }
222
223 #[must_use]
224 pub fn with_enable_limit_buys(mut self, enable: bool) -> Self {
225 self.enable_limit_buys = enable;
226 self
227 }
228
229 #[must_use]
230 pub fn with_enable_limit_sells(mut self, enable: bool) -> Self {
231 self.enable_limit_sells = enable;
232 self
233 }
234
235 #[must_use]
236 pub fn with_enable_stop_buys(mut self, enable: bool) -> Self {
237 self.enable_stop_buys = enable;
238 self
239 }
240
241 #[must_use]
242 pub fn with_enable_stop_sells(mut self, enable: bool) -> Self {
243 self.enable_stop_sells = enable;
244 self
245 }
246
247 #[must_use]
248 pub fn with_tob_offset_ticks(mut self, ticks: u64) -> Self {
249 self.tob_offset_ticks = ticks;
250 self
251 }
252
253 #[must_use]
254 pub fn with_stop_order_type(mut self, order_type: OrderType) -> Self {
255 self.stop_order_type = order_type;
256 self
257 }
258
259 #[must_use]
260 pub fn with_stop_offset_ticks(mut self, ticks: u64) -> Self {
261 self.stop_offset_ticks = ticks;
262 self
263 }
264
265 #[must_use]
266 pub fn with_use_post_only(mut self, use_post_only: bool) -> Self {
267 self.use_post_only = use_post_only;
268 self
269 }
270
271 #[must_use]
272 pub fn with_open_position_on_start(mut self, qty: Option<Decimal>) -> Self {
273 self.open_position_on_start_qty = qty;
274 self
275 }
276
277 #[must_use]
278 pub fn with_cancel_orders_on_stop(mut self, cancel: bool) -> Self {
279 self.cancel_orders_on_stop = cancel;
280 self
281 }
282
283 #[must_use]
284 pub fn with_close_positions_on_stop(mut self, close: bool) -> Self {
285 self.close_positions_on_stop = close;
286 self
287 }
288
289 #[must_use]
290 pub fn with_close_positions_time_in_force(
291 mut self,
292 time_in_force: Option<TimeInForce>,
293 ) -> Self {
294 self.close_positions_time_in_force = time_in_force;
295 self
296 }
297
298 #[must_use]
299 pub fn with_use_batch_cancel_on_stop(mut self, use_batch: bool) -> Self {
300 self.use_batch_cancel_on_stop = use_batch;
301 self
302 }
303
304 #[must_use]
305 pub fn with_can_unsubscribe(mut self, can_unsubscribe: bool) -> Self {
306 self.can_unsubscribe = can_unsubscribe;
307 self
308 }
309}
310
311impl Default for ExecTesterConfig {
312 fn default() -> Self {
313 Self {
314 base: StrategyConfig::default(),
315 instrument_id: InstrumentId::from("BTCUSDT-PERP.BINANCE"),
316 client_id: None,
317 order_qty: Quantity::from("0.001"),
318 order_display_qty: None,
319 order_expire_time_delta_mins: None,
320 subscribe_quotes: true,
321 subscribe_trades: true,
322 subscribe_book: false,
323 book_type: BookType::L2_MBP,
324 book_depth: None,
325 book_interval_ms: NonZeroUsize::new(1000).unwrap(),
326 book_levels_to_print: 10,
327 open_position_on_start_qty: None,
328 open_position_time_in_force: TimeInForce::Gtc,
329 enable_limit_buys: false,
330 enable_limit_sells: false,
331 tob_offset_ticks: 500,
332 enable_stop_buys: false,
333 enable_stop_sells: false,
334 stop_order_type: OrderType::StopMarket,
335 stop_offset_ticks: 100,
336 stop_limit_offset_ticks: None,
337 stop_trigger_type: TriggerType::Default,
338 modify_orders_to_maintain_tob_offset: false,
339 modify_stop_orders_to_maintain_offset: false,
340 cancel_replace_orders_to_maintain_tob_offset: false,
341 cancel_replace_stop_orders_to_maintain_offset: false,
342 use_post_only: false,
343 cancel_orders_on_stop: true,
344 close_positions_on_stop: true,
345 close_positions_time_in_force: None,
346 reduce_only_on_stop: true,
347 use_individual_cancels_on_stop: false,
348 use_batch_cancel_on_stop: false,
349 dry_run: false,
350 log_data: true,
351 can_unsubscribe: true,
352 }
353 }
354}
355
356#[derive(Debug)]
365pub struct ExecTester {
366 core: StrategyCore,
367 config: ExecTesterConfig,
368 instrument: Option<InstrumentAny>,
369 price_offset: Option<f64>,
370
371 buy_order: Option<OrderAny>,
373 sell_order: Option<OrderAny>,
374 buy_stop_order: Option<OrderAny>,
375 sell_stop_order: Option<OrderAny>,
376}
377
378impl Deref for ExecTester {
379 type Target = DataActorCore;
380
381 fn deref(&self) -> &Self::Target {
382 &self.core.actor
383 }
384}
385
386impl DerefMut for ExecTester {
387 fn deref_mut(&mut self) -> &mut Self::Target {
388 &mut self.core.actor
389 }
390}
391
392impl DataActor for ExecTester {
393 fn on_start(&mut self) -> anyhow::Result<()> {
394 Strategy::on_start(self)?;
395
396 let instrument_id = self.config.instrument_id;
397 let client_id = self.config.client_id;
398
399 let instrument = {
400 let cache = self.cache();
401 cache.instrument(&instrument_id).cloned()
402 };
403
404 if let Some(inst) = instrument {
405 self.initialize_with_instrument(inst)?;
406 } else {
407 log::info!("Instrument {instrument_id} not in cache, subscribing...");
408 self.subscribe_instrument(instrument_id, client_id, None);
409 }
410
411 Ok(())
412 }
413
414 fn on_instrument(&mut self, instrument: &InstrumentAny) -> anyhow::Result<()> {
415 if instrument.id() == self.config.instrument_id && self.instrument.is_none() {
416 let id = instrument.id();
417 log::info!("Received instrument {id}, initializing...");
418 self.initialize_with_instrument(instrument.clone())?;
419 }
420 Ok(())
421 }
422
423 fn on_stop(&mut self) -> anyhow::Result<()> {
424 if self.config.dry_run {
425 log_warn!("Dry run mode, skipping cancel all orders and close all positions");
426 return Ok(());
427 }
428
429 let instrument_id = self.config.instrument_id;
430 let client_id = self.config.client_id;
431
432 if self.config.cancel_orders_on_stop {
433 let strategy_id = StrategyId::from(self.core.actor.actor_id.inner().as_str());
434 if self.config.use_individual_cancels_on_stop {
435 let cache = self.cache();
436 let open_orders: Vec<OrderAny> = cache
437 .orders_open(None, Some(&instrument_id), Some(&strategy_id), None)
438 .iter()
439 .map(|o| (*o).clone())
440 .collect();
441 drop(cache);
442
443 for order in open_orders {
444 if let Err(e) = self.cancel_order(order, client_id) {
445 log::error!("Failed to cancel order: {e}");
446 }
447 }
448 } else if self.config.use_batch_cancel_on_stop {
449 let cache = self.cache();
450 let open_orders: Vec<OrderAny> = cache
451 .orders_open(None, Some(&instrument_id), Some(&strategy_id), None)
452 .iter()
453 .map(|o| (*o).clone())
454 .collect();
455 drop(cache);
456
457 if let Err(e) = self.cancel_orders(open_orders, client_id, None) {
458 log::error!("Failed to batch cancel orders: {e}");
459 }
460 } else if let Err(e) = self.cancel_all_orders(instrument_id, None, client_id) {
461 log::error!("Failed to cancel all orders: {e}");
462 }
463 }
464
465 if self.config.close_positions_on_stop {
466 let time_in_force = self
467 .config
468 .close_positions_time_in_force
469 .or(Some(TimeInForce::Gtc));
470 if let Err(e) = self.close_all_positions(
471 instrument_id,
472 None,
473 client_id,
474 None,
475 time_in_force,
476 Some(self.config.reduce_only_on_stop),
477 None,
478 ) {
479 log::error!("Failed to close all positions: {e}");
480 }
481 }
482
483 if self.config.can_unsubscribe && self.instrument.is_some() {
484 if self.config.subscribe_quotes {
485 self.unsubscribe_quotes(instrument_id, client_id, None);
486 }
487
488 if self.config.subscribe_trades {
489 self.unsubscribe_trades(instrument_id, client_id, None);
490 }
491
492 if self.config.subscribe_book {
493 self.unsubscribe_book_at_interval(
494 instrument_id,
495 self.config.book_interval_ms,
496 client_id,
497 None,
498 );
499 }
500 }
501
502 Ok(())
503 }
504
505 fn on_quote(&mut self, quote: &QuoteTick) -> anyhow::Result<()> {
506 if self.config.log_data {
507 log_info!("Received {quote:?}", color = LogColor::Cyan);
508 }
509
510 self.maintain_orders(quote.bid_price, quote.ask_price);
511 Ok(())
512 }
513
514 fn on_trade(&mut self, trade: &TradeTick) -> anyhow::Result<()> {
515 if self.config.log_data {
516 log_info!("Received {trade:?}", color = LogColor::Cyan);
517 }
518 Ok(())
519 }
520
521 fn on_book(&mut self, book: &OrderBook) -> anyhow::Result<()> {
522 if self.config.log_data {
523 let num_levels = self.config.book_levels_to_print;
524 let instrument_id = book.instrument_id;
525 let book_str = book.pprint(num_levels, None);
526 log_info!("\n{instrument_id}\n{book_str}", color = LogColor::Cyan);
527
528 if self.is_registered() {
530 let cache = self.cache();
531 if let Some(own_book) = cache.own_order_book(&instrument_id) {
532 let own_book_str = own_book.pprint(num_levels, None);
533 log_info!(
534 "\n{instrument_id} (own)\n{own_book_str}",
535 color = LogColor::Magenta
536 );
537 }
538 }
539 }
540
541 let Some(best_bid) = book.best_bid_price() else {
542 return Ok(()); };
544 let Some(best_ask) = book.best_ask_price() else {
545 return Ok(()); };
547
548 self.maintain_orders(best_bid, best_ask);
549 Ok(())
550 }
551
552 fn on_book_deltas(&mut self, deltas: &OrderBookDeltas) -> anyhow::Result<()> {
553 if self.config.log_data {
554 log_info!("Received {deltas:?}", color = LogColor::Cyan);
555 }
556 Ok(())
557 }
558
559 fn on_bar(&mut self, bar: &Bar) -> anyhow::Result<()> {
560 if self.config.log_data {
561 log_info!("Received {bar:?}", color = LogColor::Cyan);
562 }
563 Ok(())
564 }
565
566 fn on_mark_price(&mut self, mark_price: &MarkPriceUpdate) -> anyhow::Result<()> {
567 if self.config.log_data {
568 log_info!("Received {mark_price:?}", color = LogColor::Cyan);
569 }
570 Ok(())
571 }
572
573 fn on_index_price(&mut self, index_price: &IndexPriceUpdate) -> anyhow::Result<()> {
574 if self.config.log_data {
575 log_info!("Received {index_price:?}", color = LogColor::Cyan);
576 }
577 Ok(())
578 }
579
580 fn on_time_event(&mut self, event: &TimeEvent) -> anyhow::Result<()> {
581 Strategy::on_time_event(self, event)
582 }
583}
584
585impl Strategy for ExecTester {
586 fn core_mut(&mut self) -> &mut StrategyCore {
587 &mut self.core
588 }
589}
590
591impl ExecTester {
592 #[must_use]
594 pub fn new(config: ExecTesterConfig) -> Self {
595 Self {
596 core: StrategyCore::new(config.base.clone()),
597 config,
598 instrument: None,
599 price_offset: None,
600 buy_order: None,
601 sell_order: None,
602 buy_stop_order: None,
603 sell_stop_order: None,
604 }
605 }
606
607 fn initialize_with_instrument(&mut self, instrument: InstrumentAny) -> anyhow::Result<()> {
608 let instrument_id = self.config.instrument_id;
609 let client_id = self.config.client_id;
610
611 self.price_offset = Some(self.get_price_offset(&instrument));
612 self.instrument = Some(instrument);
613
614 if self.config.subscribe_quotes {
615 self.subscribe_quotes(instrument_id, client_id, None);
616 }
617
618 if self.config.subscribe_trades {
619 self.subscribe_trades(instrument_id, client_id, None);
620 }
621
622 if self.config.subscribe_book {
623 self.subscribe_book_at_interval(
624 instrument_id,
625 self.config.book_type,
626 self.config.book_depth,
627 self.config.book_interval_ms,
628 client_id,
629 None,
630 );
631 }
632
633 if let Some(qty) = self.config.open_position_on_start_qty {
634 self.open_position(qty)?;
635 }
636
637 Ok(())
638 }
639
640 fn get_price_offset(&self, instrument: &InstrumentAny) -> f64 {
642 instrument.price_increment().as_f64() * self.config.tob_offset_ticks as f64
643 }
644
645 fn is_order_active(&self, order: &OrderAny) -> bool {
647 matches!(
648 order.status(),
649 OrderStatus::Initialized
650 | OrderStatus::Submitted
651 | OrderStatus::Accepted
652 | OrderStatus::PartiallyFilled
653 | OrderStatus::PendingUpdate
654 | OrderStatus::PendingCancel
655 )
656 }
657
658 fn get_order_trigger_price(&self, order: &OrderAny) -> Option<Price> {
660 order.trigger_price()
661 }
662
663 fn maintain_orders(&mut self, best_bid: Price, best_ask: Price) {
665 if self.instrument.is_none() || self.config.dry_run {
666 return;
667 }
668
669 if self.config.enable_limit_buys {
670 self.maintain_buy_orders(best_bid, best_ask);
671 }
672
673 if self.config.enable_limit_sells {
674 self.maintain_sell_orders(best_bid, best_ask);
675 }
676
677 if self.config.enable_stop_buys {
678 self.maintain_stop_buy_orders(best_bid, best_ask);
679 }
680
681 if self.config.enable_stop_sells {
682 self.maintain_stop_sell_orders(best_bid, best_ask);
683 }
684 }
685
686 fn maintain_buy_orders(&mut self, best_bid: Price, _best_ask: Price) {
688 let Some(instrument) = &self.instrument else {
689 return;
690 };
691 let Some(price_offset) = self.price_offset else {
692 return;
693 };
694
695 let price_value = best_bid.as_f64() - price_offset;
696 let price = instrument.make_price(price_value);
697
698 let needs_new_order = match &self.buy_order {
699 None => true,
700 Some(order) => !self.is_order_active(order),
701 };
702
703 if needs_new_order {
704 if let Err(e) = self.submit_limit_order(OrderSide::Buy, price) {
705 log::error!("Failed to submit buy limit order: {e}");
706 }
707 } else if let Some(order) = &self.buy_order
708 && order.venue_order_id().is_some()
709 && order.status() != OrderStatus::PendingUpdate
710 && order.status() != OrderStatus::PendingCancel
711 && let Some(order_price) = order.price()
712 && order_price < price
713 {
714 let client_id = self.config.client_id;
715 if self.config.modify_orders_to_maintain_tob_offset {
716 let order_clone = order.clone();
717 if let Err(e) = self.modify_order(order_clone, None, Some(price), None, client_id) {
718 log::error!("Failed to modify buy order: {e}");
719 }
720 } else if self.config.cancel_replace_orders_to_maintain_tob_offset {
721 let order_clone = order.clone();
722 let _ = self.cancel_order(order_clone, client_id);
723 if let Err(e) = self.submit_limit_order(OrderSide::Buy, price) {
724 log::error!("Failed to submit replacement buy order: {e}");
725 }
726 }
727 }
728 }
729
730 fn maintain_sell_orders(&mut self, _best_bid: Price, best_ask: Price) {
732 let Some(instrument) = &self.instrument else {
733 return;
734 };
735 let Some(price_offset) = self.price_offset else {
736 return;
737 };
738
739 let price_value = best_ask.as_f64() + price_offset;
740 let price = instrument.make_price(price_value);
741
742 let needs_new_order = match &self.sell_order {
743 None => true,
744 Some(order) => !self.is_order_active(order),
745 };
746
747 if needs_new_order {
748 if let Err(e) = self.submit_limit_order(OrderSide::Sell, price) {
749 log::error!("Failed to submit sell limit order: {e}");
750 }
751 } else if let Some(order) = &self.sell_order
752 && order.venue_order_id().is_some()
753 && order.status() != OrderStatus::PendingUpdate
754 && order.status() != OrderStatus::PendingCancel
755 && let Some(order_price) = order.price()
756 && order_price > price
757 {
758 let client_id = self.config.client_id;
759 if self.config.modify_orders_to_maintain_tob_offset {
760 let order_clone = order.clone();
761 if let Err(e) = self.modify_order(order_clone, None, Some(price), None, client_id) {
762 log::error!("Failed to modify sell order: {e}");
763 }
764 } else if self.config.cancel_replace_orders_to_maintain_tob_offset {
765 let order_clone = order.clone();
766 let _ = self.cancel_order(order_clone, client_id);
767 if let Err(e) = self.submit_limit_order(OrderSide::Sell, price) {
768 log::error!("Failed to submit replacement sell order: {e}");
769 }
770 }
771 }
772 }
773
774 fn maintain_stop_buy_orders(&mut self, best_bid: Price, best_ask: Price) {
776 let Some(instrument) = &self.instrument else {
777 return;
778 };
779
780 let price_increment = instrument.price_increment().as_f64();
781 let stop_offset = price_increment * self.config.stop_offset_ticks as f64;
782
783 let trigger_price = if matches!(
785 self.config.stop_order_type,
786 OrderType::LimitIfTouched | OrderType::MarketIfTouched
787 ) {
788 instrument.make_price(best_bid.as_f64() - stop_offset)
790 } else {
791 instrument.make_price(best_ask.as_f64() + stop_offset)
793 };
794
795 let limit_price = if matches!(
797 self.config.stop_order_type,
798 OrderType::StopLimit | OrderType::LimitIfTouched
799 ) {
800 if let Some(limit_offset_ticks) = self.config.stop_limit_offset_ticks {
801 let limit_offset = price_increment * limit_offset_ticks as f64;
802 if self.config.stop_order_type == OrderType::LimitIfTouched {
803 Some(instrument.make_price(trigger_price.as_f64() - limit_offset))
804 } else {
805 Some(instrument.make_price(trigger_price.as_f64() + limit_offset))
806 }
807 } else {
808 Some(trigger_price)
809 }
810 } else {
811 None
812 };
813
814 let needs_new_order = match &self.buy_stop_order {
815 None => true,
816 Some(order) => !self.is_order_active(order),
817 };
818
819 if needs_new_order {
820 if let Err(e) = self.submit_stop_order(OrderSide::Buy, trigger_price, limit_price) {
821 log::error!("Failed to submit buy stop order: {e}");
822 }
823 } else if let Some(order) = &self.buy_stop_order
824 && order.venue_order_id().is_some()
825 && order.status() != OrderStatus::PendingUpdate
826 && order.status() != OrderStatus::PendingCancel
827 {
828 let current_trigger = self.get_order_trigger_price(order);
829 if current_trigger.is_some() && current_trigger != Some(trigger_price) {
830 if self.config.modify_stop_orders_to_maintain_offset {
831 log_warn!("Stop order modification not yet implemented");
832 } else if self.config.cancel_replace_stop_orders_to_maintain_offset {
833 let order_clone = order.clone();
834 let _ = self.cancel_order(order_clone, self.config.client_id);
835 if let Err(e) =
836 self.submit_stop_order(OrderSide::Buy, trigger_price, limit_price)
837 {
838 log::error!("Failed to submit replacement buy stop order: {e}");
839 }
840 }
841 }
842 }
843 }
844
845 fn maintain_stop_sell_orders(&mut self, best_bid: Price, best_ask: Price) {
847 let Some(instrument) = &self.instrument else {
848 return;
849 };
850
851 let price_increment = instrument.price_increment().as_f64();
852 let stop_offset = price_increment * self.config.stop_offset_ticks as f64;
853
854 let trigger_price = if matches!(
856 self.config.stop_order_type,
857 OrderType::LimitIfTouched | OrderType::MarketIfTouched
858 ) {
859 instrument.make_price(best_ask.as_f64() + stop_offset)
861 } else {
862 instrument.make_price(best_bid.as_f64() - stop_offset)
864 };
865
866 let limit_price = if matches!(
868 self.config.stop_order_type,
869 OrderType::StopLimit | OrderType::LimitIfTouched
870 ) {
871 if let Some(limit_offset_ticks) = self.config.stop_limit_offset_ticks {
872 let limit_offset = price_increment * limit_offset_ticks as f64;
873 if self.config.stop_order_type == OrderType::LimitIfTouched {
874 Some(instrument.make_price(trigger_price.as_f64() + limit_offset))
875 } else {
876 Some(instrument.make_price(trigger_price.as_f64() - limit_offset))
877 }
878 } else {
879 Some(trigger_price)
880 }
881 } else {
882 None
883 };
884
885 let needs_new_order = match &self.sell_stop_order {
886 None => true,
887 Some(order) => !self.is_order_active(order),
888 };
889
890 if needs_new_order {
891 if let Err(e) = self.submit_stop_order(OrderSide::Sell, trigger_price, limit_price) {
892 log::error!("Failed to submit sell stop order: {e}");
893 }
894 } else if let Some(order) = &self.sell_stop_order
895 && order.venue_order_id().is_some()
896 && order.status() != OrderStatus::PendingUpdate
897 && order.status() != OrderStatus::PendingCancel
898 {
899 let current_trigger = self.get_order_trigger_price(order);
900 if current_trigger.is_some() && current_trigger != Some(trigger_price) {
901 if self.config.modify_stop_orders_to_maintain_offset {
902 log_warn!("Stop order modification not yet implemented");
903 } else if self.config.cancel_replace_stop_orders_to_maintain_offset {
904 let order_clone = order.clone();
905 let _ = self.cancel_order(order_clone, self.config.client_id);
906 if let Err(e) =
907 self.submit_stop_order(OrderSide::Sell, trigger_price, limit_price)
908 {
909 log::error!("Failed to submit replacement sell stop order: {e}");
910 }
911 }
912 }
913 }
914 }
915
916 fn submit_limit_order(&mut self, order_side: OrderSide, price: Price) -> anyhow::Result<()> {
922 let Some(instrument) = &self.instrument else {
923 anyhow::bail!("No instrument loaded");
924 };
925
926 if self.config.dry_run {
927 log_warn!("Dry run, skipping create {order_side:?} order");
928 return Ok(());
929 }
930
931 if order_side == OrderSide::Buy && !self.config.enable_limit_buys {
932 log_warn!("BUY orders not enabled, skipping");
933 return Ok(());
934 } else if order_side == OrderSide::Sell && !self.config.enable_limit_sells {
935 log_warn!("SELL orders not enabled, skipping");
936 return Ok(());
937 }
938
939 let time_in_force = if self.config.order_expire_time_delta_mins.is_some() {
940 TimeInForce::Gtd
941 } else {
942 TimeInForce::Gtc
943 };
944
945 let quantity = instrument.make_qty(self.config.order_qty.as_f64(), None);
947
948 let Some(factory) = &mut self.core.order_factory else {
949 anyhow::bail!("Strategy not registered: OrderFactory missing");
950 };
951
952 let order = factory.limit(
953 self.config.instrument_id,
954 order_side,
955 quantity,
956 price,
957 Some(time_in_force),
958 None, Some(self.config.use_post_only),
960 None, None, self.config.order_display_qty,
963 None, None, None, None, None, None, );
970
971 if order_side == OrderSide::Buy {
972 self.buy_order = Some(order.clone());
973 } else {
974 self.sell_order = Some(order.clone());
975 }
976
977 self.submit_order(order, None, self.config.client_id)
978 }
979
980 fn submit_stop_order(
986 &mut self,
987 order_side: OrderSide,
988 trigger_price: Price,
989 limit_price: Option<Price>,
990 ) -> anyhow::Result<()> {
991 let Some(instrument) = &self.instrument else {
992 anyhow::bail!("No instrument loaded");
993 };
994
995 if self.config.dry_run {
996 log_warn!("Dry run, skipping create {order_side:?} stop order");
997 return Ok(());
998 }
999
1000 if order_side == OrderSide::Buy && !self.config.enable_stop_buys {
1001 log_warn!("BUY stop orders not enabled, skipping");
1002 return Ok(());
1003 } else if order_side == OrderSide::Sell && !self.config.enable_stop_sells {
1004 log_warn!("SELL stop orders not enabled, skipping");
1005 return Ok(());
1006 }
1007
1008 let time_in_force = if self.config.order_expire_time_delta_mins.is_some() {
1009 TimeInForce::Gtd
1010 } else {
1011 TimeInForce::Gtc
1012 };
1013
1014 let quantity = instrument.make_qty(self.config.order_qty.as_f64(), None);
1016
1017 let Some(factory) = &mut self.core.order_factory else {
1018 anyhow::bail!("Strategy not registered: OrderFactory missing");
1019 };
1020
1021 let order: OrderAny = match self.config.stop_order_type {
1022 OrderType::StopMarket => factory.stop_market(
1023 self.config.instrument_id,
1024 order_side,
1025 quantity,
1026 trigger_price,
1027 Some(self.config.stop_trigger_type),
1028 Some(time_in_force),
1029 None, None, None, None, None, None, None, None, None, None, ),
1040 OrderType::StopLimit => {
1041 let Some(limit_price) = limit_price else {
1042 anyhow::bail!("STOP_LIMIT order requires limit_price");
1043 };
1044 factory.stop_limit(
1045 self.config.instrument_id,
1046 order_side,
1047 quantity,
1048 limit_price,
1049 trigger_price,
1050 Some(self.config.stop_trigger_type),
1051 Some(time_in_force),
1052 None, None, None, None, self.config.order_display_qty,
1057 None, None, None, None, None, None, )
1064 }
1065 OrderType::MarketIfTouched => factory.market_if_touched(
1066 self.config.instrument_id,
1067 order_side,
1068 quantity,
1069 trigger_price,
1070 Some(self.config.stop_trigger_type),
1071 Some(time_in_force),
1072 None, None, None, None, None, None, None, None, None, ),
1082 OrderType::LimitIfTouched => {
1083 let Some(limit_price) = limit_price else {
1084 anyhow::bail!("LIMIT_IF_TOUCHED order requires limit_price");
1085 };
1086 factory.limit_if_touched(
1087 self.config.instrument_id,
1088 order_side,
1089 quantity,
1090 limit_price,
1091 trigger_price,
1092 Some(self.config.stop_trigger_type),
1093 Some(time_in_force),
1094 None, None, None, None, self.config.order_display_qty,
1099 None, None, None, None, None, None, )
1106 }
1107 _ => {
1108 anyhow::bail!("Unknown stop order type: {:?}", self.config.stop_order_type);
1109 }
1110 };
1111
1112 if order_side == OrderSide::Buy {
1113 self.buy_stop_order = Some(order.clone());
1114 } else {
1115 self.sell_stop_order = Some(order.clone());
1116 }
1117
1118 self.submit_order(order, None, self.config.client_id)
1119 }
1120
1121 fn open_position(&mut self, net_qty: Decimal) -> anyhow::Result<()> {
1127 let Some(instrument) = &self.instrument else {
1128 anyhow::bail!("No instrument loaded");
1129 };
1130
1131 if net_qty == Decimal::ZERO {
1132 log_warn!("Open position with zero quantity, skipping");
1133 return Ok(());
1134 }
1135
1136 let order_side = if net_qty > Decimal::ZERO {
1137 OrderSide::Buy
1138 } else {
1139 OrderSide::Sell
1140 };
1141
1142 let quantity = instrument.make_qty(net_qty.abs().to_f64().unwrap_or(0.0), None);
1143
1144 let Some(factory) = &mut self.core.order_factory else {
1145 anyhow::bail!("Strategy not registered: OrderFactory missing");
1146 };
1147
1148 let order = factory.market(
1149 self.config.instrument_id,
1150 order_side,
1151 quantity,
1152 Some(self.config.open_position_time_in_force),
1153 None, None, None, None, None, None, );
1160
1161 self.submit_order(order, None, self.config.client_id)
1162 }
1163}
1164
1165#[cfg(test)]
1166mod tests {
1167 use nautilus_core::UnixNanos;
1168 use nautilus_model::{
1169 data::stubs::{OrderBookDeltaTestBuilder, stub_bar},
1170 enums::AggressorSide,
1171 identifiers::{StrategyId, TradeId},
1172 instruments::stubs::crypto_perpetual_ethusdt,
1173 orders::LimitOrder,
1174 };
1175 use rstest::*;
1176
1177 use super::*;
1178
1179 #[fixture]
1184 fn config() -> ExecTesterConfig {
1185 ExecTesterConfig::new(
1186 StrategyId::from("EXEC_TESTER-001"),
1187 InstrumentId::from("ETHUSDT-PERP.BINANCE"),
1188 ClientId::new("BINANCE"),
1189 Quantity::from("0.001"),
1190 )
1191 }
1192
1193 #[fixture]
1194 fn instrument() -> InstrumentAny {
1195 InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt())
1196 }
1197
1198 fn create_initialized_limit_order() -> OrderAny {
1199 OrderAny::Limit(LimitOrder::default())
1200 }
1201
1202 #[rstest]
1207 fn test_config_creation(config: ExecTesterConfig) {
1208 assert_eq!(
1209 config.base.strategy_id,
1210 Some(StrategyId::from("EXEC_TESTER-001"))
1211 );
1212 assert_eq!(
1213 config.instrument_id,
1214 InstrumentId::from("ETHUSDT-PERP.BINANCE")
1215 );
1216 assert_eq!(config.client_id, Some(ClientId::new("BINANCE")));
1217 assert_eq!(config.order_qty, Quantity::from("0.001"));
1218 assert!(config.subscribe_quotes);
1219 assert!(config.subscribe_trades);
1220 assert!(!config.subscribe_book);
1221 assert!(config.enable_limit_buys);
1222 assert!(config.enable_limit_sells);
1223 assert!(!config.enable_stop_buys);
1224 assert!(!config.enable_stop_sells);
1225 assert_eq!(config.tob_offset_ticks, 500);
1226 }
1227
1228 #[rstest]
1229 fn test_config_default() {
1230 let config = ExecTesterConfig::default();
1231
1232 assert!(config.base.strategy_id.is_none());
1233 assert!(config.subscribe_quotes);
1234 assert!(config.subscribe_trades);
1235 assert!(!config.enable_limit_buys);
1236 assert!(!config.enable_limit_sells);
1237 assert!(config.cancel_orders_on_stop);
1238 assert!(config.close_positions_on_stop);
1239 assert!(config.close_positions_time_in_force.is_none());
1240 assert!(!config.use_batch_cancel_on_stop);
1241 }
1242
1243 #[rstest]
1244 fn test_config_with_stop_orders(mut config: ExecTesterConfig) {
1245 config.enable_stop_buys = true;
1246 config.enable_stop_sells = true;
1247 config.stop_order_type = OrderType::StopLimit;
1248 config.stop_offset_ticks = 200;
1249 config.stop_limit_offset_ticks = Some(50);
1250
1251 let tester = ExecTester::new(config);
1252
1253 assert!(tester.config.enable_stop_buys);
1254 assert!(tester.config.enable_stop_sells);
1255 assert_eq!(tester.config.stop_order_type, OrderType::StopLimit);
1256 assert_eq!(tester.config.stop_offset_ticks, 200);
1257 assert_eq!(tester.config.stop_limit_offset_ticks, Some(50));
1258 }
1259
1260 #[rstest]
1261 fn test_config_with_batch_cancel() {
1262 let config = ExecTesterConfig::default().with_use_batch_cancel_on_stop(true);
1263 assert!(config.use_batch_cancel_on_stop);
1264 }
1265
1266 #[rstest]
1267 fn test_config_with_order_maintenance(mut config: ExecTesterConfig) {
1268 config.modify_orders_to_maintain_tob_offset = true;
1269 config.cancel_replace_orders_to_maintain_tob_offset = false;
1270
1271 let tester = ExecTester::new(config);
1272
1273 assert!(tester.config.modify_orders_to_maintain_tob_offset);
1274 assert!(!tester.config.cancel_replace_orders_to_maintain_tob_offset);
1275 }
1276
1277 #[rstest]
1278 fn test_config_with_dry_run(mut config: ExecTesterConfig) {
1279 config.dry_run = true;
1280
1281 let tester = ExecTester::new(config);
1282
1283 assert!(tester.config.dry_run);
1284 }
1285
1286 #[rstest]
1287 fn test_config_with_position_opening(mut config: ExecTesterConfig) {
1288 config.open_position_on_start_qty = Some(Decimal::from(1));
1289 config.open_position_time_in_force = TimeInForce::Ioc;
1290
1291 let tester = ExecTester::new(config);
1292
1293 assert_eq!(
1294 tester.config.open_position_on_start_qty,
1295 Some(Decimal::from(1))
1296 );
1297 assert_eq!(tester.config.open_position_time_in_force, TimeInForce::Ioc);
1298 }
1299
1300 #[rstest]
1301 fn test_config_with_close_positions_time_in_force_builder() {
1302 let config =
1303 ExecTesterConfig::default().with_close_positions_time_in_force(Some(TimeInForce::Ioc));
1304
1305 assert_eq!(config.close_positions_time_in_force, Some(TimeInForce::Ioc));
1306 }
1307
1308 #[rstest]
1309 fn test_config_with_all_stop_order_types(mut config: ExecTesterConfig) {
1310 config.stop_order_type = OrderType::StopMarket;
1312 assert_eq!(config.stop_order_type, OrderType::StopMarket);
1313
1314 config.stop_order_type = OrderType::StopLimit;
1316 assert_eq!(config.stop_order_type, OrderType::StopLimit);
1317
1318 config.stop_order_type = OrderType::MarketIfTouched;
1320 assert_eq!(config.stop_order_type, OrderType::MarketIfTouched);
1321
1322 config.stop_order_type = OrderType::LimitIfTouched;
1324 assert_eq!(config.stop_order_type, OrderType::LimitIfTouched);
1325 }
1326
1327 #[rstest]
1332 fn test_exec_tester_creation(config: ExecTesterConfig) {
1333 let tester = ExecTester::new(config);
1334
1335 assert!(tester.instrument.is_none());
1336 assert!(tester.price_offset.is_none());
1337 assert!(tester.buy_order.is_none());
1338 assert!(tester.sell_order.is_none());
1339 assert!(tester.buy_stop_order.is_none());
1340 assert!(tester.sell_stop_order.is_none());
1341 }
1342
1343 #[rstest]
1348 fn test_get_price_offset(config: ExecTesterConfig, instrument: InstrumentAny) {
1349 let tester = ExecTester::new(config);
1350
1351 let offset = tester.get_price_offset(&instrument);
1354
1355 assert!((offset - 5.0).abs() < 1e-10);
1356 }
1357
1358 #[rstest]
1359 fn test_get_price_offset_different_ticks(instrument: InstrumentAny) {
1360 let config = ExecTesterConfig {
1361 tob_offset_ticks: 100,
1362 ..Default::default()
1363 };
1364
1365 let tester = ExecTester::new(config);
1366
1367 let offset = tester.get_price_offset(&instrument);
1369
1370 assert!((offset - 1.0).abs() < 1e-10);
1371 }
1372
1373 #[rstest]
1374 fn test_get_price_offset_single_tick(instrument: InstrumentAny) {
1375 let config = ExecTesterConfig {
1376 tob_offset_ticks: 1,
1377 ..Default::default()
1378 };
1379
1380 let tester = ExecTester::new(config);
1381
1382 let offset = tester.get_price_offset(&instrument);
1384
1385 assert!((offset - 0.01).abs() < 1e-10);
1386 }
1387
1388 #[rstest]
1393 fn test_is_order_active_initialized(config: ExecTesterConfig) {
1394 let tester = ExecTester::new(config);
1395 let order = create_initialized_limit_order();
1396
1397 assert!(tester.is_order_active(&order));
1398 assert_eq!(order.status(), OrderStatus::Initialized);
1399 }
1400
1401 #[rstest]
1406 fn test_get_order_trigger_price_limit_order_returns_none(config: ExecTesterConfig) {
1407 let tester = ExecTester::new(config);
1408 let order = create_initialized_limit_order();
1409
1410 assert!(tester.get_order_trigger_price(&order).is_none());
1411 }
1412
1413 #[rstest]
1418 fn test_on_quote_with_logging(config: ExecTesterConfig) {
1419 let mut tester = ExecTester::new(config);
1420
1421 let quote = QuoteTick::new(
1422 InstrumentId::from("BTCUSDT-PERP.BINANCE"),
1423 Price::from("50000.0"),
1424 Price::from("50001.0"),
1425 Quantity::from("1.0"),
1426 Quantity::from("1.0"),
1427 UnixNanos::default(),
1428 UnixNanos::default(),
1429 );
1430
1431 let result = tester.on_quote("e);
1432 assert!(result.is_ok());
1433 }
1434
1435 #[rstest]
1436 fn test_on_quote_without_logging(mut config: ExecTesterConfig) {
1437 config.log_data = false;
1438 let mut tester = ExecTester::new(config);
1439
1440 let quote = QuoteTick::new(
1441 InstrumentId::from("BTCUSDT-PERP.BINANCE"),
1442 Price::from("50000.0"),
1443 Price::from("50001.0"),
1444 Quantity::from("1.0"),
1445 Quantity::from("1.0"),
1446 UnixNanos::default(),
1447 UnixNanos::default(),
1448 );
1449
1450 let result = tester.on_quote("e);
1451 assert!(result.is_ok());
1452 }
1453
1454 #[rstest]
1455 fn test_on_trade_with_logging(config: ExecTesterConfig) {
1456 let mut tester = ExecTester::new(config);
1457
1458 let trade = TradeTick::new(
1459 InstrumentId::from("BTCUSDT-PERP.BINANCE"),
1460 Price::from("50000.0"),
1461 Quantity::from("0.1"),
1462 AggressorSide::Buyer,
1463 TradeId::new("12345"),
1464 UnixNanos::default(),
1465 UnixNanos::default(),
1466 );
1467
1468 let result = tester.on_trade(&trade);
1469 assert!(result.is_ok());
1470 }
1471
1472 #[rstest]
1473 fn test_on_trade_without_logging(mut config: ExecTesterConfig) {
1474 config.log_data = false;
1475 let mut tester = ExecTester::new(config);
1476
1477 let trade = TradeTick::new(
1478 InstrumentId::from("BTCUSDT-PERP.BINANCE"),
1479 Price::from("50000.0"),
1480 Quantity::from("0.1"),
1481 AggressorSide::Buyer,
1482 TradeId::new("12345"),
1483 UnixNanos::default(),
1484 UnixNanos::default(),
1485 );
1486
1487 let result = tester.on_trade(&trade);
1488 assert!(result.is_ok());
1489 }
1490
1491 #[rstest]
1492 fn test_on_book_without_bids_or_asks(config: ExecTesterConfig) {
1493 let mut tester = ExecTester::new(config);
1494
1495 let book = OrderBook::new(InstrumentId::from("BTCUSDT-PERP.BINANCE"), BookType::L2_MBP);
1496
1497 let result = tester.on_book(&book);
1498 assert!(result.is_ok());
1499 }
1500
1501 #[rstest]
1502 fn test_on_book_deltas_with_logging(config: ExecTesterConfig) {
1503 let mut tester = ExecTester::new(config);
1504 let instrument_id = InstrumentId::from("BTCUSDT-PERP.BINANCE");
1505 let delta = OrderBookDeltaTestBuilder::new(instrument_id).build();
1506 let deltas = OrderBookDeltas::new(instrument_id, vec![delta]);
1507
1508 let result = tester.on_book_deltas(&deltas);
1509
1510 assert!(result.is_ok());
1511 }
1512
1513 #[rstest]
1514 fn test_on_book_deltas_without_logging(mut config: ExecTesterConfig) {
1515 config.log_data = false;
1516 let mut tester = ExecTester::new(config);
1517 let instrument_id = InstrumentId::from("BTCUSDT-PERP.BINANCE");
1518 let delta = OrderBookDeltaTestBuilder::new(instrument_id).build();
1519 let deltas = OrderBookDeltas::new(instrument_id, vec![delta]);
1520
1521 let result = tester.on_book_deltas(&deltas);
1522
1523 assert!(result.is_ok());
1524 }
1525
1526 #[rstest]
1527 fn test_on_bar_with_logging(config: ExecTesterConfig) {
1528 let mut tester = ExecTester::new(config);
1529 let bar = stub_bar();
1530
1531 let result = tester.on_bar(&bar);
1532
1533 assert!(result.is_ok());
1534 }
1535
1536 #[rstest]
1537 fn test_on_bar_without_logging(mut config: ExecTesterConfig) {
1538 config.log_data = false;
1539 let mut tester = ExecTester::new(config);
1540 let bar = stub_bar();
1541
1542 let result = tester.on_bar(&bar);
1543
1544 assert!(result.is_ok());
1545 }
1546
1547 #[rstest]
1548 fn test_on_mark_price_with_logging(config: ExecTesterConfig) {
1549 let mut tester = ExecTester::new(config);
1550 let instrument_id = InstrumentId::from("BTCUSDT-PERP.BINANCE");
1551 let mark_price = MarkPriceUpdate::new(
1552 instrument_id,
1553 Price::from("50000.0"),
1554 UnixNanos::default(),
1555 UnixNanos::default(),
1556 );
1557
1558 let result = tester.on_mark_price(&mark_price);
1559
1560 assert!(result.is_ok());
1561 }
1562
1563 #[rstest]
1564 fn test_on_mark_price_without_logging(mut config: ExecTesterConfig) {
1565 config.log_data = false;
1566 let mut tester = ExecTester::new(config);
1567 let instrument_id = InstrumentId::from("BTCUSDT-PERP.BINANCE");
1568 let mark_price = MarkPriceUpdate::new(
1569 instrument_id,
1570 Price::from("50000.0"),
1571 UnixNanos::default(),
1572 UnixNanos::default(),
1573 );
1574
1575 let result = tester.on_mark_price(&mark_price);
1576
1577 assert!(result.is_ok());
1578 }
1579
1580 #[rstest]
1581 fn test_on_index_price_with_logging(config: ExecTesterConfig) {
1582 let mut tester = ExecTester::new(config);
1583 let instrument_id = InstrumentId::from("BTCUSDT-PERP.BINANCE");
1584 let index_price = IndexPriceUpdate::new(
1585 instrument_id,
1586 Price::from("49999.0"),
1587 UnixNanos::default(),
1588 UnixNanos::default(),
1589 );
1590
1591 let result = tester.on_index_price(&index_price);
1592
1593 assert!(result.is_ok());
1594 }
1595
1596 #[rstest]
1597 fn test_on_index_price_without_logging(mut config: ExecTesterConfig) {
1598 config.log_data = false;
1599 let mut tester = ExecTester::new(config);
1600 let instrument_id = InstrumentId::from("BTCUSDT-PERP.BINANCE");
1601 let index_price = IndexPriceUpdate::new(
1602 instrument_id,
1603 Price::from("49999.0"),
1604 UnixNanos::default(),
1605 UnixNanos::default(),
1606 );
1607
1608 let result = tester.on_index_price(&index_price);
1609
1610 assert!(result.is_ok());
1611 }
1612
1613 #[rstest]
1614 fn test_on_stop_dry_run(mut config: ExecTesterConfig) {
1615 config.dry_run = true;
1616 let mut tester = ExecTester::new(config);
1617
1618 let result = tester.on_stop();
1619
1620 assert!(result.is_ok());
1621 }
1622
1623 #[rstest]
1628 fn test_maintain_orders_dry_run_does_nothing(mut config: ExecTesterConfig) {
1629 config.dry_run = true;
1630 config.enable_limit_buys = true;
1631 config.enable_limit_sells = true;
1632 let mut tester = ExecTester::new(config);
1633
1634 let best_bid = Price::from("50000.0");
1635 let best_ask = Price::from("50001.0");
1636
1637 tester.maintain_orders(best_bid, best_ask);
1638
1639 assert!(tester.buy_order.is_none());
1640 assert!(tester.sell_order.is_none());
1641 }
1642
1643 #[rstest]
1644 fn test_maintain_orders_no_instrument_does_nothing(config: ExecTesterConfig) {
1645 let mut tester = ExecTester::new(config);
1646
1647 let best_bid = Price::from("50000.0");
1648 let best_ask = Price::from("50001.0");
1649
1650 tester.maintain_orders(best_bid, best_ask);
1651
1652 assert!(tester.buy_order.is_none());
1653 assert!(tester.sell_order.is_none());
1654 }
1655
1656 #[rstest]
1661 fn test_submit_limit_order_no_instrument_returns_error(config: ExecTesterConfig) {
1662 let mut tester = ExecTester::new(config);
1663
1664 let result = tester.submit_limit_order(OrderSide::Buy, Price::from("50000.0"));
1665
1666 assert!(result.is_err());
1667 assert!(result.unwrap_err().to_string().contains("No instrument"));
1668 }
1669
1670 #[rstest]
1671 fn test_submit_limit_order_dry_run_returns_ok(
1672 mut config: ExecTesterConfig,
1673 instrument: InstrumentAny,
1674 ) {
1675 config.dry_run = true;
1676 let mut tester = ExecTester::new(config);
1677 tester.instrument = Some(instrument);
1678
1679 let result = tester.submit_limit_order(OrderSide::Buy, Price::from("50000.0"));
1680
1681 assert!(result.is_ok());
1682 assert!(tester.buy_order.is_none());
1683 }
1684
1685 #[rstest]
1686 fn test_submit_limit_order_buys_disabled_returns_ok(
1687 mut config: ExecTesterConfig,
1688 instrument: InstrumentAny,
1689 ) {
1690 config.enable_limit_buys = false;
1691 let mut tester = ExecTester::new(config);
1692 tester.instrument = Some(instrument);
1693
1694 let result = tester.submit_limit_order(OrderSide::Buy, Price::from("50000.0"));
1695
1696 assert!(result.is_ok());
1697 assert!(tester.buy_order.is_none());
1698 }
1699
1700 #[rstest]
1701 fn test_submit_limit_order_sells_disabled_returns_ok(
1702 mut config: ExecTesterConfig,
1703 instrument: InstrumentAny,
1704 ) {
1705 config.enable_limit_sells = false;
1706 let mut tester = ExecTester::new(config);
1707 tester.instrument = Some(instrument);
1708
1709 let result = tester.submit_limit_order(OrderSide::Sell, Price::from("50000.0"));
1710
1711 assert!(result.is_ok());
1712 assert!(tester.sell_order.is_none());
1713 }
1714
1715 #[rstest]
1716 fn test_submit_stop_order_no_instrument_returns_error(config: ExecTesterConfig) {
1717 let mut tester = ExecTester::new(config);
1718
1719 let result = tester.submit_stop_order(OrderSide::Buy, Price::from("51000.0"), None);
1720
1721 assert!(result.is_err());
1722 assert!(result.unwrap_err().to_string().contains("No instrument"));
1723 }
1724
1725 #[rstest]
1726 fn test_submit_stop_order_dry_run_returns_ok(
1727 mut config: ExecTesterConfig,
1728 instrument: InstrumentAny,
1729 ) {
1730 config.dry_run = true;
1731 config.enable_stop_buys = true;
1732 let mut tester = ExecTester::new(config);
1733 tester.instrument = Some(instrument);
1734
1735 let result = tester.submit_stop_order(OrderSide::Buy, Price::from("51000.0"), None);
1736
1737 assert!(result.is_ok());
1738 assert!(tester.buy_stop_order.is_none());
1739 }
1740
1741 #[rstest]
1742 fn test_submit_stop_order_buys_disabled_returns_ok(
1743 mut config: ExecTesterConfig,
1744 instrument: InstrumentAny,
1745 ) {
1746 config.enable_stop_buys = false;
1747 let mut tester = ExecTester::new(config);
1748 tester.instrument = Some(instrument);
1749
1750 let result = tester.submit_stop_order(OrderSide::Buy, Price::from("51000.0"), None);
1751
1752 assert!(result.is_ok());
1753 assert!(tester.buy_stop_order.is_none());
1754 }
1755
1756 #[rstest]
1757 fn test_submit_stop_limit_without_limit_price_returns_error(
1758 mut config: ExecTesterConfig,
1759 instrument: InstrumentAny,
1760 ) {
1761 config.enable_stop_buys = true;
1762 config.stop_order_type = OrderType::StopLimit;
1763 let mut tester = ExecTester::new(config);
1764 tester.instrument = Some(instrument);
1765
1766 }
1768
1769 #[rstest]
1774 fn test_open_position_no_instrument_returns_error(config: ExecTesterConfig) {
1775 let mut tester = ExecTester::new(config);
1776
1777 let result = tester.open_position(Decimal::from(1));
1778
1779 assert!(result.is_err());
1780 assert!(result.unwrap_err().to_string().contains("No instrument"));
1781 }
1782
1783 #[rstest]
1784 fn test_open_position_zero_quantity_returns_ok(
1785 config: ExecTesterConfig,
1786 instrument: InstrumentAny,
1787 ) {
1788 let mut tester = ExecTester::new(config);
1789 tester.instrument = Some(instrument);
1790
1791 let result = tester.open_position(Decimal::ZERO);
1792
1793 assert!(result.is_ok());
1794 }
1795}