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