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 std::num::NonZeroUsize;
17
18use ahash::AHashSet;
19use nautilus_common::{
20    actor::{DataActor, DataActorNative},
21    config::ConfigError,
22    enums::LogColor,
23    log_info, log_warn,
24    timer::TimeEvent,
25};
26use nautilus_core::UnixNanos;
27use nautilus_model::{
28    data::{Bar, IndexPriceUpdate, MarkPriceUpdate, OrderBookDeltas, QuoteTick, TradeTick},
29    enums::{ContingencyType, OrderSide, OrderStatus, OrderType, TimeInForce},
30    identifiers::{ClientId, ClientOrderId, InstrumentId, StrategyId},
31    instruments::{Instrument, InstrumentAny},
32    orderbook::OrderBook,
33    orders::{Order, OrderAny},
34    types::Price,
35};
36use nautilus_trading::{
37    nautilus_strategy,
38    strategy::{Strategy, StrategyCore, StrategyNative},
39};
40use rust_decimal::{Decimal, prelude::ToPrimitive};
41
42use super::config::ExecTesterConfig;
43
44/// An execution tester strategy for live testing order execution functionality.
45///
46/// This strategy is designed for testing execution adapters by submitting
47/// limit orders, stop orders, and managing positions. It can maintain orders
48/// at a configurable offset from the top of book.
49///
50/// **WARNING**: This strategy has no alpha advantage whatsoever.
51/// It is not intended to be used for live trading with real money.
52#[derive(Debug)]
53#[expect(
54    clippy::struct_excessive_bools,
55    reason = "tester state tracks independent execution scenarios"
56)]
57pub struct ExecTester {
58    pub(super) core: StrategyCore,
59    pub(super) config: ExecTesterConfig,
60    pub(super) instrument: Option<InstrumentAny>,
61    pub(super) price_offset: Option<u64>,
62    pub(super) preinitialized_market_data: bool,
63
64    // Order tracking
65    pub(super) buy_order: Option<OrderAny>,
66    pub(super) sell_order: Option<OrderAny>,
67    pub(super) buy_stop_order: Option<OrderAny>,
68    pub(super) sell_stop_order: Option<OrderAny>,
69    pub(super) open_position_submitted: bool,
70
71    // One-shot guard for `test_modify_rejected`: ensures the programmatic
72    // modify is attempted at most once across the strategy's lifetime.
73    pub(super) modify_rejected_attempted: bool,
74    pub(super) pending_open_position_qty: Option<Decimal>,
75    pub(super) buy_cancel_replace_attempted: bool,
76    pub(super) sell_cancel_replace_attempted: bool,
77    pub(super) buy_stop_cancel_replace_attempted: bool,
78    pub(super) sell_stop_cancel_replace_attempted: bool,
79}
80
81nautilus_strategy!(ExecTester, {
82    fn external_order_claims(&self) -> Option<Vec<InstrumentId>> {
83        self.config.base.external_order_claims.clone()
84    }
85});
86
87impl DataActor for ExecTester {
88    fn on_start(&mut self) -> anyhow::Result<()> {
89        Strategy::on_start(self)?;
90
91        let instrument_id = self.config.instrument_id;
92        let client_id = self.config.client_id;
93
94        let instrument = self.cache().instrument(&instrument_id);
95
96        if let Some(inst) = instrument {
97            self.initialize_with_instrument(inst, true)?;
98        } else {
99            log::info!("Instrument {instrument_id} not in cache, subscribing...");
100            self.subscribe_instrument(instrument_id, client_id, None);
101
102            // Also subscribe to market data to trigger instrument definitions from data providers
103            // (e.g., Databento sends instrument definitions as part of market data subscriptions)
104            if self.config.subscribe_quotes {
105                self.subscribe_quotes(instrument_id, client_id, None);
106            }
107
108            if self.config.subscribe_trades {
109                self.subscribe_trades(instrument_id, client_id, None);
110            }
111            self.preinitialized_market_data =
112                self.config.subscribe_quotes || self.config.subscribe_trades;
113        }
114
115        Ok(())
116    }
117
118    fn on_instrument(&mut self, instrument: &InstrumentAny) -> anyhow::Result<()> {
119        if instrument.id() == self.config.instrument_id && self.instrument.is_none() {
120            let id = instrument.id();
121            log::info!("Received instrument {id}, initializing...");
122            self.initialize_with_instrument(instrument.clone(), !self.preinitialized_market_data)?;
123        }
124        Ok(())
125    }
126
127    fn on_stop(&mut self) -> anyhow::Result<()> {
128        if self.config.dry_run {
129            log_warn!("Dry run mode, skipping cancel all orders and close all positions");
130            return Ok(());
131        }
132
133        let instrument_id = self.config.instrument_id;
134        let client_id = self.config.client_id;
135        let strategy_id = StrategyId::from(
136            DataActorNative::core(&self.core)
137                .actor_id()
138                .inner()
139                .as_str(),
140        );
141
142        if self.config.cancel_orders_on_stop {
143            self.cancel_active_orders(instrument_id, strategy_id, client_id);
144        }
145
146        if self.config.close_positions_on_stop {
147            let time_in_force = self
148                .config
149                .close_positions_time_in_force
150                .or(Some(TimeInForce::Gtc));
151
152            if let Err(e) = self.close_all_positions(
153                instrument_id,
154                None,
155                client_id,
156                None,
157                time_in_force,
158                Some(self.config.reduce_only_on_stop),
159                None,
160            ) {
161                log::error!("Failed to close all positions: {e}");
162            }
163        }
164
165        if self.config.can_unsubscribe && self.instrument.is_some() {
166            if self.config.subscribe_quotes {
167                self.unsubscribe_quotes(instrument_id, client_id, None);
168            }
169
170            if self.config.subscribe_trades {
171                self.unsubscribe_trades(instrument_id, client_id, None);
172            }
173
174            if self.config.subscribe_book {
175                self.unsubscribe_book_at_interval(
176                    instrument_id,
177                    NonZeroUsize::new(self.config.book_interval_ms).ok_or_else(|| {
178                        ConfigError::range("book_interval_ms", "must be positive, was 0")
179                    })?,
180                    client_id,
181                    None,
182                );
183            }
184        }
185
186        Ok(())
187    }
188
189    fn on_quote(&mut self, quote: &QuoteTick) -> anyhow::Result<()> {
190        if self.config.log_data {
191            log_info!("{quote:?}", color = LogColor::Cyan);
192        }
193
194        if quote.instrument_id == self.config.instrument_id
195            && self.config.open_position_on_first_quote
196        {
197            self.submit_pending_open_position();
198        }
199
200        self.maintain_orders(quote.bid_price, quote.ask_price);
201        Ok(())
202    }
203
204    fn on_trade(&mut self, trade: &TradeTick) -> anyhow::Result<()> {
205        if self.config.log_data {
206            log_info!("{trade:?}", color = LogColor::Cyan);
207        }
208        Ok(())
209    }
210
211    fn on_book(&mut self, book: &OrderBook) -> anyhow::Result<()> {
212        if self.config.log_data {
213            let num_levels = self.config.book_levels_to_print;
214            let instrument_id = book.instrument_id;
215            let book_str = book.pprint(num_levels, None);
216            log_info!("\n{instrument_id}\n{book_str}", color = LogColor::Cyan);
217
218            // Log own order book if available
219            if DataActorNative::core(&self.core).is_registered() {
220                let cache = self.cache();
221                if let Some(own_book) = cache.own_order_book(&instrument_id) {
222                    let own_book_str = own_book.pprint(num_levels, None);
223                    log_info!(
224                        "\n{instrument_id} (own)\n{own_book_str}",
225                        color = LogColor::Magenta
226                    );
227                }
228            }
229        }
230
231        let Some(best_bid) = book.best_bid_price() else {
232            return Ok(()); // Wait for market
233        };
234        let Some(best_ask) = book.best_ask_price() else {
235            return Ok(()); // Wait for market
236        };
237
238        self.maintain_orders(best_bid, best_ask);
239        Ok(())
240    }
241
242    fn on_book_deltas(&mut self, deltas: &OrderBookDeltas) -> anyhow::Result<()> {
243        if self.config.log_data {
244            log_info!("{deltas:?}", color = LogColor::Cyan);
245        }
246        Ok(())
247    }
248
249    fn on_bar(&mut self, bar: &Bar) -> anyhow::Result<()> {
250        if self.config.log_data {
251            log_info!("{bar:?}", color = LogColor::Cyan);
252        }
253        Ok(())
254    }
255
256    fn on_mark_price(&mut self, mark_price: &MarkPriceUpdate) -> anyhow::Result<()> {
257        if self.config.log_data {
258            log_info!("{mark_price:?}", color = LogColor::Cyan);
259        }
260        Ok(())
261    }
262
263    fn on_index_price(&mut self, index_price: &IndexPriceUpdate) -> anyhow::Result<()> {
264        if self.config.log_data {
265            log_info!("{index_price:?}", color = LogColor::Cyan);
266        }
267        Ok(())
268    }
269
270    fn on_time_event(&mut self, event: &TimeEvent) -> anyhow::Result<()> {
271        Strategy::on_time_event(self, event)
272    }
273}
274
275impl ExecTester {
276    /// Creates a new [`ExecTester`] instance.
277    #[must_use]
278    pub fn new(config: ExecTesterConfig) -> Self {
279        let pending_open_position_qty = config.open_position_on_start_qty;
280
281        Self {
282            core: StrategyCore::new(config.base.clone()),
283            config,
284            instrument: None,
285            price_offset: None,
286            preinitialized_market_data: false,
287            buy_order: None,
288            sell_order: None,
289            buy_stop_order: None,
290            sell_stop_order: None,
291            open_position_submitted: false,
292            modify_rejected_attempted: false,
293            pending_open_position_qty,
294            buy_cancel_replace_attempted: false,
295            sell_cancel_replace_attempted: false,
296            buy_stop_cancel_replace_attempted: false,
297            sell_stop_cancel_replace_attempted: false,
298        }
299    }
300
301    fn initialize_with_instrument(
302        &mut self,
303        instrument: InstrumentAny,
304        subscribe_market_data: bool,
305    ) -> anyhow::Result<()> {
306        let instrument_id = self.config.instrument_id;
307        let client_id = self.config.client_id;
308
309        self.price_offset = Some(self.get_price_offset(&instrument));
310        self.instrument = Some(instrument);
311
312        if subscribe_market_data && self.config.subscribe_quotes {
313            self.subscribe_quotes(instrument_id, client_id, None);
314        }
315
316        if subscribe_market_data && self.config.subscribe_trades {
317            self.subscribe_trades(instrument_id, client_id, None);
318        }
319
320        if self.config.subscribe_book {
321            self.subscribe_book_at_interval(
322                instrument_id,
323                self.config.book_type,
324                self.config
325                    .book_depth
326                    .map(|depth| {
327                        NonZeroUsize::new(depth).ok_or_else(|| {
328                            ConfigError::range("book_depth", "must be positive, was 0")
329                        })
330                    })
331                    .transpose()?,
332                NonZeroUsize::new(self.config.book_interval_ms).ok_or_else(|| {
333                    ConfigError::range("book_interval_ms", "must be positive, was 0")
334                })?,
335                client_id,
336                None,
337            );
338        }
339
340        if let Some(qty) = self.pending_open_position_qty {
341            let quote_ready = {
342                let cache = self.cache();
343                cache.quote(&instrument_id).is_some()
344            };
345
346            if self.config.open_position_on_first_quote
347                && self.config.subscribe_quotes
348                && !quote_ready
349            {
350                log::info!("Waiting for first quote before opening {instrument_id} position");
351            } else {
352                self.pending_open_position_qty = None;
353                self.open_position(qty)?;
354                self.open_position_submitted = true;
355            }
356        }
357
358        Ok(())
359    }
360
361    pub(super) fn get_price_offset(&self, _instrument: &InstrumentAny) -> u64 {
362        self.config.tob_offset_ticks
363    }
364
365    fn expire_time_from_delta(&self, mins: u64) -> UnixNanos {
366        let current_ns = DataActorNative::core(&self.core).timestamp_ns();
367        let delta_ns = mins.saturating_mul(60).saturating_mul(1_000_000_000);
368        UnixNanos::from(current_ns.as_u64() + delta_ns)
369    }
370
371    fn resolve_time_in_force(
372        &self,
373        tif_override: Option<TimeInForce>,
374    ) -> (TimeInForce, Option<UnixNanos>) {
375        match (tif_override, self.config.order_expire_time_delta_mins) {
376            (Some(TimeInForce::Gtd), Some(mins)) => {
377                (TimeInForce::Gtd, Some(self.expire_time_from_delta(mins)))
378            }
379            (Some(TimeInForce::Gtd), None) => {
380                log_warn!(
381                    "GTD time in force requires order_expire_time_delta_mins, falling back to GTC"
382                );
383                (TimeInForce::Gtc, None)
384            }
385            (Some(tif), _) => (tif, None),
386            (None, Some(mins)) => (TimeInForce::Gtd, Some(self.expire_time_from_delta(mins))),
387            (None, None) => (TimeInForce::Gtc, None),
388        }
389    }
390
391    fn submit_pending_open_position(&mut self) {
392        if self.instrument.is_none() {
393            return;
394        }
395
396        let Some(qty) = self.pending_open_position_qty.take() else {
397            return;
398        };
399
400        if let Err(e) = self.open_position(qty) {
401            log::error!("Failed to submit pending open position: {e}");
402        } else {
403            self.open_position_submitted = true;
404        }
405    }
406
407    pub(super) fn is_order_active(order: &OrderAny) -> bool {
408        order.is_active_local() || order.is_inflight() || order.is_open()
409    }
410
411    pub(super) fn limit_order_is_one_shot(&self) -> bool {
412        self.config.test_reject_post_only
413            || self.config.limit_aggressive
414            || self.config.order_expire_time_delta_mins.is_some()
415            || matches!(
416                self.config.limit_time_in_force,
417                Some(TimeInForce::Ioc | TimeInForce::Fok)
418            )
419    }
420
421    pub(super) fn stop_order_is_one_shot(&self) -> bool {
422        self.config.order_expire_time_delta_mins.is_some()
423            || matches!(
424                self.config.stop_time_in_force,
425                Some(TimeInForce::Ioc | TimeInForce::Fok)
426            )
427            || matches!(self.config.stop_order_type, OrderType::TrailingStopMarket)
428    }
429
430    pub(super) fn get_order_trigger_price(order: &OrderAny) -> Option<Price> {
431        order.trigger_price()
432    }
433
434    fn modify_stop_order(
435        &mut self,
436        order: &OrderAny,
437        trigger_price: Price,
438        limit_price: Option<Price>,
439    ) -> anyhow::Result<()> {
440        let client_id = self.config.client_id;
441
442        match order {
443            OrderAny::StopMarket(_)
444            | OrderAny::MarketIfTouched(_)
445            | OrderAny::TrailingStopMarket(_) => self.modify_order(
446                order.client_order_id(),
447                None,
448                None,
449                Some(trigger_price),
450                client_id,
451                None,
452            ),
453            OrderAny::StopLimit(_) | OrderAny::LimitIfTouched(_) => self.modify_order(
454                order.client_order_id(),
455                None,
456                limit_price,
457                Some(trigger_price),
458                client_id,
459                None,
460            ),
461            _ => {
462                log_warn!("Cannot modify order of type {:?}", order.order_type());
463                Ok(())
464            }
465        }
466    }
467
468    /// Submit an order, applying `order_params` if configured.
469    fn submit_order_apply_params(&mut self, order: OrderAny) -> anyhow::Result<()> {
470        let client_id = self.config.client_id;
471        if let Some(params) = &self.config.order_params {
472            self.submit_order(order, None, client_id, Some(params.clone()))
473        } else {
474            self.submit_order(order, None, client_id, None)
475        }
476    }
477
478    /// Maintain orders based on current market prices.
479    pub(super) fn maintain_orders(&mut self, best_bid: Price, best_ask: Price) {
480        if self.instrument.is_none() || self.config.dry_run {
481            return;
482        }
483
484        if self.config.batch_submit_limit_pair
485            && self.config.enable_limit_buys
486            && self.config.enable_limit_sells
487        {
488            self.maintain_batch_limit_pair(best_bid, best_ask);
489            return;
490        }
491
492        if self.config.enable_limit_buys {
493            self.maintain_buy_orders(best_bid, best_ask);
494        }
495
496        if self.config.enable_limit_sells {
497            self.maintain_sell_orders(best_bid, best_ask);
498        }
499
500        if self.config.enable_stop_buys {
501            self.maintain_stop_buy_orders(best_bid, best_ask);
502        }
503
504        if self.config.enable_stop_sells {
505            self.maintain_stop_sell_orders(best_bid, best_ask);
506        }
507    }
508
509    /// Refreshes the locally-tracked order for `side` from the cache so that
510    /// downstream checks (`venue_order_id()`, `is_pending_*`, status) see the
511    /// latest event-driven state instead of the stale clone captured at submit.
512    fn refresh_tracked_order(&mut self, side: OrderSide) {
513        let cid = match side {
514            OrderSide::Buy => self.buy_order.as_ref().map(OrderAny::client_order_id),
515            OrderSide::Sell => self.sell_order.as_ref().map(OrderAny::client_order_id),
516            OrderSide::NoOrderSide => None,
517        };
518        let Some(cid) = cid else {
519            return;
520        };
521        let latest = self.cache().order(&cid);
522        if let Some(latest) = latest {
523            match side {
524                OrderSide::Buy => self.buy_order = Some(latest),
525                OrderSide::Sell => self.sell_order = Some(latest),
526                OrderSide::NoOrderSide => {}
527            }
528        }
529    }
530
531    fn refresh_tracked_stop_order(&mut self, side: OrderSide) {
532        let cid = match side {
533            OrderSide::Buy => self.buy_stop_order.as_ref().map(OrderAny::client_order_id),
534            OrderSide::Sell => self.sell_stop_order.as_ref().map(OrderAny::client_order_id),
535            OrderSide::NoOrderSide => None,
536        };
537        let Some(cid) = cid else {
538            return;
539        };
540        let latest = self.cache().order(&cid);
541        if let Some(latest) = latest {
542            match side {
543                OrderSide::Buy => self.buy_stop_order = Some(latest),
544                OrderSide::Sell => self.sell_stop_order = Some(latest),
545                OrderSide::NoOrderSide => {}
546            }
547        }
548    }
549
550    /// Maintain buy limit orders.
551    fn maintain_buy_orders(&mut self, best_bid: Price, best_ask: Price) {
552        // Refresh from cache first so post-submit event state (venue_order_id,
553        // status) is visible. Done before binding `&self.instrument` to avoid
554        // holding an immutable borrow across the mutable refresh call.
555        self.refresh_tracked_order(OrderSide::Buy);
556
557        let Some(instrument) = &self.instrument else {
558            return;
559        };
560        let Some(price_offset_ticks) = self.price_offset else {
561            return;
562        };
563
564        let increment = instrument.price_increment();
565        let precision = instrument.price_precision();
566
567        // `test_reject_post_only` and `limit_aggressive` both cross the spread for
568        // BUY (place at/above the ask). `test_reject_post_only` additionally sets
569        // post_only=true downstream to trigger venue rejection; `limit_aggressive`
570        // pairs with IOC/FOK TIF for marketable-fill scenarios.
571        let cross_spread = self.config.test_reject_post_only || self.config.limit_aggressive;
572        let unclamped_price = if cross_spread {
573            add_price_ticks(best_ask, increment, price_offset_ticks, precision)
574        } else {
575            sub_price_ticks(best_bid, increment, price_offset_ticks, precision)
576        };
577        let price = clamp_price_to_range(
578            unclamped_price,
579            instrument,
580            self.config.clamp_to_instrument_price_range,
581        );
582
583        let needs_new_order = match &self.buy_order {
584            None => true,
585            Some(order) => !Self::is_order_active(order) && !self.limit_order_is_one_shot(),
586        };
587
588        if needs_new_order {
589            let result = if self.config.enable_brackets {
590                self.submit_bracket_order(OrderSide::Buy, price)
591            } else {
592                self.submit_limit_order(OrderSide::Buy, price)
593            };
594
595            if let Err(e) = result {
596                log::error!("Failed to submit buy order: {e}");
597            }
598        } else if let Some(order) = &self.buy_order
599            && order.venue_order_id().is_some()
600            && !order.is_pending_update()
601            && !order.is_pending_cancel()
602        {
603            let client_id = self.config.client_id;
604
605            // One-shot programmatic modify to exercise the adapter's modify-rejection
606            // path (TC-E36). Uses a small price bump rather than waiting for drift.
607            if self.config.test_modify_rejected && !self.modify_rejected_attempted {
608                self.modify_rejected_attempted = true;
609                let order_clone = order.clone();
610                let bumped = clamp_price_to_range(
611                    add_price_ticks(price, increment, 1, precision),
612                    instrument,
613                    self.config.clamp_to_instrument_price_range,
614                );
615
616                if let Err(e) = self.modify_order(
617                    order_clone.client_order_id(),
618                    None,
619                    Some(bumped),
620                    None,
621                    client_id,
622                    None,
623                ) {
624                    log::error!("Failed to submit test modify on buy order: {e}");
625                }
626                return;
627            }
628
629            if let Some(order_price) = order.price()
630                && order_price < price
631            {
632                if self.config.modify_orders_to_maintain_tob_offset {
633                    let order_clone = order.clone();
634                    if let Err(e) = self.modify_order(
635                        order_clone.client_order_id(),
636                        None,
637                        Some(price),
638                        None,
639                        client_id,
640                        None,
641                    ) {
642                        log::error!("Failed to modify buy order: {e}");
643                    }
644                } else if self.config.cancel_replace_orders_to_maintain_tob_offset
645                    && !self.buy_cancel_replace_attempted
646                {
647                    self.buy_cancel_replace_attempted = true;
648                    let order_clone = order.clone();
649                    let _ = self.cancel_order(order_clone.client_order_id(), client_id, None);
650
651                    if let Err(e) = self.submit_limit_order(OrderSide::Buy, price) {
652                        log::error!("Failed to submit replacement buy order: {e}");
653                    }
654                }
655            }
656        }
657    }
658
659    /// Maintain sell limit orders.
660    fn maintain_sell_orders(&mut self, best_bid: Price, best_ask: Price) {
661        // Refresh from cache before borrowing `&self.instrument`; see the
662        // matching comment in `maintain_buy_orders`.
663        self.refresh_tracked_order(OrderSide::Sell);
664
665        let Some(instrument) = &self.instrument else {
666            return;
667        };
668        let Some(price_offset_ticks) = self.price_offset else {
669            return;
670        };
671
672        let increment = instrument.price_increment();
673        let precision = instrument.price_precision();
674
675        // See `maintain_buy_orders` for the cross_spread and refresh rationale.
676        let cross_spread = self.config.test_reject_post_only || self.config.limit_aggressive;
677        let unclamped_price = if cross_spread {
678            sub_price_ticks(best_bid, increment, price_offset_ticks, precision)
679        } else {
680            add_price_ticks(best_ask, increment, price_offset_ticks, precision)
681        };
682        let price = clamp_price_to_range(
683            unclamped_price,
684            instrument,
685            self.config.clamp_to_instrument_price_range,
686        );
687
688        let needs_new_order = match &self.sell_order {
689            None => true,
690            Some(order) => !Self::is_order_active(order) && !self.limit_order_is_one_shot(),
691        };
692
693        if needs_new_order {
694            let result = if self.config.enable_brackets {
695                self.submit_bracket_order(OrderSide::Sell, price)
696            } else {
697                self.submit_limit_order(OrderSide::Sell, price)
698            };
699
700            if let Err(e) = result {
701                log::error!("Failed to submit sell order: {e}");
702            }
703        } else if let Some(order) = &self.sell_order
704            && order.venue_order_id().is_some()
705            && !order.is_pending_update()
706            && !order.is_pending_cancel()
707        {
708            let client_id = self.config.client_id;
709
710            // One-shot programmatic modify (TC-E36); see maintain_buy_orders.
711            if self.config.test_modify_rejected && !self.modify_rejected_attempted {
712                self.modify_rejected_attempted = true;
713                let order_clone = order.clone();
714                let bumped = clamp_price_to_range(
715                    sub_price_ticks(price, increment, 1, precision),
716                    instrument,
717                    self.config.clamp_to_instrument_price_range,
718                );
719
720                if let Err(e) = self.modify_order(
721                    order_clone.client_order_id(),
722                    None,
723                    Some(bumped),
724                    None,
725                    client_id,
726                    None,
727                ) {
728                    log::error!("Failed to submit test modify on sell order: {e}");
729                }
730                return;
731            }
732
733            if let Some(order_price) = order.price()
734                && order_price > price
735            {
736                if self.config.modify_orders_to_maintain_tob_offset {
737                    let order_clone = order.clone();
738                    if let Err(e) = self.modify_order(
739                        order_clone.client_order_id(),
740                        None,
741                        Some(price),
742                        None,
743                        client_id,
744                        None,
745                    ) {
746                        log::error!("Failed to modify sell order: {e}");
747                    }
748                } else if self.config.cancel_replace_orders_to_maintain_tob_offset
749                    && !self.sell_cancel_replace_attempted
750                {
751                    self.sell_cancel_replace_attempted = true;
752                    let order_clone = order.clone();
753                    let _ = self.cancel_order(order_clone.client_order_id(), client_id, None);
754
755                    if let Err(e) = self.submit_limit_order(OrderSide::Sell, price) {
756                        log::error!("Failed to submit replacement sell order: {e}");
757                    }
758                }
759            }
760        }
761    }
762
763    /// Submits a buy and sell limit order as an order list (batch).
764    fn maintain_batch_limit_pair(&mut self, best_bid: Price, best_ask: Price) {
765        // Same rationale as the non-batch path: refresh from cache so the
766        // active-order check sees the latest status. Done before binding
767        // `&self.instrument` to avoid an immutable-vs-mutable borrow conflict.
768        self.refresh_tracked_order(OrderSide::Buy);
769        self.refresh_tracked_order(OrderSide::Sell);
770
771        let Some(instrument) = &self.instrument else {
772            return;
773        };
774        let Some(price_offset_ticks) = self.price_offset else {
775            return;
776        };
777
778        let buy_needs = match &self.buy_order {
779            None => true,
780            Some(order) => !Self::is_order_active(order) && !self.limit_order_is_one_shot(),
781        };
782        let sell_needs = match &self.sell_order {
783            None => true,
784            Some(order) => !Self::is_order_active(order) && !self.limit_order_is_one_shot(),
785        };
786
787        if !buy_needs || !sell_needs {
788            return;
789        }
790
791        let increment = instrument.price_increment();
792        let precision = instrument.price_precision();
793
794        // `test_reject_post_only` and `limit_aggressive` flip the BUY/SELL
795        // pricing to cross the spread; mirrored from `maintain_buy_orders` /
796        // `maintain_sell_orders` so batch mode supports the same scenarios.
797        let cross_spread = self.config.test_reject_post_only || self.config.limit_aggressive;
798        let (unclamped_buy_price, unclamped_sell_price) = if cross_spread {
799            (
800                add_price_ticks(best_ask, increment, price_offset_ticks, precision),
801                sub_price_ticks(best_bid, increment, price_offset_ticks, precision),
802            )
803        } else {
804            (
805                sub_price_ticks(best_bid, increment, price_offset_ticks, precision),
806                add_price_ticks(best_ask, increment, price_offset_ticks, precision),
807            )
808        };
809        let clamp = self.config.clamp_to_instrument_price_range;
810        let buy_price = clamp_price_to_range(unclamped_buy_price, instrument, clamp);
811        let sell_price = clamp_price_to_range(unclamped_sell_price, instrument, clamp);
812        let quantity = instrument.make_qty(self.config.order_qty.as_f64(), None);
813        let (time_in_force, expire_time) =
814            self.resolve_time_in_force(self.config.limit_time_in_force);
815        let instrument_id = self.config.instrument_id;
816        let post_only = self.config.use_post_only || self.config.test_reject_post_only;
817        let quote_quantity = self.config.use_quote_quantity;
818        let display_qty = self.config.order_display_qty;
819        let emulation_trigger = self.config.emulation_trigger;
820
821        let buy_order = self.order_factory().limit(
822            instrument_id,
823            OrderSide::Buy,
824            quantity,
825            buy_price,
826            Some(time_in_force),
827            expire_time,
828            Some(post_only),
829            None,
830            Some(quote_quantity),
831            display_qty,
832            emulation_trigger,
833            None,
834            None,
835            None,
836            None,
837            None,
838        );
839
840        let sell_order = self.order_factory().limit(
841            instrument_id,
842            OrderSide::Sell,
843            quantity,
844            sell_price,
845            Some(time_in_force),
846            expire_time,
847            Some(post_only),
848            None,
849            Some(quote_quantity),
850            display_qty,
851            emulation_trigger,
852            None,
853            None,
854            None,
855            None,
856            None,
857        );
858
859        self.buy_order = Some(buy_order.clone());
860        self.sell_order = Some(sell_order.clone());
861
862        let client_id = self.config.client_id;
863        if let Err(e) = self.submit_order_list(vec![buy_order, sell_order], None, client_id, None) {
864            log::error!("Failed to submit batch limit pair: {e}");
865        }
866    }
867
868    /// Maintain stop buy orders.
869    fn maintain_stop_buy_orders(&mut self, best_bid: Price, best_ask: Price) {
870        self.refresh_tracked_stop_order(OrderSide::Buy);
871
872        // Avoid churn: leave a rejected/denied stop alone (no resubmit or modify)
873        if let Some(order) = self.buy_stop_order.as_ref()
874            && matches!(order.status(), OrderStatus::Rejected | OrderStatus::Denied)
875        {
876            return;
877        }
878
879        let Some(instrument) = &self.instrument else {
880            return;
881        };
882
883        let increment = instrument.price_increment();
884        let precision = instrument.price_precision();
885        let stop_offset_ticks = self.config.stop_offset_ticks;
886
887        // Determine trigger price based on order type
888        let unclamped_trigger_price = if matches!(
889            self.config.stop_order_type,
890            OrderType::LimitIfTouched | OrderType::MarketIfTouched | OrderType::TrailingStopMarket
891        ) {
892            // IF_TOUCHED and trailing-stop buy: place BELOW market
893            sub_price_ticks(best_bid, increment, stop_offset_ticks, precision)
894        } else {
895            // STOP buy orders are placed ABOVE the market (stop loss on short)
896            add_price_ticks(best_ask, increment, stop_offset_ticks, precision)
897        };
898        let clamp = self.config.clamp_to_instrument_price_range;
899        let trigger_price = clamp_price_to_range(unclamped_trigger_price, instrument, clamp);
900
901        // Calculate limit price if needed
902        let limit_price = if matches!(
903            self.config.stop_order_type,
904            OrderType::StopLimit | OrderType::LimitIfTouched
905        ) {
906            let unclamped_limit_price =
907                if let Some(limit_offset_ticks) = self.config.stop_limit_offset_ticks {
908                    // BUY LIT/StopLimit both require trigger_price <= price.
909                    add_price_ticks(trigger_price, increment, limit_offset_ticks, precision)
910                } else {
911                    trigger_price
912                };
913            Some(clamp_price_to_range(
914                unclamped_limit_price,
915                instrument,
916                clamp,
917            ))
918        } else {
919            None
920        };
921
922        let needs_new_order = match &self.buy_stop_order {
923            None => true,
924            Some(order) => !Self::is_order_active(order) && !self.stop_order_is_one_shot(),
925        };
926
927        if needs_new_order {
928            if let Err(e) = self.submit_stop_order(OrderSide::Buy, trigger_price, limit_price) {
929                log::error!("Failed to submit buy stop order: {e}");
930            }
931        } else if let Some(order) = &self.buy_stop_order
932            && order.venue_order_id().is_some()
933            && !order.is_pending_update()
934            && !order.is_pending_cancel()
935        {
936            let current_trigger = Self::get_order_trigger_price(order);
937            if current_trigger.is_some() && current_trigger != Some(trigger_price) {
938                if self.config.modify_stop_orders_to_maintain_offset {
939                    let order_clone = order.clone();
940                    if let Err(e) = self.modify_stop_order(&order_clone, trigger_price, limit_price)
941                    {
942                        log::error!("Failed to modify buy stop order: {e}");
943                    }
944                } else if self.config.cancel_replace_stop_orders_to_maintain_offset
945                    && !self.buy_stop_cancel_replace_attempted
946                {
947                    self.buy_stop_cancel_replace_attempted = true;
948                    let order_clone = order.clone();
949                    let _ = self.cancel_order(
950                        order_clone.client_order_id(),
951                        self.config.client_id,
952                        None,
953                    );
954
955                    if let Err(e) =
956                        self.submit_stop_order(OrderSide::Buy, trigger_price, limit_price)
957                    {
958                        log::error!("Failed to submit replacement buy stop order: {e}");
959                    }
960                }
961            }
962        }
963    }
964
965    /// Maintain stop sell orders.
966    fn maintain_stop_sell_orders(&mut self, best_bid: Price, best_ask: Price) {
967        self.refresh_tracked_stop_order(OrderSide::Sell);
968
969        // Avoid churn: leave a rejected/denied stop alone (no resubmit or modify)
970        if let Some(order) = self.sell_stop_order.as_ref()
971            && matches!(order.status(), OrderStatus::Rejected | OrderStatus::Denied)
972        {
973            return;
974        }
975
976        let Some(instrument) = &self.instrument else {
977            return;
978        };
979
980        let increment = instrument.price_increment();
981        let precision = instrument.price_precision();
982        let stop_offset_ticks = self.config.stop_offset_ticks;
983
984        // Determine trigger price based on order type
985        let unclamped_trigger_price = if matches!(
986            self.config.stop_order_type,
987            OrderType::LimitIfTouched | OrderType::MarketIfTouched | OrderType::TrailingStopMarket
988        ) {
989            // IF_TOUCHED and trailing-stop sell: place ABOVE market
990            add_price_ticks(best_ask, increment, stop_offset_ticks, precision)
991        } else {
992            // STOP sell orders are placed BELOW the market (stop loss on long)
993            sub_price_ticks(best_bid, increment, stop_offset_ticks, precision)
994        };
995        let clamp = self.config.clamp_to_instrument_price_range;
996        let trigger_price = clamp_price_to_range(unclamped_trigger_price, instrument, clamp);
997
998        // Calculate limit price if needed
999        let limit_price = if matches!(
1000            self.config.stop_order_type,
1001            OrderType::StopLimit | OrderType::LimitIfTouched
1002        ) {
1003            let unclamped_limit_price =
1004                if let Some(limit_offset_ticks) = self.config.stop_limit_offset_ticks {
1005                    // SELL LIT/StopLimit both require trigger_price >= price.
1006                    sub_price_ticks(trigger_price, increment, limit_offset_ticks, precision)
1007                } else {
1008                    trigger_price
1009                };
1010            Some(clamp_price_to_range(
1011                unclamped_limit_price,
1012                instrument,
1013                clamp,
1014            ))
1015        } else {
1016            None
1017        };
1018
1019        let needs_new_order = match &self.sell_stop_order {
1020            None => true,
1021            Some(order) => !Self::is_order_active(order) && !self.stop_order_is_one_shot(),
1022        };
1023
1024        if needs_new_order {
1025            if let Err(e) = self.submit_stop_order(OrderSide::Sell, trigger_price, limit_price) {
1026                log::error!("Failed to submit sell stop order: {e}");
1027            }
1028        } else if let Some(order) = &self.sell_stop_order
1029            && order.venue_order_id().is_some()
1030            && !order.is_pending_update()
1031            && !order.is_pending_cancel()
1032        {
1033            let current_trigger = Self::get_order_trigger_price(order);
1034            if current_trigger.is_some() && current_trigger != Some(trigger_price) {
1035                if self.config.modify_stop_orders_to_maintain_offset {
1036                    let order_clone = order.clone();
1037                    if let Err(e) = self.modify_stop_order(&order_clone, trigger_price, limit_price)
1038                    {
1039                        log::error!("Failed to modify sell stop order: {e}");
1040                    }
1041                } else if self.config.cancel_replace_stop_orders_to_maintain_offset
1042                    && !self.sell_stop_cancel_replace_attempted
1043                {
1044                    self.sell_stop_cancel_replace_attempted = true;
1045                    let order_clone = order.clone();
1046                    let _ = self.cancel_order(
1047                        order_clone.client_order_id(),
1048                        self.config.client_id,
1049                        None,
1050                    );
1051
1052                    if let Err(e) =
1053                        self.submit_stop_order(OrderSide::Sell, trigger_price, limit_price)
1054                    {
1055                        log::error!("Failed to submit replacement sell stop order: {e}");
1056                    }
1057                }
1058            }
1059        }
1060    }
1061
1062    /// Submit a limit order.
1063    ///
1064    /// # Errors
1065    ///
1066    /// Returns an error if order creation or submission fails.
1067    pub(super) fn submit_limit_order(
1068        &mut self,
1069        order_side: OrderSide,
1070        price: Price,
1071    ) -> anyhow::Result<()> {
1072        let Some(instrument) = &self.instrument else {
1073            anyhow::bail!("No instrument loaded");
1074        };
1075
1076        if self.config.dry_run {
1077            log_warn!("Dry run, skipping create {order_side:?} order");
1078            return Ok(());
1079        }
1080
1081        if order_side == OrderSide::Buy && !self.config.enable_limit_buys {
1082            log_warn!("BUY orders not enabled, skipping");
1083            return Ok(());
1084        } else if order_side == OrderSide::Sell && !self.config.enable_limit_sells {
1085            log_warn!("SELL orders not enabled, skipping");
1086            return Ok(());
1087        }
1088
1089        let (time_in_force, expire_time) =
1090            self.resolve_time_in_force(self.config.limit_time_in_force);
1091
1092        let quantity = instrument.make_qty(self.config.order_qty.as_f64(), None);
1093        let instrument_id = self.config.instrument_id;
1094        let post_only = self.config.use_post_only || self.config.test_reject_post_only;
1095        let quote_quantity = self.config.use_quote_quantity;
1096        let display_qty = self.config.order_display_qty;
1097        let emulation_trigger = self.config.emulation_trigger;
1098
1099        let order = self.order_factory().limit(
1100            instrument_id,
1101            order_side,
1102            quantity,
1103            price,
1104            Some(time_in_force),
1105            expire_time,
1106            Some(post_only),
1107            None, // reduce_only
1108            Some(quote_quantity),
1109            display_qty,
1110            emulation_trigger,
1111            None, // trigger_instrument_id
1112            None, // exec_algorithm_id
1113            None, // exec_algorithm_params
1114            None, // tags
1115            None, // client_order_id
1116        );
1117
1118        if order_side == OrderSide::Buy {
1119            self.buy_order = Some(order.clone());
1120        } else {
1121            self.sell_order = Some(order.clone());
1122        }
1123
1124        self.submit_order_apply_params(order)
1125    }
1126
1127    /// Submit a stop order.
1128    ///
1129    /// # Errors
1130    ///
1131    /// Returns an error if order creation or submission fails.
1132    #[expect(
1133        clippy::too_many_lines,
1134        reason = "stop order submission covers all supported stop order scenarios"
1135    )]
1136    pub(super) fn submit_stop_order(
1137        &mut self,
1138        order_side: OrderSide,
1139        trigger_price: Price,
1140        limit_price: Option<Price>,
1141    ) -> anyhow::Result<()> {
1142        let Some(instrument) = &self.instrument else {
1143            anyhow::bail!("No instrument loaded");
1144        };
1145
1146        if self.config.dry_run {
1147            log_warn!("Dry run, skipping create {order_side:?} stop order");
1148            return Ok(());
1149        }
1150
1151        if order_side == OrderSide::Buy && !self.config.enable_stop_buys {
1152            log_warn!("BUY stop orders not enabled, skipping");
1153            return Ok(());
1154        } else if order_side == OrderSide::Sell && !self.config.enable_stop_sells {
1155            log_warn!("SELL stop orders not enabled, skipping");
1156            return Ok(());
1157        }
1158
1159        let (time_in_force, expire_time) =
1160            self.resolve_time_in_force(self.config.stop_time_in_force);
1161
1162        // Use instrument's make_qty to ensure correct precision
1163        let quantity = instrument.make_qty(self.config.order_qty.as_f64(), None);
1164        let instrument_id = self.config.instrument_id;
1165        let trigger_type = self.config.stop_trigger_type;
1166        let quote_quantity = self.config.use_quote_quantity;
1167        let display_qty = self.config.order_display_qty;
1168        let emulation_trigger = self.config.emulation_trigger;
1169        let stop_order_type = self.config.stop_order_type;
1170        let trailing_offset = self.config.trailing_offset;
1171        let trailing_offset_type = self.config.trailing_offset_type;
1172
1173        let mut factory = self.order_factory();
1174
1175        let mut order: OrderAny = match stop_order_type {
1176            OrderType::StopMarket => factory.stop_market(
1177                instrument_id,
1178                order_side,
1179                quantity,
1180                trigger_price,
1181                Some(trigger_type),
1182                Some(time_in_force),
1183                expire_time,
1184                None, // reduce_only
1185                Some(quote_quantity),
1186                None, // display_qty
1187                emulation_trigger,
1188                None, // trigger_instrument_id
1189                None, // exec_algorithm_id
1190                None, // exec_algorithm_params
1191                None, // tags
1192                None, // client_order_id
1193            ),
1194            OrderType::StopLimit => {
1195                let Some(limit_price) = limit_price else {
1196                    anyhow::bail!("STOP_LIMIT order requires limit_price");
1197                };
1198                factory.stop_limit(
1199                    instrument_id,
1200                    order_side,
1201                    quantity,
1202                    limit_price,
1203                    trigger_price,
1204                    Some(trigger_type),
1205                    Some(time_in_force),
1206                    expire_time,
1207                    None, // post_only
1208                    None, // reduce_only
1209                    Some(quote_quantity),
1210                    display_qty,
1211                    emulation_trigger,
1212                    None, // trigger_instrument_id
1213                    None, // exec_algorithm_id
1214                    None, // exec_algorithm_params
1215                    None, // tags
1216                    None, // client_order_id
1217                )
1218            }
1219            OrderType::MarketIfTouched => factory.market_if_touched(
1220                instrument_id,
1221                order_side,
1222                quantity,
1223                trigger_price,
1224                Some(trigger_type),
1225                Some(time_in_force),
1226                expire_time,
1227                None, // reduce_only
1228                Some(quote_quantity),
1229                emulation_trigger,
1230                None, // trigger_instrument_id
1231                None, // exec_algorithm_id
1232                None, // exec_algorithm_params
1233                None, // tags
1234                None, // client_order_id
1235            ),
1236            OrderType::LimitIfTouched => {
1237                let Some(limit_price) = limit_price else {
1238                    anyhow::bail!("LIMIT_IF_TOUCHED order requires limit_price");
1239                };
1240                factory.limit_if_touched(
1241                    instrument_id,
1242                    order_side,
1243                    quantity,
1244                    limit_price,
1245                    trigger_price,
1246                    Some(trigger_type),
1247                    Some(time_in_force),
1248                    expire_time,
1249                    None, // post_only
1250                    None, // reduce_only
1251                    Some(quote_quantity),
1252                    display_qty,
1253                    emulation_trigger,
1254                    None, // trigger_instrument_id
1255                    None, // exec_algorithm_id
1256                    None, // exec_algorithm_params
1257                    None, // tags
1258                    None, // client_order_id
1259                )
1260            }
1261            OrderType::TrailingStopMarket => {
1262                let Some(trailing_offset) = trailing_offset else {
1263                    anyhow::bail!("TRAILING_STOP_MARKET order requires trailing_offset config");
1264                };
1265                factory.trailing_stop_market(
1266                    instrument_id,
1267                    order_side,
1268                    quantity,
1269                    trailing_offset,
1270                    Some(trailing_offset_type),
1271                    None,
1272                    Some(trigger_price),
1273                    Some(trigger_type),
1274                    Some(time_in_force),
1275                    expire_time,
1276                    None, // reduce_only
1277                    Some(quote_quantity),
1278                    None, // display_qty
1279                    emulation_trigger,
1280                    None, // trigger_instrument_id
1281                    None, // exec_algorithm_id
1282                    None, // exec_algorithm_params
1283                    None, // tags
1284                    None, // client_order_id
1285                )
1286            }
1287            _ => {
1288                anyhow::bail!("Unknown stop order type: {stop_order_type:?}");
1289            }
1290        };
1291        drop(factory);
1292
1293        if let OrderAny::TrailingStopMarket(order) = &mut order {
1294            order.activation_price = Some(trigger_price);
1295        }
1296
1297        if order_side == OrderSide::Buy {
1298            self.buy_stop_order = Some(order.clone());
1299        } else {
1300            self.sell_stop_order = Some(order.clone());
1301        }
1302
1303        self.submit_order_apply_params(order)
1304    }
1305
1306    /// Submit a bracket order (entry with stop-loss and take-profit).
1307    ///
1308    /// # Errors
1309    ///
1310    /// Returns an error if order creation or submission fails.
1311    pub(super) fn submit_bracket_order(
1312        &mut self,
1313        order_side: OrderSide,
1314        entry_price: Price,
1315    ) -> anyhow::Result<()> {
1316        let Some(instrument) = &self.instrument else {
1317            anyhow::bail!("No instrument loaded");
1318        };
1319
1320        if self.config.dry_run {
1321            log_warn!("Dry run, skipping create {order_side:?} bracket order");
1322            return Ok(());
1323        }
1324
1325        if self.config.bracket_entry_order_type != OrderType::Limit {
1326            anyhow::bail!(
1327                "Only Limit entry orders are supported for brackets, was {:?}",
1328                self.config.bracket_entry_order_type
1329            );
1330        }
1331
1332        if order_side == OrderSide::Buy && !self.config.enable_limit_buys {
1333            log_warn!("BUY orders not enabled, skipping bracket");
1334            return Ok(());
1335        } else if order_side == OrderSide::Sell && !self.config.enable_limit_sells {
1336            log_warn!("SELL orders not enabled, skipping bracket");
1337            return Ok(());
1338        }
1339
1340        let (time_in_force, expire_time) =
1341            self.resolve_time_in_force(self.config.limit_time_in_force);
1342        let sl_time_in_force = self.config.stop_time_in_force.unwrap_or(TimeInForce::Gtc);
1343        if sl_time_in_force == TimeInForce::Gtd {
1344            anyhow::bail!("GTD time in force not supported for bracket stop-loss legs");
1345        }
1346
1347        let quantity = instrument.make_qty(self.config.order_qty.as_f64(), None);
1348        let increment = instrument.price_increment();
1349        let precision = instrument.price_precision();
1350        let bracket_offset_ticks = self.config.bracket_offset_ticks;
1351
1352        let (unclamped_tp_price, unclamped_sl_trigger_price) = match order_side {
1353            OrderSide::Buy => {
1354                let tp = add_price_ticks(entry_price, increment, bracket_offset_ticks, precision);
1355                let sl = sub_price_ticks(entry_price, increment, bracket_offset_ticks, precision);
1356                (tp, sl)
1357            }
1358            OrderSide::Sell => {
1359                let tp = sub_price_ticks(entry_price, increment, bracket_offset_ticks, precision);
1360                let sl = add_price_ticks(entry_price, increment, bracket_offset_ticks, precision);
1361                (tp, sl)
1362            }
1363            OrderSide::NoOrderSide => {
1364                anyhow::bail!("Invalid order side for bracket: {order_side:?}")
1365            }
1366        };
1367        let clamp = self.config.clamp_to_instrument_price_range;
1368        let tp_price = clamp_price_to_range(unclamped_tp_price, instrument, clamp);
1369        let sl_trigger_price = clamp_price_to_range(unclamped_sl_trigger_price, instrument, clamp);
1370
1371        let entry_post_only = self.config.use_post_only || self.config.test_reject_post_only;
1372        let instrument_id = self.config.instrument_id;
1373        let quote_quantity = self.config.use_quote_quantity;
1374        let emulation_trigger = self.config.emulation_trigger;
1375        let stop_trigger_type = self.config.stop_trigger_type;
1376        let orders = self
1377            .order_factory()
1378            .bracket()
1379            .instrument_id(instrument_id)
1380            .order_side(order_side)
1381            .quantity(quantity)
1382            .quote_quantity(quote_quantity)
1383            .entry_order_type(OrderType::Limit)
1384            .entry_price(entry_price)
1385            .time_in_force(time_in_force)
1386            .entry_post_only(entry_post_only)
1387            .maybe_emulation_trigger(emulation_trigger)
1388            .maybe_expire_time(expire_time)
1389            .tp_price(tp_price)
1390            .tp_post_only(entry_post_only)
1391            .tp_time_in_force(time_in_force)
1392            .sl_trigger_price(sl_trigger_price)
1393            .sl_trigger_type(stop_trigger_type)
1394            .sl_time_in_force(sl_time_in_force)
1395            .call();
1396
1397        if let Some(entry_order) = orders.first() {
1398            if order_side == OrderSide::Buy {
1399                self.buy_order = Some(entry_order.clone());
1400            } else {
1401                self.sell_order = Some(entry_order.clone());
1402            }
1403        }
1404
1405        let client_id = self.config.client_id;
1406        if let Some(params) = &self.config.order_params {
1407            self.submit_order_list(orders, None, client_id, Some(params.clone()))
1408        } else {
1409            self.submit_order_list(orders, None, client_id, None)
1410        }
1411    }
1412
1413    /// Open a position with a market order.
1414    ///
1415    /// # Errors
1416    ///
1417    /// Returns an error if order creation or submission fails.
1418    pub(super) fn open_position(&mut self, net_qty: Decimal) -> anyhow::Result<()> {
1419        let Some(instrument) = &self.instrument else {
1420            anyhow::bail!("No instrument loaded");
1421        };
1422
1423        if net_qty == Decimal::ZERO {
1424            log_warn!("Open position with zero quantity, skipping");
1425            return Ok(());
1426        }
1427
1428        let order_side = if net_qty > Decimal::ZERO {
1429            OrderSide::Buy
1430        } else {
1431            OrderSide::Sell
1432        };
1433
1434        let quantity = instrument.make_qty(net_qty.abs().to_f64().unwrap_or(0.0), None);
1435
1436        // Test reduce_only rejection by setting reduce_only on open position order
1437        let reduce_only = if self.config.test_reject_reduce_only {
1438            Some(true)
1439        } else {
1440            None
1441        };
1442        let instrument_id = self.config.instrument_id;
1443        let time_in_force = self.config.open_position_time_in_force;
1444        let quote_quantity = self.config.use_quote_quantity;
1445
1446        let order = self.order_factory().market(
1447            instrument_id,
1448            order_side,
1449            quantity,
1450            Some(time_in_force),
1451            reduce_only,
1452            Some(quote_quantity),
1453            None, // exec_algorithm_id
1454            None, // exec_algorithm_params
1455            None, // tags
1456            None, // client_order_id
1457        );
1458
1459        self.submit_order_apply_params(order)
1460    }
1461
1462    pub(super) fn cancel_active_orders(
1463        &mut self,
1464        instrument_id: InstrumentId,
1465        strategy_id: StrategyId,
1466        client_id: Option<ClientId>,
1467    ) {
1468        // Reach INITIALIZED contingent legs that the open/emulated/inflight indexes
1469        // miss. Skip non-bracket lists so the configured cancel mode owns them.
1470        let bracket_targets: Vec<ClientOrderId> = {
1471            let cache = self.cache();
1472            let mut targets = Vec::new();
1473
1474            for order_list in
1475                cache.order_lists(None, Some(&instrument_id), Some(&strategy_id), None)
1476            {
1477                let is_bracket = order_list.client_order_ids.iter().any(|cid| {
1478                    cache
1479                        .order(cid)
1480                        .is_some_and(|o| is_in_contingency_group(&o))
1481                });
1482
1483                if !is_bracket {
1484                    continue;
1485                }
1486
1487                for cid in &order_list.client_order_ids {
1488                    if let Some(order) = cache.order(cid)
1489                        && !order.is_closed()
1490                        && !order.is_pending_cancel()
1491                    {
1492                        targets.push(*cid);
1493                    }
1494                }
1495            }
1496            targets
1497        };
1498
1499        for cid in bracket_targets {
1500            if let Err(e) = self.cancel_order(cid, client_id, None) {
1501                log::error!("Failed to cancel bracket leg {cid}: {e}");
1502            }
1503        }
1504
1505        if self.config.use_individual_cancels_on_stop {
1506            for cid in self.collect_cancellable_order_ids(instrument_id, strategy_id) {
1507                if let Err(e) = self.cancel_order(cid, client_id, None) {
1508                    log::error!("Failed to cancel order {cid}: {e}");
1509                }
1510            }
1511        } else if self.config.use_batch_cancel_on_stop {
1512            let candidates = self.collect_cancellable_orders(instrument_id, strategy_id);
1513            let mut batchable: Vec<ClientOrderId> = Vec::new();
1514
1515            for order in candidates {
1516                let cid = order.client_order_id();
1517                if order.is_emulated() || order.is_active_local() {
1518                    if let Err(e) = self.cancel_order(cid, client_id, None) {
1519                        log::error!("Failed to cancel local order {cid}: {e}");
1520                    }
1521                } else {
1522                    batchable.push(cid);
1523                }
1524            }
1525
1526            if !batchable.is_empty()
1527                && let Err(e) = self.cancel_orders(batchable, client_id, None)
1528            {
1529                log::error!("Failed to batch cancel orders: {e}");
1530            }
1531        } else {
1532            // `cancel_all_orders` does not reach active-local orders; cancel those
1533            // individually first. Brackets are handled by the sweep above.
1534            let local_ids: Vec<ClientOrderId> = {
1535                let cache = self.cache();
1536                cache
1537                    .orders_active_local(None, Some(&instrument_id), Some(&strategy_id), None, None)
1538                    .into_iter()
1539                    .filter(|o| {
1540                        !o.is_closed() && !o.is_pending_cancel() && !is_in_contingency_group(o)
1541                    })
1542                    .map(|o| o.client_order_id())
1543                    .collect()
1544            };
1545
1546            for cid in local_ids {
1547                if let Err(e) = self.cancel_order(cid, client_id, None) {
1548                    log::error!("Failed to cancel active-local order {cid}: {e}");
1549                }
1550            }
1551
1552            if let Err(e) = self.cancel_all_orders(instrument_id, None, client_id, None) {
1553                log::error!("Failed to cancel all orders: {e}");
1554            }
1555        }
1556    }
1557
1558    pub(super) fn collect_cancellable_orders(
1559        &self,
1560        instrument_id: InstrumentId,
1561        strategy_id: StrategyId,
1562    ) -> Vec<OrderAny> {
1563        let cache = self.cache();
1564        let mut seen: AHashSet<ClientOrderId> = AHashSet::new();
1565        let mut candidates: Vec<OrderAny> = Vec::new();
1566        // `orders_active_local` catches just-submitted orders not yet in the other
1567        // indexes. Bracket legs are excluded; the sweep in `cancel_active_orders` owns them.
1568        let sources = [
1569            cache.orders_active_local(None, Some(&instrument_id), Some(&strategy_id), None, None),
1570            cache.orders_emulated(None, Some(&instrument_id), Some(&strategy_id), None, None),
1571            cache.orders_inflight(None, Some(&instrument_id), Some(&strategy_id), None, None),
1572            cache.orders_open(None, Some(&instrument_id), Some(&strategy_id), None, None),
1573        ];
1574
1575        for orders in sources {
1576            for order in orders {
1577                if order.is_closed() || order.is_pending_cancel() || is_in_contingency_group(&order)
1578                {
1579                    continue;
1580                }
1581                let cid = order.client_order_id();
1582                if seen.insert(cid) {
1583                    candidates.push(order);
1584                }
1585            }
1586        }
1587        candidates
1588    }
1589
1590    pub(super) fn collect_cancellable_order_ids(
1591        &self,
1592        instrument_id: InstrumentId,
1593        strategy_id: StrategyId,
1594    ) -> Vec<ClientOrderId> {
1595        self.collect_cancellable_orders(instrument_id, strategy_id)
1596            .into_iter()
1597            .map(|o| o.client_order_id())
1598            .collect()
1599    }
1600}
1601
1602fn add_price_ticks(base: Price, increment: Price, ticks: u64, precision: u8) -> Price {
1603    let offset = price_tick_offset(increment, ticks, precision);
1604    base + offset
1605}
1606
1607fn sub_price_ticks(base: Price, increment: Price, ticks: u64, precision: u8) -> Price {
1608    let offset = price_tick_offset(increment, ticks, precision);
1609    base - offset
1610}
1611
1612fn price_tick_offset(increment: Price, ticks: u64, precision: u8) -> Price {
1613    let offset = increment * Decimal::from(ticks);
1614    Price::from_decimal_dp(offset, precision)
1615        .unwrap_or_else(|e| panic!("Failed to calculate price tick offset: {e}"))
1616}
1617
1618// `OrderAny::is_contingency` returns true for `Some(NoContingency)` (the factory
1619// default on every order), so match the variant directly to distinguish bracket legs.
1620fn is_in_contingency_group(order: &OrderAny) -> bool {
1621    matches!(
1622        order.contingency_type(),
1623        Some(ContingencyType::Oto | ContingencyType::Oco | ContingencyType::Ouo)
1624    )
1625}
1626
1627fn clamp_price_to_range(price: Price, instrument: &InstrumentAny, enabled: bool) -> Price {
1628    if !enabled {
1629        return price;
1630    }
1631    let mut clamped = price;
1632    if let Some(min) = instrument.min_price()
1633        && clamped < min
1634    {
1635        clamped = min;
1636    }
1637
1638    if let Some(max) = instrument.max_price()
1639        && clamped > max
1640    {
1641        clamped = max;
1642    }
1643
1644    clamped
1645}