Skip to main content

nautilus_model/reports/
order.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::fmt::Display;
17
18use nautilus_core::{UUID4, UnixNanos};
19use rust_decimal::Decimal;
20use serde::{Deserialize, Serialize};
21
22use crate::{
23    enums::{
24        ContingencyType, OrderSide, OrderStatus, OrderType, TimeInForce, TrailingOffsetType,
25        TriggerType,
26    },
27    identifiers::{AccountId, ClientOrderId, InstrumentId, OrderListId, PositionId, VenueOrderId},
28    orders::Order,
29    types::{Price, Quantity},
30};
31
32/// Represents an order status at a point in time.
33#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
34#[serde(tag = "type")]
35#[cfg_attr(
36    feature = "python",
37    pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
38)]
39#[cfg_attr(
40    feature = "python",
41    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
42)]
43pub struct OrderStatusReport {
44    /// The account ID associated with the position.
45    pub account_id: AccountId,
46    /// The instrument ID associated with the event.
47    pub instrument_id: InstrumentId,
48    /// The client order ID.
49    pub client_order_id: Option<ClientOrderId>,
50    /// The venue assigned order ID.
51    pub venue_order_id: VenueOrderId,
52    /// The order side.
53    #[serde(with = "crate::enums::serde_option_order_side")]
54    pub order_side: Option<OrderSide>,
55    /// The order type.
56    pub order_type: OrderType,
57    /// The order time in force.
58    pub time_in_force: TimeInForce,
59    /// The order status.
60    pub order_status: OrderStatus,
61    /// The order quantity.
62    pub quantity: Quantity,
63    /// The order total filled quantity.
64    pub filled_qty: Quantity,
65    /// The unique identifier for the event.
66    pub report_id: UUID4,
67    /// UNIX timestamp (nanoseconds) when the order was accepted.
68    pub ts_accepted: UnixNanos,
69    /// UNIX timestamp (nanoseconds) when the last event occurred.
70    pub ts_last: UnixNanos,
71    /// UNIX timestamp (nanoseconds) when the event was initialized.
72    pub ts_init: UnixNanos,
73    /// The order list ID associated with the order.
74    pub order_list_id: Option<OrderListId>,
75    /// The position ID associated with the order (assigned by the venue).
76    pub venue_position_id: Option<PositionId>,
77    /// The reported linked client order IDs related to contingency orders.
78    pub linked_order_ids: Option<Vec<ClientOrderId>>,
79    /// The parent order ID for contingent child orders, if available.
80    pub parent_order_id: Option<ClientOrderId>,
81    /// The orders contingency type.
82    #[serde(default, with = "crate::enums::serde_option_contingency_type")]
83    pub contingency_type: Option<ContingencyType>,
84    /// The order expiration (UNIX epoch nanoseconds), zero for no expiration.
85    pub expire_time: Option<UnixNanos>,
86    /// The order price (LIMIT).
87    pub price: Option<Price>,
88    /// The order activation price (trailing stop).
89    pub activation_price: Option<Price>,
90    /// The order trigger price (STOP).
91    pub trigger_price: Option<Price>,
92    /// The trigger type for the order.
93    #[serde(default, with = "crate::enums::serde_option_trigger_type")]
94    pub trigger_type: Option<TriggerType>,
95    /// The trailing offset for the orders limit price.
96    pub limit_offset: Option<Decimal>,
97    /// The trailing offset for the orders trigger price (STOP).
98    pub trailing_offset: Option<Decimal>,
99    /// The trailing offset type.
100    #[serde(default, with = "crate::enums::serde_option_trailing_offset_type")]
101    pub trailing_offset_type: Option<TrailingOffsetType>,
102    /// The order average fill price.
103    pub avg_px: Option<Decimal>,
104    /// The quantity of the `LIMIT` order to display on the public book (iceberg).
105    pub display_qty: Option<Quantity>,
106    /// If the order will only provide liquidity (make a market).
107    pub post_only: bool,
108    /// If the order carries the 'reduce-only' execution instruction.
109    pub reduce_only: bool,
110    /// The reason for order cancellation.
111    pub cancel_reason: Option<String>,
112    /// UNIX timestamp (nanoseconds) when the order was triggered.
113    pub ts_triggered: Option<UnixNanos>,
114}
115
116impl OrderStatusReport {
117    /// Creates a new [`OrderStatusReport`] instance with required fields.
118    #[expect(clippy::too_many_arguments)]
119    #[must_use]
120    pub fn new(
121        account_id: AccountId,
122        instrument_id: InstrumentId,
123        client_order_id: Option<ClientOrderId>,
124        venue_order_id: VenueOrderId,
125        order_side: Option<OrderSide>,
126        order_type: OrderType,
127        time_in_force: TimeInForce,
128        order_status: OrderStatus,
129        quantity: Quantity,
130        filled_qty: Quantity,
131        ts_accepted: UnixNanos,
132        ts_last: UnixNanos,
133        ts_init: UnixNanos,
134        report_id: Option<UUID4>,
135    ) -> Self {
136        Self {
137            account_id,
138            instrument_id,
139            client_order_id,
140            venue_order_id,
141            order_side,
142            order_type,
143            time_in_force,
144            order_status,
145            quantity,
146            filled_qty,
147            report_id: report_id.unwrap_or_default(),
148            ts_accepted,
149            ts_last,
150            ts_init,
151            order_list_id: None,
152            venue_position_id: None,
153            linked_order_ids: None,
154            parent_order_id: None,
155            contingency_type: None,
156            expire_time: None,
157            price: None,
158            activation_price: None,
159            trigger_price: None,
160            trigger_type: None,
161            limit_offset: None,
162            trailing_offset: None,
163            trailing_offset_type: None,
164            avg_px: None,
165            display_qty: None,
166            post_only: false,
167            reduce_only: false,
168            cancel_reason: None,
169            ts_triggered: None,
170        }
171    }
172
173    /// Sets the client order ID.
174    #[must_use]
175    pub const fn with_client_order_id(mut self, client_order_id: ClientOrderId) -> Self {
176        self.client_order_id = Some(client_order_id);
177        self
178    }
179
180    /// Sets the order list ID.
181    #[must_use]
182    pub const fn with_order_list_id(mut self, order_list_id: OrderListId) -> Self {
183        self.order_list_id = Some(order_list_id);
184        self
185    }
186
187    /// Sets the linked client order IDs.
188    #[must_use]
189    pub fn with_linked_order_ids(
190        mut self,
191        linked_order_ids: impl IntoIterator<Item = ClientOrderId>,
192    ) -> Self {
193        self.linked_order_ids = Some(linked_order_ids.into_iter().collect());
194        self
195    }
196
197    /// Sets the parent order ID.
198    #[must_use]
199    pub const fn with_parent_order_id(mut self, parent_order_id: ClientOrderId) -> Self {
200        self.parent_order_id = Some(parent_order_id);
201        self
202    }
203
204    /// Sets the venue position ID.
205    #[must_use]
206    pub const fn with_venue_position_id(mut self, venue_position_id: PositionId) -> Self {
207        self.venue_position_id = Some(venue_position_id);
208        self
209    }
210
211    /// Sets the price.
212    #[must_use]
213    pub const fn with_price(mut self, price: Price) -> Self {
214        self.price = Some(price);
215        self
216    }
217
218    /// Sets the average price.
219    #[must_use]
220    pub const fn with_avg_px(mut self, avg_px: Decimal) -> Self {
221        self.avg_px = Some(avg_px);
222        self
223    }
224
225    /// Sets the activation price.
226    #[must_use]
227    pub const fn with_activation_price(mut self, activation_price: Price) -> Self {
228        self.activation_price = Some(activation_price);
229        self
230    }
231
232    /// Sets the trigger price.
233    #[must_use]
234    pub const fn with_trigger_price(mut self, trigger_price: Price) -> Self {
235        self.trigger_price = Some(trigger_price);
236        self
237    }
238
239    /// Sets the trigger type.
240    #[must_use]
241    pub const fn with_trigger_type(mut self, trigger_type: TriggerType) -> Self {
242        self.trigger_type = Some(trigger_type);
243        self
244    }
245
246    /// Sets the limit offset.
247    #[must_use]
248    pub const fn with_limit_offset(mut self, limit_offset: Decimal) -> Self {
249        self.limit_offset = Some(limit_offset);
250        self
251    }
252
253    /// Sets the trailing offset.
254    #[must_use]
255    pub const fn with_trailing_offset(mut self, trailing_offset: Decimal) -> Self {
256        self.trailing_offset = Some(trailing_offset);
257        self
258    }
259
260    /// Sets the trailing offset type.
261    #[must_use]
262    pub const fn with_trailing_offset_type(
263        mut self,
264        trailing_offset_type: TrailingOffsetType,
265    ) -> Self {
266        self.trailing_offset_type = Some(trailing_offset_type);
267        self
268    }
269
270    /// Sets the display quantity.
271    #[must_use]
272    pub const fn with_display_qty(mut self, display_qty: Quantity) -> Self {
273        self.display_qty = Some(display_qty);
274        self
275    }
276
277    /// Sets the expire time.
278    #[must_use]
279    pub const fn with_expire_time(mut self, expire_time: UnixNanos) -> Self {
280        self.expire_time = Some(expire_time);
281        self
282    }
283
284    /// Sets `post_only` flag.
285    #[must_use]
286    pub const fn with_post_only(mut self, post_only: bool) -> Self {
287        self.post_only = post_only;
288        self
289    }
290
291    /// Sets `reduce_only` flag.
292    #[must_use]
293    pub const fn with_reduce_only(mut self, reduce_only: bool) -> Self {
294        self.reduce_only = reduce_only;
295        self
296    }
297
298    /// Sets cancel reason.
299    #[must_use]
300    pub fn with_cancel_reason(mut self, cancel_reason: String) -> Self {
301        self.cancel_reason = Some(cancel_reason);
302        self
303    }
304
305    /// Sets the triggered timestamp.
306    #[must_use]
307    pub const fn with_ts_triggered(mut self, ts_triggered: UnixNanos) -> Self {
308        self.ts_triggered = Some(ts_triggered);
309        self
310    }
311
312    /// Sets the contingency type.
313    #[must_use]
314    pub const fn with_contingency_type(mut self, contingency_type: ContingencyType) -> Self {
315        self.contingency_type = Some(contingency_type);
316        self
317    }
318
319    /// Returns whether the order has been updated based on this report.
320    ///
321    /// An order is considered updated if any of the following differ:
322    /// - Price (if both the order and report have a price).
323    /// - Trigger price (if both the order and report have a trigger price).
324    /// - Quantity.
325    #[must_use]
326    pub fn is_order_updated(&self, order: &impl Order) -> bool {
327        if order.has_price()
328            && let Some(report_price) = self.price
329            && let Some(order_price) = order.price()
330            && order_price != report_price
331        {
332            return true;
333        }
334
335        if let Some(order_trigger_price) = order.trigger_price()
336            && let Some(report_trigger_price) = self.trigger_price
337            && order_trigger_price != report_trigger_price
338        {
339            return true;
340        }
341
342        order.quantity() != self.quantity
343    }
344}
345
346impl Display for OrderStatusReport {
347    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
348        write!(
349            f,
350            "OrderStatusReport(\
351                account_id={}, \
352                instrument_id={}, \
353                venue_order_id={}, \
354                order_side={}, \
355                order_type={}, \
356                time_in_force={}, \
357                order_status={}, \
358                quantity={}, \
359                filled_qty={}, \
360                report_id={}, \
361                ts_accepted={}, \
362                ts_last={}, \
363                ts_init={}, \
364                client_order_id={:?}, \
365                order_list_id={:?}, \
366                venue_position_id={:?}, \
367                linked_order_ids={:?}, \
368                parent_order_id={:?}, \
369                contingency_type={}, \
370                expire_time={:?}, \
371                price={:?}, \
372                activation_price={:?}, \
373                trigger_price={:?}, \
374                trigger_type={:?}, \
375                limit_offset={:?}, \
376                trailing_offset={:?}, \
377                trailing_offset_type={}, \
378                avg_px={:?}, \
379                display_qty={:?}, \
380                post_only={}, \
381                reduce_only={}, \
382                cancel_reason={:?}, \
383                ts_triggered={:?}\
384            )",
385            self.account_id,
386            self.instrument_id,
387            self.venue_order_id,
388            self.order_side
389                .as_ref()
390                .map_or("NO_ORDER_SIDE", AsRef::as_ref),
391            self.order_type,
392            self.time_in_force,
393            self.order_status,
394            self.quantity,
395            self.filled_qty,
396            self.report_id,
397            self.ts_accepted,
398            self.ts_last,
399            self.ts_init,
400            self.client_order_id,
401            self.order_list_id,
402            self.venue_position_id,
403            self.linked_order_ids,
404            self.parent_order_id,
405            self.contingency_type
406                .as_ref()
407                .map_or("NO_CONTINGENCY", AsRef::as_ref),
408            self.expire_time,
409            self.price,
410            self.activation_price,
411            self.trigger_price,
412            self.trigger_type,
413            self.limit_offset,
414            self.trailing_offset,
415            self.trailing_offset_type
416                .as_ref()
417                .map_or("NO_TRAILING_OFFSET", AsRef::as_ref),
418            self.avg_px,
419            self.display_qty,
420            self.post_only,
421            self.reduce_only,
422            self.cancel_reason,
423            self.ts_triggered,
424        )
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use nautilus_core::UnixNanos;
431    use rstest::*;
432    use rust_decimal_macros::dec;
433
434    use super::*;
435    use crate::{
436        enums::{
437            ContingencyType, OrderSide, OrderStatus, OrderType, TimeInForce, TrailingOffsetType,
438            TriggerType,
439        },
440        identifiers::{
441            AccountId, ClientOrderId, InstrumentId, OrderListId, PositionId, VenueOrderId,
442        },
443        orders::builder::OrderTestBuilder,
444        types::{Price, Quantity},
445    };
446
447    fn test_order_status_report() -> OrderStatusReport {
448        OrderStatusReport::new(
449            AccountId::from("SIM-001"),
450            InstrumentId::from("AUDUSD.SIM"),
451            Some(ClientOrderId::from("O-19700101-000000-001-001-1")),
452            VenueOrderId::from("1"),
453            OrderSide::Buy.into(),
454            OrderType::Limit,
455            TimeInForce::Gtc,
456            OrderStatus::Accepted,
457            Quantity::from("100"),
458            Quantity::from("0"),
459            UnixNanos::from(1_000_000_000),
460            UnixNanos::from(2_000_000_000),
461            UnixNanos::from(3_000_000_000),
462            None,
463        )
464    }
465
466    #[rstest]
467    fn test_order_status_report_new() {
468        let report = test_order_status_report();
469
470        assert_eq!(report.account_id, AccountId::from("SIM-001"));
471        assert_eq!(report.instrument_id, InstrumentId::from("AUDUSD.SIM"));
472        assert_eq!(
473            report.client_order_id,
474            Some(ClientOrderId::from("O-19700101-000000-001-001-1"))
475        );
476        assert_eq!(report.venue_order_id, VenueOrderId::from("1"));
477        assert_eq!(report.order_side, OrderSide::Buy.into());
478        assert_eq!(report.order_type, OrderType::Limit);
479        assert_eq!(report.time_in_force, TimeInForce::Gtc);
480        assert_eq!(report.order_status, OrderStatus::Accepted);
481        assert_eq!(report.quantity, Quantity::from("100"));
482        assert_eq!(report.filled_qty, Quantity::from("0"));
483        assert_eq!(report.ts_accepted, UnixNanos::from(1_000_000_000));
484        assert_eq!(report.ts_last, UnixNanos::from(2_000_000_000));
485        assert_eq!(report.ts_init, UnixNanos::from(3_000_000_000));
486
487        // Test default values
488        assert_eq!(report.order_list_id, None);
489        assert_eq!(report.venue_position_id, None);
490        assert_eq!(report.linked_order_ids, None);
491        assert_eq!(report.parent_order_id, None);
492        assert_eq!(report.contingency_type, None);
493        assert_eq!(report.expire_time, None);
494        assert_eq!(report.price, None);
495        assert_eq!(report.trigger_price, None);
496        assert_eq!(report.trigger_type, None);
497        assert_eq!(report.limit_offset, None);
498        assert_eq!(report.trailing_offset, None);
499        assert_eq!(report.trailing_offset_type, None);
500        assert_eq!(report.avg_px, None);
501        assert_eq!(report.display_qty, None);
502        assert!(!report.post_only);
503        assert!(!report.reduce_only);
504        assert_eq!(report.cancel_reason, None);
505        assert_eq!(report.ts_triggered, None);
506    }
507
508    #[rstest]
509    fn test_order_status_report_with_generated_report_id() {
510        let report = OrderStatusReport::new(
511            AccountId::from("SIM-001"),
512            InstrumentId::from("AUDUSD.SIM"),
513            None,
514            VenueOrderId::from("1"),
515            OrderSide::Buy.into(),
516            OrderType::Market,
517            TimeInForce::Ioc,
518            OrderStatus::Filled,
519            Quantity::from("100"),
520            Quantity::from("100"),
521            UnixNanos::from(1_000_000_000),
522            UnixNanos::from(2_000_000_000),
523            UnixNanos::from(3_000_000_000),
524            None, // No report ID provided, should generate one
525        );
526
527        // Should have a generated UUID
528        assert_ne!(
529            report.report_id.to_string(),
530            "00000000-0000-0000-0000-000000000000"
531        );
532    }
533
534    #[rstest]
535    fn test_order_status_report_builder_methods() {
536        let report = test_order_status_report()
537            .with_client_order_id(ClientOrderId::from("O-19700101-000000-001-001-2"))
538            .with_order_list_id(OrderListId::from("OL-001"))
539            .with_venue_position_id(PositionId::from("P-001"))
540            .with_parent_order_id(ClientOrderId::from("O-PARENT"))
541            .with_price(Price::from("1.00000"))
542            .with_avg_px(dec!(1.00001))
543            .with_trigger_price(Price::from("0.99000"))
544            .with_trigger_type(TriggerType::Default)
545            .with_limit_offset(dec!(0.0001))
546            .with_trailing_offset(dec!(0.0002))
547            .with_trailing_offset_type(TrailingOffsetType::BasisPoints)
548            .with_display_qty(Quantity::from("50"))
549            .with_expire_time(UnixNanos::from(4_000_000_000))
550            .with_post_only(true)
551            .with_reduce_only(true)
552            .with_cancel_reason("User requested".to_string())
553            .with_ts_triggered(UnixNanos::from(1_500_000_000))
554            .with_contingency_type(ContingencyType::Oco);
555
556        assert_eq!(
557            report.client_order_id,
558            Some(ClientOrderId::from("O-19700101-000000-001-001-2"))
559        );
560        assert_eq!(report.order_list_id, Some(OrderListId::from("OL-001")));
561        assert_eq!(report.venue_position_id, Some(PositionId::from("P-001")));
562        assert_eq!(
563            report.parent_order_id,
564            Some(ClientOrderId::from("O-PARENT"))
565        );
566        assert_eq!(report.price, Some(Price::from("1.00000")));
567        assert_eq!(report.avg_px, Some(dec!(1.00001)));
568        assert_eq!(report.trigger_price, Some(Price::from("0.99000")));
569        assert_eq!(report.trigger_type, Some(TriggerType::Default));
570        assert_eq!(report.limit_offset, Some(dec!(0.0001)));
571        assert_eq!(report.trailing_offset, Some(dec!(0.0002)));
572        assert_eq!(
573            report.trailing_offset_type,
574            Some(TrailingOffsetType::BasisPoints),
575        );
576        assert_eq!(report.display_qty, Some(Quantity::from("50")));
577        assert_eq!(report.expire_time, Some(UnixNanos::from(4_000_000_000)));
578        assert!(report.post_only);
579        assert!(report.reduce_only);
580        assert_eq!(report.cancel_reason, Some("User requested".to_string()));
581        assert_eq!(report.ts_triggered, Some(UnixNanos::from(1_500_000_000)));
582        assert_eq!(report.contingency_type, Some(ContingencyType::Oco));
583    }
584
585    #[rstest]
586    fn test_display() {
587        let report = test_order_status_report();
588        let display_str = format!("{report}");
589
590        assert!(display_str.contains("OrderStatusReport"));
591        assert!(display_str.contains("SIM-001"));
592        assert!(display_str.contains("AUDUSD.SIM"));
593        assert!(display_str.contains("BUY"));
594        assert!(display_str.contains("LIMIT"));
595        assert!(display_str.contains("GTC"));
596        assert!(display_str.contains("ACCEPTED"));
597        assert!(display_str.contains("100"));
598    }
599
600    #[rstest]
601    fn test_clone_and_equality() {
602        let report1 = test_order_status_report();
603        let report2 = report1.clone();
604
605        assert_eq!(report1, report2);
606    }
607
608    #[rstest]
609    fn test_serialization_roundtrip() {
610        let original = test_order_status_report();
611
612        // Test JSON serialization
613        let json = serde_json::to_string(&original).unwrap();
614        let deserialized: OrderStatusReport = serde_json::from_str(&json).unwrap();
615        assert_eq!(original, deserialized);
616    }
617
618    #[rstest]
619    fn test_order_status_report_different_order_types() {
620        let market_report = OrderStatusReport::new(
621            AccountId::from("SIM-001"),
622            InstrumentId::from("AUDUSD.SIM"),
623            None,
624            VenueOrderId::from("1"),
625            OrderSide::Buy.into(),
626            OrderType::Market,
627            TimeInForce::Ioc,
628            OrderStatus::Filled,
629            Quantity::from("100"),
630            Quantity::from("100"),
631            UnixNanos::from(1_000_000_000),
632            UnixNanos::from(2_000_000_000),
633            UnixNanos::from(3_000_000_000),
634            None,
635        );
636
637        let stop_report = OrderStatusReport::new(
638            AccountId::from("SIM-001"),
639            InstrumentId::from("AUDUSD.SIM"),
640            None,
641            VenueOrderId::from("2"),
642            OrderSide::Sell.into(),
643            OrderType::StopMarket,
644            TimeInForce::Gtc,
645            OrderStatus::Accepted,
646            Quantity::from("50"),
647            Quantity::from("0"),
648            UnixNanos::from(1_000_000_000),
649            UnixNanos::from(2_000_000_000),
650            UnixNanos::from(3_000_000_000),
651            None,
652        );
653
654        assert_eq!(market_report.order_type, OrderType::Market);
655        assert_eq!(stop_report.order_type, OrderType::StopMarket);
656        assert_ne!(market_report, stop_report);
657    }
658
659    #[rstest]
660    fn test_order_status_report_different_statuses() {
661        let accepted_report = test_order_status_report();
662
663        let filled_report = OrderStatusReport::new(
664            AccountId::from("SIM-001"),
665            InstrumentId::from("AUDUSD.SIM"),
666            Some(ClientOrderId::from("O-19700101-000000-001-001-1")),
667            VenueOrderId::from("1"),
668            OrderSide::Buy.into(),
669            OrderType::Limit,
670            TimeInForce::Gtc,
671            OrderStatus::Filled,
672            Quantity::from("100"),
673            Quantity::from("100"), // Fully filled
674            UnixNanos::from(1_000_000_000),
675            UnixNanos::from(2_000_000_000),
676            UnixNanos::from(3_000_000_000),
677            None,
678        );
679
680        assert_eq!(accepted_report.order_status, OrderStatus::Accepted);
681        assert_eq!(filled_report.order_status, OrderStatus::Filled);
682        assert_ne!(accepted_report, filled_report);
683    }
684
685    #[rstest]
686    fn test_order_status_report_with_optional_fields() {
687        let mut report = test_order_status_report();
688
689        // Initially no optional fields set
690        assert_eq!(report.price, None);
691        assert_eq!(report.avg_px, None);
692        assert!(!report.post_only);
693        assert!(!report.reduce_only);
694
695        // Test builder pattern with various optional fields
696        report = report
697            .with_price(Price::from("1.00000"))
698            .with_avg_px(dec!(1.00001))
699            .with_post_only(true)
700            .with_reduce_only(true);
701
702        assert_eq!(report.price, Some(Price::from("1.00000")));
703        assert_eq!(report.avg_px, Some(dec!(1.00001)));
704        assert!(report.post_only);
705        assert!(report.reduce_only);
706    }
707
708    #[rstest]
709    fn test_order_status_report_partial_fill() {
710        let partial_fill_report = OrderStatusReport::new(
711            AccountId::from("SIM-001"),
712            InstrumentId::from("AUDUSD.SIM"),
713            Some(ClientOrderId::from("O-19700101-000000-001-001-1")),
714            VenueOrderId::from("1"),
715            OrderSide::Buy.into(),
716            OrderType::Limit,
717            TimeInForce::Gtc,
718            OrderStatus::PartiallyFilled,
719            Quantity::from("100"),
720            Quantity::from("30"), // Partially filled
721            UnixNanos::from(1_000_000_000),
722            UnixNanos::from(2_000_000_000),
723            UnixNanos::from(3_000_000_000),
724            None,
725        );
726
727        assert_eq!(partial_fill_report.quantity, Quantity::from("100"));
728        assert_eq!(partial_fill_report.filled_qty, Quantity::from("30"));
729        assert_eq!(
730            partial_fill_report.order_status,
731            OrderStatus::PartiallyFilled
732        );
733    }
734
735    #[rstest]
736    fn test_order_status_report_with_all_timestamp_fields() {
737        let report = OrderStatusReport::new(
738            AccountId::from("SIM-001"),
739            InstrumentId::from("AUDUSD.SIM"),
740            None,
741            VenueOrderId::from("1"),
742            OrderSide::Buy.into(),
743            OrderType::StopLimit,
744            TimeInForce::Gtc,
745            OrderStatus::Triggered,
746            Quantity::from("100"),
747            Quantity::from("0"),
748            UnixNanos::from(1_000_000_000), // ts_accepted
749            UnixNanos::from(2_000_000_000), // ts_last
750            UnixNanos::from(3_000_000_000), // ts_init
751            None,
752        )
753        .with_ts_triggered(UnixNanos::from(1_500_000_000));
754
755        assert_eq!(report.ts_accepted, UnixNanos::from(1_000_000_000));
756        assert_eq!(report.ts_last, UnixNanos::from(2_000_000_000));
757        assert_eq!(report.ts_init, UnixNanos::from(3_000_000_000));
758        assert_eq!(report.ts_triggered, Some(UnixNanos::from(1_500_000_000)));
759    }
760
761    #[rstest]
762    fn test_is_order_updated_returns_true_when_price_differs() {
763        let order = OrderTestBuilder::new(OrderType::Limit)
764            .instrument_id(InstrumentId::from("AUDUSD.SIM"))
765            .quantity(Quantity::from(100))
766            .price(Price::from("1.00000"))
767            .build();
768
769        let report = OrderStatusReport::new(
770            AccountId::from("SIM-001"),
771            InstrumentId::from("AUDUSD.SIM"),
772            None,
773            VenueOrderId::from("1"),
774            OrderSide::Buy.into(),
775            OrderType::Limit,
776            TimeInForce::Gtc,
777            OrderStatus::Accepted,
778            Quantity::from("100"),
779            Quantity::from("0"),
780            UnixNanos::from(1_000_000_000),
781            UnixNanos::from(2_000_000_000),
782            UnixNanos::from(3_000_000_000),
783            None,
784        )
785        .with_price(Price::from("1.00100")); // Different price
786
787        assert!(report.is_order_updated(&order));
788    }
789
790    #[rstest]
791    fn test_is_order_updated_returns_true_when_trigger_price_differs() {
792        let order = OrderTestBuilder::new(OrderType::StopMarket)
793            .instrument_id(InstrumentId::from("AUDUSD.SIM"))
794            .quantity(Quantity::from(100))
795            .trigger_price(Price::from("0.99000"))
796            .build();
797
798        let report = OrderStatusReport::new(
799            AccountId::from("SIM-001"),
800            InstrumentId::from("AUDUSD.SIM"),
801            None,
802            VenueOrderId::from("1"),
803            OrderSide::Buy.into(),
804            OrderType::StopMarket,
805            TimeInForce::Gtc,
806            OrderStatus::Accepted,
807            Quantity::from("100"),
808            Quantity::from("0"),
809            UnixNanos::from(1_000_000_000),
810            UnixNanos::from(2_000_000_000),
811            UnixNanos::from(3_000_000_000),
812            None,
813        )
814        .with_trigger_price(Price::from("0.99100")); // Different trigger price
815
816        assert!(report.is_order_updated(&order));
817    }
818
819    #[rstest]
820    fn test_is_order_updated_returns_true_when_quantity_differs() {
821        let order = OrderTestBuilder::new(OrderType::Limit)
822            .instrument_id(InstrumentId::from("AUDUSD.SIM"))
823            .quantity(Quantity::from(100))
824            .price(Price::from("1.00000"))
825            .build();
826
827        let report = OrderStatusReport::new(
828            AccountId::from("SIM-001"),
829            InstrumentId::from("AUDUSD.SIM"),
830            None,
831            VenueOrderId::from("1"),
832            OrderSide::Buy.into(),
833            OrderType::Limit,
834            TimeInForce::Gtc,
835            OrderStatus::Accepted,
836            Quantity::from("200"), // Different quantity
837            Quantity::from("0"),
838            UnixNanos::from(1_000_000_000),
839            UnixNanos::from(2_000_000_000),
840            UnixNanos::from(3_000_000_000),
841            None,
842        )
843        .with_price(Price::from("1.00000"));
844
845        assert!(report.is_order_updated(&order));
846    }
847
848    #[rstest]
849    fn test_is_order_updated_returns_false_when_all_match() {
850        let order = OrderTestBuilder::new(OrderType::Limit)
851            .instrument_id(InstrumentId::from("AUDUSD.SIM"))
852            .quantity(Quantity::from(100))
853            .price(Price::from("1.00000"))
854            .build();
855
856        let report = OrderStatusReport::new(
857            AccountId::from("SIM-001"),
858            InstrumentId::from("AUDUSD.SIM"),
859            None,
860            VenueOrderId::from("1"),
861            OrderSide::Buy.into(),
862            OrderType::Limit,
863            TimeInForce::Gtc,
864            OrderStatus::Accepted,
865            Quantity::from("100"), // Same quantity
866            Quantity::from("0"),
867            UnixNanos::from(1_000_000_000),
868            UnixNanos::from(2_000_000_000),
869            UnixNanos::from(3_000_000_000),
870            None,
871        )
872        .with_price(Price::from("1.00000")); // Same price
873
874        assert!(!report.is_order_updated(&order));
875    }
876
877    #[rstest]
878    fn test_is_order_updated_returns_false_when_order_has_no_price() {
879        // Market orders have no price, so only quantity comparison matters
880        let order = OrderTestBuilder::new(OrderType::Market)
881            .instrument_id(InstrumentId::from("AUDUSD.SIM"))
882            .quantity(Quantity::from(100))
883            .build();
884
885        let report = OrderStatusReport::new(
886            AccountId::from("SIM-001"),
887            InstrumentId::from("AUDUSD.SIM"),
888            None,
889            VenueOrderId::from("1"),
890            OrderSide::Buy.into(),
891            OrderType::Market,
892            TimeInForce::Ioc,
893            OrderStatus::Accepted,
894            Quantity::from("100"), // Same quantity
895            Quantity::from("0"),
896            UnixNanos::from(1_000_000_000),
897            UnixNanos::from(2_000_000_000),
898            UnixNanos::from(3_000_000_000),
899            None,
900        )
901        .with_price(Price::from("1.00000")); // Report has price, but order doesn't
902
903        assert!(!report.is_order_updated(&order));
904    }
905
906    #[rstest]
907    fn test_is_order_updated_stop_limit_order_with_both_prices() {
908        let order = OrderTestBuilder::new(OrderType::StopLimit)
909            .instrument_id(InstrumentId::from("AUDUSD.SIM"))
910            .quantity(Quantity::from(100))
911            .price(Price::from("1.00000"))
912            .trigger_price(Price::from("0.99000"))
913            .build();
914
915        // Same everything
916        let report_same = OrderStatusReport::new(
917            AccountId::from("SIM-001"),
918            InstrumentId::from("AUDUSD.SIM"),
919            None,
920            VenueOrderId::from("1"),
921            OrderSide::Buy.into(),
922            OrderType::StopLimit,
923            TimeInForce::Gtc,
924            OrderStatus::Accepted,
925            Quantity::from("100"),
926            Quantity::from("0"),
927            UnixNanos::from(1_000_000_000),
928            UnixNanos::from(2_000_000_000),
929            UnixNanos::from(3_000_000_000),
930            None,
931        )
932        .with_price(Price::from("1.00000"))
933        .with_trigger_price(Price::from("0.99000"));
934
935        assert!(!report_same.is_order_updated(&order));
936
937        // Different limit price
938        let report_diff_price = OrderStatusReport::new(
939            AccountId::from("SIM-001"),
940            InstrumentId::from("AUDUSD.SIM"),
941            None,
942            VenueOrderId::from("1"),
943            OrderSide::Buy.into(),
944            OrderType::StopLimit,
945            TimeInForce::Gtc,
946            OrderStatus::Accepted,
947            Quantity::from("100"),
948            Quantity::from("0"),
949            UnixNanos::from(1_000_000_000),
950            UnixNanos::from(2_000_000_000),
951            UnixNanos::from(3_000_000_000),
952            None,
953        )
954        .with_price(Price::from("1.00100")) // Different
955        .with_trigger_price(Price::from("0.99000"));
956
957        assert!(report_diff_price.is_order_updated(&order));
958
959        // Different trigger price
960        let report_diff_trigger = OrderStatusReport::new(
961            AccountId::from("SIM-001"),
962            InstrumentId::from("AUDUSD.SIM"),
963            None,
964            VenueOrderId::from("1"),
965            OrderSide::Buy.into(),
966            OrderType::StopLimit,
967            TimeInForce::Gtc,
968            OrderStatus::Accepted,
969            Quantity::from("100"),
970            Quantity::from("0"),
971            UnixNanos::from(1_000_000_000),
972            UnixNanos::from(2_000_000_000),
973            UnixNanos::from(3_000_000_000),
974            None,
975        )
976        .with_price(Price::from("1.00000"))
977        .with_trigger_price(Price::from("0.99100")); // Different
978
979        assert!(report_diff_trigger.is_order_updated(&order));
980    }
981}