Skip to main content

nautilus_testkit/testers/exec/
strategy.rs

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