nautilus_model/reports/
order.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 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.core.nautilus_pyo3.model")
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 = Some(Decimal::from_f64_retain(avg_px).ok_or_else(|| {
223            anyhow::anyhow!(
224                "Failed to convert avg_px to Decimal: {avg_px} (possible overflow/underflow)"
225            )
226        })?);
227        Ok(self)
228    }
229
230    /// Sets the trigger price.
231    #[must_use]
232    pub const fn with_trigger_price(mut self, trigger_price: Price) -> Self {
233        self.trigger_price = Some(trigger_price);
234        self
235    }
236
237    /// Sets the trigger type.
238    #[must_use]
239    pub const fn with_trigger_type(mut self, trigger_type: TriggerType) -> Self {
240        self.trigger_type = Some(trigger_type);
241        self
242    }
243
244    /// Sets the limit offset.
245    #[must_use]
246    pub const fn with_limit_offset(mut self, limit_offset: Decimal) -> Self {
247        self.limit_offset = Some(limit_offset);
248        self
249    }
250
251    /// Sets the trailing offset.
252    #[must_use]
253    pub const fn with_trailing_offset(mut self, trailing_offset: Decimal) -> Self {
254        self.trailing_offset = Some(trailing_offset);
255        self
256    }
257
258    /// Sets the trailing offset type.
259    #[must_use]
260    pub const fn with_trailing_offset_type(
261        mut self,
262        trailing_offset_type: TrailingOffsetType,
263    ) -> Self {
264        self.trailing_offset_type = trailing_offset_type;
265        self
266    }
267
268    /// Sets the display quantity.
269    #[must_use]
270    pub const fn with_display_qty(mut self, display_qty: Quantity) -> Self {
271        self.display_qty = Some(display_qty);
272        self
273    }
274
275    /// Sets the expire time.
276    #[must_use]
277    pub const fn with_expire_time(mut self, expire_time: UnixNanos) -> Self {
278        self.expire_time = Some(expire_time);
279        self
280    }
281
282    /// Sets `post_only` flag.
283    #[must_use]
284    pub const fn with_post_only(mut self, post_only: bool) -> Self {
285        self.post_only = post_only;
286        self
287    }
288
289    /// Sets `reduce_only` flag.
290    #[must_use]
291    pub const fn with_reduce_only(mut self, reduce_only: bool) -> Self {
292        self.reduce_only = reduce_only;
293        self
294    }
295
296    /// Sets cancel reason.
297    #[must_use]
298    pub fn with_cancel_reason(mut self, cancel_reason: String) -> Self {
299        self.cancel_reason = Some(cancel_reason);
300        self
301    }
302
303    /// Sets the triggered timestamp.
304    #[must_use]
305    pub const fn with_ts_triggered(mut self, ts_triggered: UnixNanos) -> Self {
306        self.ts_triggered = Some(ts_triggered);
307        self
308    }
309
310    /// Sets the contingency type.
311    #[must_use]
312    pub const fn with_contingency_type(mut self, contingency_type: ContingencyType) -> Self {
313        self.contingency_type = contingency_type;
314        self
315    }
316
317    /// Returns whether the order has been updated based on this report.
318    ///
319    /// An order is considered updated if any of the following differ:
320    /// - Price (if both the order and report have a price).
321    /// - Trigger price (if both the order and report have a trigger price).
322    /// - Quantity.
323    #[must_use]
324    pub fn is_order_updated(&self, order: &impl Order) -> bool {
325        if order.has_price()
326            && let Some(report_price) = self.price
327            && let Some(order_price) = order.price()
328            && order_price != report_price
329        {
330            return true;
331        }
332
333        if let Some(order_trigger_price) = order.trigger_price()
334            && let Some(report_trigger_price) = self.trigger_price
335            && order_trigger_price != report_trigger_price
336        {
337            return true;
338        }
339
340        order.quantity() != self.quantity
341    }
342}
343
344impl Display for OrderStatusReport {
345    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
346        write!(
347            f,
348            "OrderStatusReport(\
349                account_id={}, \
350                instrument_id={}, \
351                venue_order_id={}, \
352                order_side={}, \
353                order_type={}, \
354                time_in_force={}, \
355                order_status={}, \
356                quantity={}, \
357                filled_qty={}, \
358                report_id={}, \
359                ts_accepted={}, \
360                ts_last={}, \
361                ts_init={}, \
362                client_order_id={:?}, \
363                order_list_id={:?}, \
364                venue_position_id={:?}, \
365                linked_order_ids={:?}, \
366                parent_order_id={:?}, \
367                contingency_type={}, \
368                expire_time={:?}, \
369                price={:?}, \
370                trigger_price={:?}, \
371                trigger_type={:?}, \
372                limit_offset={:?}, \
373                trailing_offset={:?}, \
374                trailing_offset_type={}, \
375                avg_px={:?}, \
376                display_qty={:?}, \
377                post_only={}, \
378                reduce_only={}, \
379                cancel_reason={:?}, \
380                ts_triggered={:?}\
381            )",
382            self.account_id,
383            self.instrument_id,
384            self.venue_order_id,
385            self.order_side,
386            self.order_type,
387            self.time_in_force,
388            self.order_status,
389            self.quantity,
390            self.filled_qty,
391            self.report_id,
392            self.ts_accepted,
393            self.ts_last,
394            self.ts_init,
395            self.client_order_id,
396            self.order_list_id,
397            self.venue_position_id,
398            self.linked_order_ids,
399            self.parent_order_id,
400            self.contingency_type,
401            self.expire_time,
402            self.price,
403            self.trigger_price,
404            self.trigger_type,
405            self.limit_offset,
406            self.trailing_offset,
407            self.trailing_offset_type,
408            self.avg_px,
409            self.display_qty,
410            self.post_only,
411            self.reduce_only,
412            self.cancel_reason,
413            self.ts_triggered,
414        )
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use nautilus_core::UnixNanos;
421    use rstest::*;
422    use rust_decimal::Decimal;
423
424    use super::*;
425    use crate::{
426        enums::{
427            ContingencyType, OrderSide, OrderStatus, OrderType, TimeInForce, TrailingOffsetType,
428            TriggerType,
429        },
430        identifiers::{
431            AccountId, ClientOrderId, InstrumentId, OrderListId, PositionId, VenueOrderId,
432        },
433        orders::builder::OrderTestBuilder,
434        types::{Price, Quantity},
435    };
436
437    fn test_order_status_report() -> OrderStatusReport {
438        OrderStatusReport::new(
439            AccountId::from("SIM-001"),
440            InstrumentId::from("AUDUSD.SIM"),
441            Some(ClientOrderId::from("O-19700101-000000-001-001-1")),
442            VenueOrderId::from("1"),
443            OrderSide::Buy,
444            OrderType::Limit,
445            TimeInForce::Gtc,
446            OrderStatus::Accepted,
447            Quantity::from("100"),
448            Quantity::from("0"),
449            UnixNanos::from(1_000_000_000),
450            UnixNanos::from(2_000_000_000),
451            UnixNanos::from(3_000_000_000),
452            None,
453        )
454    }
455
456    #[rstest]
457    fn test_order_status_report_new() {
458        let report = test_order_status_report();
459
460        assert_eq!(report.account_id, AccountId::from("SIM-001"));
461        assert_eq!(report.instrument_id, InstrumentId::from("AUDUSD.SIM"));
462        assert_eq!(
463            report.client_order_id,
464            Some(ClientOrderId::from("O-19700101-000000-001-001-1"))
465        );
466        assert_eq!(report.venue_order_id, VenueOrderId::from("1"));
467        assert_eq!(report.order_side, OrderSide::Buy);
468        assert_eq!(report.order_type, OrderType::Limit);
469        assert_eq!(report.time_in_force, TimeInForce::Gtc);
470        assert_eq!(report.order_status, OrderStatus::Accepted);
471        assert_eq!(report.quantity, Quantity::from("100"));
472        assert_eq!(report.filled_qty, Quantity::from("0"));
473        assert_eq!(report.ts_accepted, UnixNanos::from(1_000_000_000));
474        assert_eq!(report.ts_last, UnixNanos::from(2_000_000_000));
475        assert_eq!(report.ts_init, UnixNanos::from(3_000_000_000));
476
477        // Test default values
478        assert_eq!(report.order_list_id, None);
479        assert_eq!(report.venue_position_id, None);
480        assert_eq!(report.linked_order_ids, None);
481        assert_eq!(report.parent_order_id, None);
482        assert_eq!(report.contingency_type, ContingencyType::default());
483        assert_eq!(report.expire_time, None);
484        assert_eq!(report.price, None);
485        assert_eq!(report.trigger_price, None);
486        assert_eq!(report.trigger_type, None);
487        assert_eq!(report.limit_offset, None);
488        assert_eq!(report.trailing_offset, None);
489        assert_eq!(report.trailing_offset_type, TrailingOffsetType::default());
490        assert_eq!(report.avg_px, None);
491        assert_eq!(report.display_qty, None);
492        assert!(!report.post_only);
493        assert!(!report.reduce_only);
494        assert_eq!(report.cancel_reason, None);
495        assert_eq!(report.ts_triggered, None);
496    }
497
498    #[rstest]
499    fn test_order_status_report_with_generated_report_id() {
500        let report = OrderStatusReport::new(
501            AccountId::from("SIM-001"),
502            InstrumentId::from("AUDUSD.SIM"),
503            None,
504            VenueOrderId::from("1"),
505            OrderSide::Buy,
506            OrderType::Market,
507            TimeInForce::Ioc,
508            OrderStatus::Filled,
509            Quantity::from("100"),
510            Quantity::from("100"),
511            UnixNanos::from(1_000_000_000),
512            UnixNanos::from(2_000_000_000),
513            UnixNanos::from(3_000_000_000),
514            None, // No report ID provided, should generate one
515        );
516
517        // Should have a generated UUID
518        assert_ne!(
519            report.report_id.to_string(),
520            "00000000-0000-0000-0000-000000000000"
521        );
522    }
523
524    #[rstest]
525    #[allow(clippy::panic_in_result_fn)]
526    fn test_order_status_report_builder_methods() -> anyhow::Result<()> {
527        let report = test_order_status_report()
528            .with_client_order_id(ClientOrderId::from("O-19700101-000000-001-001-2"))
529            .with_order_list_id(OrderListId::from("OL-001"))
530            .with_venue_position_id(PositionId::from("P-001"))
531            .with_parent_order_id(ClientOrderId::from("O-PARENT"))
532            .with_price(Price::from("1.00000"))
533            .with_avg_px(1.00001)?
534            .with_trigger_price(Price::from("0.99000"))
535            .with_trigger_type(TriggerType::Default)
536            .with_limit_offset(Decimal::from_f64_retain(0.0001).unwrap())
537            .with_trailing_offset(Decimal::from_f64_retain(0.0002).unwrap())
538            .with_trailing_offset_type(TrailingOffsetType::BasisPoints)
539            .with_display_qty(Quantity::from("50"))
540            .with_expire_time(UnixNanos::from(4_000_000_000))
541            .with_post_only(true)
542            .with_reduce_only(true)
543            .with_cancel_reason("User requested".to_string())
544            .with_ts_triggered(UnixNanos::from(1_500_000_000))
545            .with_contingency_type(ContingencyType::Oco);
546
547        assert_eq!(
548            report.client_order_id,
549            Some(ClientOrderId::from("O-19700101-000000-001-001-2"))
550        );
551        assert_eq!(report.order_list_id, Some(OrderListId::from("OL-001")));
552        assert_eq!(report.venue_position_id, Some(PositionId::from("P-001")));
553        assert_eq!(
554            report.parent_order_id,
555            Some(ClientOrderId::from("O-PARENT"))
556        );
557        assert_eq!(report.price, Some(Price::from("1.00000")));
558        assert_eq!(
559            report.avg_px,
560            Some(Decimal::from_f64_retain(1.00001).unwrap())
561        );
562        assert_eq!(report.trigger_price, Some(Price::from("0.99000")));
563        assert_eq!(report.trigger_type, Some(TriggerType::Default));
564        assert_eq!(
565            report.limit_offset,
566            Some(Decimal::from_f64_retain(0.0001).unwrap())
567        );
568        assert_eq!(
569            report.trailing_offset,
570            Some(Decimal::from_f64_retain(0.0002).unwrap())
571        );
572        assert_eq!(report.trailing_offset_type, TrailingOffsetType::BasisPoints);
573        assert_eq!(report.display_qty, Some(Quantity::from("50")));
574        assert_eq!(report.expire_time, Some(UnixNanos::from(4_000_000_000)));
575        assert!(report.post_only);
576        assert!(report.reduce_only);
577        assert_eq!(report.cancel_reason, Some("User requested".to_string()));
578        assert_eq!(report.ts_triggered, Some(UnixNanos::from(1_500_000_000)));
579        assert_eq!(report.contingency_type, ContingencyType::Oco);
580        Ok(())
581    }
582
583    #[rstest]
584    fn test_display() {
585        let report = test_order_status_report();
586        let display_str = format!("{report}");
587
588        assert!(display_str.contains("OrderStatusReport"));
589        assert!(display_str.contains("SIM-001"));
590        assert!(display_str.contains("AUDUSD.SIM"));
591        assert!(display_str.contains("BUY"));
592        assert!(display_str.contains("LIMIT"));
593        assert!(display_str.contains("GTC"));
594        assert!(display_str.contains("ACCEPTED"));
595        assert!(display_str.contains("100"));
596    }
597
598    #[rstest]
599    fn test_clone_and_equality() {
600        let report1 = test_order_status_report();
601        let report2 = report1.clone();
602
603        assert_eq!(report1, report2);
604    }
605
606    #[rstest]
607    fn test_serialization_roundtrip() {
608        let original = test_order_status_report();
609
610        // Test JSON serialization
611        let json = serde_json::to_string(&original).unwrap();
612        let deserialized: OrderStatusReport = serde_json::from_str(&json).unwrap();
613        assert_eq!(original, deserialized);
614    }
615
616    #[rstest]
617    fn test_order_status_report_different_order_types() {
618        let market_report = OrderStatusReport::new(
619            AccountId::from("SIM-001"),
620            InstrumentId::from("AUDUSD.SIM"),
621            None,
622            VenueOrderId::from("1"),
623            OrderSide::Buy,
624            OrderType::Market,
625            TimeInForce::Ioc,
626            OrderStatus::Filled,
627            Quantity::from("100"),
628            Quantity::from("100"),
629            UnixNanos::from(1_000_000_000),
630            UnixNanos::from(2_000_000_000),
631            UnixNanos::from(3_000_000_000),
632            None,
633        );
634
635        let stop_report = OrderStatusReport::new(
636            AccountId::from("SIM-001"),
637            InstrumentId::from("AUDUSD.SIM"),
638            None,
639            VenueOrderId::from("2"),
640            OrderSide::Sell,
641            OrderType::StopMarket,
642            TimeInForce::Gtc,
643            OrderStatus::Accepted,
644            Quantity::from("50"),
645            Quantity::from("0"),
646            UnixNanos::from(1_000_000_000),
647            UnixNanos::from(2_000_000_000),
648            UnixNanos::from(3_000_000_000),
649            None,
650        );
651
652        assert_eq!(market_report.order_type, OrderType::Market);
653        assert_eq!(stop_report.order_type, OrderType::StopMarket);
654        assert_ne!(market_report, stop_report);
655    }
656
657    #[rstest]
658    fn test_order_status_report_different_statuses() {
659        let accepted_report = test_order_status_report();
660
661        let filled_report = OrderStatusReport::new(
662            AccountId::from("SIM-001"),
663            InstrumentId::from("AUDUSD.SIM"),
664            Some(ClientOrderId::from("O-19700101-000000-001-001-1")),
665            VenueOrderId::from("1"),
666            OrderSide::Buy,
667            OrderType::Limit,
668            TimeInForce::Gtc,
669            OrderStatus::Filled,
670            Quantity::from("100"),
671            Quantity::from("100"), // Fully filled
672            UnixNanos::from(1_000_000_000),
673            UnixNanos::from(2_000_000_000),
674            UnixNanos::from(3_000_000_000),
675            None,
676        );
677
678        assert_eq!(accepted_report.order_status, OrderStatus::Accepted);
679        assert_eq!(filled_report.order_status, OrderStatus::Filled);
680        assert_ne!(accepted_report, filled_report);
681    }
682
683    #[rstest]
684    #[allow(clippy::panic_in_result_fn)]
685    fn test_order_status_report_with_optional_fields() -> anyhow::Result<()> {
686        let mut report = test_order_status_report();
687
688        // Initially no optional fields set
689        assert_eq!(report.price, None);
690        assert_eq!(report.avg_px, None);
691        assert!(!report.post_only);
692        assert!(!report.reduce_only);
693
694        // Test builder pattern with various optional fields
695        report = report
696            .with_price(Price::from("1.00000"))
697            .with_avg_px(1.00001)?
698            .with_post_only(true)
699            .with_reduce_only(true);
700
701        assert_eq!(report.price, Some(Price::from("1.00000")));
702        assert_eq!(
703            report.avg_px,
704            Some(Decimal::from_f64_retain(1.00001).unwrap())
705        );
706        assert!(report.post_only);
707        assert!(report.reduce_only);
708        Ok(())
709    }
710
711    #[rstest]
712    fn test_order_status_report_partial_fill() {
713        let partial_fill_report = OrderStatusReport::new(
714            AccountId::from("SIM-001"),
715            InstrumentId::from("AUDUSD.SIM"),
716            Some(ClientOrderId::from("O-19700101-000000-001-001-1")),
717            VenueOrderId::from("1"),
718            OrderSide::Buy,
719            OrderType::Limit,
720            TimeInForce::Gtc,
721            OrderStatus::PartiallyFilled,
722            Quantity::from("100"),
723            Quantity::from("30"), // Partially filled
724            UnixNanos::from(1_000_000_000),
725            UnixNanos::from(2_000_000_000),
726            UnixNanos::from(3_000_000_000),
727            None,
728        );
729
730        assert_eq!(partial_fill_report.quantity, Quantity::from("100"));
731        assert_eq!(partial_fill_report.filled_qty, Quantity::from("30"));
732        assert_eq!(
733            partial_fill_report.order_status,
734            OrderStatus::PartiallyFilled
735        );
736    }
737
738    #[rstest]
739    fn test_order_status_report_with_all_timestamp_fields() {
740        let report = OrderStatusReport::new(
741            AccountId::from("SIM-001"),
742            InstrumentId::from("AUDUSD.SIM"),
743            None,
744            VenueOrderId::from("1"),
745            OrderSide::Buy,
746            OrderType::StopLimit,
747            TimeInForce::Gtc,
748            OrderStatus::Triggered,
749            Quantity::from("100"),
750            Quantity::from("0"),
751            UnixNanos::from(1_000_000_000), // ts_accepted
752            UnixNanos::from(2_000_000_000), // ts_last
753            UnixNanos::from(3_000_000_000), // ts_init
754            None,
755        )
756        .with_ts_triggered(UnixNanos::from(1_500_000_000));
757
758        assert_eq!(report.ts_accepted, UnixNanos::from(1_000_000_000));
759        assert_eq!(report.ts_last, UnixNanos::from(2_000_000_000));
760        assert_eq!(report.ts_init, UnixNanos::from(3_000_000_000));
761        assert_eq!(report.ts_triggered, Some(UnixNanos::from(1_500_000_000)));
762    }
763
764    #[rstest]
765    fn test_is_order_updated_returns_true_when_price_differs() {
766        let order = OrderTestBuilder::new(OrderType::Limit)
767            .instrument_id(InstrumentId::from("AUDUSD.SIM"))
768            .quantity(Quantity::from(100))
769            .price(Price::from("1.00000"))
770            .build();
771
772        let report = OrderStatusReport::new(
773            AccountId::from("SIM-001"),
774            InstrumentId::from("AUDUSD.SIM"),
775            None,
776            VenueOrderId::from("1"),
777            OrderSide::Buy,
778            OrderType::Limit,
779            TimeInForce::Gtc,
780            OrderStatus::Accepted,
781            Quantity::from("100"),
782            Quantity::from("0"),
783            UnixNanos::from(1_000_000_000),
784            UnixNanos::from(2_000_000_000),
785            UnixNanos::from(3_000_000_000),
786            None,
787        )
788        .with_price(Price::from("1.00100")); // Different price
789
790        assert!(report.is_order_updated(&order));
791    }
792
793    #[rstest]
794    fn test_is_order_updated_returns_true_when_trigger_price_differs() {
795        let order = OrderTestBuilder::new(OrderType::StopMarket)
796            .instrument_id(InstrumentId::from("AUDUSD.SIM"))
797            .quantity(Quantity::from(100))
798            .trigger_price(Price::from("0.99000"))
799            .build();
800
801        let report = OrderStatusReport::new(
802            AccountId::from("SIM-001"),
803            InstrumentId::from("AUDUSD.SIM"),
804            None,
805            VenueOrderId::from("1"),
806            OrderSide::Buy,
807            OrderType::StopMarket,
808            TimeInForce::Gtc,
809            OrderStatus::Accepted,
810            Quantity::from("100"),
811            Quantity::from("0"),
812            UnixNanos::from(1_000_000_000),
813            UnixNanos::from(2_000_000_000),
814            UnixNanos::from(3_000_000_000),
815            None,
816        )
817        .with_trigger_price(Price::from("0.99100")); // Different trigger price
818
819        assert!(report.is_order_updated(&order));
820    }
821
822    #[rstest]
823    fn test_is_order_updated_returns_true_when_quantity_differs() {
824        let order = OrderTestBuilder::new(OrderType::Limit)
825            .instrument_id(InstrumentId::from("AUDUSD.SIM"))
826            .quantity(Quantity::from(100))
827            .price(Price::from("1.00000"))
828            .build();
829
830        let report = OrderStatusReport::new(
831            AccountId::from("SIM-001"),
832            InstrumentId::from("AUDUSD.SIM"),
833            None,
834            VenueOrderId::from("1"),
835            OrderSide::Buy,
836            OrderType::Limit,
837            TimeInForce::Gtc,
838            OrderStatus::Accepted,
839            Quantity::from("200"), // Different quantity
840            Quantity::from("0"),
841            UnixNanos::from(1_000_000_000),
842            UnixNanos::from(2_000_000_000),
843            UnixNanos::from(3_000_000_000),
844            None,
845        )
846        .with_price(Price::from("1.00000"));
847
848        assert!(report.is_order_updated(&order));
849    }
850
851    #[rstest]
852    fn test_is_order_updated_returns_false_when_all_match() {
853        let order = OrderTestBuilder::new(OrderType::Limit)
854            .instrument_id(InstrumentId::from("AUDUSD.SIM"))
855            .quantity(Quantity::from(100))
856            .price(Price::from("1.00000"))
857            .build();
858
859        let report = OrderStatusReport::new(
860            AccountId::from("SIM-001"),
861            InstrumentId::from("AUDUSD.SIM"),
862            None,
863            VenueOrderId::from("1"),
864            OrderSide::Buy,
865            OrderType::Limit,
866            TimeInForce::Gtc,
867            OrderStatus::Accepted,
868            Quantity::from("100"), // Same quantity
869            Quantity::from("0"),
870            UnixNanos::from(1_000_000_000),
871            UnixNanos::from(2_000_000_000),
872            UnixNanos::from(3_000_000_000),
873            None,
874        )
875        .with_price(Price::from("1.00000")); // Same price
876
877        assert!(!report.is_order_updated(&order));
878    }
879
880    #[rstest]
881    fn test_is_order_updated_returns_false_when_order_has_no_price() {
882        // Market orders have no price, so only quantity comparison matters
883        let order = OrderTestBuilder::new(OrderType::Market)
884            .instrument_id(InstrumentId::from("AUDUSD.SIM"))
885            .quantity(Quantity::from(100))
886            .build();
887
888        let report = OrderStatusReport::new(
889            AccountId::from("SIM-001"),
890            InstrumentId::from("AUDUSD.SIM"),
891            None,
892            VenueOrderId::from("1"),
893            OrderSide::Buy,
894            OrderType::Market,
895            TimeInForce::Ioc,
896            OrderStatus::Accepted,
897            Quantity::from("100"), // Same quantity
898            Quantity::from("0"),
899            UnixNanos::from(1_000_000_000),
900            UnixNanos::from(2_000_000_000),
901            UnixNanos::from(3_000_000_000),
902            None,
903        )
904        .with_price(Price::from("1.00000")); // Report has price, but order doesn't
905
906        assert!(!report.is_order_updated(&order));
907    }
908
909    #[rstest]
910    fn test_is_order_updated_stop_limit_order_with_both_prices() {
911        let order = OrderTestBuilder::new(OrderType::StopLimit)
912            .instrument_id(InstrumentId::from("AUDUSD.SIM"))
913            .quantity(Quantity::from(100))
914            .price(Price::from("1.00000"))
915            .trigger_price(Price::from("0.99000"))
916            .build();
917
918        // Same everything
919        let report_same = OrderStatusReport::new(
920            AccountId::from("SIM-001"),
921            InstrumentId::from("AUDUSD.SIM"),
922            None,
923            VenueOrderId::from("1"),
924            OrderSide::Buy,
925            OrderType::StopLimit,
926            TimeInForce::Gtc,
927            OrderStatus::Accepted,
928            Quantity::from("100"),
929            Quantity::from("0"),
930            UnixNanos::from(1_000_000_000),
931            UnixNanos::from(2_000_000_000),
932            UnixNanos::from(3_000_000_000),
933            None,
934        )
935        .with_price(Price::from("1.00000"))
936        .with_trigger_price(Price::from("0.99000"));
937
938        assert!(!report_same.is_order_updated(&order));
939
940        // Different limit price
941        let report_diff_price = OrderStatusReport::new(
942            AccountId::from("SIM-001"),
943            InstrumentId::from("AUDUSD.SIM"),
944            None,
945            VenueOrderId::from("1"),
946            OrderSide::Buy,
947            OrderType::StopLimit,
948            TimeInForce::Gtc,
949            OrderStatus::Accepted,
950            Quantity::from("100"),
951            Quantity::from("0"),
952            UnixNanos::from(1_000_000_000),
953            UnixNanos::from(2_000_000_000),
954            UnixNanos::from(3_000_000_000),
955            None,
956        )
957        .with_price(Price::from("1.00100")) // Different
958        .with_trigger_price(Price::from("0.99000"));
959
960        assert!(report_diff_price.is_order_updated(&order));
961
962        // Different trigger price
963        let report_diff_trigger = OrderStatusReport::new(
964            AccountId::from("SIM-001"),
965            InstrumentId::from("AUDUSD.SIM"),
966            None,
967            VenueOrderId::from("1"),
968            OrderSide::Buy,
969            OrderType::StopLimit,
970            TimeInForce::Gtc,
971            OrderStatus::Accepted,
972            Quantity::from("100"),
973            Quantity::from("0"),
974            UnixNanos::from(1_000_000_000),
975            UnixNanos::from(2_000_000_000),
976            UnixNanos::from(3_000_000_000),
977            None,
978        )
979        .with_price(Price::from("1.00000"))
980        .with_trigger_price(Price::from("0.99100")); // Different
981
982        assert!(report_diff_trigger.is_order_updated(&order));
983    }
984}