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