nautilus_testkit/testers/
exec.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Execution tester strategy for live testing order execution.
17
18use 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/// Configuration for the execution tester strategy.
42#[derive(Debug, Clone)]
43pub struct ExecTesterConfig {
44    /// Base strategy configuration.
45    pub base: StrategyConfig,
46    /// Instrument ID to test.
47    pub instrument_id: InstrumentId,
48    /// Client ID to use for orders and subscriptions.
49    pub client_id: Option<ClientId>,
50    /// Order quantity.
51    pub order_qty: Quantity,
52    /// Display quantity for iceberg orders (None for full display, Some(0) for hidden).
53    pub order_display_qty: Option<Quantity>,
54    /// Minutes until GTD orders expire (None for GTC).
55    pub order_expire_time_delta_mins: Option<u64>,
56    /// Whether to subscribe to quotes.
57    pub subscribe_quotes: bool,
58    /// Whether to subscribe to trades.
59    pub subscribe_trades: bool,
60    /// Whether to subscribe to order book.
61    pub subscribe_book: bool,
62    /// Book type for order book subscriptions.
63    pub book_type: BookType,
64    /// Order book depth for subscriptions.
65    pub book_depth: Option<NonZeroUsize>,
66    /// Order book interval in milliseconds.
67    pub book_interval_ms: NonZeroUsize,
68    /// Number of order book levels to print when logging.
69    pub book_levels_to_print: usize,
70    /// Quantity to open position on start (positive for buy, negative for sell).
71    pub open_position_on_start_qty: Option<Decimal>,
72    /// Time in force for opening position order.
73    pub open_position_time_in_force: TimeInForce,
74    /// Enable limit buy orders.
75    pub enable_limit_buys: bool,
76    /// Enable limit sell orders.
77    pub enable_limit_sells: bool,
78    /// Offset from TOB in price ticks for limit orders.
79    pub tob_offset_ticks: u64,
80    /// Enable stop buy orders.
81    pub enable_stop_buys: bool,
82    /// Enable stop sell orders.
83    pub enable_stop_sells: bool,
84    /// Type of stop order (STOP_MARKET, STOP_LIMIT, MARKET_IF_TOUCHED, LIMIT_IF_TOUCHED).
85    pub stop_order_type: OrderType,
86    /// Offset from market in price ticks for stop trigger.
87    pub stop_offset_ticks: u64,
88    /// Offset from trigger price in ticks for stop limit price.
89    pub stop_limit_offset_ticks: Option<u64>,
90    /// Trigger type for stop orders.
91    pub stop_trigger_type: TriggerType,
92    /// Modify limit orders to maintain TOB offset.
93    pub modify_orders_to_maintain_tob_offset: bool,
94    /// Modify stop orders to maintain offset.
95    pub modify_stop_orders_to_maintain_offset: bool,
96    /// Cancel and replace limit orders to maintain TOB offset.
97    pub cancel_replace_orders_to_maintain_tob_offset: bool,
98    /// Cancel and replace stop orders to maintain offset.
99    pub cancel_replace_stop_orders_to_maintain_offset: bool,
100    /// Use post-only for limit orders.
101    pub use_post_only: bool,
102    /// Cancel all orders on stop.
103    pub cancel_orders_on_stop: bool,
104    /// Close all positions on stop.
105    pub close_positions_on_stop: bool,
106    /// Time in force for closing positions (None defaults to GTC).
107    pub close_positions_time_in_force: Option<TimeInForce>,
108    /// Use reduce_only when closing positions.
109    pub reduce_only_on_stop: bool,
110    /// Use individual cancel commands instead of cancel_all.
111    pub use_individual_cancels_on_stop: bool,
112    /// Use batch cancel command when stopping.
113    pub use_batch_cancel_on_stop: bool,
114    /// Dry run mode (no order submission).
115    pub dry_run: bool,
116    /// Log received data.
117    pub log_data: bool,
118    /// Whether unsubscribe is supported on stop.
119    pub can_unsubscribe: bool,
120}
121
122impl ExecTesterConfig {
123    /// Creates a new [`ExecTesterConfig`] with minimal settings.
124    ///
125    /// # Panics
126    ///
127    /// Panics if `NonZeroUsize::new(1000)` fails (which should never happen).
128    #[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/// An execution tester strategy for live testing order execution functionality.
357///
358/// This strategy is designed for testing execution adapters by submitting
359/// limit orders, stop orders, and managing positions. It can maintain orders
360/// at a configurable offset from the top of book.
361///
362/// **WARNING**: This strategy has no alpha advantage whatsoever.
363/// It is not intended to be used for live trading with real money.
364#[derive(Debug)]
365pub struct ExecTester {
366    core: StrategyCore,
367    config: ExecTesterConfig,
368    instrument: Option<InstrumentAny>,
369    price_offset: Option<f64>,
370
371    // Order tracking
372    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            // Log own order book if available
529            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(()); // Wait for market
543        };
544        let Some(best_ask) = book.best_ask_price() else {
545            return Ok(()); // Wait for market
546        };
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    /// Creates a new [`ExecTester`] instance.
593    #[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    /// Calculate the price offset from TOB based on configuration.
641    fn get_price_offset(&self, instrument: &InstrumentAny) -> f64 {
642        instrument.price_increment().as_f64() * self.config.tob_offset_ticks as f64
643    }
644
645    /// Check if an order is still active.
646    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    /// Get the trigger price from a stop/conditional order.
659    fn get_order_trigger_price(&self, order: &OrderAny) -> Option<Price> {
660        order.trigger_price()
661    }
662
663    /// Maintain orders based on current market prices.
664    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    /// Maintain buy limit orders.
687    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    /// Maintain sell limit orders.
731    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    /// Maintain stop buy orders.
775    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        // Determine trigger price based on order type
784        let trigger_price = if matches!(
785            self.config.stop_order_type,
786            OrderType::LimitIfTouched | OrderType::MarketIfTouched
787        ) {
788            // IF_TOUCHED buy: place BELOW market (buy on dip)
789            instrument.make_price(best_bid.as_f64() - stop_offset)
790        } else {
791            // STOP buy orders are placed ABOVE the market (stop loss on short)
792            instrument.make_price(best_ask.as_f64() + stop_offset)
793        };
794
795        // Calculate limit price if needed
796        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    /// Maintain stop sell orders.
846    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        // Determine trigger price based on order type
855        let trigger_price = if matches!(
856            self.config.stop_order_type,
857            OrderType::LimitIfTouched | OrderType::MarketIfTouched
858        ) {
859            // IF_TOUCHED sell: place ABOVE market (sell on rally)
860            instrument.make_price(best_ask.as_f64() + stop_offset)
861        } else {
862            // STOP sell orders are placed BELOW the market (stop loss on long)
863            instrument.make_price(best_bid.as_f64() - stop_offset)
864        };
865
866        // Calculate limit price if needed
867        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    /// Submit a limit order.
917    ///
918    /// # Errors
919    ///
920    /// Returns an error if order creation or submission fails.
921    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        // TODO: Calculate expire_time from order_expire_time_delta_mins
946        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, // expire_time
959            Some(self.config.use_post_only),
960            None, // reduce_only
961            None, // quote_quantity
962            self.config.order_display_qty,
963            None, // emulation_trigger
964            None, // trigger_instrument_id
965            None, // exec_algorithm_id
966            None, // exec_algorithm_params
967            None, // tags
968            None, // client_order_id
969        );
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    /// Submit a stop order.
981    ///
982    /// # Errors
983    ///
984    /// Returns an error if order creation or submission fails.
985    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        // Use instrument's make_qty to ensure correct precision
1015        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, // expire_time
1030                None, // reduce_only
1031                None, // quote_quantity
1032                None, // display_qty
1033                None, // emulation_trigger
1034                None, // trigger_instrument_id
1035                None, // exec_algorithm_id
1036                None, // exec_algorithm_params
1037                None, // tags
1038                None, // client_order_id
1039            ),
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, // expire_time
1053                    None, // post_only
1054                    None, // reduce_only
1055                    None, // quote_quantity
1056                    self.config.order_display_qty,
1057                    None, // emulation_trigger
1058                    None, // trigger_instrument_id
1059                    None, // exec_algorithm_id
1060                    None, // exec_algorithm_params
1061                    None, // tags
1062                    None, // client_order_id
1063                )
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, // expire_time
1073                None, // reduce_only
1074                None, // quote_quantity
1075                None, // emulation_trigger
1076                None, // trigger_instrument_id
1077                None, // exec_algorithm_id
1078                None, // exec_algorithm_params
1079                None, // tags
1080                None, // client_order_id
1081            ),
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, // expire_time
1095                    None, // post_only
1096                    None, // reduce_only
1097                    None, // quote_quantity
1098                    self.config.order_display_qty,
1099                    None, // emulation_trigger
1100                    None, // trigger_instrument_id
1101                    None, // exec_algorithm_id
1102                    None, // exec_algorithm_params
1103                    None, // tags
1104                    None, // client_order_id
1105                )
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    /// Open a position with a market order.
1122    ///
1123    /// # Errors
1124    ///
1125    /// Returns an error if order creation or submission fails.
1126    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, // reduce_only
1154            None, // quote_quantity
1155            None, // exec_algorithm_id
1156            None, // exec_algorithm_params
1157            None, // tags
1158            None, // client_order_id
1159        );
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    // =========================================================================
1180    // Fixtures
1181    // =========================================================================
1182
1183    #[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    // =========================================================================
1203    // Config Tests
1204    // =========================================================================
1205
1206    #[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        // Test STOP_MARKET
1311        config.stop_order_type = OrderType::StopMarket;
1312        assert_eq!(config.stop_order_type, OrderType::StopMarket);
1313
1314        // Test STOP_LIMIT
1315        config.stop_order_type = OrderType::StopLimit;
1316        assert_eq!(config.stop_order_type, OrderType::StopLimit);
1317
1318        // Test MARKET_IF_TOUCHED
1319        config.stop_order_type = OrderType::MarketIfTouched;
1320        assert_eq!(config.stop_order_type, OrderType::MarketIfTouched);
1321
1322        // Test LIMIT_IF_TOUCHED
1323        config.stop_order_type = OrderType::LimitIfTouched;
1324        assert_eq!(config.stop_order_type, OrderType::LimitIfTouched);
1325    }
1326
1327    // =========================================================================
1328    // ExecTester Creation Tests
1329    // =========================================================================
1330
1331    #[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    // =========================================================================
1344    // Price Offset Calculation Tests
1345    // =========================================================================
1346
1347    #[rstest]
1348    fn test_get_price_offset(config: ExecTesterConfig, instrument: InstrumentAny) {
1349        let tester = ExecTester::new(config);
1350
1351        // price_increment = 0.01, tob_offset_ticks = 500
1352        // Expected: 0.01 * 500 = 5.0
1353        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        // price_increment = 0.01, tob_offset_ticks = 100
1368        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        // price_increment = 0.01, tob_offset_ticks = 1
1383        let offset = tester.get_price_offset(&instrument);
1384
1385        assert!((offset - 0.01).abs() < 1e-10);
1386    }
1387
1388    // =========================================================================
1389    // Order Activity Status Tests
1390    // =========================================================================
1391
1392    #[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    // =========================================================================
1402    // Trigger Price Extraction Tests
1403    // =========================================================================
1404
1405    #[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    // =========================================================================
1414    // Data Handler Tests
1415    // =========================================================================
1416
1417    #[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(&quote);
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(&quote);
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    // =========================================================================
1624    // Maintain Orders - Dry Run Tests
1625    // =========================================================================
1626
1627    #[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    // =========================================================================
1657    // Submit Order Error Handling Tests
1658    // =========================================================================
1659
1660    #[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        // Cannot actually submit without a registered OrderFactory
1767    }
1768
1769    // =========================================================================
1770    // Open Position Tests
1771    // =========================================================================
1772
1773    #[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}