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};
25use nautilus_core::{DurationNanos, UnixNanos};
26use nautilus_model::{
27    data::{Bar, IndexPriceUpdate, MarkPriceUpdate, OrderBookDeltas, QuoteTick, TradeTick},
28    enums::{ContingencyType, OrderSide, OrderStatus, OrderType, TimeInForce},
29    events::{
30        OrderAccepted, OrderCancelDenied, OrderCancelRejected, OrderCanceled, OrderDenied,
31        OrderEvent, OrderEventAny, OrderExpired, OrderFilled, OrderModifyDenied,
32        OrderModifyRejected, OrderRejected, OrderUpdated,
33    },
34    identifiers::{ClientId, ClientOrderId, InstrumentId, StrategyId},
35    instruments::{Instrument, InstrumentAny},
36    orderbook::OrderBook,
37    orders::{Order, OrderAny, OrderCore},
38    types::{Price, Quantity},
39};
40use nautilus_trading::{
41    nautilus_strategy,
42    strategy::{Strategy, StrategyCore, StrategyNative},
43};
44use rust_decimal::Decimal;
45
46use super::config::ExecTesterConfig;
47use crate::testers::timestamps::warn_if_implausible_unix_nanos;
48
49/// An execution tester strategy for live testing order execution functionality.
50///
51/// This strategy is designed for testing execution adapters by submitting
52/// limit orders, stop orders, and managing positions. It can maintain orders
53/// at a configurable offset from the top of book.
54///
55/// **WARNING**: This strategy has no alpha advantage whatsoever.
56/// It is not intended to be used for live trading with real money.
57#[derive(Debug)]
58#[expect(
59    clippy::struct_excessive_bools,
60    reason = "tester state tracks independent execution scenarios"
61)]
62pub struct ExecTester {
63    pub(super) core: StrategyCore,
64    pub(super) config: ExecTesterConfig,
65    pub(super) instrument: Option<InstrumentAny>,
66    pub(super) price_offset: Option<u64>,
67    pub(super) preinitialized_market_data: bool,
68
69    // Order tracking
70    pub(super) buy_order: Option<OrderAny>,
71    pub(super) sell_order: Option<OrderAny>,
72    pub(super) buy_stop_order: Option<OrderAny>,
73    pub(super) sell_stop_order: Option<OrderAny>,
74    pub(super) open_position_submitted: bool,
75
76    // One-shot guard for `test_modify_rejected`: ensures the programmatic
77    // modify is attempted at most once across the strategy's lifetime.
78    pub(super) modify_rejected_attempted: bool,
79    pub(super) pending_open_position_qty: Option<Decimal>,
80    pub(super) buy_stop_cancel_replace_attempted: bool,
81    pub(super) sell_stop_cancel_replace_attempted: bool,
82    buy_stop_replacement: Option<StopOrderReplacement>,
83    sell_stop_replacement: Option<StopOrderReplacement>,
84    pub(super) buy_limit_maintenance_state: LimitOrderMaintenanceState,
85    pub(super) sell_limit_maintenance_state: LimitOrderMaintenanceState,
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub(super) enum LimitOrderMaintenanceState {
90    Disabled,
91    Ready,
92    AwaitingAcceptance {
93        client_order_id: ClientOrderId,
94    },
95    Active {
96        client_order_id: ClientOrderId,
97    },
98    AwaitingUpdate {
99        client_order_id: ClientOrderId,
100        price: Price,
101    },
102    AwaitingCancel {
103        client_order_id: ClientOrderId,
104        replacement_price: Price,
105    },
106    AwaitingReplacementAcceptance {
107        client_order_id: ClientOrderId,
108        price: Price,
109    },
110    Completed,
111    Failed,
112}
113
114nautilus_strategy!(ExecTester, {
115    fn external_order_instrument_ids(&self) -> Option<Vec<InstrumentId>> {
116        self.core.config.external_order_instrument_ids.clone()
117    }
118
119    fn on_order_accepted(&mut self, event: OrderAccepted) {
120        self.handle_limit_order_accepted(event.client_order_id);
121    }
122
123    fn on_order_updated(&mut self, event: OrderUpdated) {
124        self.handle_limit_order_updated(event.client_order_id, event.price);
125    }
126
127    fn on_order_canceled(&mut self, event: &OrderCanceled) {
128        self.handle_limit_order_canceled(event.client_order_id);
129        self.handle_stop_order_canceled(event);
130    }
131
132    fn on_order_modify_denied(&mut self, event: OrderModifyDenied) {
133        self.finish_limit_order_maintenance(event.client_order_id);
134    }
135
136    fn on_order_modify_rejected(&mut self, event: OrderModifyRejected) {
137        self.finish_limit_order_maintenance(event.client_order_id);
138    }
139
140    fn on_order_cancel_denied(&mut self, event: OrderCancelDenied) {
141        self.finish_limit_order_maintenance(event.client_order_id);
142        self.clear_stop_order_replacement(event.client_order_id);
143    }
144
145    fn on_order_cancel_rejected(&mut self, event: OrderCancelRejected) {
146        self.finish_limit_order_maintenance(event.client_order_id);
147        self.clear_stop_order_replacement(event.client_order_id);
148    }
149
150    fn on_order_denied(&mut self, event: OrderDenied) {
151        self.handle_limit_order_closed(event.client_order_id);
152        self.clear_stop_order_replacement(event.client_order_id);
153    }
154
155    fn on_order_rejected(&mut self, event: OrderRejected) {
156        self.handle_limit_order_closed(event.client_order_id);
157        self.clear_stop_order_replacement(event.client_order_id);
158    }
159
160    fn on_order_expired(&mut self, event: OrderExpired) {
161        self.handle_limit_order_closed(event.client_order_id);
162        self.clear_stop_order_replacement(event.client_order_id);
163    }
164
165    fn on_order_filled(&mut self, event: &OrderFilled) {
166        let (is_closed, is_filled) = self
167            .cache()
168            .order(&event.client_order_id)
169            .map_or((true, false), |order| {
170                (order.is_closed(), order.status() == OrderStatus::Filled)
171            });
172
173        if is_closed {
174            self.handle_limit_order_closed(event.client_order_id);
175        }
176
177        if is_filled {
178            self.clear_stop_order_replacement(event.client_order_id);
179        }
180    }
181
182    fn on_order_event(&mut self, event: OrderEventAny) {
183        let event = event.into_boxed();
184        warn_if_implausible_unix_nanos(
185            "order event",
186            OrderEvent::ts_event(&*event),
187            OrderEvent::ts_init(&*event),
188        );
189    }
190});
191
192impl DataActor for ExecTester {
193    fn on_start(&mut self) -> anyhow::Result<()> {
194        let instrument_id = self.config.instrument_id;
195        let client_id = self.config.client_id;
196
197        let instrument = self.cache().instrument(&instrument_id);
198
199        if let Some(inst) = instrument {
200            self.initialize_with_instrument(inst, true)?;
201        } else {
202            log::info!("Instrument {instrument_id} not in cache, subscribing...");
203            self.subscribe_instrument(instrument_id, client_id, None);
204
205            // Also subscribe to market data to trigger instrument definitions from data providers
206            // (e.g., Databento sends instrument definitions as part of market data subscriptions)
207            if self.config.subscribe_quotes {
208                self.subscribe_quotes(instrument_id, client_id, None);
209            }
210
211            if self.config.subscribe_trades {
212                self.subscribe_trades(instrument_id, client_id, None);
213            }
214            self.preinitialized_market_data =
215                self.config.subscribe_quotes || self.config.subscribe_trades;
216        }
217
218        Ok(())
219    }
220
221    fn on_instrument(&mut self, instrument: &InstrumentAny) -> anyhow::Result<()> {
222        warn_if_implausible_unix_nanos("instrument", instrument.ts_event(), instrument.ts_init());
223
224        if instrument.id() == self.config.instrument_id && self.instrument.is_none() {
225            let id = instrument.id();
226            log::info!("Received instrument {id}, initializing...");
227            self.initialize_with_instrument(instrument.clone(), !self.preinitialized_market_data)?;
228        }
229        Ok(())
230    }
231
232    fn on_stop(&mut self) -> anyhow::Result<()> {
233        self.buy_stop_replacement = None;
234        self.sell_stop_replacement = None;
235
236        let instrument_id = self.config.instrument_id;
237        let client_id = self.config.client_id;
238
239        if self.config.dry_run {
240            log_warn!("Dry run mode, skipping cancel all orders and close all positions");
241        } else {
242            let strategy_id = StrategyId::from(
243                DataActorNative::core(&self.core)
244                    .actor_id()
245                    .inner()
246                    .as_str(),
247            );
248
249            if self.config.cancel_orders_on_stop {
250                self.cancel_active_orders(instrument_id, strategy_id, client_id);
251            }
252
253            if self.config.close_positions_on_stop {
254                let time_in_force = self
255                    .config
256                    .close_positions_time_in_force
257                    .or(Some(TimeInForce::Gtc));
258
259                if let Err(e) = self.close_positions_on_stop(strategy_id, time_in_force) {
260                    log::error!("Failed to close all positions: {e}");
261                }
262            }
263        }
264
265        if !self.config.can_unsubscribe {
266            return Ok(());
267        }
268
269        self.unsubscribe_instrument(instrument_id, client_id, None);
270        if self.config.subscribe_quotes {
271            self.unsubscribe_quotes(instrument_id, client_id, None);
272        }
273
274        if self.config.subscribe_trades {
275            self.unsubscribe_trades(instrument_id, client_id, None);
276        }
277
278        if self.config.subscribe_book {
279            self.unsubscribe_book_at_interval(
280                instrument_id,
281                NonZeroUsize::new(self.config.book_interval_ms).ok_or_else(|| {
282                    ConfigError::range("book_interval_ms", "must be positive, was 0")
283                })?,
284                client_id,
285                None,
286            );
287        }
288
289        Ok(())
290    }
291
292    fn on_quote(&mut self, quote: &QuoteTick) -> anyhow::Result<()> {
293        warn_if_implausible_unix_nanos("quote", quote.ts_event, quote.ts_init);
294
295        if self.config.log_data {
296            log_info!("{quote:?}", color = LogColor::Cyan);
297        }
298
299        if quote.instrument_id == self.config.instrument_id
300            && self.config.open_position_on_first_quote
301        {
302            self.submit_pending_open_position();
303        }
304
305        self.maintain_orders(quote.bid_price, quote.ask_price);
306        Ok(())
307    }
308
309    fn on_trade(&mut self, trade: &TradeTick) -> anyhow::Result<()> {
310        warn_if_implausible_unix_nanos("trade", trade.ts_event, trade.ts_init);
311
312        if self.config.log_data {
313            log_info!("{trade:?}", color = LogColor::Cyan);
314        }
315        Ok(())
316    }
317
318    fn on_book(&mut self, book: &OrderBook) -> anyhow::Result<()> {
319        if self.config.log_data {
320            let num_levels = self.config.book_levels_to_print;
321            let instrument_id = book.instrument_id;
322            let book_str = book.pprint(num_levels, None);
323            log_info!("\n{instrument_id}\n{book_str}", color = LogColor::Cyan);
324
325            // Log own order book if available
326            if DataActorNative::core(&self.core).is_registered() {
327                let cache = self.cache();
328                if let Some(own_book) = cache.own_order_book(&instrument_id) {
329                    let own_book_str = own_book.pprint(num_levels, None);
330                    log_info!(
331                        "\n{instrument_id} (own)\n{own_book_str}",
332                        color = LogColor::Magenta
333                    );
334                }
335            }
336        }
337
338        let Some(best_bid) = book.best_bid_price() else {
339            return Ok(()); // Wait for market
340        };
341        let Some(best_ask) = book.best_ask_price() else {
342            return Ok(()); // Wait for market
343        };
344
345        self.maintain_orders(best_bid, best_ask);
346        Ok(())
347    }
348
349    fn on_book_deltas(&mut self, deltas: &OrderBookDeltas) -> anyhow::Result<()> {
350        warn_if_implausible_unix_nanos("book deltas", deltas.ts_event, deltas.ts_init);
351
352        if self.config.log_data {
353            log_info!("{deltas:?}", color = LogColor::Cyan);
354        }
355        Ok(())
356    }
357
358    fn on_bar(&mut self, bar: &Bar) -> anyhow::Result<()> {
359        warn_if_implausible_unix_nanos("bar", bar.ts_event, bar.ts_init);
360
361        if self.config.log_data {
362            log_info!("{bar:?}", color = LogColor::Cyan);
363        }
364        Ok(())
365    }
366
367    fn on_mark_price(&mut self, mark_price: &MarkPriceUpdate) -> anyhow::Result<()> {
368        warn_if_implausible_unix_nanos("mark price", mark_price.ts_event, mark_price.ts_init);
369
370        if self.config.log_data {
371            log_info!("{mark_price:?}", color = LogColor::Cyan);
372        }
373        Ok(())
374    }
375
376    fn on_index_price(&mut self, index_price: &IndexPriceUpdate) -> anyhow::Result<()> {
377        warn_if_implausible_unix_nanos("index price", index_price.ts_event, index_price.ts_init);
378
379        if self.config.log_data {
380            log_info!("{index_price:?}", color = LogColor::Cyan);
381        }
382        Ok(())
383    }
384}
385
386impl ExecTester {
387    /// Creates a new [`ExecTester`] instance.
388    #[must_use]
389    pub fn new(config: ExecTesterConfig) -> Self {
390        let pending_open_position_qty = config.open_position_on_start_qty;
391        let buy_limit_maintenance_state = LimitOrderMaintenanceState::new(
392            config.enable_limit_buys
393                && (config.trigger_limit_order_maintenance_once
394                    || config.cancel_replace_orders_to_maintain_tob_offset),
395        );
396        let sell_limit_maintenance_state = LimitOrderMaintenanceState::new(
397            config.enable_limit_sells
398                && (config.trigger_limit_order_maintenance_once
399                    || config.cancel_replace_orders_to_maintain_tob_offset),
400        );
401
402        Self {
403            core: StrategyCore::new(config.base.clone()),
404            config,
405            instrument: None,
406            price_offset: None,
407            preinitialized_market_data: false,
408            buy_order: None,
409            sell_order: None,
410            buy_stop_order: None,
411            sell_stop_order: None,
412            open_position_submitted: false,
413            modify_rejected_attempted: false,
414            pending_open_position_qty,
415            buy_stop_cancel_replace_attempted: false,
416            sell_stop_cancel_replace_attempted: false,
417            buy_stop_replacement: None,
418            sell_stop_replacement: None,
419            buy_limit_maintenance_state,
420            sell_limit_maintenance_state,
421        }
422    }
423
424    fn initialize_with_instrument(
425        &mut self,
426        instrument: InstrumentAny,
427        subscribe_market_data: bool,
428    ) -> anyhow::Result<()> {
429        let instrument_id = self.config.instrument_id;
430        let client_id = self.config.client_id;
431
432        self.price_offset = Some(self.get_price_offset(&instrument));
433        self.instrument = Some(instrument);
434
435        if subscribe_market_data && self.config.subscribe_quotes {
436            self.subscribe_quotes(instrument_id, client_id, None);
437        }
438
439        if subscribe_market_data && self.config.subscribe_trades {
440            self.subscribe_trades(instrument_id, client_id, None);
441        }
442
443        if self.config.subscribe_book {
444            self.subscribe_book_at_interval(
445                instrument_id,
446                self.config.book_type,
447                self.config
448                    .book_depth
449                    .map(|depth| {
450                        NonZeroUsize::new(depth).ok_or_else(|| {
451                            ConfigError::range("book_depth", "must be positive, was 0")
452                        })
453                    })
454                    .transpose()?,
455                NonZeroUsize::new(self.config.book_interval_ms).ok_or_else(|| {
456                    ConfigError::range("book_interval_ms", "must be positive, was 0")
457                })?,
458                client_id,
459                None,
460            );
461        }
462
463        if let Some(qty) = self.pending_open_position_qty {
464            let quote_ready = {
465                let cache = self.cache();
466                cache.quote(&instrument_id).is_some()
467            };
468
469            if self.config.open_position_on_first_quote
470                && self.config.subscribe_quotes
471                && !quote_ready
472            {
473                log::info!("Waiting for first quote before opening {instrument_id} position");
474            } else {
475                self.pending_open_position_qty = None;
476                self.open_position(qty)?;
477                self.open_position_submitted = true;
478            }
479        }
480
481        Ok(())
482    }
483
484    pub(super) fn get_price_offset(&self, _instrument: &InstrumentAny) -> u64 {
485        self.config.tob_offset_ticks
486    }
487
488    fn expire_time_from_delta(&self, mins: u64) -> UnixNanos {
489        let current_ns = DataActorNative::core(&self.core).timestamp_ns();
490        let delta = DurationNanos::try_from_mins(mins).unwrap_or(DurationNanos::MAX);
491        current_ns.saturating_add(delta)
492    }
493
494    fn resolve_time_in_force(
495        &self,
496        tif_override: Option<TimeInForce>,
497    ) -> (TimeInForce, Option<UnixNanos>) {
498        match (tif_override, self.config.order_expire_time_delta_mins) {
499            (Some(TimeInForce::Gtd), Some(mins)) => {
500                (TimeInForce::Gtd, Some(self.expire_time_from_delta(mins)))
501            }
502            (Some(TimeInForce::Gtd), None) => {
503                log_warn!(
504                    "GTD time in force requires order_expire_time_delta_mins, falling back to GTC"
505                );
506                (TimeInForce::Gtc, None)
507            }
508            (Some(tif), _) => (tif, None),
509            (None, Some(mins)) => (TimeInForce::Gtd, Some(self.expire_time_from_delta(mins))),
510            (None, None) => (TimeInForce::Gtc, None),
511        }
512    }
513
514    fn submit_pending_open_position(&mut self) {
515        if self.instrument.is_none() {
516            return;
517        }
518
519        let Some(qty) = self.pending_open_position_qty.take() else {
520            return;
521        };
522
523        if let Err(e) = self.open_position(qty) {
524            log::error!("Failed to submit pending open position: {e}");
525        } else {
526            self.open_position_submitted = true;
527        }
528    }
529
530    pub(super) fn is_order_active(order: &OrderAny) -> bool {
531        order.is_active_local() || order.is_inflight() || order.is_open()
532    }
533
534    pub(super) fn limit_order_is_one_shot(&self) -> bool {
535        self.config.test_reject_post_only
536            || self.config.limit_aggressive
537            || self.config.order_expire_time_delta_mins.is_some()
538            || matches!(
539                self.config.limit_time_in_force,
540                Some(TimeInForce::Ioc | TimeInForce::Fok)
541            )
542    }
543
544    pub(super) fn stop_order_is_one_shot(&self) -> bool {
545        self.config.order_expire_time_delta_mins.is_some()
546            || matches!(
547                self.config.stop_time_in_force,
548                Some(TimeInForce::Ioc | TimeInForce::Fok)
549            )
550            || matches!(self.config.stop_order_type, OrderType::TrailingStopMarket)
551    }
552
553    pub(super) fn get_order_trigger_price(order: &OrderAny) -> Option<Price> {
554        order.trigger_price()
555    }
556
557    fn modify_stop_order(
558        &mut self,
559        order: &OrderAny,
560        trigger_price: Price,
561        limit_price: Option<Price>,
562    ) -> anyhow::Result<()> {
563        let client_id = self.config.client_id;
564
565        match order {
566            OrderAny::StopMarket(_)
567            | OrderAny::MarketIfTouched(_)
568            | OrderAny::TrailingStopMarket(_) => self.modify_order(
569                order.client_order_id(),
570                None,
571                None,
572                Some(trigger_price),
573                client_id,
574                None,
575            ),
576            OrderAny::StopLimit(_) | OrderAny::LimitIfTouched(_) => self.modify_order(
577                order.client_order_id(),
578                None,
579                limit_price,
580                Some(trigger_price),
581                client_id,
582                None,
583            ),
584            _ => {
585                log_warn!("Cannot modify order of type {:?}", order.order_type());
586                Ok(())
587            }
588        }
589    }
590
591    /// Submit an order, applying `order_params` if configured.
592    fn submit_order_apply_params(&mut self, order: OrderAny) -> anyhow::Result<()> {
593        let client_id = self.config.client_id;
594        if let Some(params) = &self.config.order_params {
595            self.submit_order(order, None, client_id, Some(params.clone()))
596        } else {
597            self.submit_order(order, None, client_id, None)
598        }
599    }
600
601    /// Maintain orders based on current market prices.
602    pub(super) fn maintain_orders(&mut self, best_bid: Price, best_ask: Price) {
603        if self.instrument.is_none() || self.config.dry_run {
604            return;
605        }
606
607        if self.config.batch_submit_limit_pair
608            && self.config.enable_limit_buys
609            && self.config.enable_limit_sells
610        {
611            self.maintain_batch_limit_pair(best_bid, best_ask);
612            return;
613        }
614
615        if self.config.enable_limit_buys {
616            self.maintain_buy_orders(best_bid, best_ask);
617        }
618
619        if self.config.enable_limit_sells {
620            self.maintain_sell_orders(best_bid, best_ask);
621        }
622
623        if self.config.enable_stop_buys {
624            self.maintain_stop_buy_orders(best_bid, best_ask);
625        }
626
627        if self.config.enable_stop_sells {
628            self.maintain_stop_sell_orders(best_bid, best_ask);
629        }
630    }
631
632    /// Refreshes the locally-tracked order for `side` from the cache so that
633    /// downstream checks (`venue_order_id()`, `is_pending_*`, status) see the
634    /// latest event-driven state instead of the stale clone captured at submit.
635    fn refresh_tracked_order(&mut self, side: OrderSide) {
636        let cid = match side {
637            OrderSide::Buy => self.buy_order.as_ref().map(OrderAny::client_order_id),
638            OrderSide::Sell => self.sell_order.as_ref().map(OrderAny::client_order_id),
639        };
640        let Some(cid) = cid else {
641            return;
642        };
643        let latest = self.cache().order(&cid);
644        if let Some(latest) = latest {
645            match side {
646                OrderSide::Buy => self.buy_order = Some(latest),
647                OrderSide::Sell => self.sell_order = Some(latest),
648            }
649        }
650    }
651
652    fn refresh_tracked_stop_order(&mut self, side: OrderSide) {
653        let cid = match side {
654            OrderSide::Buy => self.buy_stop_order.as_ref().map(OrderAny::client_order_id),
655            OrderSide::Sell => self.sell_stop_order.as_ref().map(OrderAny::client_order_id),
656        };
657        let Some(cid) = cid else {
658            return;
659        };
660        let latest = self.cache().order(&cid);
661        if let Some(latest) = latest {
662            match side {
663                OrderSide::Buy => self.buy_stop_order = Some(latest),
664                OrderSide::Sell => self.sell_stop_order = Some(latest),
665            }
666        }
667    }
668
669    /// Maintain buy limit orders.
670    fn maintain_buy_orders(&mut self, best_bid: Price, best_ask: Price) {
671        // Refresh from cache first so post-submit event state (venue_order_id,
672        // status) is visible. Done before binding `&self.instrument` to avoid
673        // holding an immutable borrow across the mutable refresh call.
674        self.refresh_tracked_order(OrderSide::Buy);
675
676        let Some(instrument) = &self.instrument else {
677            return;
678        };
679        let Some(price_offset_ticks) = self.price_offset else {
680            return;
681        };
682
683        let increment = instrument.price_increment();
684        let precision = instrument.price_precision();
685
686        // `test_reject_post_only` and `limit_aggressive` both cross the spread for
687        // BUY (place at/above the ask). `test_reject_post_only` additionally sets
688        // post_only=true downstream to trigger venue rejection; `limit_aggressive`
689        // pairs with IOC/FOK TIF for marketable-fill scenarios.
690        let cross_spread = self.config.test_reject_post_only || self.config.limit_aggressive;
691        let unclamped_price = if cross_spread {
692            add_price_ticks(best_ask, increment, price_offset_ticks, precision)
693        } else {
694            sub_price_ticks(best_bid, increment, price_offset_ticks, precision)
695        };
696        let price = clamp_price_to_range(
697            unclamped_price,
698            instrument,
699            self.config.clamp_to_instrument_price_range,
700        );
701
702        if self.limit_order_maintenance_suppressed(OrderSide::Buy) {
703            return;
704        }
705
706        let needs_new_order = match &self.buy_order {
707            None => true,
708            Some(order) => !Self::is_order_active(order) && !self.limit_order_is_one_shot(),
709        };
710
711        if needs_new_order {
712            let result = if self.config.enable_brackets {
713                self.submit_bracket_order(OrderSide::Buy, price)
714            } else {
715                self.submit_limit_order(OrderSide::Buy, price)
716            };
717
718            if let Err(e) = result {
719                log::error!("Failed to submit buy order: {e}");
720            }
721        } else if let Some(order) = &self.buy_order
722            && order.venue_order_id().is_some()
723            && !order.is_pending_update()
724            && !order.is_pending_cancel()
725        {
726            let client_id = self.config.client_id;
727
728            // One-shot programmatic modify to exercise the adapter's modify-rejection
729            // path (TC-E36). Uses a small price bump rather than waiting for drift.
730            if self.config.test_modify_rejected && !self.modify_rejected_attempted {
731                self.modify_rejected_attempted = true;
732                let order_clone = order.clone();
733                let bumped = clamp_price_to_range(
734                    add_price_ticks(price, increment, 1, precision),
735                    instrument,
736                    self.config.clamp_to_instrument_price_range,
737                );
738
739                if let Err(e) = self.modify_order(
740                    order_clone.client_order_id(),
741                    None,
742                    Some(bumped),
743                    None,
744                    client_id,
745                    None,
746                ) {
747                    log::error!("Failed to submit test modify on buy order: {e}");
748                }
749                return;
750            }
751
752            if let Some(order_price) = order.price()
753                && order_price < price
754            {
755                if self.config.modify_orders_to_maintain_tob_offset {
756                    let order_clone = order.clone();
757                    if let Err(e) = self.modify_order(
758                        order_clone.client_order_id(),
759                        None,
760                        Some(price),
761                        None,
762                        client_id,
763                        None,
764                    ) {
765                        log::error!("Failed to modify buy order: {e}");
766                    }
767                } else if self.config.cancel_replace_orders_to_maintain_tob_offset {
768                    let client_order_id = order.client_order_id();
769                    self.start_limit_order_cancel_replace(OrderSide::Buy, client_order_id, price);
770                }
771            }
772        }
773    }
774
775    /// Maintain sell limit orders.
776    fn maintain_sell_orders(&mut self, best_bid: Price, best_ask: Price) {
777        // Refresh from cache before borrowing `&self.instrument`; see the
778        // matching comment in `maintain_buy_orders`.
779        self.refresh_tracked_order(OrderSide::Sell);
780
781        let Some(instrument) = &self.instrument else {
782            return;
783        };
784        let Some(price_offset_ticks) = self.price_offset else {
785            return;
786        };
787
788        let increment = instrument.price_increment();
789        let precision = instrument.price_precision();
790
791        // See `maintain_buy_orders` for the cross_spread and refresh rationale.
792        let cross_spread = self.config.test_reject_post_only || self.config.limit_aggressive;
793        let unclamped_price = if cross_spread {
794            sub_price_ticks(best_bid, increment, price_offset_ticks, precision)
795        } else {
796            add_price_ticks(best_ask, increment, price_offset_ticks, precision)
797        };
798        let price = clamp_price_to_range(
799            unclamped_price,
800            instrument,
801            self.config.clamp_to_instrument_price_range,
802        );
803
804        if self.limit_order_maintenance_suppressed(OrderSide::Sell) {
805            return;
806        }
807
808        let needs_new_order = match &self.sell_order {
809            None => true,
810            Some(order) => !Self::is_order_active(order) && !self.limit_order_is_one_shot(),
811        };
812
813        if needs_new_order {
814            let result = if self.config.enable_brackets {
815                self.submit_bracket_order(OrderSide::Sell, price)
816            } else {
817                self.submit_limit_order(OrderSide::Sell, price)
818            };
819
820            if let Err(e) = result {
821                log::error!("Failed to submit sell order: {e}");
822            }
823        } else if let Some(order) = &self.sell_order
824            && order.venue_order_id().is_some()
825            && !order.is_pending_update()
826            && !order.is_pending_cancel()
827        {
828            let client_id = self.config.client_id;
829
830            // One-shot programmatic modify (TC-E36); see maintain_buy_orders.
831            if self.config.test_modify_rejected && !self.modify_rejected_attempted {
832                self.modify_rejected_attempted = true;
833                let order_clone = order.clone();
834                let bumped = clamp_price_to_range(
835                    sub_price_ticks(price, increment, 1, precision),
836                    instrument,
837                    self.config.clamp_to_instrument_price_range,
838                );
839
840                if let Err(e) = self.modify_order(
841                    order_clone.client_order_id(),
842                    None,
843                    Some(bumped),
844                    None,
845                    client_id,
846                    None,
847                ) {
848                    log::error!("Failed to submit test modify on sell order: {e}");
849                }
850                return;
851            }
852
853            if let Some(order_price) = order.price()
854                && order_price > price
855            {
856                if self.config.modify_orders_to_maintain_tob_offset {
857                    let order_clone = order.clone();
858                    if let Err(e) = self.modify_order(
859                        order_clone.client_order_id(),
860                        None,
861                        Some(price),
862                        None,
863                        client_id,
864                        None,
865                    ) {
866                        log::error!("Failed to modify sell order: {e}");
867                    }
868                } else if self.config.cancel_replace_orders_to_maintain_tob_offset {
869                    let client_order_id = order.client_order_id();
870                    self.start_limit_order_cancel_replace(OrderSide::Sell, client_order_id, price);
871                }
872            }
873        }
874    }
875
876    fn handle_limit_order_accepted(&mut self, client_order_id: ClientOrderId) {
877        let Some(side) = self.limit_order_side_for_state(client_order_id) else {
878            return;
879        };
880
881        match self.limit_order_maintenance_state(side) {
882            LimitOrderMaintenanceState::AwaitingAcceptance { .. } => {
883                self.refresh_tracked_order(side);
884                if self.config.trigger_limit_order_maintenance_once {
885                    self.trigger_limit_order_maintenance(side, client_order_id);
886                } else {
887                    self.set_limit_order_maintenance_state(
888                        side,
889                        LimitOrderMaintenanceState::Active { client_order_id },
890                    );
891                }
892            }
893            LimitOrderMaintenanceState::AwaitingReplacementAcceptance { price, .. } => {
894                self.refresh_tracked_order(side);
895                let accepted_at_expected_price = self
896                    .tracked_limit_order(side)
897                    .is_some_and(|order| order.price() == Some(price) && order.is_open());
898                if !accepted_at_expected_price {
899                    log::error!(
900                        "Replacement {side} order {client_order_id} was accepted with unexpected state"
901                    );
902                    self.set_limit_order_maintenance_state(
903                        side,
904                        LimitOrderMaintenanceState::Failed,
905                    );
906                    return;
907                }
908                self.set_limit_order_maintenance_state(side, LimitOrderMaintenanceState::Completed);
909            }
910            _ => {}
911        }
912    }
913
914    fn trigger_limit_order_maintenance(&mut self, side: OrderSide, client_order_id: ClientOrderId) {
915        let Some(order_price) = self.tracked_limit_order(side).and_then(Order::price) else {
916            log::error!("Accepted {side} limit order {client_order_id} has no price");
917            self.set_limit_order_maintenance_state(side, LimitOrderMaintenanceState::Failed);
918            return;
919        };
920        let Some(price) = self.limit_order_trigger_price(side, order_price) else {
921            log::error!(
922                "Cannot find a different valid price for accepted {side} limit order {client_order_id}"
923            );
924            self.set_limit_order_maintenance_state(side, LimitOrderMaintenanceState::Failed);
925            return;
926        };
927
928        if self.config.modify_orders_to_maintain_tob_offset {
929            self.set_limit_order_maintenance_state(
930                side,
931                LimitOrderMaintenanceState::AwaitingUpdate {
932                    client_order_id,
933                    price,
934                },
935            );
936
937            if let Err(e) = self.modify_order(
938                client_order_id,
939                None,
940                Some(price),
941                None,
942                self.config.client_id,
943                None,
944            ) {
945                self.set_limit_order_maintenance_state(side, LimitOrderMaintenanceState::Failed);
946                log::error!("Failed to submit one-shot {side} limit order modify: {e}");
947            }
948        } else if self.config.cancel_replace_orders_to_maintain_tob_offset {
949            self.start_limit_order_cancel_replace(side, client_order_id, price);
950        } else {
951            self.set_limit_order_maintenance_state(side, LimitOrderMaintenanceState::Failed);
952            log::error!("No limit order maintenance mode configured for one-shot trigger");
953        }
954    }
955
956    fn start_limit_order_cancel_replace(
957        &mut self,
958        side: OrderSide,
959        client_order_id: ClientOrderId,
960        replacement_price: Price,
961    ) {
962        let state = self.limit_order_maintenance_state(side);
963        if !matches!(
964            state,
965            LimitOrderMaintenanceState::AwaitingAcceptance {
966                client_order_id: expected,
967            } | LimitOrderMaintenanceState::Active {
968                client_order_id: expected,
969            } if expected == client_order_id
970        ) {
971            return;
972        }
973
974        self.set_limit_order_maintenance_state(
975            side,
976            LimitOrderMaintenanceState::AwaitingCancel {
977                client_order_id,
978                replacement_price,
979            },
980        );
981
982        if let Err(e) = self.cancel_order(client_order_id, self.config.client_id, None) {
983            self.set_limit_order_maintenance_state(side, LimitOrderMaintenanceState::Failed);
984            log::error!("Failed to cancel {side} limit order for replacement: {e}");
985        }
986    }
987
988    fn handle_limit_order_updated(
989        &mut self,
990        client_order_id: ClientOrderId,
991        updated_price: Option<Price>,
992    ) {
993        let Some(side) = self.limit_order_side_for_state(client_order_id) else {
994            return;
995        };
996        let LimitOrderMaintenanceState::AwaitingUpdate { price, .. } =
997            self.limit_order_maintenance_state(side)
998        else {
999            return;
1000        };
1001
1002        self.refresh_tracked_order(side);
1003        let updated_at_expected_price = updated_price == Some(price)
1004            && self
1005                .tracked_limit_order(side)
1006                .is_some_and(|order| order.price() == Some(price) && order.is_open());
1007        if !updated_at_expected_price {
1008            log::error!(
1009                "Updated {side} limit order {client_order_id} has unexpected price or state"
1010            );
1011            self.set_limit_order_maintenance_state(side, LimitOrderMaintenanceState::Failed);
1012            return;
1013        }
1014        self.set_limit_order_maintenance_state(side, LimitOrderMaintenanceState::Completed);
1015    }
1016
1017    fn handle_limit_order_canceled(&mut self, client_order_id: ClientOrderId) {
1018        let Some(side) = self.limit_order_side_for_state(client_order_id) else {
1019            return;
1020        };
1021        let LimitOrderMaintenanceState::AwaitingCancel {
1022            replacement_price, ..
1023        } = self.limit_order_maintenance_state(side)
1024        else {
1025            self.handle_limit_order_closed(client_order_id);
1026            return;
1027        };
1028
1029        self.refresh_tracked_order(side);
1030        let Some(leaves_qty) = self
1031            .tracked_limit_order(side)
1032            .filter(|order| order.client_order_id() == client_order_id)
1033            .map(Order::leaves_qty)
1034        else {
1035            self.set_limit_order_maintenance_state(side, LimitOrderMaintenanceState::Failed);
1036            log::error!("Canceled {side} limit order {client_order_id} is not tracked");
1037            return;
1038        };
1039
1040        if leaves_qty.is_zero() {
1041            self.set_limit_order_maintenance_state(side, LimitOrderMaintenanceState::Failed);
1042            log::warn!(
1043                "Canceled {side} limit order {client_order_id} has no remaining quantity to replace"
1044            );
1045            return;
1046        }
1047
1048        if let Err(e) = self.submit_limit_order_replacement(side, replacement_price, leaves_qty) {
1049            self.set_limit_order_maintenance_state(side, LimitOrderMaintenanceState::Failed);
1050            log::error!("Failed to submit replacement {side} limit order: {e}");
1051        }
1052    }
1053
1054    fn finish_limit_order_maintenance(&mut self, client_order_id: ClientOrderId) {
1055        let Some(side) = self.limit_order_side_for_state(client_order_id) else {
1056            return;
1057        };
1058
1059        if matches!(
1060            self.limit_order_maintenance_state(side),
1061            LimitOrderMaintenanceState::AwaitingUpdate { .. }
1062                | LimitOrderMaintenanceState::AwaitingCancel { .. }
1063                | LimitOrderMaintenanceState::AwaitingReplacementAcceptance { .. }
1064        ) {
1065            self.set_limit_order_maintenance_state(side, LimitOrderMaintenanceState::Failed);
1066        }
1067    }
1068
1069    fn handle_limit_order_closed(&mut self, client_order_id: ClientOrderId) {
1070        let Some(side) = self.limit_order_side_for_state(client_order_id) else {
1071            return;
1072        };
1073        self.refresh_tracked_order(side);
1074        let state = match self.limit_order_maintenance_state(side) {
1075            LimitOrderMaintenanceState::AwaitingAcceptance { .. }
1076            | LimitOrderMaintenanceState::Active { .. } => LimitOrderMaintenanceState::Ready,
1077            LimitOrderMaintenanceState::AwaitingUpdate { .. }
1078            | LimitOrderMaintenanceState::AwaitingCancel { .. }
1079            | LimitOrderMaintenanceState::AwaitingReplacementAcceptance { .. } => {
1080                LimitOrderMaintenanceState::Failed
1081            }
1082            state => state,
1083        };
1084        self.set_limit_order_maintenance_state(side, state);
1085    }
1086
1087    fn limit_order_trigger_price(&self, side: OrderSide, order_price: Price) -> Option<Price> {
1088        let instrument = self.instrument.as_ref()?;
1089        let increment = instrument.price_increment();
1090        let precision = instrument.price_precision();
1091        let passive = match side {
1092            OrderSide::Buy => sub_price_ticks(order_price, increment, 1, precision),
1093            OrderSide::Sell => add_price_ticks(order_price, increment, 1, precision),
1094        };
1095        let passive = clamp_price_to_range(passive, instrument, true);
1096        if passive != order_price {
1097            return Some(passive);
1098        }
1099
1100        let aggressive = match side {
1101            OrderSide::Buy => add_price_ticks(order_price, increment, 1, precision),
1102            OrderSide::Sell => sub_price_ticks(order_price, increment, 1, precision),
1103        };
1104        let aggressive = clamp_price_to_range(aggressive, instrument, true);
1105        (aggressive != order_price).then_some(aggressive)
1106    }
1107
1108    fn limit_order_maintenance_suppressed(&self, side: OrderSide) -> bool {
1109        let state = self.limit_order_maintenance_state(side);
1110        matches!(
1111            state,
1112            LimitOrderMaintenanceState::AwaitingUpdate { .. }
1113                | LimitOrderMaintenanceState::AwaitingCancel { .. }
1114                | LimitOrderMaintenanceState::AwaitingReplacementAcceptance { .. }
1115        ) || (self.config.trigger_limit_order_maintenance_once
1116            && matches!(
1117                state,
1118                LimitOrderMaintenanceState::Completed | LimitOrderMaintenanceState::Failed
1119            ))
1120    }
1121
1122    fn limit_order_side_for_state(&self, client_order_id: ClientOrderId) -> Option<OrderSide> {
1123        [OrderSide::Buy, OrderSide::Sell].into_iter().find(|side| {
1124            self.limit_order_maintenance_state(*side).client_order_id() == Some(client_order_id)
1125        })
1126    }
1127
1128    const fn limit_order_maintenance_state(&self, side: OrderSide) -> LimitOrderMaintenanceState {
1129        match side {
1130            OrderSide::Buy => self.buy_limit_maintenance_state,
1131            OrderSide::Sell => self.sell_limit_maintenance_state,
1132        }
1133    }
1134
1135    fn set_limit_order_maintenance_state(
1136        &mut self,
1137        side: OrderSide,
1138        state: LimitOrderMaintenanceState,
1139    ) {
1140        match side {
1141            OrderSide::Buy => self.buy_limit_maintenance_state = state,
1142            OrderSide::Sell => self.sell_limit_maintenance_state = state,
1143        }
1144    }
1145
1146    fn tracked_limit_order(&self, side: OrderSide) -> Option<&OrderAny> {
1147        match side {
1148            OrderSide::Buy => self.buy_order.as_ref(),
1149            OrderSide::Sell => self.sell_order.as_ref(),
1150        }
1151    }
1152
1153    /// Submits a buy and sell limit order as an order list (batch).
1154    fn maintain_batch_limit_pair(&mut self, best_bid: Price, best_ask: Price) {
1155        // Same rationale as the non-batch path: refresh from cache so the
1156        // active-order check sees the latest status. Done before binding
1157        // `&self.instrument` to avoid an immutable-vs-mutable borrow conflict.
1158        self.refresh_tracked_order(OrderSide::Buy);
1159        self.refresh_tracked_order(OrderSide::Sell);
1160
1161        let Some(instrument) = &self.instrument else {
1162            return;
1163        };
1164        let Some(price_offset_ticks) = self.price_offset else {
1165            return;
1166        };
1167
1168        let buy_needs = match &self.buy_order {
1169            None => true,
1170            Some(order) => !Self::is_order_active(order) && !self.limit_order_is_one_shot(),
1171        };
1172        let sell_needs = match &self.sell_order {
1173            None => true,
1174            Some(order) => !Self::is_order_active(order) && !self.limit_order_is_one_shot(),
1175        };
1176
1177        if !buy_needs || !sell_needs {
1178            return;
1179        }
1180
1181        let increment = instrument.price_increment();
1182        let precision = instrument.price_precision();
1183
1184        // `test_reject_post_only` and `limit_aggressive` flip the BUY/SELL
1185        // pricing to cross the spread; mirrored from `maintain_buy_orders` /
1186        // `maintain_sell_orders` so batch mode supports the same scenarios.
1187        let cross_spread = self.config.test_reject_post_only || self.config.limit_aggressive;
1188        let (unclamped_buy_price, unclamped_sell_price) = if cross_spread {
1189            (
1190                add_price_ticks(best_ask, increment, price_offset_ticks, precision),
1191                sub_price_ticks(best_bid, increment, price_offset_ticks, precision),
1192            )
1193        } else {
1194            (
1195                sub_price_ticks(best_bid, increment, price_offset_ticks, precision),
1196                add_price_ticks(best_ask, increment, price_offset_ticks, precision),
1197            )
1198        };
1199        let clamp = self.config.clamp_to_instrument_price_range;
1200        let buy_price = clamp_price_to_range(unclamped_buy_price, instrument, clamp);
1201        let sell_price = clamp_price_to_range(unclamped_sell_price, instrument, clamp);
1202        let quantity = instrument.make_qty_from_decimal(self.config.order_qty.as_decimal(), None);
1203        let (time_in_force, expire_time) =
1204            self.resolve_time_in_force(self.config.limit_time_in_force);
1205        let instrument_id = self.config.instrument_id;
1206        let post_only = self.config.use_post_only || self.config.test_reject_post_only;
1207        let quote_quantity = self.config.use_quote_quantity;
1208        let display_qty = self.config.order_display_qty;
1209        let emulation_trigger = self.config.emulation_trigger;
1210
1211        let buy_order = self.order_factory().limit(
1212            instrument_id,
1213            OrderSide::Buy,
1214            quantity,
1215            buy_price,
1216            Some(time_in_force),
1217            expire_time,
1218            Some(post_only),
1219            None,
1220            Some(quote_quantity),
1221            display_qty,
1222            emulation_trigger,
1223            None,
1224            None,
1225            None,
1226            None,
1227            None,
1228        );
1229
1230        let sell_order = self.order_factory().limit(
1231            instrument_id,
1232            OrderSide::Sell,
1233            quantity,
1234            sell_price,
1235            Some(time_in_force),
1236            expire_time,
1237            Some(post_only),
1238            None,
1239            Some(quote_quantity),
1240            display_qty,
1241            emulation_trigger,
1242            None,
1243            None,
1244            None,
1245            None,
1246            None,
1247        );
1248
1249        self.buy_order = Some(buy_order.clone());
1250        self.sell_order = Some(sell_order.clone());
1251
1252        let client_id = self.config.client_id;
1253        let params = self.config.order_params.clone();
1254        if let Err(e) = self.submit_order_list(vec![buy_order, sell_order], None, client_id, params)
1255        {
1256            log::error!("Failed to submit batch limit pair: {e}");
1257        }
1258    }
1259
1260    /// Maintain stop buy orders.
1261    fn maintain_stop_buy_orders(&mut self, best_bid: Price, best_ask: Price) {
1262        self.refresh_tracked_stop_order(OrderSide::Buy);
1263
1264        // Avoid churn: leave a rejected/denied stop alone (no resubmit or modify)
1265        if let Some(order) = self.buy_stop_order.as_ref()
1266            && matches!(order.status(), OrderStatus::Rejected | OrderStatus::Denied)
1267        {
1268            return;
1269        }
1270
1271        let Some(instrument) = &self.instrument else {
1272            return;
1273        };
1274
1275        let increment = instrument.price_increment();
1276        let precision = instrument.price_precision();
1277        let stop_offset_ticks = self.config.stop_offset_ticks;
1278
1279        // Determine trigger price based on order type
1280        let unclamped_trigger_price = if matches!(
1281            self.config.stop_order_type,
1282            OrderType::LimitIfTouched | OrderType::MarketIfTouched | OrderType::TrailingStopMarket
1283        ) {
1284            // IF_TOUCHED and trailing-stop buy: place BELOW market
1285            sub_price_ticks(best_bid, increment, stop_offset_ticks, precision)
1286        } else {
1287            // STOP buy orders are placed ABOVE the market (stop loss on short)
1288            add_price_ticks(best_ask, increment, stop_offset_ticks, precision)
1289        };
1290        let clamp = self.config.clamp_to_instrument_price_range;
1291        let trigger_price = clamp_price_to_range(unclamped_trigger_price, instrument, clamp);
1292
1293        // Calculate limit price if needed
1294        let limit_price = if matches!(
1295            self.config.stop_order_type,
1296            OrderType::StopLimit | OrderType::LimitIfTouched
1297        ) {
1298            let unclamped_limit_price =
1299                if let Some(limit_offset_ticks) = self.config.stop_limit_offset_ticks {
1300                    // BUY LIT/StopLimit both require trigger_price <= price.
1301                    add_price_ticks(trigger_price, increment, limit_offset_ticks, precision)
1302                } else {
1303                    trigger_price
1304                };
1305            Some(clamp_price_to_range(
1306                unclamped_limit_price,
1307                instrument,
1308                clamp,
1309            ))
1310        } else {
1311            None
1312        };
1313
1314        if let Some(replacement) = &mut self.buy_stop_replacement {
1315            replacement.trigger_price = trigger_price;
1316            replacement.limit_price = limit_price;
1317            return;
1318        }
1319
1320        let needs_new_order = match &self.buy_stop_order {
1321            None => true,
1322            Some(order) => !Self::is_order_active(order) && !self.stop_order_is_one_shot(),
1323        };
1324
1325        if needs_new_order {
1326            if let Err(e) = self.submit_stop_order(OrderSide::Buy, trigger_price, limit_price) {
1327                log::error!("Failed to submit buy stop order: {e}");
1328            }
1329        } else if let Some(order) = &self.buy_stop_order
1330            && order.venue_order_id().is_some()
1331            && !order.is_pending_update()
1332            && !order.is_pending_cancel()
1333        {
1334            let current_trigger = Self::get_order_trigger_price(order);
1335            if current_trigger.is_some() && current_trigger != Some(trigger_price) {
1336                if self.config.modify_stop_orders_to_maintain_offset {
1337                    let order_clone = order.clone();
1338                    if let Err(e) = self.modify_stop_order(&order_clone, trigger_price, limit_price)
1339                    {
1340                        log::error!("Failed to modify buy stop order: {e}");
1341                    }
1342                } else if self.config.cancel_replace_stop_orders_to_maintain_offset
1343                    && !self.buy_stop_cancel_replace_attempted
1344                {
1345                    let client_order_id = order.client_order_id();
1346                    self.start_stop_order_cancel_replace(
1347                        OrderSide::Buy,
1348                        client_order_id,
1349                        trigger_price,
1350                        limit_price,
1351                    );
1352                }
1353            }
1354        }
1355    }
1356
1357    /// Maintain stop sell orders.
1358    fn maintain_stop_sell_orders(&mut self, best_bid: Price, best_ask: Price) {
1359        self.refresh_tracked_stop_order(OrderSide::Sell);
1360
1361        // Avoid churn: leave a rejected/denied stop alone (no resubmit or modify)
1362        if let Some(order) = self.sell_stop_order.as_ref()
1363            && matches!(order.status(), OrderStatus::Rejected | OrderStatus::Denied)
1364        {
1365            return;
1366        }
1367
1368        let Some(instrument) = &self.instrument else {
1369            return;
1370        };
1371
1372        let increment = instrument.price_increment();
1373        let precision = instrument.price_precision();
1374        let stop_offset_ticks = self.config.stop_offset_ticks;
1375
1376        // Determine trigger price based on order type
1377        let unclamped_trigger_price = if matches!(
1378            self.config.stop_order_type,
1379            OrderType::LimitIfTouched | OrderType::MarketIfTouched | OrderType::TrailingStopMarket
1380        ) {
1381            // IF_TOUCHED and trailing-stop sell: place ABOVE market
1382            add_price_ticks(best_ask, increment, stop_offset_ticks, precision)
1383        } else {
1384            // STOP sell orders are placed BELOW the market (stop loss on long)
1385            sub_price_ticks(best_bid, increment, stop_offset_ticks, precision)
1386        };
1387        let clamp = self.config.clamp_to_instrument_price_range;
1388        let trigger_price = clamp_price_to_range(unclamped_trigger_price, instrument, clamp);
1389
1390        // Calculate limit price if needed
1391        let limit_price = if matches!(
1392            self.config.stop_order_type,
1393            OrderType::StopLimit | OrderType::LimitIfTouched
1394        ) {
1395            let unclamped_limit_price =
1396                if let Some(limit_offset_ticks) = self.config.stop_limit_offset_ticks {
1397                    // SELL LIT/StopLimit both require trigger_price >= price.
1398                    sub_price_ticks(trigger_price, increment, limit_offset_ticks, precision)
1399                } else {
1400                    trigger_price
1401                };
1402            Some(clamp_price_to_range(
1403                unclamped_limit_price,
1404                instrument,
1405                clamp,
1406            ))
1407        } else {
1408            None
1409        };
1410
1411        if let Some(replacement) = &mut self.sell_stop_replacement {
1412            replacement.trigger_price = trigger_price;
1413            replacement.limit_price = limit_price;
1414            return;
1415        }
1416
1417        let needs_new_order = match &self.sell_stop_order {
1418            None => true,
1419            Some(order) => !Self::is_order_active(order) && !self.stop_order_is_one_shot(),
1420        };
1421
1422        if needs_new_order {
1423            if let Err(e) = self.submit_stop_order(OrderSide::Sell, trigger_price, limit_price) {
1424                log::error!("Failed to submit sell stop order: {e}");
1425            }
1426        } else if let Some(order) = &self.sell_stop_order
1427            && order.venue_order_id().is_some()
1428            && !order.is_pending_update()
1429            && !order.is_pending_cancel()
1430        {
1431            let current_trigger = Self::get_order_trigger_price(order);
1432            if current_trigger.is_some() && current_trigger != Some(trigger_price) {
1433                if self.config.modify_stop_orders_to_maintain_offset {
1434                    let order_clone = order.clone();
1435                    if let Err(e) = self.modify_stop_order(&order_clone, trigger_price, limit_price)
1436                    {
1437                        log::error!("Failed to modify sell stop order: {e}");
1438                    }
1439                } else if self.config.cancel_replace_stop_orders_to_maintain_offset
1440                    && !self.sell_stop_cancel_replace_attempted
1441                {
1442                    let client_order_id = order.client_order_id();
1443                    self.start_stop_order_cancel_replace(
1444                        OrderSide::Sell,
1445                        client_order_id,
1446                        trigger_price,
1447                        limit_price,
1448                    );
1449                }
1450            }
1451        }
1452    }
1453
1454    fn start_stop_order_cancel_replace(
1455        &mut self,
1456        side: OrderSide,
1457        client_order_id: ClientOrderId,
1458        trigger_price: Price,
1459        limit_price: Option<Price>,
1460    ) {
1461        let replacement = Some(StopOrderReplacement {
1462            client_order_id,
1463            trigger_price,
1464            limit_price,
1465        });
1466
1467        match side {
1468            OrderSide::Buy => {
1469                self.buy_stop_cancel_replace_attempted = true;
1470                self.buy_stop_replacement = replacement;
1471            }
1472            OrderSide::Sell => {
1473                self.sell_stop_cancel_replace_attempted = true;
1474                self.sell_stop_replacement = replacement;
1475            }
1476        }
1477
1478        if let Err(e) = self.cancel_order(client_order_id, self.config.client_id, None) {
1479            self.clear_stop_order_replacement(client_order_id);
1480            log::error!("Failed to cancel {side} stop order for replacement: {e}");
1481        }
1482    }
1483
1484    fn handle_stop_order_canceled(&mut self, event: &OrderCanceled) {
1485        let client_order_id = event.client_order_id;
1486
1487        let replacement = [
1488            (OrderSide::Buy, &mut self.buy_stop_replacement),
1489            (OrderSide::Sell, &mut self.sell_stop_replacement),
1490        ]
1491        .into_iter()
1492        .find_map(|(side, pending)| {
1493            if pending
1494                .as_ref()
1495                .is_some_and(|replacement| replacement.client_order_id == client_order_id)
1496            {
1497                if event.reconciliation {
1498                    log::error!(
1499                        "Cannot complete {side} stop cancel-replace from a reconciliation event for {client_order_id}, replacement remains pending"
1500                    );
1501                    return None;
1502                }
1503
1504                pending.take().map(|replacement| (side, replacement))
1505            } else {
1506                None
1507            }
1508        });
1509
1510        let Some((side, replacement)) = replacement else {
1511            return;
1512        };
1513
1514        let Some((quantity, quote_quantity)) = self
1515            .cache()
1516            .order(&client_order_id)
1517            .map(|order| (order.leaves_qty(), order.is_quote_quantity()))
1518        else {
1519            log::error!("Canceled {side} stop order {client_order_id} is not cached");
1520            return;
1521        };
1522
1523        if quantity.is_zero() {
1524            return;
1525        }
1526
1527        if let Err(e) = self.submit_stop_order_with_qty(
1528            side,
1529            quantity,
1530            replacement.trigger_price,
1531            replacement.limit_price,
1532            quote_quantity,
1533        ) {
1534            log::error!("Failed to submit replacement {side} stop order: {e}");
1535        }
1536    }
1537
1538    fn clear_stop_order_replacement(&mut self, client_order_id: ClientOrderId) {
1539        for pending in [
1540            &mut self.buy_stop_replacement,
1541            &mut self.sell_stop_replacement,
1542        ] {
1543            if pending
1544                .as_ref()
1545                .is_some_and(|replacement| replacement.client_order_id == client_order_id)
1546            {
1547                *pending = None;
1548            }
1549        }
1550    }
1551
1552    /// Submit a limit order.
1553    ///
1554    /// # Errors
1555    ///
1556    /// Returns an error if order creation or submission fails.
1557    pub(super) fn submit_limit_order(
1558        &mut self,
1559        order_side: OrderSide,
1560        price: Price,
1561    ) -> anyhow::Result<()> {
1562        let Some(instrument) = self.instrument.as_ref() else {
1563            anyhow::bail!("No instrument loaded");
1564        };
1565
1566        let quantity = instrument.make_qty_from_decimal(self.config.order_qty.as_decimal(), None);
1567
1568        let Some(order) = self.create_tracked_limit_order(order_side, price, quantity)? else {
1569            return Ok(());
1570        };
1571        let client_order_id = order.client_order_id();
1572        self.arm_limit_order_maintenance(order_side, client_order_id);
1573
1574        if let Err(e) = self.submit_order_apply_params(order) {
1575            self.handle_limit_order_closed(client_order_id);
1576            return Err(e);
1577        }
1578        Ok(())
1579    }
1580
1581    fn submit_limit_order_replacement(
1582        &mut self,
1583        order_side: OrderSide,
1584        price: Price,
1585        quantity: Quantity,
1586    ) -> anyhow::Result<()> {
1587        let Some(order) = self.create_tracked_limit_order(order_side, price, quantity)? else {
1588            anyhow::bail!("Replacement {order_side} limit order was not created");
1589        };
1590        let client_order_id = order.client_order_id();
1591        self.set_limit_order_maintenance_state(
1592            order_side,
1593            LimitOrderMaintenanceState::AwaitingReplacementAcceptance {
1594                client_order_id,
1595                price,
1596            },
1597        );
1598        self.submit_order_apply_params(order)
1599    }
1600
1601    fn create_tracked_limit_order(
1602        &mut self,
1603        order_side: OrderSide,
1604        price: Price,
1605        quantity: Quantity,
1606    ) -> anyhow::Result<Option<OrderAny>> {
1607        if self.instrument.is_none() {
1608            anyhow::bail!("No instrument loaded");
1609        }
1610
1611        if self.config.dry_run {
1612            log_warn!("Dry run, skipping create {order_side:?} order");
1613            return Ok(None);
1614        }
1615
1616        if order_side == OrderSide::Buy && !self.config.enable_limit_buys {
1617            log_warn!("BUY orders not enabled, skipping");
1618            return Ok(None);
1619        } else if order_side == OrderSide::Sell && !self.config.enable_limit_sells {
1620            log_warn!("SELL orders not enabled, skipping");
1621            return Ok(None);
1622        }
1623
1624        let (time_in_force, expire_time) =
1625            self.resolve_time_in_force(self.config.limit_time_in_force);
1626
1627        let instrument_id = self.config.instrument_id;
1628        let post_only = self.config.use_post_only || self.config.test_reject_post_only;
1629        let quote_quantity = self.config.use_quote_quantity;
1630        let display_qty = self
1631            .config
1632            .order_display_qty
1633            .map(|display_qty| display_qty.min(quantity));
1634        let emulation_trigger = self.config.emulation_trigger;
1635
1636        let order = self.order_factory().limit(
1637            instrument_id,
1638            order_side,
1639            quantity,
1640            price,
1641            Some(time_in_force),
1642            expire_time,
1643            Some(post_only),
1644            None, // reduce_only
1645            Some(quote_quantity),
1646            display_qty,
1647            emulation_trigger,
1648            None, // trigger_instrument_id
1649            None, // exec_algorithm_id
1650            None, // exec_algorithm_params
1651            None, // tags
1652            None, // client_order_id
1653        );
1654
1655        if order_side == OrderSide::Buy {
1656            self.buy_order = Some(order.clone());
1657        } else {
1658            self.sell_order = Some(order.clone());
1659        }
1660
1661        Ok(Some(order))
1662    }
1663
1664    fn arm_limit_order_maintenance(&mut self, side: OrderSide, client_order_id: ClientOrderId) {
1665        if matches!(
1666            self.limit_order_maintenance_state(side),
1667            LimitOrderMaintenanceState::Ready
1668                | LimitOrderMaintenanceState::AwaitingAcceptance { .. }
1669                | LimitOrderMaintenanceState::Active { .. }
1670        ) {
1671            self.set_limit_order_maintenance_state(
1672                side,
1673                LimitOrderMaintenanceState::AwaitingAcceptance { client_order_id },
1674            );
1675        }
1676    }
1677
1678    /// Submit a stop order.
1679    ///
1680    /// # Errors
1681    ///
1682    /// Returns an error if order creation or submission fails.
1683    pub(super) fn submit_stop_order(
1684        &mut self,
1685        order_side: OrderSide,
1686        trigger_price: Price,
1687        limit_price: Option<Price>,
1688    ) -> anyhow::Result<()> {
1689        let Some(instrument) = &self.instrument else {
1690            anyhow::bail!("No instrument loaded");
1691        };
1692
1693        if self.config.dry_run {
1694            log_warn!("Dry run, skipping create {order_side:?} stop order");
1695            return Ok(());
1696        }
1697
1698        if order_side == OrderSide::Buy && !self.config.enable_stop_buys {
1699            log_warn!("BUY stop orders not enabled, skipping");
1700            return Ok(());
1701        } else if order_side == OrderSide::Sell && !self.config.enable_stop_sells {
1702            log_warn!("SELL stop orders not enabled, skipping");
1703            return Ok(());
1704        }
1705
1706        let quantity = instrument.make_qty_from_decimal(self.config.order_qty.as_decimal(), None);
1707
1708        self.submit_stop_order_with_qty(
1709            order_side,
1710            quantity,
1711            trigger_price,
1712            limit_price,
1713            self.config.use_quote_quantity,
1714        )
1715    }
1716
1717    #[expect(
1718        clippy::too_many_lines,
1719        reason = "stop order submission covers all supported stop order scenarios"
1720    )]
1721    fn submit_stop_order_with_qty(
1722        &mut self,
1723        order_side: OrderSide,
1724        quantity: Quantity,
1725        trigger_price: Price,
1726        limit_price: Option<Price>,
1727        quote_quantity: bool,
1728    ) -> anyhow::Result<()> {
1729        let (time_in_force, expire_time) =
1730            self.resolve_time_in_force(self.config.stop_time_in_force);
1731
1732        let instrument_id = self.config.instrument_id;
1733        let trigger_type = self.config.stop_trigger_type;
1734        let display_qty = self
1735            .config
1736            .order_display_qty
1737            .map(|display_qty| display_qty.min(quantity));
1738        let emulation_trigger = self.config.emulation_trigger;
1739        let stop_order_type = self.config.stop_order_type;
1740        let trailing_offset = self.config.trailing_offset;
1741        let trailing_offset_type = self.config.trailing_offset_type;
1742
1743        let mut factory = self.order_factory();
1744
1745        let mut order: OrderAny = match stop_order_type {
1746            OrderType::StopMarket => factory.stop_market(
1747                instrument_id,
1748                order_side,
1749                quantity,
1750                trigger_price,
1751                Some(trigger_type),
1752                Some(time_in_force),
1753                expire_time,
1754                None, // reduce_only
1755                Some(quote_quantity),
1756                None, // display_qty
1757                emulation_trigger,
1758                None, // trigger_instrument_id
1759                None, // exec_algorithm_id
1760                None, // exec_algorithm_params
1761                None, // tags
1762                None, // client_order_id
1763            ),
1764            OrderType::StopLimit => {
1765                let Some(limit_price) = limit_price else {
1766                    anyhow::bail!("STOP_LIMIT order requires limit_price");
1767                };
1768                factory.stop_limit(
1769                    instrument_id,
1770                    order_side,
1771                    quantity,
1772                    limit_price,
1773                    trigger_price,
1774                    Some(trigger_type),
1775                    Some(time_in_force),
1776                    expire_time,
1777                    None, // post_only
1778                    None, // reduce_only
1779                    Some(quote_quantity),
1780                    display_qty,
1781                    emulation_trigger,
1782                    None, // trigger_instrument_id
1783                    None, // exec_algorithm_id
1784                    None, // exec_algorithm_params
1785                    None, // tags
1786                    None, // client_order_id
1787                )
1788            }
1789            OrderType::MarketIfTouched => factory.market_if_touched(
1790                instrument_id,
1791                order_side,
1792                quantity,
1793                trigger_price,
1794                Some(trigger_type),
1795                Some(time_in_force),
1796                expire_time,
1797                None, // reduce_only
1798                Some(quote_quantity),
1799                emulation_trigger,
1800                None, // trigger_instrument_id
1801                None, // exec_algorithm_id
1802                None, // exec_algorithm_params
1803                None, // tags
1804                None, // client_order_id
1805            ),
1806            OrderType::LimitIfTouched => {
1807                let Some(limit_price) = limit_price else {
1808                    anyhow::bail!("LIMIT_IF_TOUCHED order requires limit_price");
1809                };
1810                factory.limit_if_touched(
1811                    instrument_id,
1812                    order_side,
1813                    quantity,
1814                    limit_price,
1815                    trigger_price,
1816                    Some(trigger_type),
1817                    Some(time_in_force),
1818                    expire_time,
1819                    None, // post_only
1820                    None, // reduce_only
1821                    Some(quote_quantity),
1822                    display_qty,
1823                    emulation_trigger,
1824                    None, // trigger_instrument_id
1825                    None, // exec_algorithm_id
1826                    None, // exec_algorithm_params
1827                    None, // tags
1828                    None, // client_order_id
1829                )
1830            }
1831            OrderType::TrailingStopMarket => {
1832                let Some(trailing_offset) = trailing_offset else {
1833                    anyhow::bail!("TRAILING_STOP_MARKET order requires trailing_offset config");
1834                };
1835                factory.trailing_stop_market(
1836                    instrument_id,
1837                    order_side,
1838                    quantity,
1839                    trailing_offset,
1840                    Some(trailing_offset_type),
1841                    None,
1842                    Some(trigger_price),
1843                    Some(trigger_type),
1844                    Some(time_in_force),
1845                    expire_time,
1846                    None, // reduce_only
1847                    Some(quote_quantity),
1848                    None, // display_qty
1849                    emulation_trigger,
1850                    None, // trigger_instrument_id
1851                    None, // exec_algorithm_id
1852                    None, // exec_algorithm_params
1853                    None, // tags
1854                    None, // client_order_id
1855                )
1856            }
1857            _ => {
1858                anyhow::bail!("Unknown stop order type: {stop_order_type:?}");
1859            }
1860        };
1861        drop(factory);
1862
1863        if let OrderAny::TrailingStopMarket(order) = &mut order {
1864            order.activation_price = Some(trigger_price);
1865        }
1866
1867        if order_side == OrderSide::Buy {
1868            self.buy_stop_order = Some(order.clone());
1869        } else {
1870            self.sell_stop_order = Some(order.clone());
1871        }
1872
1873        self.submit_order_apply_params(order)
1874    }
1875
1876    /// Submit a bracket order (entry with stop-loss and take-profit).
1877    ///
1878    /// # Errors
1879    ///
1880    /// Returns an error if order creation or submission fails.
1881    #[expect(
1882        clippy::too_many_lines,
1883        reason = "bracket validation and linked entry, stop-loss, and take-profit construction stay together"
1884    )]
1885    pub(super) fn submit_bracket_order(
1886        &mut self,
1887        order_side: OrderSide,
1888        entry_price: Price,
1889    ) -> anyhow::Result<()> {
1890        let Some(instrument) = &self.instrument else {
1891            anyhow::bail!("No instrument loaded");
1892        };
1893
1894        if self.config.dry_run {
1895            log_warn!("Dry run, skipping create {order_side:?} bracket order");
1896            return Ok(());
1897        }
1898
1899        if self.config.bracket_entry_order_type != OrderType::Limit {
1900            anyhow::bail!(
1901                "Only Limit entry orders are supported for brackets, was {:?}",
1902                self.config.bracket_entry_order_type
1903            );
1904        }
1905
1906        anyhow::ensure!(
1907            matches!(
1908                self.config.bracket_contingency_type,
1909                ContingencyType::Oco | ContingencyType::Ouo
1910            ),
1911            "Bracket exits require OCO or OUO contingency, was {:?}",
1912            self.config.bracket_contingency_type
1913        );
1914        anyhow::ensure!(
1915            matches!(
1916                self.config.bracket_tp_order_type,
1917                OrderType::Limit | OrderType::MarketIfTouched | OrderType::LimitIfTouched
1918            ),
1919            "Bracket take-profit requires LIMIT, MARKET_IF_TOUCHED, or LIMIT_IF_TOUCHED, was {:?}",
1920            self.config.bracket_tp_order_type
1921        );
1922
1923        if order_side == OrderSide::Buy && !self.config.enable_limit_buys {
1924            log_warn!("BUY orders not enabled, skipping bracket");
1925            return Ok(());
1926        } else if order_side == OrderSide::Sell && !self.config.enable_limit_sells {
1927            log_warn!("SELL orders not enabled, skipping bracket");
1928            return Ok(());
1929        }
1930
1931        if matches!(
1932            (
1933                self.config.limit_time_in_force,
1934                self.config.order_expire_time_delta_mins
1935            ),
1936            (Some(TimeInForce::Gtd), _) | (None, Some(_))
1937        ) {
1938            anyhow::bail!("GTD time in force not supported for bracket take-profit legs");
1939        }
1940
1941        let (time_in_force, expire_time) =
1942            self.resolve_time_in_force(self.config.limit_time_in_force);
1943        let sl_time_in_force = self.config.stop_time_in_force.unwrap_or(TimeInForce::Gtc);
1944        if sl_time_in_force == TimeInForce::Gtd {
1945            anyhow::bail!("GTD time in force not supported for bracket stop-loss legs");
1946        }
1947
1948        let quantity = instrument.make_qty_from_decimal(self.config.order_qty.as_decimal(), None);
1949        let increment = instrument.price_increment();
1950        let precision = instrument.price_precision();
1951        let bracket_offset_ticks = self.config.bracket_offset_ticks;
1952
1953        let (unclamped_tp_price, unclamped_sl_trigger_price) = match order_side {
1954            OrderSide::Buy => {
1955                let tp = add_price_ticks(entry_price, increment, bracket_offset_ticks, precision);
1956                let sl = sub_price_ticks(entry_price, increment, bracket_offset_ticks, precision);
1957                (tp, sl)
1958            }
1959            OrderSide::Sell => {
1960                let tp = sub_price_ticks(entry_price, increment, bracket_offset_ticks, precision);
1961                let sl = add_price_ticks(entry_price, increment, bracket_offset_ticks, precision);
1962                (tp, sl)
1963            }
1964        };
1965        let clamp = self.config.clamp_to_instrument_price_range;
1966        let tp_price = clamp_price_to_range(unclamped_tp_price, instrument, clamp);
1967        let sl_trigger_price = clamp_price_to_range(unclamped_sl_trigger_price, instrument, clamp);
1968
1969        let entry_post_only = self.config.use_post_only || self.config.test_reject_post_only;
1970        let instrument_id = self.config.instrument_id;
1971        let quote_quantity = self.config.use_quote_quantity;
1972        let emulation_trigger = self.config.emulation_trigger;
1973        let stop_trigger_type = self.config.stop_trigger_type;
1974        let contingency_type = self.config.bracket_contingency_type;
1975        let tp_order_type = self.config.bracket_tp_order_type;
1976        let orders = self
1977            .order_factory()
1978            .bracket()
1979            .instrument_id(instrument_id)
1980            .order_side(order_side)
1981            .quantity(quantity)
1982            .quote_quantity(quote_quantity)
1983            .contingency_type(contingency_type)
1984            .entry_order_type(OrderType::Limit)
1985            .entry_price(entry_price)
1986            .time_in_force(time_in_force)
1987            .entry_post_only(entry_post_only)
1988            .maybe_emulation_trigger(emulation_trigger)
1989            .maybe_expire_time(expire_time)
1990            .tp_order_type(tp_order_type)
1991            .tp_price(tp_price)
1992            .tp_trigger_price(tp_price)
1993            .tp_post_only(entry_post_only)
1994            .tp_time_in_force(time_in_force)
1995            .sl_trigger_price(sl_trigger_price)
1996            .sl_trigger_type(stop_trigger_type)
1997            .sl_time_in_force(sl_time_in_force)
1998            .call();
1999
2000        let entry_order = &orders[0];
2001        if order_side == OrderSide::Buy {
2002            self.buy_order = Some(entry_order.clone());
2003        } else {
2004            self.sell_order = Some(entry_order.clone());
2005        }
2006
2007        self.arm_limit_order_maintenance(order_side, entry_order.client_order_id());
2008
2009        let client_id = self.config.client_id;
2010        if let Some(params) = &self.config.order_params {
2011            self.submit_order_list(orders, None, client_id, Some(params.clone()))
2012        } else {
2013            self.submit_order_list(orders, None, client_id, None)
2014        }
2015    }
2016
2017    fn close_positions_on_stop(
2018        &mut self,
2019        strategy_id: StrategyId,
2020        time_in_force: Option<TimeInForce>,
2021    ) -> anyhow::Result<()> {
2022        let instrument_id = self.config.instrument_id;
2023        let client_id = self.config.client_id;
2024        let reduce_only = Some(self.config.reduce_only_on_stop);
2025        let Some(precision) = self.config.close_positions_qty_precision else {
2026            return self.close_all_positions(
2027                instrument_id,
2028                None,
2029                client_id,
2030                None,
2031                time_in_force,
2032                reduce_only,
2033                None,
2034                None,
2035            );
2036        };
2037
2038        let positions =
2039            self.cache()
2040                .positions_open(None, Some(&instrument_id), Some(&strategy_id), None, None);
2041
2042        if positions.is_empty() {
2043            log::info!("No {instrument_id} open positions to close");
2044            return Ok(());
2045        }
2046
2047        log::info!(
2048            "Closing {} open position{}",
2049            positions.len(),
2050            if positions.len() == 1 { "" } else { "s" },
2051        );
2052
2053        for position in positions {
2054            let position_id = position.id;
2055            let (close_quantity, residual) = split_position_quantity(position.quantity, precision)?;
2056
2057            if close_quantity.is_zero() {
2058                log_warn!(
2059                    "Position {position_id} has no venue-fillable close quantity at {precision}-decimal precision; exact residual remains {residual}"
2060                );
2061                continue;
2062            }
2063
2064            if residual > Decimal::ZERO {
2065                log_warn!(
2066                    "Position {position_id} close quantity {close_quantity} leaves exact residual {residual} after a full fill"
2067                );
2068            }
2069
2070            let Some(closing_side) = OrderCore::closing_side(position.side) else {
2071                continue;
2072            };
2073            let order = self.order_factory().market(
2074                position.instrument_id,
2075                closing_side,
2076                close_quantity,
2077                time_in_force,
2078                reduce_only,
2079                None,
2080                None,
2081                None,
2082                None,
2083                None,
2084            );
2085
2086            self.submit_order(order, Some(position_id), client_id, None)?;
2087        }
2088
2089        Ok(())
2090    }
2091
2092    /// Open a position with a market order.
2093    ///
2094    /// # Errors
2095    ///
2096    /// Returns an error if order creation or submission fails.
2097    pub(super) fn open_position(&mut self, net_qty: Decimal) -> anyhow::Result<()> {
2098        let Some(instrument) = &self.instrument else {
2099            anyhow::bail!("No instrument loaded");
2100        };
2101
2102        if self.config.dry_run {
2103            log_warn!("Dry run, skipping open position");
2104            return Ok(());
2105        }
2106
2107        if net_qty == Decimal::ZERO {
2108            log_warn!("Open position with zero quantity, skipping");
2109            return Ok(());
2110        }
2111
2112        let order_side = if net_qty > Decimal::ZERO {
2113            OrderSide::Buy
2114        } else {
2115            OrderSide::Sell
2116        };
2117
2118        let quantity = instrument.make_qty_from_decimal(net_qty.abs(), None);
2119
2120        // Test reduce_only rejection by setting reduce_only on open position order
2121        let reduce_only = if self.config.test_reject_reduce_only {
2122            Some(true)
2123        } else {
2124            None
2125        };
2126        let instrument_id = self.config.instrument_id;
2127        let time_in_force = self.config.open_position_time_in_force;
2128        let quote_quantity = self.config.use_quote_quantity;
2129
2130        let order = self.order_factory().market(
2131            instrument_id,
2132            order_side,
2133            quantity,
2134            Some(time_in_force),
2135            reduce_only,
2136            Some(quote_quantity),
2137            None, // exec_algorithm_id
2138            None, // exec_algorithm_params
2139            None, // tags
2140            None, // client_order_id
2141        );
2142
2143        self.submit_order_apply_params(order)
2144    }
2145
2146    pub(super) fn cancel_active_orders(
2147        &mut self,
2148        instrument_id: InstrumentId,
2149        strategy_id: StrategyId,
2150        client_id: Option<ClientId>,
2151    ) {
2152        // Reach INITIALIZED contingent legs that the open/emulated/inflight indexes
2153        // miss. Skip non-bracket lists so the configured cancel mode owns them.
2154        let bracket_targets: Vec<ClientOrderId> = {
2155            let cache = self.cache();
2156            let mut targets = Vec::new();
2157
2158            for order_list in
2159                cache.order_lists(None, Some(&instrument_id), Some(&strategy_id), None)
2160            {
2161                let is_bracket = order_list.client_order_ids.iter().any(|cid| {
2162                    cache
2163                        .order(cid)
2164                        .is_some_and(|o| is_in_contingency_group(&o))
2165                });
2166
2167                if !is_bracket {
2168                    continue;
2169                }
2170
2171                for cid in &order_list.client_order_ids {
2172                    if let Some(order) = cache.order(cid)
2173                        && !order.is_closed()
2174                        && !order.is_pending_cancel()
2175                    {
2176                        targets.push(*cid);
2177                    }
2178                }
2179            }
2180            targets
2181        };
2182
2183        for cid in bracket_targets {
2184            if let Err(e) = self.cancel_order(cid, client_id, None) {
2185                log::error!("Failed to cancel bracket leg {cid}: {e}");
2186            }
2187        }
2188
2189        if self.config.use_individual_cancels_on_stop {
2190            for cid in self.collect_cancellable_order_ids(instrument_id, strategy_id) {
2191                if let Err(e) = self.cancel_order(cid, client_id, None) {
2192                    log::error!("Failed to cancel order {cid}: {e}");
2193                }
2194            }
2195        } else if self.config.use_batch_cancel_on_stop {
2196            let candidates = self.collect_cancellable_orders(instrument_id, strategy_id);
2197            let mut batchable: Vec<ClientOrderId> = Vec::new();
2198
2199            for order in candidates {
2200                let cid = order.client_order_id();
2201                if order.is_emulated() || order.is_active_local() {
2202                    if let Err(e) = self.cancel_order(cid, client_id, None) {
2203                        log::error!("Failed to cancel local order {cid}: {e}");
2204                    }
2205                } else {
2206                    batchable.push(cid);
2207                }
2208            }
2209
2210            if !batchable.is_empty()
2211                && let Err(e) = self.cancel_orders(batchable, client_id, None)
2212            {
2213                log::error!("Failed to batch cancel orders: {e}");
2214            }
2215        } else {
2216            // `cancel_all_orders` does not reach active-local orders; cancel those
2217            // individually first. Brackets are handled by the sweep above.
2218            let local_ids: Vec<ClientOrderId> = {
2219                let cache = self.cache();
2220                cache
2221                    .orders_active_local(None, Some(&instrument_id), Some(&strategy_id), None, None)
2222                    .into_iter()
2223                    .filter(|o| {
2224                        !o.is_closed() && !o.is_pending_cancel() && !is_in_contingency_group(o)
2225                    })
2226                    .map(|o| o.client_order_id())
2227                    .collect()
2228            };
2229
2230            for cid in local_ids {
2231                if let Err(e) = self.cancel_order(cid, client_id, None) {
2232                    log::error!("Failed to cancel active-local order {cid}: {e}");
2233                }
2234            }
2235
2236            if let Err(e) = self.cancel_all_orders(instrument_id, None, client_id, false, None) {
2237                log::error!("Failed to cancel all orders: {e}");
2238            }
2239        }
2240    }
2241
2242    pub(super) fn collect_cancellable_orders(
2243        &self,
2244        instrument_id: InstrumentId,
2245        strategy_id: StrategyId,
2246    ) -> Vec<OrderAny> {
2247        let cache = self.cache();
2248        let mut seen: AHashSet<ClientOrderId> = AHashSet::new();
2249        let mut candidates: Vec<OrderAny> = Vec::new();
2250        // `orders_active_local` catches just-submitted orders not yet in the other
2251        // indexes. Bracket legs are excluded; the sweep in `cancel_active_orders` owns them.
2252        let sources = [
2253            cache.orders_active_local(None, Some(&instrument_id), Some(&strategy_id), None, None),
2254            cache.orders_emulated(None, Some(&instrument_id), Some(&strategy_id), None, None),
2255            cache.orders_inflight(None, Some(&instrument_id), Some(&strategy_id), None, None),
2256            cache.orders_open(None, Some(&instrument_id), Some(&strategy_id), None, None),
2257        ];
2258
2259        for orders in sources {
2260            for order in orders {
2261                if order.is_closed() || order.is_pending_cancel() || is_in_contingency_group(&order)
2262                {
2263                    continue;
2264                }
2265                let cid = order.client_order_id();
2266                if seen.insert(cid) {
2267                    candidates.push(order);
2268                }
2269            }
2270        }
2271        candidates
2272    }
2273
2274    pub(super) fn collect_cancellable_order_ids(
2275        &self,
2276        instrument_id: InstrumentId,
2277        strategy_id: StrategyId,
2278    ) -> Vec<ClientOrderId> {
2279        self.collect_cancellable_orders(instrument_id, strategy_id)
2280            .into_iter()
2281            .map(|o| o.client_order_id())
2282            .collect()
2283    }
2284}
2285
2286impl LimitOrderMaintenanceState {
2287    const fn new(enabled: bool) -> Self {
2288        if enabled { Self::Ready } else { Self::Disabled }
2289    }
2290
2291    const fn client_order_id(self) -> Option<ClientOrderId> {
2292        match self {
2293            Self::AwaitingAcceptance { client_order_id }
2294            | Self::Active { client_order_id }
2295            | Self::AwaitingUpdate {
2296                client_order_id, ..
2297            }
2298            | Self::AwaitingCancel {
2299                client_order_id, ..
2300            }
2301            | Self::AwaitingReplacementAcceptance {
2302                client_order_id, ..
2303            } => Some(client_order_id),
2304            Self::Disabled | Self::Ready | Self::Completed | Self::Failed => None,
2305        }
2306    }
2307}
2308
2309fn split_position_quantity(
2310    quantity: Quantity,
2311    precision: u8,
2312) -> anyhow::Result<(Quantity, Decimal)> {
2313    let quantity_decimal = quantity.as_decimal();
2314    let close_decimal = quantity_decimal.trunc_with_scale(u32::from(precision));
2315    let close_quantity = Quantity::from_decimal_dp(close_decimal, quantity.precision)
2316        .map_err(|e| anyhow::anyhow!("Invalid position close quantity {close_decimal}: {e}"))?;
2317    let residual = quantity_decimal - close_quantity.as_decimal();
2318    Ok((close_quantity, residual))
2319}
2320
2321fn add_price_ticks(base: Price, increment: Price, ticks: u64, precision: u8) -> Price {
2322    let offset = price_tick_offset(increment, ticks, precision);
2323    base + offset
2324}
2325
2326fn sub_price_ticks(base: Price, increment: Price, ticks: u64, precision: u8) -> Price {
2327    let offset = price_tick_offset(increment, ticks, precision);
2328    base - offset
2329}
2330
2331fn price_tick_offset(increment: Price, ticks: u64, precision: u8) -> Price {
2332    let offset = increment * Decimal::from(ticks);
2333    Price::from_decimal_dp(offset, precision)
2334        .unwrap_or_else(|e| panic!("Failed to calculate price tick offset: {e}"))
2335}
2336
2337fn is_in_contingency_group(order: &OrderAny) -> bool {
2338    matches!(
2339        order.contingency_type(),
2340        Some(ContingencyType::Oto | ContingencyType::Oco | ContingencyType::Ouo)
2341    )
2342}
2343
2344fn clamp_price_to_range(price: Price, instrument: &InstrumentAny, enabled: bool) -> Price {
2345    if !enabled {
2346        return price;
2347    }
2348    let mut clamped = price;
2349    if let Some(min) = instrument.min_price()
2350        && clamped < min
2351    {
2352        clamped = min;
2353    }
2354
2355    if let Some(max) = instrument.max_price()
2356        && clamped > max
2357    {
2358        clamped = max;
2359    }
2360
2361    clamped
2362}
2363
2364#[derive(Debug)]
2365struct StopOrderReplacement {
2366    client_order_id: ClientOrderId,
2367    trigger_price: Price,
2368    limit_price: Option<Price>,
2369}