Skip to main content

nautilus_model/reports/
mass_status.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use std::fmt::Display;
17
18use indexmap::IndexMap;
19use nautilus_core::{UUID4, UnixNanos};
20use serde::{Deserialize, Serialize};
21
22use crate::{
23    identifiers::{AccountId, ClientId, InstrumentId, Venue, VenueOrderId},
24    reports::{fill::FillReport, order::OrderStatusReport, position::PositionStatusReport},
25};
26
27/// Represents an execution mass status report for an execution client - including
28/// status of all orders, trades for those orders and open positions.
29#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
30#[serde(tag = "type")]
31#[cfg_attr(
32    feature = "python",
33    pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
34)]
35#[cfg_attr(
36    feature = "python",
37    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
38)]
39pub struct ExecutionMassStatus {
40    /// The client ID for the report.
41    pub client_id: ClientId,
42    /// The account ID for the report.
43    pub account_id: AccountId,
44    /// The venue for the report.
45    pub venue: Venue,
46    /// The report ID.
47    pub report_id: UUID4,
48    /// UNIX timestamp (nanoseconds) when the object was initialized.
49    pub ts_init: UnixNanos,
50    /// Lower timestamp bound applied to historical reports, when bounded.
51    #[serde(default)]
52    lookback_start: Option<UnixNanos>,
53    /// Whether every report source required for this mass status completed.
54    #[serde(default = "default_true")]
55    reports_complete: bool,
56    /// The order status reports.
57    order_reports: IndexMap<VenueOrderId, OrderStatusReport>,
58    /// The fill reports.
59    fill_reports: IndexMap<VenueOrderId, Vec<FillReport>>,
60    /// The position status reports.
61    position_reports: IndexMap<InstrumentId, Vec<PositionStatusReport>>,
62}
63
64impl ExecutionMassStatus {
65    /// Creates a new execution mass status report.
66    #[must_use]
67    pub fn new(
68        client_id: ClientId,
69        account_id: AccountId,
70        venue: Venue,
71        ts_init: UnixNanos,
72        report_id: Option<UUID4>,
73    ) -> Self {
74        Self {
75            client_id,
76            account_id,
77            venue,
78            report_id: report_id.unwrap_or_default(),
79            ts_init,
80            lookback_start: None,
81            reports_complete: true,
82            order_reports: IndexMap::new(),
83            fill_reports: IndexMap::new(),
84            position_reports: IndexMap::new(),
85        }
86    }
87
88    /// Get a copy of the order reports map.
89    #[must_use]
90    pub fn order_reports(&self) -> IndexMap<VenueOrderId, OrderStatusReport> {
91        self.order_reports.clone()
92    }
93
94    /// Get a copy of the fill reports map.
95    #[must_use]
96    pub fn fill_reports(&self) -> IndexMap<VenueOrderId, Vec<FillReport>> {
97        self.fill_reports.clone()
98    }
99
100    /// Get a copy of the position reports map.
101    #[must_use]
102    pub fn position_reports(&self) -> IndexMap<InstrumentId, Vec<PositionStatusReport>> {
103        self.position_reports.clone()
104    }
105
106    /// Returns the lower timestamp bound applied to historical reports.
107    #[must_use]
108    pub const fn lookback_start(&self) -> Option<UnixNanos> {
109        self.lookback_start
110    }
111
112    /// Returns whether every report source required for this mass status completed.
113    #[must_use]
114    pub const fn reports_complete(&self) -> bool {
115        self.reports_complete
116    }
117
118    /// Sets the bounded historical report contract.
119    pub const fn set_report_window(
120        &mut self,
121        lookback_start: Option<UnixNanos>,
122        reports_complete: bool,
123    ) {
124        self.lookback_start = lookback_start;
125        self.reports_complete = reports_complete;
126    }
127
128    /// Add order reports to the mass status.
129    pub fn add_order_reports(&mut self, reports: Vec<OrderStatusReport>) {
130        for report in reports {
131            self.order_reports.insert(report.venue_order_id, report);
132        }
133    }
134
135    /// Add fill reports to the mass status.
136    pub fn add_fill_reports(&mut self, reports: Vec<FillReport>) {
137        for report in reports {
138            self.fill_reports
139                .entry(report.venue_order_id)
140                .or_default()
141                .push(report);
142        }
143    }
144
145    /// Add position reports to the mass status.
146    pub fn add_position_reports(&mut self, reports: Vec<PositionStatusReport>) {
147        for report in reports {
148            self.position_reports
149                .entry(report.instrument_id)
150                .or_default()
151                .push(report);
152        }
153    }
154}
155
156const fn default_true() -> bool {
157    true
158}
159
160impl Display for ExecutionMassStatus {
161    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162        write!(
163            f,
164            "ExecutionMassStatus(client_id={}, account_id={}, venue={}, order_reports={:?}, fill_reports={:?}, position_reports={:?}, report_id={}, ts_init={})",
165            self.client_id,
166            self.account_id,
167            self.venue,
168            self.order_reports,
169            self.fill_reports,
170            self.position_reports,
171            self.report_id,
172            self.ts_init,
173        )
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use nautilus_core::UnixNanos;
180    use rstest::*;
181
182    use super::*;
183    use crate::{
184        enums::{LiquiditySide, OrderSide, OrderStatus, OrderType, PositionSide, TimeInForce},
185        identifiers::{
186            AccountId, ClientId, InstrumentId, PositionId, TradeId, Venue, VenueOrderId,
187        },
188        reports::{fill::FillReport, order::OrderStatusReport, position::PositionStatusReport},
189        types::{Currency, Money, Price, Quantity},
190    };
191
192    fn test_execution_mass_status() -> ExecutionMassStatus {
193        ExecutionMassStatus::new(
194            ClientId::from("IB"),
195            AccountId::from("IB-DU123456"),
196            Venue::from("NASDAQ"),
197            UnixNanos::from(1_000_000_000),
198            None,
199        )
200    }
201
202    fn create_test_order_report() -> OrderStatusReport {
203        OrderStatusReport::new(
204            AccountId::from("IB-DU123456"),
205            InstrumentId::from("AAPL.NASDAQ"),
206            None,
207            VenueOrderId::from("1"),
208            OrderSide::Buy.into(),
209            OrderType::Limit,
210            TimeInForce::Gtc,
211            OrderStatus::Accepted,
212            Quantity::from("100"),
213            Quantity::from("0"),
214            UnixNanos::from(1_000_000_000),
215            UnixNanos::from(2_000_000_000),
216            UnixNanos::from(3_000_000_000),
217            None,
218        )
219    }
220
221    fn create_test_fill_report() -> FillReport {
222        FillReport::new(
223            AccountId::from("IB-DU123456"),
224            InstrumentId::from("AAPL.NASDAQ"),
225            VenueOrderId::from("1"),
226            TradeId::from("T-001"),
227            OrderSide::Buy,
228            Quantity::from("50"),
229            Price::from("150.00"),
230            Money::new(1.0, Currency::USD()),
231            LiquiditySide::Taker,
232            None,
233            None,
234            UnixNanos::from(1_500_000_000),
235            UnixNanos::from(2_500_000_000),
236            None,
237        )
238    }
239
240    fn create_test_position_report() -> PositionStatusReport {
241        PositionStatusReport::new(
242            AccountId::from("IB-DU123456"),
243            InstrumentId::from("AAPL.NASDAQ"),
244            PositionSide::Long,
245            Quantity::from("50"),
246            UnixNanos::from(2_000_000_000),
247            UnixNanos::from(3_000_000_000),
248            None,                            // report_id
249            Some(PositionId::from("P-001")), // venue_position_id
250            None,                            // avg_px_open
251        )
252    }
253
254    #[rstest]
255    fn test_execution_mass_status_new() {
256        let mass_status = test_execution_mass_status();
257
258        assert_eq!(mass_status.client_id, ClientId::from("IB"));
259        assert_eq!(mass_status.account_id, AccountId::from("IB-DU123456"));
260        assert_eq!(mass_status.venue, Venue::from("NASDAQ"));
261        assert_eq!(mass_status.ts_init, UnixNanos::from(1_000_000_000));
262        assert_eq!(mass_status.lookback_start(), None);
263        assert!(mass_status.reports_complete());
264        assert!(mass_status.order_reports().is_empty());
265        assert!(mass_status.fill_reports().is_empty());
266        assert!(mass_status.position_reports().is_empty());
267    }
268
269    #[rstest]
270    fn test_set_report_window() {
271        let mut mass_status = test_execution_mass_status();
272        let lookback_start = UnixNanos::from(500_000_000);
273
274        mass_status.set_report_window(Some(lookback_start), false);
275
276        assert_eq!(mass_status.lookback_start(), Some(lookback_start));
277        assert!(!mass_status.reports_complete());
278    }
279
280    #[rstest]
281    fn test_execution_mass_status_with_generated_report_id() {
282        let mass_status = ExecutionMassStatus::new(
283            ClientId::from("IB"),
284            AccountId::from("IB-DU123456"),
285            Venue::from("NASDAQ"),
286            UnixNanos::from(1_000_000_000),
287            None, // No report ID provided, should generate one
288        );
289
290        // Should have a generated UUID
291        assert_ne!(
292            mass_status.report_id.to_string(),
293            "00000000-0000-0000-0000-000000000000"
294        );
295    }
296
297    #[rstest]
298    fn test_add_order_reports() {
299        let mut mass_status = test_execution_mass_status();
300        let order_report1 = create_test_order_report();
301        let order_report2 = OrderStatusReport::new(
302            AccountId::from("IB-DU123456"),
303            InstrumentId::from("MSFT.NASDAQ"),
304            None,
305            VenueOrderId::from("2"),
306            OrderSide::Sell.into(),
307            OrderType::Market,
308            TimeInForce::Ioc,
309            OrderStatus::Filled,
310            Quantity::from("200"),
311            Quantity::from("200"),
312            UnixNanos::from(1_000_000_000),
313            UnixNanos::from(2_000_000_000),
314            UnixNanos::from(3_000_000_000),
315            None,
316        );
317
318        mass_status.add_order_reports(vec![order_report1.clone(), order_report2.clone()]);
319
320        let order_reports = mass_status.order_reports();
321        assert_eq!(order_reports.len(), 2);
322        assert_eq!(
323            order_reports.get(&VenueOrderId::from("1")),
324            Some(&order_report1)
325        );
326        assert_eq!(
327            order_reports.get(&VenueOrderId::from("2")),
328            Some(&order_report2)
329        );
330    }
331
332    #[rstest]
333    fn test_add_fill_reports() {
334        let mut mass_status = test_execution_mass_status();
335        let fill_report1 = create_test_fill_report();
336        let fill_report2 = FillReport::new(
337            AccountId::from("IB-DU123456"),
338            InstrumentId::from("AAPL.NASDAQ"),
339            VenueOrderId::from("1"), // Same venue order ID
340            TradeId::from("T-002"),
341            OrderSide::Buy,
342            Quantity::from("50"),
343            Price::from("151.00"),
344            Money::new(1.5, Currency::USD()),
345            LiquiditySide::Maker,
346            None,
347            None,
348            UnixNanos::from(1_600_000_000),
349            UnixNanos::from(2_600_000_000),
350            None,
351        );
352
353        mass_status.add_fill_reports(vec![fill_report1.clone(), fill_report2.clone()]);
354
355        let fill_reports = mass_status.fill_reports();
356        assert_eq!(fill_reports.len(), 1); // One entry because same venue order ID
357
358        let fills_for_order = fill_reports.get(&VenueOrderId::from("1")).unwrap();
359        assert_eq!(fills_for_order.len(), 2);
360        assert_eq!(fills_for_order[0], fill_report1);
361        assert_eq!(fills_for_order[1], fill_report2);
362    }
363
364    #[rstest]
365    fn test_add_position_reports() {
366        let mut mass_status = test_execution_mass_status();
367        let position_report1 = create_test_position_report();
368        let position_report2 = PositionStatusReport::new(
369            AccountId::from("IB-DU123456"),
370            InstrumentId::from("AAPL.NASDAQ"), // Same instrument ID
371            PositionSide::Short,
372            Quantity::from("25"),
373            UnixNanos::from(2_100_000_000),
374            UnixNanos::from(3_100_000_000),
375            None,
376            None,
377            None,
378        );
379        let position_report3 = PositionStatusReport::new(
380            AccountId::from("IB-DU123456"),
381            InstrumentId::from("MSFT.NASDAQ"), // Different instrument
382            PositionSide::Long,
383            Quantity::from("100"),
384            UnixNanos::from(2_200_000_000),
385            UnixNanos::from(3_200_000_000),
386            None,
387            None,
388            None,
389        );
390
391        mass_status.add_position_reports(vec![
392            position_report1.clone(),
393            position_report2.clone(),
394            position_report3.clone(),
395        ]);
396
397        let position_reports = mass_status.position_reports();
398        assert_eq!(position_reports.len(), 2); // Two instruments
399
400        // Check AAPL positions
401        let aapl_positions = position_reports
402            .get(&InstrumentId::from("AAPL.NASDAQ"))
403            .unwrap();
404        assert_eq!(aapl_positions.len(), 2);
405        assert_eq!(aapl_positions[0], position_report1);
406        assert_eq!(aapl_positions[1], position_report2);
407
408        // Check MSFT positions
409        let msft_positions = position_reports
410            .get(&InstrumentId::from("MSFT.NASDAQ"))
411            .unwrap();
412        assert_eq!(msft_positions.len(), 1);
413        assert_eq!(msft_positions[0], position_report3);
414    }
415
416    #[rstest]
417    fn test_add_multiple_fills_for_different_orders() {
418        let mut mass_status = test_execution_mass_status();
419        let fill_report1 = create_test_fill_report(); // venue_order_id = "1"
420        let fill_report2 = FillReport::new(
421            AccountId::from("IB-DU123456"),
422            InstrumentId::from("MSFT.NASDAQ"),
423            VenueOrderId::from("2"), // Different venue order ID
424            TradeId::from("T-003"),
425            OrderSide::Sell,
426            Quantity::from("75"),
427            Price::from("300.00"),
428            Money::new(2.0, Currency::USD()),
429            LiquiditySide::Taker,
430            None,
431            None,
432            UnixNanos::from(1_700_000_000),
433            UnixNanos::from(2_700_000_000),
434            None,
435        );
436
437        mass_status.add_fill_reports(vec![fill_report1.clone(), fill_report2.clone()]);
438
439        let fill_reports = mass_status.fill_reports();
440        assert_eq!(fill_reports.len(), 2); // Two different venue order IDs
441
442        let fills_order_1 = fill_reports.get(&VenueOrderId::from("1")).unwrap();
443        assert_eq!(fills_order_1.len(), 1);
444        assert_eq!(fills_order_1[0], fill_report1);
445
446        let fills_order_2 = fill_reports.get(&VenueOrderId::from("2")).unwrap();
447        assert_eq!(fills_order_2.len(), 1);
448        assert_eq!(fills_order_2[0], fill_report2);
449    }
450
451    #[rstest]
452    fn test_comprehensive_mass_status() {
453        let mut mass_status = test_execution_mass_status();
454
455        // Add various reports
456        let order_report = create_test_order_report();
457        let fill_report = create_test_fill_report();
458        let position_report = create_test_position_report();
459
460        mass_status.add_order_reports(vec![order_report.clone()]);
461        mass_status.add_fill_reports(vec![fill_report.clone()]);
462        mass_status.add_position_reports(vec![position_report.clone()]);
463
464        // Verify all reports are present
465        assert_eq!(mass_status.order_reports().len(), 1);
466        assert_eq!(mass_status.fill_reports().len(), 1);
467        assert_eq!(mass_status.position_reports().len(), 1);
468
469        // Verify specific content
470        assert_eq!(
471            mass_status.order_reports().get(&VenueOrderId::from("1")),
472            Some(&order_report)
473        );
474        assert_eq!(
475            mass_status
476                .fill_reports()
477                .get(&VenueOrderId::from("1"))
478                .unwrap()[0],
479            fill_report
480        );
481        assert_eq!(
482            mass_status
483                .position_reports()
484                .get(&InstrumentId::from("AAPL.NASDAQ"))
485                .unwrap()[0],
486            position_report
487        );
488    }
489
490    #[rstest]
491    fn test_display() {
492        let mass_status = test_execution_mass_status();
493        let display_str = format!("{mass_status}");
494
495        assert!(display_str.contains("ExecutionMassStatus"));
496        assert!(display_str.contains("IB"));
497        assert!(display_str.contains("IB-DU123456"));
498        assert!(display_str.contains("NASDAQ"));
499    }
500
501    #[rstest]
502    fn test_clone_and_equality() {
503        let mass_status1 = test_execution_mass_status();
504        let mass_status2 = mass_status1.clone();
505
506        assert_eq!(mass_status1, mass_status2);
507    }
508
509    #[rstest]
510    fn test_serialization_roundtrip() {
511        let mut original = test_execution_mass_status();
512        original.set_report_window(Some(UnixNanos::from(500_000_000)), false);
513
514        // Test JSON serialization
515        let json = serde_json::to_string(&original).unwrap();
516        let deserialized: ExecutionMassStatus = serde_json::from_str(&json).unwrap();
517        assert_eq!(original, deserialized);
518    }
519
520    #[rstest]
521    fn test_deserialization_defaults_unbounded_report_contract() {
522        let original = test_execution_mass_status();
523        let mut value = serde_json::to_value(original).unwrap();
524        let object = value.as_object_mut().unwrap();
525        object.remove("lookback_start");
526        object.remove("reports_complete");
527
528        let deserialized: ExecutionMassStatus = serde_json::from_value(value).unwrap();
529
530        assert_eq!(deserialized.lookback_start(), None);
531        assert!(deserialized.reports_complete());
532    }
533
534    #[rstest]
535    fn test_empty_mass_status_accessors() {
536        let mass_status = test_execution_mass_status();
537
538        // All collections should be empty initially
539        assert!(mass_status.order_reports().is_empty());
540        assert!(mass_status.fill_reports().is_empty());
541        assert!(mass_status.position_reports().is_empty());
542    }
543
544    #[rstest]
545    fn test_add_empty_reports() {
546        let mut mass_status = test_execution_mass_status();
547
548        // Adding empty vectors should work without issues
549        mass_status.add_order_reports(vec![]);
550        mass_status.add_fill_reports(vec![]);
551        mass_status.add_position_reports(vec![]);
552
553        // Should still be empty
554        assert!(mass_status.order_reports().is_empty());
555        assert!(mass_status.fill_reports().is_empty());
556        assert!(mass_status.position_reports().is_empty());
557    }
558
559    #[rstest]
560    fn test_overwrite_order_reports() {
561        let mut mass_status = test_execution_mass_status();
562        let venue_order_id = VenueOrderId::from("1");
563
564        // Add first order report
565        let order_report1 = create_test_order_report();
566        mass_status.add_order_reports(vec![order_report1.clone()]);
567
568        // Add second order report with same venue order ID (should overwrite)
569        let order_report2 = OrderStatusReport::new(
570            AccountId::from("IB-DU123456"),
571            InstrumentId::from("AAPL.NASDAQ"),
572            None,
573            venue_order_id,
574            OrderSide::Sell.into(), // Different side
575            OrderType::Market,
576            TimeInForce::Ioc,
577            OrderStatus::Filled,
578            Quantity::from("200"),
579            Quantity::from("200"),
580            UnixNanos::from(1_000_000_000),
581            UnixNanos::from(2_000_000_000),
582            UnixNanos::from(3_000_000_000),
583            None,
584        );
585        mass_status.add_order_reports(vec![order_report2.clone()]);
586
587        // Should have only one report (the latest one)
588        let order_reports = mass_status.order_reports();
589        assert_eq!(order_reports.len(), 1);
590        assert_eq!(order_reports.get(&venue_order_id), Some(&order_report2));
591        assert_ne!(order_reports.get(&venue_order_id), Some(&order_report1));
592    }
593}