Skip to main content

nautilus_model/orders/
trailing_stop_limit.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::{
17    fmt::Display,
18    ops::{Deref, DerefMut},
19};
20
21use indexmap::IndexMap;
22use nautilus_core::{
23    UUID4, UnixNanos,
24    correctness::{CorrectnessError, FAILED},
25};
26use rust_decimal::Decimal;
27use serde::{Deserialize, Serialize};
28use ustr::Ustr;
29
30use super::{Order, OrderAny, OrderCore, OrderError};
31use crate::{
32    enums::{
33        ContingencyType, LiquiditySide, OrderSide, OrderStatus, OrderType, PositionSide,
34        TimeInForce, TrailingOffsetType, TriggerType,
35    },
36    events::{OrderEventAny, OrderInitialized, OrderUpdated},
37    identifiers::{
38        AccountId, ClientOrderId, ExecAlgorithmId, InstrumentId, OrderListId, PositionId,
39        StrategyId, Symbol, TradeId, TraderId, Venue, VenueOrderId,
40    },
41    orders::{check_display_qty, check_time_in_force},
42    types::{Currency, Money, Price, Quantity, quantity::check_positive_quantity},
43};
44
45#[derive(Clone, Debug, Serialize, Deserialize)]
46#[cfg_attr(
47    feature = "python",
48    pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
49)]
50#[cfg_attr(
51    feature = "python",
52    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
53)]
54pub struct TrailingStopLimitOrder {
55    core: OrderCore,
56    pub activation_price: Option<Price>,
57    pub price: Option<Price>,
58    pub trigger_price: Option<Price>,
59    pub trigger_type: TriggerType,
60    pub limit_offset: Decimal,
61    pub trailing_offset: Decimal,
62    pub trailing_offset_type: TrailingOffsetType,
63    pub expire_time: Option<UnixNanos>,
64    pub is_post_only: bool,
65    pub display_qty: Option<Quantity>,
66    pub trigger_instrument_id: Option<InstrumentId>,
67    pub is_activated: bool,
68    pub is_triggered: bool,
69    pub ts_triggered: Option<UnixNanos>,
70}
71
72impl TrailingStopLimitOrder {
73    /// Creates a new [`TrailingStopLimitOrder`] instance.
74    ///
75    /// # Errors
76    ///
77    /// Returns an error if:
78    /// - The `quantity` is not positive.
79    /// - The `display_qty` (when provided) exceeds `quantity`.
80    /// - The `time_in_force` is `GTD` **and** `expire_time` is `None` or zero.
81    /// - The order metadata violates an [`OrderInitialized::new_checked`] invariant.
82    #[expect(clippy::too_many_arguments)]
83    pub fn new_checked(
84        trader_id: TraderId,
85        strategy_id: StrategyId,
86        instrument_id: InstrumentId,
87        client_order_id: ClientOrderId,
88        order_side: OrderSide,
89        quantity: Quantity,
90        activation_price: Option<Price>,
91        price: Option<Price>,
92        trigger_price: Option<Price>,
93        trigger_type: TriggerType,
94        limit_offset: Decimal,
95        trailing_offset: Decimal,
96        trailing_offset_type: TrailingOffsetType,
97        time_in_force: TimeInForce,
98        expire_time: Option<UnixNanos>,
99        post_only: bool,
100        reduce_only: bool,
101        quote_quantity: bool,
102        display_qty: Option<Quantity>,
103        emulation_trigger: Option<TriggerType>,
104        trigger_instrument_id: Option<InstrumentId>,
105        contingency_type: Option<ContingencyType>,
106        order_list_id: Option<OrderListId>,
107        linked_order_ids: Option<Vec<ClientOrderId>>,
108        parent_order_id: Option<ClientOrderId>,
109        exec_algorithm_id: Option<ExecAlgorithmId>,
110        exec_algorithm_params: Option<IndexMap<Ustr, Ustr>>,
111        exec_spawn_id: Option<ClientOrderId>,
112        tags: Option<Vec<Ustr>>,
113        init_id: UUID4,
114        ts_init: UnixNanos,
115    ) -> Result<Self, OrderError> {
116        check_positive_quantity(quantity, stringify!(quantity))?;
117        check_display_qty(display_qty, quantity)?;
118        check_time_in_force(time_in_force, expire_time)?;
119
120        let init_order = OrderInitialized::new_checked(
121            trader_id,
122            strategy_id,
123            instrument_id,
124            client_order_id,
125            order_side,
126            OrderType::TrailingStopLimit,
127            quantity,
128            time_in_force,
129            post_only,
130            reduce_only,
131            quote_quantity,
132            false,
133            init_id,
134            ts_init,
135            ts_init,
136            price,
137            activation_price,
138            trigger_price,
139            Some(trigger_type),
140            Some(limit_offset),
141            Some(trailing_offset),
142            Some(trailing_offset_type),
143            expire_time,
144            display_qty,
145            emulation_trigger,
146            trigger_instrument_id,
147            contingency_type,
148            order_list_id,
149            linked_order_ids,
150            parent_order_id,
151            exec_algorithm_id,
152            exec_algorithm_params,
153            exec_spawn_id,
154            tags,
155        )?;
156
157        Ok(Self {
158            core: OrderCore::new(init_order),
159            activation_price,
160            price,
161            trigger_price,
162            trigger_type,
163            limit_offset,
164            trailing_offset,
165            trailing_offset_type,
166            expire_time,
167            is_post_only: post_only,
168            display_qty,
169            trigger_instrument_id,
170            is_activated: false,
171            is_triggered: false,
172            ts_triggered: None,
173        })
174    }
175
176    /// Creates a new [`TrailingStopLimitOrder`] instance.
177    ///
178    /// # Panics
179    ///
180    /// Panics if any order validation fails (see [`TrailingStopLimitOrder::new_checked`]).
181    #[expect(clippy::too_many_arguments)]
182    #[must_use]
183    pub fn new(
184        trader_id: TraderId,
185        strategy_id: StrategyId,
186        instrument_id: InstrumentId,
187        client_order_id: ClientOrderId,
188        order_side: OrderSide,
189        quantity: Quantity,
190        activation_price: Option<Price>,
191        price: Price,
192        trigger_price: Price,
193        trigger_type: TriggerType,
194        limit_offset: Decimal,
195        trailing_offset: Decimal,
196        trailing_offset_type: TrailingOffsetType,
197        time_in_force: TimeInForce,
198        expire_time: Option<UnixNanos>,
199        post_only: bool,
200        reduce_only: bool,
201        quote_quantity: bool,
202        display_qty: Option<Quantity>,
203        emulation_trigger: Option<TriggerType>,
204        trigger_instrument_id: Option<InstrumentId>,
205        contingency_type: Option<ContingencyType>,
206        order_list_id: Option<OrderListId>,
207        linked_order_ids: Option<Vec<ClientOrderId>>,
208        parent_order_id: Option<ClientOrderId>,
209        exec_algorithm_id: Option<ExecAlgorithmId>,
210        exec_algorithm_params: Option<IndexMap<Ustr, Ustr>>,
211        exec_spawn_id: Option<ClientOrderId>,
212        tags: Option<Vec<Ustr>>,
213        init_id: UUID4,
214        ts_init: UnixNanos,
215    ) -> Self {
216        Self::new_checked(
217            trader_id,
218            strategy_id,
219            instrument_id,
220            client_order_id,
221            order_side,
222            quantity,
223            activation_price,
224            Some(price),
225            Some(trigger_price),
226            trigger_type,
227            limit_offset,
228            trailing_offset,
229            trailing_offset_type,
230            time_in_force,
231            expire_time,
232            post_only,
233            reduce_only,
234            quote_quantity,
235            display_qty,
236            emulation_trigger,
237            trigger_instrument_id,
238            contingency_type,
239            order_list_id,
240            linked_order_ids,
241            parent_order_id,
242            exec_algorithm_id,
243            exec_algorithm_params,
244            exec_spawn_id,
245            tags,
246            init_id,
247            ts_init,
248        )
249        .unwrap_or_else(|e| panic!("{FAILED}: {e}"))
250    }
251
252    #[must_use]
253    pub fn has_activation_price(&self) -> bool {
254        self.activation_price.is_some()
255    }
256
257    pub fn set_activated(&mut self) {
258        debug_assert!(!self.is_activated, "double activation");
259        self.is_activated = true;
260    }
261}
262
263impl PartialEq for TrailingStopLimitOrder {
264    fn eq(&self, other: &Self) -> bool {
265        self.client_order_id == other.client_order_id
266    }
267}
268
269impl Deref for TrailingStopLimitOrder {
270    type Target = OrderCore;
271    fn deref(&self) -> &Self::Target {
272        &self.core
273    }
274}
275
276impl DerefMut for TrailingStopLimitOrder {
277    fn deref_mut(&mut self) -> &mut Self::Target {
278        &mut self.core
279    }
280}
281
282impl Order for TrailingStopLimitOrder {
283    fn activation_price(&self) -> Option<Price> {
284        self.activation_price
285    }
286    fn into_any(self) -> OrderAny {
287        OrderAny::TrailingStopLimit(self)
288    }
289
290    fn status(&self) -> OrderStatus {
291        self.status
292    }
293
294    fn trader_id(&self) -> TraderId {
295        self.trader_id
296    }
297
298    fn strategy_id(&self) -> StrategyId {
299        self.strategy_id
300    }
301
302    fn instrument_id(&self) -> InstrumentId {
303        self.instrument_id
304    }
305
306    fn symbol(&self) -> Symbol {
307        self.instrument_id.symbol
308    }
309
310    fn venue(&self) -> Venue {
311        self.instrument_id.venue
312    }
313
314    fn client_order_id(&self) -> ClientOrderId {
315        self.client_order_id
316    }
317
318    fn venue_order_id(&self) -> Option<VenueOrderId> {
319        self.venue_order_id
320    }
321
322    fn position_id(&self) -> Option<PositionId> {
323        self.position_id
324    }
325
326    fn account_id(&self) -> Option<AccountId> {
327        self.account_id
328    }
329
330    fn last_trade_id(&self) -> Option<TradeId> {
331        self.last_trade_id
332    }
333
334    fn order_side(&self) -> OrderSide {
335        self.side
336    }
337
338    fn order_type(&self) -> OrderType {
339        self.order_type
340    }
341
342    fn quantity(&self) -> Quantity {
343        self.quantity
344    }
345
346    fn time_in_force(&self) -> TimeInForce {
347        self.time_in_force
348    }
349
350    fn expire_time(&self) -> Option<UnixNanos> {
351        self.expire_time
352    }
353
354    fn price(&self) -> Option<Price> {
355        self.price
356    }
357
358    fn trigger_price(&self) -> Option<Price> {
359        self.trigger_price
360    }
361
362    fn trigger_type(&self) -> Option<TriggerType> {
363        Some(self.trigger_type)
364    }
365
366    fn liquidity_side(&self) -> Option<LiquiditySide> {
367        self.liquidity_side
368    }
369
370    fn is_post_only(&self) -> bool {
371        self.is_post_only
372    }
373
374    fn is_reduce_only(&self) -> bool {
375        self.is_reduce_only
376    }
377
378    fn is_quote_quantity(&self) -> bool {
379        self.is_quote_quantity
380    }
381
382    fn has_price(&self) -> bool {
383        // The limit price may be unset until it materializes from `limit_offset` on the first
384        // trail update; own-book and price-dependent paths must not assume a price is present.
385        self.price.is_some()
386    }
387
388    fn display_qty(&self) -> Option<Quantity> {
389        self.display_qty
390    }
391
392    fn limit_offset(&self) -> Option<Decimal> {
393        Some(self.limit_offset)
394    }
395
396    fn trailing_offset(&self) -> Option<Decimal> {
397        Some(self.trailing_offset)
398    }
399
400    fn trailing_offset_type(&self) -> Option<TrailingOffsetType> {
401        Some(self.trailing_offset_type)
402    }
403
404    fn emulation_trigger(&self) -> Option<TriggerType> {
405        self.emulation_trigger
406    }
407
408    fn trigger_instrument_id(&self) -> Option<InstrumentId> {
409        self.trigger_instrument_id
410    }
411
412    fn contingency_type(&self) -> Option<ContingencyType> {
413        self.contingency_type
414    }
415
416    fn order_list_id(&self) -> Option<OrderListId> {
417        self.order_list_id
418    }
419
420    fn linked_order_ids(&self) -> Option<&[ClientOrderId]> {
421        self.linked_order_ids.as_deref()
422    }
423
424    fn parent_order_id(&self) -> Option<ClientOrderId> {
425        self.parent_order_id
426    }
427
428    fn exec_algorithm_id(&self) -> Option<ExecAlgorithmId> {
429        self.exec_algorithm_id
430    }
431
432    fn exec_algorithm_params(&self) -> Option<&IndexMap<Ustr, Ustr>> {
433        self.exec_algorithm_params.as_ref()
434    }
435
436    fn exec_spawn_id(&self) -> Option<ClientOrderId> {
437        self.exec_spawn_id
438    }
439
440    fn tags(&self) -> Option<&[Ustr]> {
441        self.tags.as_deref()
442    }
443
444    fn filled_qty(&self) -> Quantity {
445        self.filled_qty
446    }
447
448    fn voided_qty(&self) -> Quantity {
449        self.voided_qty
450    }
451
452    fn non_reopened_voided_qty(&self) -> Quantity {
453        self.non_reopened_voided_qty
454    }
455
456    fn leaves_qty(&self) -> Quantity {
457        self.leaves_qty
458    }
459
460    fn overfill_qty(&self) -> Quantity {
461        self.overfill_qty
462    }
463
464    fn avg_px(&self) -> Option<Decimal> {
465        self.avg_px
466    }
467
468    fn slippage(&self) -> Option<Decimal> {
469        self.slippage
470    }
471
472    fn init_id(&self) -> UUID4 {
473        self.init_id
474    }
475
476    fn ts_init(&self) -> UnixNanos {
477        self.ts_init
478    }
479
480    fn ts_submitted(&self) -> Option<UnixNanos> {
481        self.ts_submitted
482    }
483
484    fn ts_accepted(&self) -> Option<UnixNanos> {
485        self.ts_accepted
486    }
487
488    fn ts_closed(&self) -> Option<UnixNanos> {
489        self.ts_closed
490    }
491
492    fn ts_last(&self) -> UnixNanos {
493        self.ts_last
494    }
495
496    fn events(&self) -> Vec<&OrderEventAny> {
497        self.events.iter().collect()
498    }
499
500    fn last_event(&self) -> &OrderEventAny {
501        self.events
502            .last()
503            .expect("Order invariant violated: no events")
504    }
505
506    fn venue_order_ids(&self) -> Vec<&VenueOrderId> {
507        self.venue_order_ids.iter().collect()
508    }
509
510    fn trade_ids(&self) -> Vec<&TradeId> {
511        self.trade_ids.iter().collect()
512    }
513
514    fn commissions(&self) -> &IndexMap<Currency, Money> {
515        &self.commissions
516    }
517
518    fn apply(&mut self, event: OrderEventAny) -> Result<(), OrderError> {
519        let updates_slippage = matches!(
520            event,
521            OrderEventAny::Filled(_) | OrderEventAny::FillVoided(_),
522        );
523        let is_order_triggered = matches!(event, OrderEventAny::Triggered(_));
524        let ts_event = if is_order_triggered {
525            Some(event.ts_event())
526        } else {
527            None
528        };
529
530        self.core.apply(event.clone())?;
531
532        if let OrderEventAny::Updated(ref event) = event {
533            self.update(event);
534        }
535
536        if is_order_triggered {
537            self.is_triggered = true;
538            self.ts_triggered = ts_event;
539        }
540
541        if updates_slippage && let Some(price) = self.price {
542            self.core.set_slippage(price);
543        }
544
545        Ok(())
546    }
547
548    fn update(&mut self, event: &OrderUpdated) {
549        if event.price.is_some() {
550            self.price = event.price;
551        }
552
553        if event.trigger_price.is_some() {
554            self.trigger_price = event.trigger_price;
555        }
556        self.core.apply_updated_quantity(event.quantity);
557    }
558
559    fn is_triggered(&self) -> Option<bool> {
560        Some(self.is_triggered)
561    }
562
563    fn set_position_id(&mut self, position_id: Option<PositionId>) {
564        self.position_id = position_id;
565    }
566
567    fn set_quantity(&mut self, quantity: Quantity) {
568        self.quantity = quantity;
569    }
570
571    fn set_leaves_qty(&mut self, leaves_qty: Quantity) {
572        self.leaves_qty = leaves_qty;
573    }
574
575    fn set_emulation_trigger(&mut self, emulation_trigger: Option<TriggerType>) {
576        self.emulation_trigger = emulation_trigger;
577    }
578
579    fn set_is_quote_quantity(&mut self, is_quote_quantity: bool) {
580        self.is_quote_quantity = is_quote_quantity;
581    }
582
583    fn set_liquidity_side(&mut self, liquidity_side: LiquiditySide) {
584        self.liquidity_side = Some(liquidity_side);
585    }
586
587    fn would_reduce_only(&self, side: PositionSide, position_qty: Quantity) -> bool {
588        self.core.would_reduce_only(side, position_qty)
589    }
590
591    fn previous_status(&self) -> Option<OrderStatus> {
592        self.core.previous_status
593    }
594}
595
596impl Display for TrailingStopLimitOrder {
597    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
598        write!(
599            f,
600            "TrailingStopLimitOrder({} {} {} {} {}, status={}, client_order_id={}, venue_order_id={}, position_id={}, exec_algorithm_id={}, exec_spawn_id={}, tags={:?}, activation_price={:?}, is_activated={})",
601            self.side,
602            self.quantity.to_formatted_string(),
603            self.instrument_id,
604            self.order_type,
605            self.time_in_force,
606            self.status,
607            self.client_order_id,
608            self.venue_order_id
609                .map_or_else(|| "None".to_string(), |id| format!("{id}")),
610            self.position_id
611                .map_or_else(|| "None".to_string(), |id| format!("{id}")),
612            self.exec_algorithm_id
613                .map_or_else(|| "None".to_string(), |id| format!("{id}")),
614            self.exec_spawn_id
615                .map_or_else(|| "None".to_string(), |id| format!("{id}")),
616            self.tags,
617            self.activation_price,
618            self.is_activated
619        )
620    }
621}
622
623impl TryFrom<OrderInitialized> for TrailingStopLimitOrder {
624    type Error = OrderError;
625
626    fn try_from(event: OrderInitialized) -> Result<Self, Self::Error> {
627        let trigger_type =
628            event
629                .trigger_type
630                .ok_or_else(|| CorrectnessError::PredicateViolation {
631                    message:
632                        "`trigger_type` is required for `TrailingStopLimitOrder` initialization"
633                            .to_string(),
634                })?;
635        let limit_offset =
636            event
637                .limit_offset
638                .ok_or_else(|| CorrectnessError::PredicateViolation {
639                    message:
640                        "`limit_offset` is required for `TrailingStopLimitOrder` initialization"
641                            .to_string(),
642                })?;
643        let trailing_offset =
644            event
645                .trailing_offset
646                .ok_or_else(|| CorrectnessError::PredicateViolation {
647                    message:
648                        "`trailing_offset` is required for `TrailingStopLimitOrder` initialization"
649                            .to_string(),
650                })?;
651        let trailing_offset_type =
652            event
653                .trailing_offset_type
654                .ok_or_else(|| {
655                    CorrectnessError::PredicateViolation {
656                message:
657                    "`trailing_offset_type` is required for `TrailingStopLimitOrder` initialization"
658                        .to_string(),
659            }
660                })?;
661        Self::new_checked(
662            event.trader_id,
663            event.strategy_id,
664            event.instrument_id,
665            event.client_order_id,
666            event.order_side,
667            event.quantity,
668            event.activation_price,
669            event.price,
670            event.trigger_price,
671            trigger_type,
672            limit_offset,
673            trailing_offset,
674            trailing_offset_type,
675            event.time_in_force,
676            event.expire_time,
677            event.post_only,
678            event.reduce_only,
679            event.quote_quantity,
680            event.display_qty,
681            event.emulation_trigger,
682            event.trigger_instrument_id,
683            event.contingency_type,
684            event.order_list_id,
685            event.linked_order_ids,
686            event.parent_order_id,
687            event.exec_algorithm_id,
688            event.exec_algorithm_params,
689            event.exec_spawn_id,
690            event.tags,
691            event.event_id,
692            event.ts_event,
693        )
694    }
695}
696
697#[cfg(test)]
698mod tests {
699    use rstest::rstest;
700    use rust_decimal_macros::dec;
701
702    use super::*;
703    use crate::{
704        enums::{TimeInForce, TrailingOffsetType, TriggerType},
705        events::order::spec::OrderInitializedSpec,
706        identifiers::InstrumentId,
707        instruments::{CurrencyPair, stubs::*},
708        orders::{OrderTestBuilder, stubs::TestOrderStubs},
709        types::{Price, Quantity},
710    };
711
712    #[rstest]
713    fn test_initialize(audusd_sim: CurrencyPair) {
714        // Create and accept a basic trailing stop limit order
715        let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
716            .instrument_id(audusd_sim.id)
717            .side(OrderSide::Buy)
718            .price(Price::from("0.67500"))
719            .limit_offset(dec!(5))
720            .trigger_price(Price::from("0.68000"))
721            .trailing_offset(dec!(10))
722            .trailing_offset_type(TrailingOffsetType::Price)
723            .quantity(Quantity::from(1))
724            .build();
725
726        assert_eq!(order.trigger_price(), Some(Price::from("0.68000")));
727        assert_eq!(order.price(), Some(Price::from("0.67500")));
728        assert_eq!(order.time_in_force(), TimeInForce::Gtc);
729        assert_eq!(order.is_triggered(), Some(false));
730        assert_eq!(order.filled_qty(), Quantity::from(0));
731        assert_eq!(order.leaves_qty(), Quantity::from(1));
732        assert_eq!(order.display_qty(), None);
733        assert_eq!(order.trigger_instrument_id(), None);
734        assert_eq!(order.order_list_id(), None);
735    }
736
737    #[rstest]
738    fn test_display(audusd_sim: CurrencyPair) {
739        let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
740            .instrument_id(audusd_sim.id)
741            .side(OrderSide::Buy)
742            .price(Price::from("0.67500"))
743            .trigger_price(Price::from("0.68000"))
744            .trigger_type(TriggerType::LastPrice)
745            .limit_offset(dec!(5))
746            .trailing_offset(dec!(10))
747            .trailing_offset_type(TrailingOffsetType::Price)
748            .quantity(Quantity::from(1))
749            .build();
750
751        assert_eq!(
752            order.to_string(),
753            "TrailingStopLimitOrder(BUY 1 AUD/USD.SIM TRAILING_STOP_LIMIT GTC, status=INITIALIZED, client_order_id=O-19700101-000000-001-001-1, venue_order_id=None, position_id=None, exec_algorithm_id=None, exec_spawn_id=None, tags=None, activation_price=None, is_activated=false)"
754        );
755    }
756
757    #[rstest]
758    #[should_panic(expected = "Condition failed: `display_qty` may not exceed `quantity`")]
759    fn test_display_qty_gt_quantity_err(audusd_sim: CurrencyPair) {
760        let _ = OrderTestBuilder::new(OrderType::TrailingStopLimit)
761            .instrument_id(audusd_sim.id)
762            .side(OrderSide::Buy)
763            .price(Price::from("0.67500"))
764            .trigger_price(Price::from("0.68000"))
765            .trigger_type(TriggerType::LastPrice)
766            .limit_offset(dec!(5))
767            .trailing_offset(dec!(10))
768            .trailing_offset_type(TrailingOffsetType::Price)
769            .quantity(Quantity::from(1))
770            .display_qty(Quantity::from(2))
771            .build();
772    }
773
774    #[rstest]
775    #[should_panic(
776        expected = "Condition failed: invalid `Quantity` for 'quantity' not positive, was 0"
777    )]
778    fn test_quantity_zero_err(audusd_sim: CurrencyPair) {
779        let _ = OrderTestBuilder::new(OrderType::TrailingStopLimit)
780            .instrument_id(audusd_sim.id)
781            .side(OrderSide::Buy)
782            .price(Price::from("0.67500"))
783            .trigger_price(Price::from("0.68000"))
784            .trigger_type(TriggerType::LastPrice)
785            .limit_offset(dec!(5))
786            .trailing_offset(dec!(10))
787            .trailing_offset_type(TrailingOffsetType::Price)
788            .quantity(Quantity::from(0))
789            .build();
790    }
791
792    #[rstest]
793    #[should_panic(expected = "Condition failed: `expire_time` is required for `GTD` order")]
794    fn test_gtd_without_expire_err(audusd_sim: CurrencyPair) {
795        let _ = OrderTestBuilder::new(OrderType::TrailingStopLimit)
796            .instrument_id(audusd_sim.id)
797            .side(OrderSide::Buy)
798            .price(Price::from("0.67500"))
799            .trigger_price(Price::from("0.68000"))
800            .trigger_type(TriggerType::LastPrice)
801            .limit_offset(dec!(5))
802            .trailing_offset(dec!(10))
803            .trailing_offset_type(TrailingOffsetType::Price)
804            .time_in_force(TimeInForce::Gtd)
805            .quantity(Quantity::from(1))
806            .build();
807    }
808
809    #[rstest]
810    fn test_trailing_stop_limit_order_update() {
811        let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
812            .instrument_id(InstrumentId::from("BTC-USDT.BINANCE"))
813            .quantity(Quantity::from(10))
814            .price(Price::new(100.0, 2))
815            .trigger_price(Price::new(95.0, 2))
816            .limit_offset(dec!(2.0))
817            .trailing_offset(dec!(1.0))
818            .trailing_offset_type(TrailingOffsetType::Price)
819            .build();
820
821        let mut accepted_order = TestOrderStubs::make_accepted_order(&order);
822
823        let updated_trigger_price = Price::new(90.0, 2);
824        let updated_quantity = Quantity::from(5);
825
826        let event = OrderUpdated {
827            client_order_id: accepted_order.client_order_id(),
828            strategy_id: accepted_order.strategy_id(),
829            trigger_price: Some(updated_trigger_price),
830            quantity: updated_quantity,
831            ..Default::default()
832        };
833
834        accepted_order.apply(OrderEventAny::Updated(event)).unwrap();
835
836        assert_eq!(accepted_order.quantity(), updated_quantity);
837        assert_eq!(accepted_order.trigger_price(), Some(updated_trigger_price));
838    }
839
840    #[rstest]
841    fn test_trailing_stop_limit_order_trigger_instrument_id() {
842        let trigger_instrument_id = InstrumentId::from("ETH-USDT.BINANCE");
843        let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
844            .instrument_id(InstrumentId::from("BTC-USDT.BINANCE"))
845            .quantity(Quantity::from(10))
846            .price(Price::new(100.0, 2))
847            .trigger_price(Price::new(95.0, 2))
848            .limit_offset(dec!(2.0))
849            .trailing_offset(dec!(1.0))
850            .trailing_offset_type(TrailingOffsetType::Price)
851            .trigger_instrument_id(trigger_instrument_id)
852            .build();
853
854        assert_eq!(order.trigger_instrument_id(), Some(trigger_instrument_id));
855    }
856
857    #[rstest]
858    fn test_activation_price_round_trips_through_event(audusd_sim: CurrencyPair) {
859        let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
860            .instrument_id(audusd_sim.id)
861            .side(OrderSide::Buy)
862            .activation_price(Price::from("0.68500"))
863            .price(Price::from("0.67500"))
864            .trigger_price(Price::from("0.68000"))
865            .limit_offset(dec!(5))
866            .trailing_offset(dec!(10))
867            .trailing_offset_type(TrailingOffsetType::Price)
868            .quantity(Quantity::from(1))
869            .build();
870
871        assert_eq!(order.activation_price(), Some(Price::from("0.68500")));
872
873        let init = order.init_event().clone();
874        assert_eq!(init.activation_price, Some(Price::from("0.68500")));
875
876        let rebuilt: TrailingStopLimitOrder = init.try_into().unwrap();
877        assert_eq!(rebuilt.activation_price, Some(Price::from("0.68500")));
878        assert_eq!(rebuilt.price, Some(Price::from("0.67500")));
879        assert_eq!(rebuilt.trigger_price, Some(Price::from("0.68000")));
880    }
881
882    #[rstest]
883    fn test_has_price_false_until_limit_materializes(audusd_sim: CurrencyPair) {
884        // A trailing-stop-limit with no explicit limit price has no price until it materializes
885        // from `limit_offset`; own-book/price-dependent paths gate on `has_price()`.
886        let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
887            .instrument_id(audusd_sim.id)
888            .side(OrderSide::Buy)
889            .limit_offset(dec!(5))
890            .trailing_offset(dec!(10))
891            .trailing_offset_type(TrailingOffsetType::Price)
892            .quantity(Quantity::from(1))
893            .build();
894
895        assert_eq!(order.price(), None);
896        assert!(!order.has_price());
897    }
898
899    #[rstest]
900    fn test_reconstruct_with_price_trigger_and_activation_none() {
901        let init = OrderInitializedSpec::builder()
902            .order_type(OrderType::TrailingStopLimit)
903            .trigger_type(TriggerType::Default)
904            .limit_offset(dec!(5))
905            .trailing_offset(dec!(10))
906            .trailing_offset_type(TrailingOffsetType::Price)
907            .build();
908
909        let order: TrailingStopLimitOrder = init.try_into().unwrap();
910
911        assert_eq!(order.price(), None);
912        assert_eq!(order.trigger_price(), None);
913        assert_eq!(order.activation_price(), None);
914    }
915
916    #[rstest]
917    fn test_trailing_stop_limit_order_from_order_initialized() {
918        let order_initialized = OrderInitializedSpec::builder()
919            .order_type(OrderType::TrailingStopLimit)
920            .price(Price::new(100.0, 2))
921            .trigger_price(Price::new(95.0, 2))
922            .trigger_type(TriggerType::Default)
923            .limit_offset(dec!(2.0))
924            .trailing_offset(dec!(1.0))
925            .trailing_offset_type(TrailingOffsetType::Price)
926            .build();
927
928        let order: TrailingStopLimitOrder = order_initialized.clone().try_into().unwrap();
929
930        assert_eq!(order.trader_id(), order_initialized.trader_id);
931        assert_eq!(order.strategy_id(), order_initialized.strategy_id);
932        assert_eq!(order.instrument_id(), order_initialized.instrument_id);
933        assert_eq!(order.client_order_id(), order_initialized.client_order_id);
934        assert_eq!(order.order_side(), order_initialized.order_side);
935        assert_eq!(order.quantity(), order_initialized.quantity);
936        assert_eq!(order.price, order_initialized.price);
937        assert_eq!(order.trigger_price, order_initialized.trigger_price);
938        assert_eq!(order.trigger_type, order_initialized.trigger_type.unwrap());
939        assert_eq!(order.limit_offset, order_initialized.limit_offset.unwrap());
940        assert_eq!(
941            order.trailing_offset,
942            order_initialized.trailing_offset.unwrap()
943        );
944        assert_eq!(
945            order.trailing_offset_type,
946            order_initialized.trailing_offset_type.unwrap()
947        );
948        assert_eq!(order.time_in_force(), order_initialized.time_in_force);
949        assert_eq!(order.expire_time(), order_initialized.expire_time);
950    }
951}