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, 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        let Some(instrument) = &self.instrument else {
873            return;
874        };
875
876        let increment = instrument.price_increment();
877        let precision = instrument.price_precision();
878        let stop_offset_ticks = self.config.stop_offset_ticks;
879
880        // Determine trigger price based on order type
881        let unclamped_trigger_price = if matches!(
882            self.config.stop_order_type,
883            OrderType::LimitIfTouched | OrderType::MarketIfTouched | OrderType::TrailingStopMarket
884        ) {
885            // IF_TOUCHED and trailing-stop buy: place BELOW market
886            sub_price_ticks(best_bid, increment, stop_offset_ticks, precision)
887        } else {
888            // STOP buy orders are placed ABOVE the market (stop loss on short)
889            add_price_ticks(best_ask, increment, stop_offset_ticks, precision)
890        };
891        let clamp = self.config.clamp_to_instrument_price_range;
892        let trigger_price = clamp_price_to_range(unclamped_trigger_price, instrument, clamp);
893
894        // Calculate limit price if needed
895        let limit_price = if matches!(
896            self.config.stop_order_type,
897            OrderType::StopLimit | OrderType::LimitIfTouched
898        ) {
899            let unclamped_limit_price =
900                if let Some(limit_offset_ticks) = self.config.stop_limit_offset_ticks {
901                    // BUY LIT/StopLimit both require trigger_price <= price.
902                    add_price_ticks(trigger_price, increment, limit_offset_ticks, precision)
903                } else {
904                    trigger_price
905                };
906            Some(clamp_price_to_range(
907                unclamped_limit_price,
908                instrument,
909                clamp,
910            ))
911        } else {
912            None
913        };
914
915        let needs_new_order = match &self.buy_stop_order {
916            None => true,
917            Some(order) => !Self::is_order_active(order) && !self.stop_order_is_one_shot(),
918        };
919
920        if needs_new_order {
921            if let Err(e) = self.submit_stop_order(OrderSide::Buy, trigger_price, limit_price) {
922                log::error!("Failed to submit buy stop order: {e}");
923            }
924        } else if let Some(order) = &self.buy_stop_order
925            && order.venue_order_id().is_some()
926            && !order.is_pending_update()
927            && !order.is_pending_cancel()
928        {
929            let current_trigger = Self::get_order_trigger_price(order);
930            if current_trigger.is_some() && current_trigger != Some(trigger_price) {
931                if self.config.modify_stop_orders_to_maintain_offset {
932                    let order_clone = order.clone();
933                    if let Err(e) = self.modify_stop_order(&order_clone, trigger_price, limit_price)
934                    {
935                        log::error!("Failed to modify buy stop order: {e}");
936                    }
937                } else if self.config.cancel_replace_stop_orders_to_maintain_offset
938                    && !self.buy_stop_cancel_replace_attempted
939                {
940                    self.buy_stop_cancel_replace_attempted = true;
941                    let order_clone = order.clone();
942                    let _ = self.cancel_order(
943                        order_clone.client_order_id(),
944                        self.config.client_id,
945                        None,
946                    );
947
948                    if let Err(e) =
949                        self.submit_stop_order(OrderSide::Buy, trigger_price, limit_price)
950                    {
951                        log::error!("Failed to submit replacement buy stop order: {e}");
952                    }
953                }
954            }
955        }
956    }
957
958    /// Maintain stop sell orders.
959    fn maintain_stop_sell_orders(&mut self, best_bid: Price, best_ask: Price) {
960        self.refresh_tracked_stop_order(OrderSide::Sell);
961
962        let Some(instrument) = &self.instrument else {
963            return;
964        };
965
966        let increment = instrument.price_increment();
967        let precision = instrument.price_precision();
968        let stop_offset_ticks = self.config.stop_offset_ticks;
969
970        // Determine trigger price based on order type
971        let unclamped_trigger_price = if matches!(
972            self.config.stop_order_type,
973            OrderType::LimitIfTouched | OrderType::MarketIfTouched | OrderType::TrailingStopMarket
974        ) {
975            // IF_TOUCHED and trailing-stop sell: place ABOVE market
976            add_price_ticks(best_ask, increment, stop_offset_ticks, precision)
977        } else {
978            // STOP sell orders are placed BELOW the market (stop loss on long)
979            sub_price_ticks(best_bid, increment, stop_offset_ticks, precision)
980        };
981        let clamp = self.config.clamp_to_instrument_price_range;
982        let trigger_price = clamp_price_to_range(unclamped_trigger_price, instrument, clamp);
983
984        // Calculate limit price if needed
985        let limit_price = if matches!(
986            self.config.stop_order_type,
987            OrderType::StopLimit | OrderType::LimitIfTouched
988        ) {
989            let unclamped_limit_price =
990                if let Some(limit_offset_ticks) = self.config.stop_limit_offset_ticks {
991                    // SELL LIT/StopLimit both require trigger_price >= price.
992                    sub_price_ticks(trigger_price, increment, limit_offset_ticks, precision)
993                } else {
994                    trigger_price
995                };
996            Some(clamp_price_to_range(
997                unclamped_limit_price,
998                instrument,
999                clamp,
1000            ))
1001        } else {
1002            None
1003        };
1004
1005        let needs_new_order = match &self.sell_stop_order {
1006            None => true,
1007            Some(order) => !Self::is_order_active(order) && !self.stop_order_is_one_shot(),
1008        };
1009
1010        if needs_new_order {
1011            if let Err(e) = self.submit_stop_order(OrderSide::Sell, trigger_price, limit_price) {
1012                log::error!("Failed to submit sell stop order: {e}");
1013            }
1014        } else if let Some(order) = &self.sell_stop_order
1015            && order.venue_order_id().is_some()
1016            && !order.is_pending_update()
1017            && !order.is_pending_cancel()
1018        {
1019            let current_trigger = Self::get_order_trigger_price(order);
1020            if current_trigger.is_some() && current_trigger != Some(trigger_price) {
1021                if self.config.modify_stop_orders_to_maintain_offset {
1022                    let order_clone = order.clone();
1023                    if let Err(e) = self.modify_stop_order(&order_clone, trigger_price, limit_price)
1024                    {
1025                        log::error!("Failed to modify sell stop order: {e}");
1026                    }
1027                } else if self.config.cancel_replace_stop_orders_to_maintain_offset
1028                    && !self.sell_stop_cancel_replace_attempted
1029                {
1030                    self.sell_stop_cancel_replace_attempted = true;
1031                    let order_clone = order.clone();
1032                    let _ = self.cancel_order(
1033                        order_clone.client_order_id(),
1034                        self.config.client_id,
1035                        None,
1036                    );
1037
1038                    if let Err(e) =
1039                        self.submit_stop_order(OrderSide::Sell, trigger_price, limit_price)
1040                    {
1041                        log::error!("Failed to submit replacement sell stop order: {e}");
1042                    }
1043                }
1044            }
1045        }
1046    }
1047
1048    /// Submit a limit order.
1049    ///
1050    /// # Errors
1051    ///
1052    /// Returns an error if order creation or submission fails.
1053    pub(super) fn submit_limit_order(
1054        &mut self,
1055        order_side: OrderSide,
1056        price: Price,
1057    ) -> anyhow::Result<()> {
1058        let Some(instrument) = &self.instrument else {
1059            anyhow::bail!("No instrument loaded");
1060        };
1061
1062        if self.config.dry_run {
1063            log_warn!("Dry run, skipping create {order_side:?} order");
1064            return Ok(());
1065        }
1066
1067        if order_side == OrderSide::Buy && !self.config.enable_limit_buys {
1068            log_warn!("BUY orders not enabled, skipping");
1069            return Ok(());
1070        } else if order_side == OrderSide::Sell && !self.config.enable_limit_sells {
1071            log_warn!("SELL orders not enabled, skipping");
1072            return Ok(());
1073        }
1074
1075        let (time_in_force, expire_time) =
1076            self.resolve_time_in_force(self.config.limit_time_in_force);
1077
1078        let quantity = instrument.make_qty(self.config.order_qty.as_f64(), None);
1079        let instrument_id = self.config.instrument_id;
1080        let post_only = self.config.use_post_only || self.config.test_reject_post_only;
1081        let quote_quantity = self.config.use_quote_quantity;
1082        let display_qty = self.config.order_display_qty;
1083        let emulation_trigger = self.config.emulation_trigger;
1084
1085        let order = self.order_factory().limit(
1086            instrument_id,
1087            order_side,
1088            quantity,
1089            price,
1090            Some(time_in_force),
1091            expire_time,
1092            Some(post_only),
1093            None, // reduce_only
1094            Some(quote_quantity),
1095            display_qty,
1096            emulation_trigger,
1097            None, // trigger_instrument_id
1098            None, // exec_algorithm_id
1099            None, // exec_algorithm_params
1100            None, // tags
1101            None, // client_order_id
1102        );
1103
1104        if order_side == OrderSide::Buy {
1105            self.buy_order = Some(order.clone());
1106        } else {
1107            self.sell_order = Some(order.clone());
1108        }
1109
1110        self.submit_order_apply_params(order)
1111    }
1112
1113    /// Submit a stop order.
1114    ///
1115    /// # Errors
1116    ///
1117    /// Returns an error if order creation or submission fails.
1118    #[expect(
1119        clippy::too_many_lines,
1120        reason = "stop order submission covers all supported stop order scenarios"
1121    )]
1122    pub(super) fn submit_stop_order(
1123        &mut self,
1124        order_side: OrderSide,
1125        trigger_price: Price,
1126        limit_price: Option<Price>,
1127    ) -> anyhow::Result<()> {
1128        let Some(instrument) = &self.instrument else {
1129            anyhow::bail!("No instrument loaded");
1130        };
1131
1132        if self.config.dry_run {
1133            log_warn!("Dry run, skipping create {order_side:?} stop order");
1134            return Ok(());
1135        }
1136
1137        if order_side == OrderSide::Buy && !self.config.enable_stop_buys {
1138            log_warn!("BUY stop orders not enabled, skipping");
1139            return Ok(());
1140        } else if order_side == OrderSide::Sell && !self.config.enable_stop_sells {
1141            log_warn!("SELL stop orders not enabled, skipping");
1142            return Ok(());
1143        }
1144
1145        let (time_in_force, expire_time) =
1146            self.resolve_time_in_force(self.config.stop_time_in_force);
1147
1148        // Use instrument's make_qty to ensure correct precision
1149        let quantity = instrument.make_qty(self.config.order_qty.as_f64(), None);
1150        let instrument_id = self.config.instrument_id;
1151        let trigger_type = self.config.stop_trigger_type;
1152        let quote_quantity = self.config.use_quote_quantity;
1153        let display_qty = self.config.order_display_qty;
1154        let emulation_trigger = self.config.emulation_trigger;
1155        let stop_order_type = self.config.stop_order_type;
1156        let trailing_offset = self.config.trailing_offset;
1157        let trailing_offset_type = self.config.trailing_offset_type;
1158
1159        let mut factory = self.order_factory();
1160
1161        let mut order: OrderAny = match stop_order_type {
1162            OrderType::StopMarket => factory.stop_market(
1163                instrument_id,
1164                order_side,
1165                quantity,
1166                trigger_price,
1167                Some(trigger_type),
1168                Some(time_in_force),
1169                expire_time,
1170                None, // reduce_only
1171                Some(quote_quantity),
1172                None, // display_qty
1173                emulation_trigger,
1174                None, // trigger_instrument_id
1175                None, // exec_algorithm_id
1176                None, // exec_algorithm_params
1177                None, // tags
1178                None, // client_order_id
1179            ),
1180            OrderType::StopLimit => {
1181                let Some(limit_price) = limit_price else {
1182                    anyhow::bail!("STOP_LIMIT order requires limit_price");
1183                };
1184                factory.stop_limit(
1185                    instrument_id,
1186                    order_side,
1187                    quantity,
1188                    limit_price,
1189                    trigger_price,
1190                    Some(trigger_type),
1191                    Some(time_in_force),
1192                    expire_time,
1193                    None, // post_only
1194                    None, // reduce_only
1195                    Some(quote_quantity),
1196                    display_qty,
1197                    emulation_trigger,
1198                    None, // trigger_instrument_id
1199                    None, // exec_algorithm_id
1200                    None, // exec_algorithm_params
1201                    None, // tags
1202                    None, // client_order_id
1203                )
1204            }
1205            OrderType::MarketIfTouched => factory.market_if_touched(
1206                instrument_id,
1207                order_side,
1208                quantity,
1209                trigger_price,
1210                Some(trigger_type),
1211                Some(time_in_force),
1212                expire_time,
1213                None, // reduce_only
1214                Some(quote_quantity),
1215                emulation_trigger,
1216                None, // trigger_instrument_id
1217                None, // exec_algorithm_id
1218                None, // exec_algorithm_params
1219                None, // tags
1220                None, // client_order_id
1221            ),
1222            OrderType::LimitIfTouched => {
1223                let Some(limit_price) = limit_price else {
1224                    anyhow::bail!("LIMIT_IF_TOUCHED order requires limit_price");
1225                };
1226                factory.limit_if_touched(
1227                    instrument_id,
1228                    order_side,
1229                    quantity,
1230                    limit_price,
1231                    trigger_price,
1232                    Some(trigger_type),
1233                    Some(time_in_force),
1234                    expire_time,
1235                    None, // post_only
1236                    None, // reduce_only
1237                    Some(quote_quantity),
1238                    display_qty,
1239                    emulation_trigger,
1240                    None, // trigger_instrument_id
1241                    None, // exec_algorithm_id
1242                    None, // exec_algorithm_params
1243                    None, // tags
1244                    None, // client_order_id
1245                )
1246            }
1247            OrderType::TrailingStopMarket => {
1248                let Some(trailing_offset) = trailing_offset else {
1249                    anyhow::bail!("TRAILING_STOP_MARKET order requires trailing_offset config");
1250                };
1251                factory.trailing_stop_market(
1252                    instrument_id,
1253                    order_side,
1254                    quantity,
1255                    trailing_offset,
1256                    Some(trailing_offset_type),
1257                    None,
1258                    Some(trigger_price),
1259                    Some(trigger_type),
1260                    Some(time_in_force),
1261                    expire_time,
1262                    None, // reduce_only
1263                    Some(quote_quantity),
1264                    None, // display_qty
1265                    emulation_trigger,
1266                    None, // trigger_instrument_id
1267                    None, // exec_algorithm_id
1268                    None, // exec_algorithm_params
1269                    None, // tags
1270                    None, // client_order_id
1271                )
1272            }
1273            _ => {
1274                anyhow::bail!("Unknown stop order type: {stop_order_type:?}");
1275            }
1276        };
1277        drop(factory);
1278
1279        if let OrderAny::TrailingStopMarket(order) = &mut order {
1280            order.activation_price = Some(trigger_price);
1281        }
1282
1283        if order_side == OrderSide::Buy {
1284            self.buy_stop_order = Some(order.clone());
1285        } else {
1286            self.sell_stop_order = Some(order.clone());
1287        }
1288
1289        self.submit_order_apply_params(order)
1290    }
1291
1292    /// Submit a bracket order (entry with stop-loss and take-profit).
1293    ///
1294    /// # Errors
1295    ///
1296    /// Returns an error if order creation or submission fails.
1297    pub(super) fn submit_bracket_order(
1298        &mut self,
1299        order_side: OrderSide,
1300        entry_price: Price,
1301    ) -> anyhow::Result<()> {
1302        let Some(instrument) = &self.instrument else {
1303            anyhow::bail!("No instrument loaded");
1304        };
1305
1306        if self.config.dry_run {
1307            log_warn!("Dry run, skipping create {order_side:?} bracket order");
1308            return Ok(());
1309        }
1310
1311        if self.config.bracket_entry_order_type != OrderType::Limit {
1312            anyhow::bail!(
1313                "Only Limit entry orders are supported for brackets, was {:?}",
1314                self.config.bracket_entry_order_type
1315            );
1316        }
1317
1318        if order_side == OrderSide::Buy && !self.config.enable_limit_buys {
1319            log_warn!("BUY orders not enabled, skipping bracket");
1320            return Ok(());
1321        } else if order_side == OrderSide::Sell && !self.config.enable_limit_sells {
1322            log_warn!("SELL orders not enabled, skipping bracket");
1323            return Ok(());
1324        }
1325
1326        let (time_in_force, expire_time) =
1327            self.resolve_time_in_force(self.config.limit_time_in_force);
1328        let sl_time_in_force = self.config.stop_time_in_force.unwrap_or(TimeInForce::Gtc);
1329        if sl_time_in_force == TimeInForce::Gtd {
1330            anyhow::bail!("GTD time in force not supported for bracket stop-loss legs");
1331        }
1332
1333        let quantity = instrument.make_qty(self.config.order_qty.as_f64(), None);
1334        let increment = instrument.price_increment();
1335        let precision = instrument.price_precision();
1336        let bracket_offset_ticks = self.config.bracket_offset_ticks;
1337
1338        let (unclamped_tp_price, unclamped_sl_trigger_price) = match order_side {
1339            OrderSide::Buy => {
1340                let tp = add_price_ticks(entry_price, increment, bracket_offset_ticks, precision);
1341                let sl = sub_price_ticks(entry_price, increment, bracket_offset_ticks, precision);
1342                (tp, sl)
1343            }
1344            OrderSide::Sell => {
1345                let tp = sub_price_ticks(entry_price, increment, bracket_offset_ticks, precision);
1346                let sl = add_price_ticks(entry_price, increment, bracket_offset_ticks, precision);
1347                (tp, sl)
1348            }
1349            OrderSide::NoOrderSide => {
1350                anyhow::bail!("Invalid order side for bracket: {order_side:?}")
1351            }
1352        };
1353        let clamp = self.config.clamp_to_instrument_price_range;
1354        let tp_price = clamp_price_to_range(unclamped_tp_price, instrument, clamp);
1355        let sl_trigger_price = clamp_price_to_range(unclamped_sl_trigger_price, instrument, clamp);
1356
1357        let entry_post_only = self.config.use_post_only || self.config.test_reject_post_only;
1358        let instrument_id = self.config.instrument_id;
1359        let quote_quantity = self.config.use_quote_quantity;
1360        let emulation_trigger = self.config.emulation_trigger;
1361        let stop_trigger_type = self.config.stop_trigger_type;
1362        let orders = self
1363            .order_factory()
1364            .bracket()
1365            .instrument_id(instrument_id)
1366            .order_side(order_side)
1367            .quantity(quantity)
1368            .quote_quantity(quote_quantity)
1369            .entry_order_type(OrderType::Limit)
1370            .entry_price(entry_price)
1371            .time_in_force(time_in_force)
1372            .entry_post_only(entry_post_only)
1373            .maybe_emulation_trigger(emulation_trigger)
1374            .maybe_expire_time(expire_time)
1375            .tp_price(tp_price)
1376            .tp_post_only(entry_post_only)
1377            .tp_time_in_force(time_in_force)
1378            .sl_trigger_price(sl_trigger_price)
1379            .sl_trigger_type(stop_trigger_type)
1380            .sl_time_in_force(sl_time_in_force)
1381            .call();
1382
1383        if let Some(entry_order) = orders.first() {
1384            if order_side == OrderSide::Buy {
1385                self.buy_order = Some(entry_order.clone());
1386            } else {
1387                self.sell_order = Some(entry_order.clone());
1388            }
1389        }
1390
1391        let client_id = self.config.client_id;
1392        if let Some(params) = &self.config.order_params {
1393            self.submit_order_list(orders, None, client_id, Some(params.clone()))
1394        } else {
1395            self.submit_order_list(orders, None, client_id, None)
1396        }
1397    }
1398
1399    /// Open a position with a market order.
1400    ///
1401    /// # Errors
1402    ///
1403    /// Returns an error if order creation or submission fails.
1404    pub(super) fn open_position(&mut self, net_qty: Decimal) -> anyhow::Result<()> {
1405        let Some(instrument) = &self.instrument else {
1406            anyhow::bail!("No instrument loaded");
1407        };
1408
1409        if net_qty == Decimal::ZERO {
1410            log_warn!("Open position with zero quantity, skipping");
1411            return Ok(());
1412        }
1413
1414        let order_side = if net_qty > Decimal::ZERO {
1415            OrderSide::Buy
1416        } else {
1417            OrderSide::Sell
1418        };
1419
1420        let quantity = instrument.make_qty(net_qty.abs().to_f64().unwrap_or(0.0), None);
1421
1422        // Test reduce_only rejection by setting reduce_only on open position order
1423        let reduce_only = if self.config.test_reject_reduce_only {
1424            Some(true)
1425        } else {
1426            None
1427        };
1428        let instrument_id = self.config.instrument_id;
1429        let time_in_force = self.config.open_position_time_in_force;
1430        let quote_quantity = self.config.use_quote_quantity;
1431
1432        let order = self.order_factory().market(
1433            instrument_id,
1434            order_side,
1435            quantity,
1436            Some(time_in_force),
1437            reduce_only,
1438            Some(quote_quantity),
1439            None, // exec_algorithm_id
1440            None, // exec_algorithm_params
1441            None, // tags
1442            None, // client_order_id
1443        );
1444
1445        self.submit_order_apply_params(order)
1446    }
1447
1448    pub(super) fn cancel_active_orders(
1449        &mut self,
1450        instrument_id: InstrumentId,
1451        strategy_id: StrategyId,
1452        client_id: Option<ClientId>,
1453    ) {
1454        // Reach INITIALIZED contingent legs that the open/emulated/inflight indexes
1455        // miss. Skip non-bracket lists so the configured cancel mode owns them.
1456        let bracket_targets: Vec<ClientOrderId> = {
1457            let cache = self.cache();
1458            let mut targets = Vec::new();
1459
1460            for order_list in
1461                cache.order_lists(None, Some(&instrument_id), Some(&strategy_id), None)
1462            {
1463                let is_bracket = order_list.client_order_ids.iter().any(|cid| {
1464                    cache
1465                        .order(cid)
1466                        .is_some_and(|o| is_in_contingency_group(&o))
1467                });
1468
1469                if !is_bracket {
1470                    continue;
1471                }
1472
1473                for cid in &order_list.client_order_ids {
1474                    if let Some(order) = cache.order(cid)
1475                        && !order.is_closed()
1476                        && !order.is_pending_cancel()
1477                    {
1478                        targets.push(*cid);
1479                    }
1480                }
1481            }
1482            targets
1483        };
1484
1485        for cid in bracket_targets {
1486            if let Err(e) = self.cancel_order(cid, client_id, None) {
1487                log::error!("Failed to cancel bracket leg {cid}: {e}");
1488            }
1489        }
1490
1491        if self.config.use_individual_cancels_on_stop {
1492            for cid in self.collect_cancellable_order_ids(instrument_id, strategy_id) {
1493                if let Err(e) = self.cancel_order(cid, client_id, None) {
1494                    log::error!("Failed to cancel order {cid}: {e}");
1495                }
1496            }
1497        } else if self.config.use_batch_cancel_on_stop {
1498            let candidates = self.collect_cancellable_orders(instrument_id, strategy_id);
1499            let mut batchable: Vec<ClientOrderId> = Vec::new();
1500
1501            for order in candidates {
1502                let cid = order.client_order_id();
1503                if order.is_emulated() || order.is_active_local() {
1504                    if let Err(e) = self.cancel_order(cid, client_id, None) {
1505                        log::error!("Failed to cancel local order {cid}: {e}");
1506                    }
1507                } else {
1508                    batchable.push(cid);
1509                }
1510            }
1511
1512            if !batchable.is_empty()
1513                && let Err(e) = self.cancel_orders(batchable, client_id, None)
1514            {
1515                log::error!("Failed to batch cancel orders: {e}");
1516            }
1517        } else {
1518            // `cancel_all_orders` does not reach active-local orders; cancel those
1519            // individually first. Brackets are handled by the sweep above.
1520            let local_ids: Vec<ClientOrderId> = {
1521                let cache = self.cache();
1522                cache
1523                    .orders_active_local(None, Some(&instrument_id), Some(&strategy_id), None, None)
1524                    .into_iter()
1525                    .filter(|o| {
1526                        !o.is_closed() && !o.is_pending_cancel() && !is_in_contingency_group(o)
1527                    })
1528                    .map(|o| o.client_order_id())
1529                    .collect()
1530            };
1531
1532            for cid in local_ids {
1533                if let Err(e) = self.cancel_order(cid, client_id, None) {
1534                    log::error!("Failed to cancel active-local order {cid}: {e}");
1535                }
1536            }
1537
1538            if let Err(e) = self.cancel_all_orders(instrument_id, None, client_id, None) {
1539                log::error!("Failed to cancel all orders: {e}");
1540            }
1541        }
1542    }
1543
1544    pub(super) fn collect_cancellable_orders(
1545        &self,
1546        instrument_id: InstrumentId,
1547        strategy_id: StrategyId,
1548    ) -> Vec<OrderAny> {
1549        let cache = self.cache();
1550        let mut seen: AHashSet<ClientOrderId> = AHashSet::new();
1551        let mut candidates: Vec<OrderAny> = Vec::new();
1552        // `orders_active_local` catches just-submitted orders not yet in the other
1553        // indexes. Bracket legs are excluded; the sweep in `cancel_active_orders` owns them.
1554        let sources = [
1555            cache.orders_active_local(None, Some(&instrument_id), Some(&strategy_id), None, None),
1556            cache.orders_emulated(None, Some(&instrument_id), Some(&strategy_id), None, None),
1557            cache.orders_inflight(None, Some(&instrument_id), Some(&strategy_id), None, None),
1558            cache.orders_open(None, Some(&instrument_id), Some(&strategy_id), None, None),
1559        ];
1560
1561        for orders in sources {
1562            for order in orders {
1563                if order.is_closed() || order.is_pending_cancel() || is_in_contingency_group(&order)
1564                {
1565                    continue;
1566                }
1567                let cid = order.client_order_id();
1568                if seen.insert(cid) {
1569                    candidates.push(order);
1570                }
1571            }
1572        }
1573        candidates
1574    }
1575
1576    pub(super) fn collect_cancellable_order_ids(
1577        &self,
1578        instrument_id: InstrumentId,
1579        strategy_id: StrategyId,
1580    ) -> Vec<ClientOrderId> {
1581        self.collect_cancellable_orders(instrument_id, strategy_id)
1582            .into_iter()
1583            .map(|o| o.client_order_id())
1584            .collect()
1585    }
1586}
1587
1588fn add_price_ticks(base: Price, increment: Price, ticks: u64, precision: u8) -> Price {
1589    let offset = price_tick_offset(increment, ticks, precision);
1590    base + offset
1591}
1592
1593fn sub_price_ticks(base: Price, increment: Price, ticks: u64, precision: u8) -> Price {
1594    let offset = price_tick_offset(increment, ticks, precision);
1595    base - offset
1596}
1597
1598fn price_tick_offset(increment: Price, ticks: u64, precision: u8) -> Price {
1599    let offset = increment * Decimal::from(ticks);
1600    Price::from_decimal_dp(offset, precision)
1601        .unwrap_or_else(|e| panic!("Failed to calculate price tick offset: {e}"))
1602}
1603
1604// `OrderAny::is_contingency` returns true for `Some(NoContingency)` (the factory
1605// default on every order), so match the variant directly to distinguish bracket legs.
1606fn is_in_contingency_group(order: &OrderAny) -> bool {
1607    matches!(
1608        order.contingency_type(),
1609        Some(ContingencyType::Oto | ContingencyType::Oco | ContingencyType::Ouo)
1610    )
1611}
1612
1613fn clamp_price_to_range(price: Price, instrument: &InstrumentAny, enabled: bool) -> Price {
1614    if !enabled {
1615        return price;
1616    }
1617    let mut clamped = price;
1618    if let Some(min) = instrument.min_price()
1619        && clamped < min
1620    {
1621        clamped = min;
1622    }
1623
1624    if let Some(max) = instrument.max_price()
1625        && clamped > max
1626    {
1627        clamped = max;
1628    }
1629
1630    clamped
1631}