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 leaves_qty(&self) -> Quantity {
453        self.leaves_qty
454    }
455
456    fn overfill_qty(&self) -> Quantity {
457        self.overfill_qty
458    }
459
460    fn avg_px(&self) -> Option<Decimal> {
461        self.avg_px
462    }
463
464    fn slippage(&self) -> Option<Decimal> {
465        self.slippage
466    }
467
468    fn init_id(&self) -> UUID4 {
469        self.init_id
470    }
471
472    fn ts_init(&self) -> UnixNanos {
473        self.ts_init
474    }
475
476    fn ts_submitted(&self) -> Option<UnixNanos> {
477        self.ts_submitted
478    }
479
480    fn ts_accepted(&self) -> Option<UnixNanos> {
481        self.ts_accepted
482    }
483
484    fn ts_closed(&self) -> Option<UnixNanos> {
485        self.ts_closed
486    }
487
488    fn ts_last(&self) -> UnixNanos {
489        self.ts_last
490    }
491
492    fn events(&self) -> Vec<&OrderEventAny> {
493        self.events.iter().collect()
494    }
495
496    fn venue_order_ids(&self) -> Vec<&VenueOrderId> {
497        self.venue_order_ids.iter().collect()
498    }
499
500    fn trade_ids(&self) -> Vec<&TradeId> {
501        self.trade_ids.iter().collect()
502    }
503
504    fn commissions(&self) -> &IndexMap<Currency, Money> {
505        &self.commissions
506    }
507
508    fn apply(&mut self, event: OrderEventAny) -> Result<(), OrderError> {
509        let updates_slippage = matches!(
510            event,
511            OrderEventAny::Filled(_) | OrderEventAny::FillVoided(_),
512        );
513        let is_order_triggered = matches!(event, OrderEventAny::Triggered(_));
514        let ts_event = if is_order_triggered {
515            Some(event.ts_event())
516        } else {
517            None
518        };
519
520        self.core.apply(event.clone())?;
521
522        if let OrderEventAny::Updated(ref event) = event {
523            self.update(event);
524        }
525
526        if is_order_triggered {
527            self.is_triggered = true;
528            self.ts_triggered = ts_event;
529        }
530
531        if updates_slippage && let Some(price) = self.price {
532            self.core.set_slippage(price);
533        }
534
535        Ok(())
536    }
537
538    fn update(&mut self, event: &OrderUpdated) {
539        if event.price.is_some() {
540            self.price = event.price;
541        }
542
543        if event.trigger_price.is_some() {
544            self.trigger_price = event.trigger_price;
545        }
546        self.quantity = event.quantity;
547        self.leaves_qty = self.quantity.saturating_sub(self.filled_qty);
548    }
549
550    fn is_triggered(&self) -> Option<bool> {
551        Some(self.is_triggered)
552    }
553
554    fn set_position_id(&mut self, position_id: Option<PositionId>) {
555        self.position_id = position_id;
556    }
557
558    fn set_quantity(&mut self, quantity: Quantity) {
559        self.quantity = quantity;
560    }
561
562    fn set_leaves_qty(&mut self, leaves_qty: Quantity) {
563        self.leaves_qty = leaves_qty;
564    }
565
566    fn set_emulation_trigger(&mut self, emulation_trigger: Option<TriggerType>) {
567        self.emulation_trigger = emulation_trigger;
568    }
569
570    fn set_is_quote_quantity(&mut self, is_quote_quantity: bool) {
571        self.is_quote_quantity = is_quote_quantity;
572    }
573
574    fn set_liquidity_side(&mut self, liquidity_side: LiquiditySide) {
575        self.liquidity_side = Some(liquidity_side);
576    }
577
578    fn would_reduce_only(&self, side: PositionSide, position_qty: Quantity) -> bool {
579        self.core.would_reduce_only(side, position_qty)
580    }
581
582    fn previous_status(&self) -> Option<OrderStatus> {
583        self.core.previous_status
584    }
585}
586
587impl Display for TrailingStopLimitOrder {
588    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
589        write!(
590            f,
591            "TrailingStopLimitOrder({} {} {} {} {}, status={}, client_order_id={}, venue_order_id={}, position_id={}, exec_algorithm_id={}, exec_spawn_id={}, tags={:?}, activation_price={:?}, is_activated={})",
592            self.side,
593            self.quantity.to_formatted_string(),
594            self.instrument_id,
595            self.order_type,
596            self.time_in_force,
597            self.status,
598            self.client_order_id,
599            self.venue_order_id
600                .map_or_else(|| "None".to_string(), |id| format!("{id}")),
601            self.position_id
602                .map_or_else(|| "None".to_string(), |id| format!("{id}")),
603            self.exec_algorithm_id
604                .map_or_else(|| "None".to_string(), |id| format!("{id}")),
605            self.exec_spawn_id
606                .map_or_else(|| "None".to_string(), |id| format!("{id}")),
607            self.tags,
608            self.activation_price,
609            self.is_activated
610        )
611    }
612}
613
614impl TryFrom<OrderInitialized> for TrailingStopLimitOrder {
615    type Error = OrderError;
616
617    fn try_from(event: OrderInitialized) -> Result<Self, Self::Error> {
618        let trigger_type =
619            event
620                .trigger_type
621                .ok_or_else(|| CorrectnessError::PredicateViolation {
622                    message:
623                        "`trigger_type` is required for `TrailingStopLimitOrder` initialization"
624                            .to_string(),
625                })?;
626        let limit_offset =
627            event
628                .limit_offset
629                .ok_or_else(|| CorrectnessError::PredicateViolation {
630                    message:
631                        "`limit_offset` is required for `TrailingStopLimitOrder` initialization"
632                            .to_string(),
633                })?;
634        let trailing_offset =
635            event
636                .trailing_offset
637                .ok_or_else(|| CorrectnessError::PredicateViolation {
638                    message:
639                        "`trailing_offset` is required for `TrailingStopLimitOrder` initialization"
640                            .to_string(),
641                })?;
642        let trailing_offset_type =
643            event
644                .trailing_offset_type
645                .ok_or_else(|| {
646                    CorrectnessError::PredicateViolation {
647                message:
648                    "`trailing_offset_type` is required for `TrailingStopLimitOrder` initialization"
649                        .to_string(),
650            }
651                })?;
652        Self::new_checked(
653            event.trader_id,
654            event.strategy_id,
655            event.instrument_id,
656            event.client_order_id,
657            event.order_side,
658            event.quantity,
659            event.activation_price,
660            event.price,
661            event.trigger_price,
662            trigger_type,
663            limit_offset,
664            trailing_offset,
665            trailing_offset_type,
666            event.time_in_force,
667            event.expire_time,
668            event.post_only,
669            event.reduce_only,
670            event.quote_quantity,
671            event.display_qty,
672            event.emulation_trigger,
673            event.trigger_instrument_id,
674            event.contingency_type,
675            event.order_list_id,
676            event.linked_order_ids,
677            event.parent_order_id,
678            event.exec_algorithm_id,
679            event.exec_algorithm_params,
680            event.exec_spawn_id,
681            event.tags,
682            event.event_id,
683            event.ts_event,
684        )
685    }
686}
687
688#[cfg(test)]
689mod tests {
690    use rstest::rstest;
691    use rust_decimal_macros::dec;
692
693    use super::*;
694    use crate::{
695        enums::{TimeInForce, TrailingOffsetType, TriggerType},
696        events::order::spec::OrderInitializedSpec,
697        identifiers::InstrumentId,
698        instruments::{CurrencyPair, stubs::*},
699        orders::{OrderTestBuilder, stubs::TestOrderStubs},
700        types::{Price, Quantity},
701    };
702
703    #[rstest]
704    fn test_initialize(audusd_sim: CurrencyPair) {
705        // Create and accept a basic trailing stop limit order
706        let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
707            .instrument_id(audusd_sim.id)
708            .side(OrderSide::Buy)
709            .price(Price::from("0.67500"))
710            .limit_offset(dec!(5))
711            .trigger_price(Price::from("0.68000"))
712            .trailing_offset(dec!(10))
713            .trailing_offset_type(TrailingOffsetType::Price)
714            .quantity(Quantity::from(1))
715            .build();
716
717        assert_eq!(order.trigger_price(), Some(Price::from("0.68000")));
718        assert_eq!(order.price(), Some(Price::from("0.67500")));
719        assert_eq!(order.time_in_force(), TimeInForce::Gtc);
720        assert_eq!(order.is_triggered(), Some(false));
721        assert_eq!(order.filled_qty(), Quantity::from(0));
722        assert_eq!(order.leaves_qty(), Quantity::from(1));
723        assert_eq!(order.display_qty(), None);
724        assert_eq!(order.trigger_instrument_id(), None);
725        assert_eq!(order.order_list_id(), None);
726    }
727
728    #[rstest]
729    fn test_display(audusd_sim: CurrencyPair) {
730        let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
731            .instrument_id(audusd_sim.id)
732            .side(OrderSide::Buy)
733            .price(Price::from("0.67500"))
734            .trigger_price(Price::from("0.68000"))
735            .trigger_type(TriggerType::LastPrice)
736            .limit_offset(dec!(5))
737            .trailing_offset(dec!(10))
738            .trailing_offset_type(TrailingOffsetType::Price)
739            .quantity(Quantity::from(1))
740            .build();
741
742        assert_eq!(
743            order.to_string(),
744            "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)"
745        );
746    }
747
748    #[rstest]
749    #[should_panic(expected = "Condition failed: `display_qty` may not exceed `quantity`")]
750    fn test_display_qty_gt_quantity_err(audusd_sim: CurrencyPair) {
751        let _ = OrderTestBuilder::new(OrderType::TrailingStopLimit)
752            .instrument_id(audusd_sim.id)
753            .side(OrderSide::Buy)
754            .price(Price::from("0.67500"))
755            .trigger_price(Price::from("0.68000"))
756            .trigger_type(TriggerType::LastPrice)
757            .limit_offset(dec!(5))
758            .trailing_offset(dec!(10))
759            .trailing_offset_type(TrailingOffsetType::Price)
760            .quantity(Quantity::from(1))
761            .display_qty(Quantity::from(2))
762            .build();
763    }
764
765    #[rstest]
766    #[should_panic(
767        expected = "Condition failed: invalid `Quantity` for 'quantity' not positive, was 0"
768    )]
769    fn test_quantity_zero_err(audusd_sim: CurrencyPair) {
770        let _ = OrderTestBuilder::new(OrderType::TrailingStopLimit)
771            .instrument_id(audusd_sim.id)
772            .side(OrderSide::Buy)
773            .price(Price::from("0.67500"))
774            .trigger_price(Price::from("0.68000"))
775            .trigger_type(TriggerType::LastPrice)
776            .limit_offset(dec!(5))
777            .trailing_offset(dec!(10))
778            .trailing_offset_type(TrailingOffsetType::Price)
779            .quantity(Quantity::from(0))
780            .build();
781    }
782
783    #[rstest]
784    #[should_panic(expected = "Condition failed: `expire_time` is required for `GTD` order")]
785    fn test_gtd_without_expire_err(audusd_sim: CurrencyPair) {
786        let _ = OrderTestBuilder::new(OrderType::TrailingStopLimit)
787            .instrument_id(audusd_sim.id)
788            .side(OrderSide::Buy)
789            .price(Price::from("0.67500"))
790            .trigger_price(Price::from("0.68000"))
791            .trigger_type(TriggerType::LastPrice)
792            .limit_offset(dec!(5))
793            .trailing_offset(dec!(10))
794            .trailing_offset_type(TrailingOffsetType::Price)
795            .time_in_force(TimeInForce::Gtd)
796            .quantity(Quantity::from(1))
797            .build();
798    }
799
800    #[rstest]
801    fn test_trailing_stop_limit_order_update() {
802        let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
803            .instrument_id(InstrumentId::from("BTC-USDT.BINANCE"))
804            .quantity(Quantity::from(10))
805            .price(Price::new(100.0, 2))
806            .trigger_price(Price::new(95.0, 2))
807            .limit_offset(dec!(2.0))
808            .trailing_offset(dec!(1.0))
809            .trailing_offset_type(TrailingOffsetType::Price)
810            .build();
811
812        let mut accepted_order = TestOrderStubs::make_accepted_order(&order);
813
814        let updated_trigger_price = Price::new(90.0, 2);
815        let updated_quantity = Quantity::from(5);
816
817        let event = OrderUpdated {
818            client_order_id: accepted_order.client_order_id(),
819            strategy_id: accepted_order.strategy_id(),
820            trigger_price: Some(updated_trigger_price),
821            quantity: updated_quantity,
822            ..Default::default()
823        };
824
825        accepted_order.apply(OrderEventAny::Updated(event)).unwrap();
826
827        assert_eq!(accepted_order.quantity(), updated_quantity);
828        assert_eq!(accepted_order.trigger_price(), Some(updated_trigger_price));
829    }
830
831    #[rstest]
832    fn test_trailing_stop_limit_order_trigger_instrument_id() {
833        let trigger_instrument_id = InstrumentId::from("ETH-USDT.BINANCE");
834        let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
835            .instrument_id(InstrumentId::from("BTC-USDT.BINANCE"))
836            .quantity(Quantity::from(10))
837            .price(Price::new(100.0, 2))
838            .trigger_price(Price::new(95.0, 2))
839            .limit_offset(dec!(2.0))
840            .trailing_offset(dec!(1.0))
841            .trailing_offset_type(TrailingOffsetType::Price)
842            .trigger_instrument_id(trigger_instrument_id)
843            .build();
844
845        assert_eq!(order.trigger_instrument_id(), Some(trigger_instrument_id));
846    }
847
848    #[rstest]
849    fn test_activation_price_round_trips_through_event(audusd_sim: CurrencyPair) {
850        let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
851            .instrument_id(audusd_sim.id)
852            .side(OrderSide::Buy)
853            .activation_price(Price::from("0.68500"))
854            .price(Price::from("0.67500"))
855            .trigger_price(Price::from("0.68000"))
856            .limit_offset(dec!(5))
857            .trailing_offset(dec!(10))
858            .trailing_offset_type(TrailingOffsetType::Price)
859            .quantity(Quantity::from(1))
860            .build();
861
862        assert_eq!(order.activation_price(), Some(Price::from("0.68500")));
863
864        let init = order.init_event().clone();
865        assert_eq!(init.activation_price, Some(Price::from("0.68500")));
866
867        let rebuilt: TrailingStopLimitOrder = init.try_into().unwrap();
868        assert_eq!(rebuilt.activation_price, Some(Price::from("0.68500")));
869        assert_eq!(rebuilt.price, Some(Price::from("0.67500")));
870        assert_eq!(rebuilt.trigger_price, Some(Price::from("0.68000")));
871    }
872
873    #[rstest]
874    fn test_has_price_false_until_limit_materializes(audusd_sim: CurrencyPair) {
875        // A trailing-stop-limit with no explicit limit price has no price until it materializes
876        // from `limit_offset`; own-book/price-dependent paths gate on `has_price()`.
877        let order = OrderTestBuilder::new(OrderType::TrailingStopLimit)
878            .instrument_id(audusd_sim.id)
879            .side(OrderSide::Buy)
880            .limit_offset(dec!(5))
881            .trailing_offset(dec!(10))
882            .trailing_offset_type(TrailingOffsetType::Price)
883            .quantity(Quantity::from(1))
884            .build();
885
886        assert_eq!(order.price(), None);
887        assert!(!order.has_price());
888    }
889
890    #[rstest]
891    fn test_reconstruct_with_price_trigger_and_activation_none() {
892        let init = OrderInitializedSpec::builder()
893            .order_type(OrderType::TrailingStopLimit)
894            .trigger_type(TriggerType::Default)
895            .limit_offset(dec!(5))
896            .trailing_offset(dec!(10))
897            .trailing_offset_type(TrailingOffsetType::Price)
898            .build();
899
900        let order: TrailingStopLimitOrder = init.try_into().unwrap();
901
902        assert_eq!(order.price(), None);
903        assert_eq!(order.trigger_price(), None);
904        assert_eq!(order.activation_price(), None);
905    }
906
907    #[rstest]
908    fn test_trailing_stop_limit_order_from_order_initialized() {
909        let order_initialized = OrderInitializedSpec::builder()
910            .order_type(OrderType::TrailingStopLimit)
911            .price(Price::new(100.0, 2))
912            .trigger_price(Price::new(95.0, 2))
913            .trigger_type(TriggerType::Default)
914            .limit_offset(dec!(2.0))
915            .trailing_offset(dec!(1.0))
916            .trailing_offset_type(TrailingOffsetType::Price)
917            .build();
918
919        let order: TrailingStopLimitOrder = order_initialized.clone().try_into().unwrap();
920
921        assert_eq!(order.trader_id(), order_initialized.trader_id);
922        assert_eq!(order.strategy_id(), order_initialized.strategy_id);
923        assert_eq!(order.instrument_id(), order_initialized.instrument_id);
924        assert_eq!(order.client_order_id(), order_initialized.client_order_id);
925        assert_eq!(order.order_side(), order_initialized.order_side);
926        assert_eq!(order.quantity(), order_initialized.quantity);
927        assert_eq!(order.price, order_initialized.price);
928        assert_eq!(order.trigger_price, order_initialized.trigger_price);
929        assert_eq!(order.trigger_type, order_initialized.trigger_type.unwrap());
930        assert_eq!(order.limit_offset, order_initialized.limit_offset.unwrap());
931        assert_eq!(
932            order.trailing_offset,
933            order_initialized.trailing_offset.unwrap()
934        );
935        assert_eq!(
936            order.trailing_offset_type,
937            order_initialized.trailing_offset_type.unwrap()
938        );
939        assert_eq!(order.time_in_force(), order_initialized.time_in_force);
940        assert_eq!(order.expire_time(), order_initialized.expire_time);
941    }
942}