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