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