Skip to main content

nautilus_testkit/testers/exec/
strategy.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 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
16use nautilus_common::{actor::DataActor, enums::LogColor, log_info, log_warn, timer::TimeEvent};
17use nautilus_core::{UnixNanos, datetime::secs_to_nanos_unchecked};
18use nautilus_model::{
19    data::{Bar, IndexPriceUpdate, MarkPriceUpdate, OrderBookDeltas, QuoteTick, TradeTick},
20    enums::{OrderSide, OrderType, TimeInForce},
21    identifiers::{InstrumentId, StrategyId},
22    instruments::{Instrument, InstrumentAny},
23    orderbook::OrderBook,
24    orders::{Order, OrderAny},
25    types::Price,
26};
27use nautilus_trading::{
28    nautilus_strategy,
29    strategy::{Strategy, StrategyCore},
30};
31use rust_decimal::{Decimal, prelude::ToPrimitive};
32
33use super::config::ExecTesterConfig;
34
35/// An execution tester strategy for live testing order execution functionality.
36///
37/// This strategy is designed for testing execution adapters by submitting
38/// limit orders, stop orders, and managing positions. It can maintain orders
39/// at a configurable offset from the top of book.
40///
41/// **WARNING**: This strategy has no alpha advantage whatsoever.
42/// It is not intended to be used for live trading with real money.
43#[derive(Debug)]
44pub struct ExecTester {
45    pub(super) core: StrategyCore,
46    pub(super) config: ExecTesterConfig,
47    pub(super) instrument: Option<InstrumentAny>,
48    pub(super) price_offset: Option<f64>,
49    pub(super) preinitialized_market_data: bool,
50
51    // Order tracking
52    pub(super) buy_order: Option<OrderAny>,
53    pub(super) sell_order: Option<OrderAny>,
54    pub(super) buy_stop_order: Option<OrderAny>,
55    pub(super) sell_stop_order: Option<OrderAny>,
56}
57
58nautilus_strategy!(ExecTester, {
59    fn external_order_claims(&self) -> Option<Vec<InstrumentId>> {
60        self.config.base.external_order_claims.clone()
61    }
62});
63
64impl DataActor for ExecTester {
65    fn on_start(&mut self) -> anyhow::Result<()> {
66        Strategy::on_start(self)?;
67
68        let instrument_id = self.config.instrument_id;
69        let client_id = self.config.client_id;
70
71        let instrument = {
72            let cache = self.cache();
73            cache.instrument(&instrument_id).cloned()
74        };
75
76        if let Some(inst) = instrument {
77            self.initialize_with_instrument(inst, true)?;
78        } else {
79            log::info!("Instrument {instrument_id} not in cache, subscribing...");
80            self.subscribe_instrument(instrument_id, client_id, None);
81
82            // Also subscribe to market data to trigger instrument definitions from data providers
83            // (e.g., Databento sends instrument definitions as part of market data subscriptions)
84            if self.config.subscribe_quotes {
85                self.subscribe_quotes(instrument_id, client_id, None);
86            }
87
88            if self.config.subscribe_trades {
89                self.subscribe_trades(instrument_id, client_id, None);
90            }
91            self.preinitialized_market_data =
92                self.config.subscribe_quotes || self.config.subscribe_trades;
93        }
94
95        Ok(())
96    }
97
98    fn on_instrument(&mut self, instrument: &InstrumentAny) -> anyhow::Result<()> {
99        if instrument.id() == self.config.instrument_id && self.instrument.is_none() {
100            let id = instrument.id();
101            log::info!("Received instrument {id}, initializing...");
102            self.initialize_with_instrument(instrument.clone(), !self.preinitialized_market_data)?;
103        }
104        Ok(())
105    }
106
107    fn on_stop(&mut self) -> anyhow::Result<()> {
108        if self.config.dry_run {
109            log_warn!("Dry run mode, skipping cancel all orders and close all positions");
110            return Ok(());
111        }
112
113        let instrument_id = self.config.instrument_id;
114        let client_id = self.config.client_id;
115
116        if self.config.cancel_orders_on_stop {
117            let strategy_id = StrategyId::from(self.core.actor_id.inner().as_str());
118
119            if self.config.use_individual_cancels_on_stop {
120                let cache = self.cache();
121                let open_orders: Vec<OrderAny> = cache
122                    .orders_open(None, Some(&instrument_id), Some(&strategy_id), None, None)
123                    .iter()
124                    .map(|o| (*o).clone())
125                    .collect();
126                drop(cache);
127
128                for order in open_orders {
129                    if let Err(e) = self.cancel_order(order, client_id) {
130                        log::error!("Failed to cancel order: {e}");
131                    }
132                }
133            } else if self.config.use_batch_cancel_on_stop {
134                let cache = self.cache();
135                let open_orders: Vec<OrderAny> = cache
136                    .orders_open(None, Some(&instrument_id), Some(&strategy_id), None, None)
137                    .iter()
138                    .map(|o| (*o).clone())
139                    .collect();
140                drop(cache);
141
142                if let Err(e) = self.cancel_orders(open_orders, client_id, None) {
143                    log::error!("Failed to batch cancel orders: {e}");
144                }
145            } else if let Err(e) = self.cancel_all_orders(instrument_id, None, client_id) {
146                log::error!("Failed to cancel all orders: {e}");
147            }
148        }
149
150        if self.config.close_positions_on_stop {
151            let time_in_force = self
152                .config
153                .close_positions_time_in_force
154                .or(Some(TimeInForce::Gtc));
155
156            if let Err(e) = self.close_all_positions(
157                instrument_id,
158                None,
159                client_id,
160                None,
161                time_in_force,
162                Some(self.config.reduce_only_on_stop),
163                None,
164            ) {
165                log::error!("Failed to close all positions: {e}");
166            }
167        }
168
169        if self.config.can_unsubscribe && self.instrument.is_some() {
170            if self.config.subscribe_quotes {
171                self.unsubscribe_quotes(instrument_id, client_id, None);
172            }
173
174            if self.config.subscribe_trades {
175                self.unsubscribe_trades(instrument_id, client_id, None);
176            }
177
178            if self.config.subscribe_book {
179                self.unsubscribe_book_at_interval(
180                    instrument_id,
181                    self.config.book_interval_ms,
182                    client_id,
183                    None,
184                );
185            }
186        }
187
188        Ok(())
189    }
190
191    fn on_quote(&mut self, quote: &QuoteTick) -> anyhow::Result<()> {
192        if self.config.log_data {
193            log_info!("{quote:?}", color = LogColor::Cyan);
194        }
195
196        self.maintain_orders(quote.bid_price, quote.ask_price);
197        Ok(())
198    }
199
200    fn on_trade(&mut self, trade: &TradeTick) -> anyhow::Result<()> {
201        if self.config.log_data {
202            log_info!("{trade:?}", color = LogColor::Cyan);
203        }
204        Ok(())
205    }
206
207    fn on_book(&mut self, book: &OrderBook) -> anyhow::Result<()> {
208        if self.config.log_data {
209            let num_levels = self.config.book_levels_to_print;
210            let instrument_id = book.instrument_id;
211            let book_str = book.pprint(num_levels, None);
212            log_info!("\n{instrument_id}\n{book_str}", color = LogColor::Cyan);
213
214            // Log own order book if available
215            if self.is_registered() {
216                let cache = self.cache();
217                if let Some(own_book) = cache.own_order_book(&instrument_id) {
218                    let own_book_str = own_book.pprint(num_levels, None);
219                    log_info!(
220                        "\n{instrument_id} (own)\n{own_book_str}",
221                        color = LogColor::Magenta
222                    );
223                }
224            }
225        }
226
227        let Some(best_bid) = book.best_bid_price() else {
228            return Ok(()); // Wait for market
229        };
230        let Some(best_ask) = book.best_ask_price() else {
231            return Ok(()); // Wait for market
232        };
233
234        self.maintain_orders(best_bid, best_ask);
235        Ok(())
236    }
237
238    fn on_book_deltas(&mut self, deltas: &OrderBookDeltas) -> anyhow::Result<()> {
239        if self.config.log_data {
240            log_info!("{deltas:?}", color = LogColor::Cyan);
241        }
242        Ok(())
243    }
244
245    fn on_bar(&mut self, bar: &Bar) -> anyhow::Result<()> {
246        if self.config.log_data {
247            log_info!("{bar:?}", color = LogColor::Cyan);
248        }
249        Ok(())
250    }
251
252    fn on_mark_price(&mut self, mark_price: &MarkPriceUpdate) -> anyhow::Result<()> {
253        if self.config.log_data {
254            log_info!("{mark_price:?}", color = LogColor::Cyan);
255        }
256        Ok(())
257    }
258
259    fn on_index_price(&mut self, index_price: &IndexPriceUpdate) -> anyhow::Result<()> {
260        if self.config.log_data {
261            log_info!("{index_price:?}", color = LogColor::Cyan);
262        }
263        Ok(())
264    }
265
266    fn on_time_event(&mut self, event: &TimeEvent) -> anyhow::Result<()> {
267        Strategy::on_time_event(self, event)
268    }
269}
270
271impl ExecTester {
272    /// Creates a new [`ExecTester`] instance.
273    #[must_use]
274    pub fn new(config: ExecTesterConfig) -> Self {
275        Self {
276            core: StrategyCore::new(config.base.clone()),
277            config,
278            instrument: None,
279            price_offset: None,
280            preinitialized_market_data: false,
281            buy_order: None,
282            sell_order: None,
283            buy_stop_order: None,
284            sell_stop_order: None,
285        }
286    }
287
288    fn initialize_with_instrument(
289        &mut self,
290        instrument: InstrumentAny,
291        subscribe_market_data: bool,
292    ) -> anyhow::Result<()> {
293        let instrument_id = self.config.instrument_id;
294        let client_id = self.config.client_id;
295
296        self.price_offset = Some(self.get_price_offset(&instrument));
297        self.instrument = Some(instrument);
298
299        if subscribe_market_data && self.config.subscribe_quotes {
300            self.subscribe_quotes(instrument_id, client_id, None);
301        }
302
303        if subscribe_market_data && self.config.subscribe_trades {
304            self.subscribe_trades(instrument_id, client_id, None);
305        }
306
307        if self.config.subscribe_book {
308            self.subscribe_book_at_interval(
309                instrument_id,
310                self.config.book_type,
311                self.config.book_depth,
312                self.config.book_interval_ms,
313                client_id,
314                None,
315            );
316        }
317
318        if let Some(qty) = self.config.open_position_on_start_qty {
319            self.open_position(qty)?;
320        }
321
322        Ok(())
323    }
324
325    pub(super) fn get_price_offset(&self, instrument: &InstrumentAny) -> f64 {
326        instrument.price_increment().as_f64() * self.config.tob_offset_ticks as f64
327    }
328
329    fn expire_time_from_delta(&self, mins: u64) -> UnixNanos {
330        let current_ns = self.timestamp_ns();
331        let delta_ns = secs_to_nanos_unchecked((mins * 60) as f64);
332        UnixNanos::from(current_ns.as_u64() + delta_ns)
333    }
334
335    fn resolve_time_in_force(
336        &self,
337        tif_override: Option<TimeInForce>,
338    ) -> (TimeInForce, Option<UnixNanos>) {
339        match (tif_override, self.config.order_expire_time_delta_mins) {
340            (Some(TimeInForce::Gtd), Some(mins)) => {
341                (TimeInForce::Gtd, Some(self.expire_time_from_delta(mins)))
342            }
343            (Some(TimeInForce::Gtd), None) => {
344                log_warn!(
345                    "GTD time in force requires order_expire_time_delta_mins, falling back to GTC"
346                );
347                (TimeInForce::Gtc, None)
348            }
349            (Some(tif), _) => (tif, None),
350            (None, Some(mins)) => (TimeInForce::Gtd, Some(self.expire_time_from_delta(mins))),
351            (None, None) => (TimeInForce::Gtc, None),
352        }
353    }
354
355    pub(super) fn is_order_active(&self, order: &OrderAny) -> bool {
356        order.is_active_local() || order.is_inflight() || order.is_open()
357    }
358
359    pub(super) fn get_order_trigger_price(&self, order: &OrderAny) -> Option<Price> {
360        order.trigger_price()
361    }
362
363    fn modify_stop_order(
364        &mut self,
365        order: OrderAny,
366        trigger_price: Price,
367        limit_price: Option<Price>,
368    ) -> anyhow::Result<()> {
369        let client_id = self.config.client_id;
370
371        match &order {
372            OrderAny::StopMarket(_)
373            | OrderAny::MarketIfTouched(_)
374            | OrderAny::TrailingStopMarket(_) => {
375                self.modify_order(order, None, None, Some(trigger_price), client_id)
376            }
377            OrderAny::StopLimit(_) | OrderAny::LimitIfTouched(_) => {
378                self.modify_order(order, None, limit_price, Some(trigger_price), client_id)
379            }
380            _ => {
381                log_warn!("Cannot modify order of type {:?}", order.order_type());
382                Ok(())
383            }
384        }
385    }
386
387    /// Submit an order, applying order_params if configured.
388    fn submit_order_apply_params(&mut self, order: OrderAny) -> anyhow::Result<()> {
389        let client_id = self.config.client_id;
390        if let Some(params) = &self.config.order_params {
391            self.submit_order_with_params(order, None, client_id, params.clone())
392        } else {
393            self.submit_order(order, None, client_id)
394        }
395    }
396
397    /// Maintain orders based on current market prices.
398    pub(super) fn maintain_orders(&mut self, best_bid: Price, best_ask: Price) {
399        if self.instrument.is_none() || self.config.dry_run {
400            return;
401        }
402
403        if self.config.batch_submit_limit_pair
404            && self.config.enable_limit_buys
405            && self.config.enable_limit_sells
406        {
407            self.maintain_batch_limit_pair(best_bid, best_ask);
408            return;
409        }
410
411        if self.config.enable_limit_buys {
412            self.maintain_buy_orders(best_bid, best_ask);
413        }
414
415        if self.config.enable_limit_sells {
416            self.maintain_sell_orders(best_bid, best_ask);
417        }
418
419        if self.config.enable_stop_buys {
420            self.maintain_stop_buy_orders(best_bid, best_ask);
421        }
422
423        if self.config.enable_stop_sells {
424            self.maintain_stop_sell_orders(best_bid, best_ask);
425        }
426    }
427
428    /// Maintain buy limit orders.
429    fn maintain_buy_orders(&mut self, best_bid: Price, best_ask: Price) {
430        let Some(instrument) = &self.instrument else {
431            return;
432        };
433        let Some(price_offset) = self.price_offset else {
434            return;
435        };
436
437        // test_reject_post_only places order on wrong side of spread to trigger rejection
438        let price = if self.config.test_reject_post_only {
439            instrument.make_price(best_ask.as_f64() + price_offset)
440        } else {
441            instrument.make_price(best_bid.as_f64() - price_offset)
442        };
443
444        let needs_new_order = match &self.buy_order {
445            None => true,
446            Some(order) => !self.is_order_active(order),
447        };
448
449        if needs_new_order {
450            let result = if self.config.enable_brackets {
451                self.submit_bracket_order(OrderSide::Buy, price)
452            } else {
453                self.submit_limit_order(OrderSide::Buy, price)
454            };
455
456            if let Err(e) = result {
457                log::error!("Failed to submit buy order: {e}");
458            }
459        } else if let Some(order) = &self.buy_order
460            && order.venue_order_id().is_some()
461            && !order.is_pending_update()
462            && !order.is_pending_cancel()
463            && let Some(order_price) = order.price()
464            && order_price < price
465        {
466            let client_id = self.config.client_id;
467            if self.config.modify_orders_to_maintain_tob_offset {
468                let order_clone = order.clone();
469                if let Err(e) = self.modify_order(order_clone, None, Some(price), None, client_id) {
470                    log::error!("Failed to modify buy order: {e}");
471                }
472            } else if self.config.cancel_replace_orders_to_maintain_tob_offset {
473                let order_clone = order.clone();
474                let _ = self.cancel_order(order_clone, client_id);
475
476                if let Err(e) = self.submit_limit_order(OrderSide::Buy, price) {
477                    log::error!("Failed to submit replacement buy order: {e}");
478                }
479            }
480        }
481    }
482
483    /// Maintain sell limit orders.
484    fn maintain_sell_orders(&mut self, best_bid: Price, best_ask: Price) {
485        let Some(instrument) = &self.instrument else {
486            return;
487        };
488        let Some(price_offset) = self.price_offset else {
489            return;
490        };
491
492        // test_reject_post_only places order on wrong side of spread to trigger rejection
493        let price = if self.config.test_reject_post_only {
494            instrument.make_price(best_bid.as_f64() - price_offset)
495        } else {
496            instrument.make_price(best_ask.as_f64() + price_offset)
497        };
498
499        let needs_new_order = match &self.sell_order {
500            None => true,
501            Some(order) => !self.is_order_active(order),
502        };
503
504        if needs_new_order {
505            let result = if self.config.enable_brackets {
506                self.submit_bracket_order(OrderSide::Sell, price)
507            } else {
508                self.submit_limit_order(OrderSide::Sell, price)
509            };
510
511            if let Err(e) = result {
512                log::error!("Failed to submit sell order: {e}");
513            }
514        } else if let Some(order) = &self.sell_order
515            && order.venue_order_id().is_some()
516            && !order.is_pending_update()
517            && !order.is_pending_cancel()
518            && let Some(order_price) = order.price()
519            && order_price > price
520        {
521            let client_id = self.config.client_id;
522            if self.config.modify_orders_to_maintain_tob_offset {
523                let order_clone = order.clone();
524                if let Err(e) = self.modify_order(order_clone, None, Some(price), None, client_id) {
525                    log::error!("Failed to modify sell order: {e}");
526                }
527            } else if self.config.cancel_replace_orders_to_maintain_tob_offset {
528                let order_clone = order.clone();
529                let _ = self.cancel_order(order_clone, client_id);
530
531                if let Err(e) = self.submit_limit_order(OrderSide::Sell, price) {
532                    log::error!("Failed to submit replacement sell order: {e}");
533                }
534            }
535        }
536    }
537
538    /// Submits a buy and sell limit order as an order list (batch).
539    fn maintain_batch_limit_pair(&mut self, best_bid: Price, best_ask: Price) {
540        let Some(instrument) = &self.instrument else {
541            return;
542        };
543        let Some(price_offset) = self.price_offset else {
544            return;
545        };
546
547        let buy_needs = match &self.buy_order {
548            None => true,
549            Some(order) => !self.is_order_active(order),
550        };
551        let sell_needs = match &self.sell_order {
552            None => true,
553            Some(order) => !self.is_order_active(order),
554        };
555
556        if !buy_needs || !sell_needs {
557            return;
558        }
559
560        let buy_price = instrument.make_price(best_bid.as_f64() - price_offset);
561        let sell_price = instrument.make_price(best_ask.as_f64() + price_offset);
562        let quantity = instrument.make_qty(self.config.order_qty.as_f64(), None);
563        let (time_in_force, expire_time) =
564            self.resolve_time_in_force(self.config.limit_time_in_force);
565
566        let buy_order = self.core.order_factory().limit(
567            self.config.instrument_id,
568            OrderSide::Buy,
569            quantity,
570            buy_price,
571            Some(time_in_force),
572            expire_time,
573            Some(self.config.use_post_only || self.config.test_reject_post_only),
574            None,
575            Some(self.config.use_quote_quantity),
576            self.config.order_display_qty,
577            self.config.emulation_trigger,
578            None,
579            None,
580            None,
581            None,
582            None,
583        );
584
585        let sell_order = self.core.order_factory().limit(
586            self.config.instrument_id,
587            OrderSide::Sell,
588            quantity,
589            sell_price,
590            Some(time_in_force),
591            expire_time,
592            Some(self.config.use_post_only || self.config.test_reject_post_only),
593            None,
594            Some(self.config.use_quote_quantity),
595            self.config.order_display_qty,
596            self.config.emulation_trigger,
597            None,
598            None,
599            None,
600            None,
601            None,
602        );
603
604        self.buy_order = Some(buy_order.clone());
605        self.sell_order = Some(sell_order.clone());
606
607        let client_id = self.config.client_id;
608        if let Err(e) = self.submit_order_list(vec![buy_order, sell_order], None, client_id) {
609            log::error!("Failed to submit batch limit pair: {e}");
610        }
611    }
612
613    /// Maintain stop buy orders.
614    fn maintain_stop_buy_orders(&mut self, best_bid: Price, best_ask: Price) {
615        let Some(instrument) = &self.instrument else {
616            return;
617        };
618
619        let price_increment = instrument.price_increment().as_f64();
620        let stop_offset = price_increment * self.config.stop_offset_ticks as f64;
621
622        // Determine trigger price based on order type
623        let trigger_price = if matches!(
624            self.config.stop_order_type,
625            OrderType::LimitIfTouched | OrderType::MarketIfTouched | OrderType::TrailingStopMarket
626        ) {
627            // IF_TOUCHED and trailing-stop buy: place BELOW market
628            instrument.make_price(best_bid.as_f64() - stop_offset)
629        } else {
630            // STOP buy orders are placed ABOVE the market (stop loss on short)
631            instrument.make_price(best_ask.as_f64() + stop_offset)
632        };
633
634        // Calculate limit price if needed
635        let limit_price = if matches!(
636            self.config.stop_order_type,
637            OrderType::StopLimit | OrderType::LimitIfTouched
638        ) {
639            if let Some(limit_offset_ticks) = self.config.stop_limit_offset_ticks {
640                let limit_offset = price_increment * limit_offset_ticks as f64;
641
642                if self.config.stop_order_type == OrderType::LimitIfTouched {
643                    Some(instrument.make_price(trigger_price.as_f64() - limit_offset))
644                } else {
645                    Some(instrument.make_price(trigger_price.as_f64() + limit_offset))
646                }
647            } else {
648                Some(trigger_price)
649            }
650        } else {
651            None
652        };
653
654        let needs_new_order = match &self.buy_stop_order {
655            None => true,
656            Some(order) => !self.is_order_active(order),
657        };
658
659        if needs_new_order {
660            if let Err(e) = self.submit_stop_order(OrderSide::Buy, trigger_price, limit_price) {
661                log::error!("Failed to submit buy stop order: {e}");
662            }
663        } else if let Some(order) = &self.buy_stop_order
664            && order.venue_order_id().is_some()
665            && !order.is_pending_update()
666            && !order.is_pending_cancel()
667        {
668            let current_trigger = self.get_order_trigger_price(order);
669            if current_trigger.is_some() && current_trigger != Some(trigger_price) {
670                if self.config.modify_stop_orders_to_maintain_offset {
671                    let order_clone = order.clone();
672                    if let Err(e) = self.modify_stop_order(order_clone, trigger_price, limit_price)
673                    {
674                        log::error!("Failed to modify buy stop order: {e}");
675                    }
676                } else if self.config.cancel_replace_stop_orders_to_maintain_offset {
677                    let order_clone = order.clone();
678                    let _ = self.cancel_order(order_clone, self.config.client_id);
679
680                    if let Err(e) =
681                        self.submit_stop_order(OrderSide::Buy, trigger_price, limit_price)
682                    {
683                        log::error!("Failed to submit replacement buy stop order: {e}");
684                    }
685                }
686            }
687        }
688    }
689
690    /// Maintain stop sell orders.
691    fn maintain_stop_sell_orders(&mut self, best_bid: Price, best_ask: Price) {
692        let Some(instrument) = &self.instrument else {
693            return;
694        };
695
696        let price_increment = instrument.price_increment().as_f64();
697        let stop_offset = price_increment * self.config.stop_offset_ticks as f64;
698
699        // Determine trigger price based on order type
700        let trigger_price = if matches!(
701            self.config.stop_order_type,
702            OrderType::LimitIfTouched | OrderType::MarketIfTouched | OrderType::TrailingStopMarket
703        ) {
704            // IF_TOUCHED and trailing-stop sell: place ABOVE market
705            instrument.make_price(best_ask.as_f64() + stop_offset)
706        } else {
707            // STOP sell orders are placed BELOW the market (stop loss on long)
708            instrument.make_price(best_bid.as_f64() - stop_offset)
709        };
710
711        // Calculate limit price if needed
712        let limit_price = if matches!(
713            self.config.stop_order_type,
714            OrderType::StopLimit | OrderType::LimitIfTouched
715        ) {
716            if let Some(limit_offset_ticks) = self.config.stop_limit_offset_ticks {
717                let limit_offset = price_increment * limit_offset_ticks as f64;
718
719                if self.config.stop_order_type == OrderType::LimitIfTouched {
720                    Some(instrument.make_price(trigger_price.as_f64() + limit_offset))
721                } else {
722                    Some(instrument.make_price(trigger_price.as_f64() - limit_offset))
723                }
724            } else {
725                Some(trigger_price)
726            }
727        } else {
728            None
729        };
730
731        let needs_new_order = match &self.sell_stop_order {
732            None => true,
733            Some(order) => !self.is_order_active(order),
734        };
735
736        if needs_new_order {
737            if let Err(e) = self.submit_stop_order(OrderSide::Sell, trigger_price, limit_price) {
738                log::error!("Failed to submit sell stop order: {e}");
739            }
740        } else if let Some(order) = &self.sell_stop_order
741            && order.venue_order_id().is_some()
742            && !order.is_pending_update()
743            && !order.is_pending_cancel()
744        {
745            let current_trigger = self.get_order_trigger_price(order);
746            if current_trigger.is_some() && current_trigger != Some(trigger_price) {
747                if self.config.modify_stop_orders_to_maintain_offset {
748                    let order_clone = order.clone();
749                    if let Err(e) = self.modify_stop_order(order_clone, trigger_price, limit_price)
750                    {
751                        log::error!("Failed to modify sell stop order: {e}");
752                    }
753                } else if self.config.cancel_replace_stop_orders_to_maintain_offset {
754                    let order_clone = order.clone();
755                    let _ = self.cancel_order(order_clone, self.config.client_id);
756
757                    if let Err(e) =
758                        self.submit_stop_order(OrderSide::Sell, trigger_price, limit_price)
759                    {
760                        log::error!("Failed to submit replacement sell stop order: {e}");
761                    }
762                }
763            }
764        }
765    }
766
767    /// Submit a limit order.
768    ///
769    /// # Errors
770    ///
771    /// Returns an error if order creation or submission fails.
772    pub(super) fn submit_limit_order(
773        &mut self,
774        order_side: OrderSide,
775        price: Price,
776    ) -> anyhow::Result<()> {
777        let Some(instrument) = &self.instrument else {
778            anyhow::bail!("No instrument loaded");
779        };
780
781        if self.config.dry_run {
782            log_warn!("Dry run, skipping create {order_side:?} order");
783            return Ok(());
784        }
785
786        if order_side == OrderSide::Buy && !self.config.enable_limit_buys {
787            log_warn!("BUY orders not enabled, skipping");
788            return Ok(());
789        } else if order_side == OrderSide::Sell && !self.config.enable_limit_sells {
790            log_warn!("SELL orders not enabled, skipping");
791            return Ok(());
792        }
793
794        let (time_in_force, expire_time) =
795            self.resolve_time_in_force(self.config.limit_time_in_force);
796
797        let quantity = instrument.make_qty(self.config.order_qty.as_f64(), None);
798
799        let order = self.core.order_factory().limit(
800            self.config.instrument_id,
801            order_side,
802            quantity,
803            price,
804            Some(time_in_force),
805            expire_time,
806            Some(self.config.use_post_only || self.config.test_reject_post_only),
807            None, // reduce_only
808            Some(self.config.use_quote_quantity),
809            self.config.order_display_qty,
810            self.config.emulation_trigger,
811            None, // trigger_instrument_id
812            None, // exec_algorithm_id
813            None, // exec_algorithm_params
814            None, // tags
815            None, // client_order_id
816        );
817
818        if order_side == OrderSide::Buy {
819            self.buy_order = Some(order.clone());
820        } else {
821            self.sell_order = Some(order.clone());
822        }
823
824        self.submit_order_apply_params(order)
825    }
826
827    /// Submit a stop order.
828    ///
829    /// # Errors
830    ///
831    /// Returns an error if order creation or submission fails.
832    pub(super) fn submit_stop_order(
833        &mut self,
834        order_side: OrderSide,
835        trigger_price: Price,
836        limit_price: Option<Price>,
837    ) -> anyhow::Result<()> {
838        let Some(instrument) = &self.instrument else {
839            anyhow::bail!("No instrument loaded");
840        };
841
842        if self.config.dry_run {
843            log_warn!("Dry run, skipping create {order_side:?} stop order");
844            return Ok(());
845        }
846
847        if order_side == OrderSide::Buy && !self.config.enable_stop_buys {
848            log_warn!("BUY stop orders not enabled, skipping");
849            return Ok(());
850        } else if order_side == OrderSide::Sell && !self.config.enable_stop_sells {
851            log_warn!("SELL stop orders not enabled, skipping");
852            return Ok(());
853        }
854
855        let (time_in_force, expire_time) =
856            self.resolve_time_in_force(self.config.stop_time_in_force);
857
858        // Use instrument's make_qty to ensure correct precision
859        let quantity = instrument.make_qty(self.config.order_qty.as_f64(), None);
860
861        let factory = self.core.order_factory();
862
863        let mut order: OrderAny = match self.config.stop_order_type {
864            OrderType::StopMarket => factory.stop_market(
865                self.config.instrument_id,
866                order_side,
867                quantity,
868                trigger_price,
869                Some(self.config.stop_trigger_type),
870                Some(time_in_force),
871                expire_time,
872                None, // reduce_only
873                Some(self.config.use_quote_quantity),
874                None, // display_qty
875                self.config.emulation_trigger,
876                None, // trigger_instrument_id
877                None, // exec_algorithm_id
878                None, // exec_algorithm_params
879                None, // tags
880                None, // client_order_id
881            ),
882            OrderType::StopLimit => {
883                let Some(limit_price) = limit_price else {
884                    anyhow::bail!("STOP_LIMIT order requires limit_price");
885                };
886                factory.stop_limit(
887                    self.config.instrument_id,
888                    order_side,
889                    quantity,
890                    limit_price,
891                    trigger_price,
892                    Some(self.config.stop_trigger_type),
893                    Some(time_in_force),
894                    expire_time,
895                    None, // post_only
896                    None, // reduce_only
897                    Some(self.config.use_quote_quantity),
898                    self.config.order_display_qty,
899                    self.config.emulation_trigger,
900                    None, // trigger_instrument_id
901                    None, // exec_algorithm_id
902                    None, // exec_algorithm_params
903                    None, // tags
904                    None, // client_order_id
905                )
906            }
907            OrderType::MarketIfTouched => factory.market_if_touched(
908                self.config.instrument_id,
909                order_side,
910                quantity,
911                trigger_price,
912                Some(self.config.stop_trigger_type),
913                Some(time_in_force),
914                expire_time,
915                None, // reduce_only
916                Some(self.config.use_quote_quantity),
917                self.config.emulation_trigger,
918                None, // trigger_instrument_id
919                None, // exec_algorithm_id
920                None, // exec_algorithm_params
921                None, // tags
922                None, // client_order_id
923            ),
924            OrderType::LimitIfTouched => {
925                let Some(limit_price) = limit_price else {
926                    anyhow::bail!("LIMIT_IF_TOUCHED order requires limit_price");
927                };
928                factory.limit_if_touched(
929                    self.config.instrument_id,
930                    order_side,
931                    quantity,
932                    limit_price,
933                    trigger_price,
934                    Some(self.config.stop_trigger_type),
935                    Some(time_in_force),
936                    expire_time,
937                    None, // post_only
938                    None, // reduce_only
939                    Some(self.config.use_quote_quantity),
940                    self.config.order_display_qty,
941                    self.config.emulation_trigger,
942                    None, // trigger_instrument_id
943                    None, // exec_algorithm_id
944                    None, // exec_algorithm_params
945                    None, // tags
946                    None, // client_order_id
947                )
948            }
949            OrderType::TrailingStopMarket => {
950                let Some(trailing_offset) = self.config.trailing_offset else {
951                    anyhow::bail!("TRAILING_STOP_MARKET order requires trailing_offset config");
952                };
953                factory.trailing_stop_market(
954                    self.config.instrument_id,
955                    order_side,
956                    quantity,
957                    trailing_offset,
958                    Some(self.config.trailing_offset_type),
959                    None,
960                    Some(trigger_price),
961                    Some(self.config.stop_trigger_type),
962                    Some(time_in_force),
963                    expire_time,
964                    None, // reduce_only
965                    Some(self.config.use_quote_quantity),
966                    None, // display_qty
967                    self.config.emulation_trigger,
968                    None, // trigger_instrument_id
969                    None, // exec_algorithm_id
970                    None, // exec_algorithm_params
971                    None, // tags
972                    None, // client_order_id
973                )
974            }
975            _ => {
976                anyhow::bail!("Unknown stop order type: {:?}", self.config.stop_order_type);
977            }
978        };
979
980        if let OrderAny::TrailingStopMarket(order) = &mut order {
981            order.activation_price = Some(trigger_price);
982        }
983
984        if order_side == OrderSide::Buy {
985            self.buy_stop_order = Some(order.clone());
986        } else {
987            self.sell_stop_order = Some(order.clone());
988        }
989
990        self.submit_order_apply_params(order)
991    }
992
993    /// Submit a bracket order (entry with stop-loss and take-profit).
994    ///
995    /// # Errors
996    ///
997    /// Returns an error if order creation or submission fails.
998    pub(super) fn submit_bracket_order(
999        &mut self,
1000        order_side: OrderSide,
1001        entry_price: Price,
1002    ) -> anyhow::Result<()> {
1003        let Some(instrument) = &self.instrument else {
1004            anyhow::bail!("No instrument loaded");
1005        };
1006
1007        if self.config.dry_run {
1008            log_warn!("Dry run, skipping create {order_side:?} bracket order");
1009            return Ok(());
1010        }
1011
1012        if self.config.bracket_entry_order_type != OrderType::Limit {
1013            anyhow::bail!(
1014                "Only Limit entry orders are supported for brackets, was {:?}",
1015                self.config.bracket_entry_order_type
1016            );
1017        }
1018
1019        if order_side == OrderSide::Buy && !self.config.enable_limit_buys {
1020            log_warn!("BUY orders not enabled, skipping bracket");
1021            return Ok(());
1022        } else if order_side == OrderSide::Sell && !self.config.enable_limit_sells {
1023            log_warn!("SELL orders not enabled, skipping bracket");
1024            return Ok(());
1025        }
1026
1027        let (time_in_force, expire_time) =
1028            self.resolve_time_in_force(self.config.limit_time_in_force);
1029        let sl_time_in_force = self.config.stop_time_in_force.unwrap_or(TimeInForce::Gtc);
1030        if sl_time_in_force == TimeInForce::Gtd {
1031            anyhow::bail!("GTD time in force not supported for bracket stop-loss legs");
1032        }
1033
1034        let quantity = instrument.make_qty(self.config.order_qty.as_f64(), None);
1035        let price_increment = instrument.price_increment().as_f64();
1036        let bracket_offset = price_increment * self.config.bracket_offset_ticks as f64;
1037
1038        let (tp_price, sl_trigger_price) = match order_side {
1039            OrderSide::Buy => {
1040                let tp = instrument.make_price(entry_price.as_f64() + bracket_offset);
1041                let sl = instrument.make_price(entry_price.as_f64() - bracket_offset);
1042                (tp, sl)
1043            }
1044            OrderSide::Sell => {
1045                let tp = instrument.make_price(entry_price.as_f64() - bracket_offset);
1046                let sl = instrument.make_price(entry_price.as_f64() + bracket_offset);
1047                (tp, sl)
1048            }
1049            _ => anyhow::bail!("Invalid order side for bracket: {order_side:?}"),
1050        };
1051
1052        let orders = self.core.order_factory().bracket(
1053            self.config.instrument_id,
1054            order_side,
1055            quantity,
1056            Some(entry_price),                   // entry_price
1057            sl_trigger_price,                    // sl_trigger_price
1058            Some(self.config.stop_trigger_type), // sl_trigger_type
1059            tp_price,                            // tp_price
1060            None,                                // entry_trigger_price (limit entry, no trigger)
1061            Some(time_in_force),
1062            expire_time,
1063            Some(sl_time_in_force),
1064            Some(self.config.use_post_only || self.config.test_reject_post_only),
1065            None, // reduce_only
1066            Some(self.config.use_quote_quantity),
1067            self.config.emulation_trigger,
1068            None, // trigger_instrument_id
1069            None, // exec_algorithm_id
1070            None, // exec_algorithm_params
1071            None, // tags
1072        );
1073
1074        if let Some(entry_order) = orders.first() {
1075            if order_side == OrderSide::Buy {
1076                self.buy_order = Some(entry_order.clone());
1077            } else {
1078                self.sell_order = Some(entry_order.clone());
1079            }
1080        }
1081
1082        let client_id = self.config.client_id;
1083        if let Some(params) = &self.config.order_params {
1084            self.submit_order_list_with_params(orders, None, client_id, params.clone())
1085        } else {
1086            self.submit_order_list(orders, None, client_id)
1087        }
1088    }
1089
1090    /// Open a position with a market order.
1091    ///
1092    /// # Errors
1093    ///
1094    /// Returns an error if order creation or submission fails.
1095    pub(super) fn open_position(&mut self, net_qty: Decimal) -> anyhow::Result<()> {
1096        let Some(instrument) = &self.instrument else {
1097            anyhow::bail!("No instrument loaded");
1098        };
1099
1100        if net_qty == Decimal::ZERO {
1101            log_warn!("Open position with zero quantity, skipping");
1102            return Ok(());
1103        }
1104
1105        let order_side = if net_qty > Decimal::ZERO {
1106            OrderSide::Buy
1107        } else {
1108            OrderSide::Sell
1109        };
1110
1111        let quantity = instrument.make_qty(net_qty.abs().to_f64().unwrap_or(0.0), None);
1112
1113        // Test reduce_only rejection by setting reduce_only on open position order
1114        let reduce_only = if self.config.test_reject_reduce_only {
1115            Some(true)
1116        } else {
1117            None
1118        };
1119
1120        let order = self.core.order_factory().market(
1121            self.config.instrument_id,
1122            order_side,
1123            quantity,
1124            Some(self.config.open_position_time_in_force),
1125            reduce_only,
1126            Some(self.config.use_quote_quantity),
1127            None, // exec_algorithm_id
1128            None, // exec_algorithm_params
1129            None, // tags
1130            None, // client_order_id
1131        );
1132
1133        self.submit_order_apply_params(order)
1134    }
1135}