nautilus_model/data/
status.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
16//! An `InstrumentStatus` data type representing a change in an instrument market status.
17
18use std::{collections::HashMap, fmt::Display, hash::Hash};
19
20use derive_builder::Builder;
21use nautilus_core::{UnixNanos, serialization::Serializable};
22use serde::{Deserialize, Serialize};
23use ustr::Ustr;
24
25use super::HasTsInit;
26use crate::{enums::MarketStatusAction, identifiers::InstrumentId};
27
28/// Represents an event that indicates a change in an instrument market status.
29#[repr(C)]
30#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Builder)]
31#[serde(tag = "type")]
32#[cfg_attr(
33    feature = "python",
34    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model")
35)]
36pub struct InstrumentStatus {
37    /// The instrument ID for the status change.
38    pub instrument_id: InstrumentId,
39    /// The instrument market status action.
40    pub action: MarketStatusAction,
41    /// UNIX timestamp (nanoseconds) when the status event occurred.
42    pub ts_event: UnixNanos,
43    /// UNIX timestamp (nanoseconds) when the instance was created.
44    pub ts_init: UnixNanos,
45    /// Additional details about the cause of the status change.
46    pub reason: Option<Ustr>,
47    /// Further information about the status change (if provided).
48    pub trading_event: Option<Ustr>,
49    /// The state of trading in the instrument.
50    pub is_trading: Option<bool>,
51    /// The state of quoting in the instrument.
52    pub is_quoting: Option<bool>,
53    /// The state of short sell restrictions for the instrument (if applicable).
54    pub is_short_sell_restricted: Option<bool>,
55}
56
57impl InstrumentStatus {
58    /// Creates a new [`InstrumentStatus`] instance.
59    #[allow(clippy::too_many_arguments)]
60    pub fn new(
61        instrument_id: InstrumentId,
62        action: MarketStatusAction,
63        ts_event: UnixNanos,
64        ts_init: UnixNanos,
65        reason: Option<Ustr>,
66        trading_event: Option<Ustr>,
67        is_trading: Option<bool>,
68        is_quoting: Option<bool>,
69        is_short_sell_restricted: Option<bool>,
70    ) -> Self {
71        Self {
72            instrument_id,
73            action,
74            ts_event,
75            ts_init,
76            reason,
77            trading_event,
78            is_trading,
79            is_quoting,
80            is_short_sell_restricted,
81        }
82    }
83
84    /// Returns the metadata for the type, for use with serialization formats.
85    #[must_use]
86    pub fn get_metadata(instrument_id: &InstrumentId) -> HashMap<String, String> {
87        let mut metadata = HashMap::new();
88        metadata.insert("instrument_id".to_string(), instrument_id.to_string());
89        metadata
90    }
91}
92
93// TODO: Revisit this
94impl Display for InstrumentStatus {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        write!(
97            f,
98            "{},{},{},{}",
99            self.instrument_id, self.action, self.ts_event, self.ts_init,
100        )
101    }
102}
103
104impl Serializable for InstrumentStatus {}
105
106impl HasTsInit for InstrumentStatus {
107    fn ts_init(&self) -> UnixNanos {
108        self.ts_init
109    }
110}
111
112////////////////////////////////////////////////////////////////////////////////
113// Tests
114////////////////////////////////////////////////////////////////////////////////
115#[cfg(test)]
116mod tests {
117    use std::{
118        collections::hash_map::DefaultHasher,
119        hash::{Hash, Hasher},
120    };
121
122    use rstest::rstest;
123    use ustr::Ustr;
124
125    use super::*;
126    use crate::data::stubs::stub_instrument_status;
127
128    fn create_test_instrument_status() -> InstrumentStatus {
129        InstrumentStatus::new(
130            InstrumentId::from("EURUSD.SIM"),
131            MarketStatusAction::Trading,
132            UnixNanos::from(1_000_000_000),
133            UnixNanos::from(2_000_000_000),
134            Some(Ustr::from("Normal trading")),
135            Some(Ustr::from("MARKET_OPEN")),
136            Some(true),
137            Some(true),
138            Some(false),
139        )
140    }
141
142    fn create_test_instrument_status_minimal() -> InstrumentStatus {
143        InstrumentStatus::new(
144            InstrumentId::from("GBPUSD.SIM"),
145            MarketStatusAction::PreOpen,
146            UnixNanos::from(500_000_000),
147            UnixNanos::from(1_000_000_000),
148            None,
149            None,
150            None,
151            None,
152            None,
153        )
154    }
155
156    #[rstest]
157    fn test_instrument_status_new() {
158        let status = create_test_instrument_status();
159
160        assert_eq!(status.instrument_id, InstrumentId::from("EURUSD.SIM"));
161        assert_eq!(status.action, MarketStatusAction::Trading);
162        assert_eq!(status.ts_event, UnixNanos::from(1_000_000_000));
163        assert_eq!(status.ts_init, UnixNanos::from(2_000_000_000));
164        assert_eq!(status.reason, Some(Ustr::from("Normal trading")));
165        assert_eq!(status.trading_event, Some(Ustr::from("MARKET_OPEN")));
166        assert_eq!(status.is_trading, Some(true));
167        assert_eq!(status.is_quoting, Some(true));
168        assert_eq!(status.is_short_sell_restricted, Some(false));
169    }
170
171    #[rstest]
172    fn test_instrument_status_new_minimal() {
173        let status = create_test_instrument_status_minimal();
174
175        assert_eq!(status.instrument_id, InstrumentId::from("GBPUSD.SIM"));
176        assert_eq!(status.action, MarketStatusAction::PreOpen);
177        assert_eq!(status.ts_event, UnixNanos::from(500_000_000));
178        assert_eq!(status.ts_init, UnixNanos::from(1_000_000_000));
179        assert_eq!(status.reason, None);
180        assert_eq!(status.trading_event, None);
181        assert_eq!(status.is_trading, None);
182        assert_eq!(status.is_quoting, None);
183        assert_eq!(status.is_short_sell_restricted, None);
184    }
185
186    #[rstest]
187    fn test_instrument_status_builder() {
188        let status = InstrumentStatusBuilder::default()
189            .instrument_id(InstrumentId::from("BTCUSD.CRYPTO"))
190            .action(MarketStatusAction::Halt)
191            .ts_event(UnixNanos::from(3_000_000_000))
192            .ts_init(UnixNanos::from(4_000_000_000))
193            .reason(Some(Ustr::from("Technical issue")))
194            .trading_event(Some(Ustr::from("HALT_REQUESTED")))
195            .is_trading(Some(false))
196            .is_quoting(Some(false))
197            .is_short_sell_restricted(Some(true))
198            .build()
199            .unwrap();
200
201        assert_eq!(status.instrument_id, InstrumentId::from("BTCUSD.CRYPTO"));
202        assert_eq!(status.action, MarketStatusAction::Halt);
203        assert_eq!(status.ts_event, UnixNanos::from(3_000_000_000));
204        assert_eq!(status.ts_init, UnixNanos::from(4_000_000_000));
205        assert_eq!(status.reason, Some(Ustr::from("Technical issue")));
206        assert_eq!(status.trading_event, Some(Ustr::from("HALT_REQUESTED")));
207        assert_eq!(status.is_trading, Some(false));
208        assert_eq!(status.is_quoting, Some(false));
209        assert_eq!(status.is_short_sell_restricted, Some(true));
210    }
211
212    #[rstest]
213    fn test_instrument_status_builder_minimal() {
214        let status = InstrumentStatusBuilder::default()
215            .instrument_id(InstrumentId::from("AAPL.XNAS"))
216            .action(MarketStatusAction::Close)
217            .ts_event(UnixNanos::from(1_500_000_000))
218            .ts_init(UnixNanos::from(2_500_000_000))
219            .reason(None)
220            .trading_event(None)
221            .is_trading(None)
222            .is_quoting(None)
223            .is_short_sell_restricted(None)
224            .build()
225            .unwrap();
226
227        assert_eq!(status.instrument_id, InstrumentId::from("AAPL.XNAS"));
228        assert_eq!(status.action, MarketStatusAction::Close);
229        assert_eq!(status.ts_event, UnixNanos::from(1_500_000_000));
230        assert_eq!(status.ts_init, UnixNanos::from(2_500_000_000));
231        assert_eq!(status.reason, None);
232        assert_eq!(status.trading_event, None);
233        assert_eq!(status.is_trading, None);
234        assert_eq!(status.is_quoting, None);
235        assert_eq!(status.is_short_sell_restricted, None);
236    }
237
238    #[rstest]
239    #[case(MarketStatusAction::None)]
240    #[case(MarketStatusAction::PreOpen)]
241    #[case(MarketStatusAction::PreCross)]
242    #[case(MarketStatusAction::Quoting)]
243    #[case(MarketStatusAction::Cross)]
244    #[case(MarketStatusAction::Rotation)]
245    #[case(MarketStatusAction::NewPriceIndication)]
246    #[case(MarketStatusAction::Trading)]
247    #[case(MarketStatusAction::Halt)]
248    #[case(MarketStatusAction::Pause)]
249    #[case(MarketStatusAction::Suspend)]
250    #[case(MarketStatusAction::PreClose)]
251    #[case(MarketStatusAction::Close)]
252    #[case(MarketStatusAction::PostClose)]
253    #[case(MarketStatusAction::ShortSellRestrictionChange)]
254    #[case(MarketStatusAction::NotAvailableForTrading)]
255    fn test_instrument_status_with_all_actions(#[case] action: MarketStatusAction) {
256        let status = InstrumentStatus::new(
257            InstrumentId::from("TEST.SIM"),
258            action,
259            UnixNanos::from(1_000_000_000),
260            UnixNanos::from(2_000_000_000),
261            None,
262            None,
263            None,
264            None,
265            None,
266        );
267
268        assert_eq!(status.action, action);
269    }
270
271    #[rstest]
272    fn test_get_metadata() {
273        let instrument_id = InstrumentId::from("EURUSD.SIM");
274        let metadata = InstrumentStatus::get_metadata(&instrument_id);
275
276        assert_eq!(metadata.len(), 1);
277        assert_eq!(
278            metadata.get("instrument_id"),
279            Some(&"EURUSD.SIM".to_string())
280        );
281    }
282
283    #[rstest]
284    fn test_get_metadata_different_instruments() {
285        let eur_metadata = InstrumentStatus::get_metadata(&InstrumentId::from("EURUSD.SIM"));
286        let gbp_metadata = InstrumentStatus::get_metadata(&InstrumentId::from("GBPUSD.SIM"));
287
288        assert_eq!(
289            eur_metadata.get("instrument_id"),
290            Some(&"EURUSD.SIM".to_string())
291        );
292        assert_eq!(
293            gbp_metadata.get("instrument_id"),
294            Some(&"GBPUSD.SIM".to_string())
295        );
296        assert_ne!(eur_metadata, gbp_metadata);
297    }
298
299    #[rstest]
300    fn test_instrument_status_partial_eq() {
301        let status1 = create_test_instrument_status();
302        let status2 = create_test_instrument_status();
303        let status3 = create_test_instrument_status_minimal();
304
305        assert_eq!(status1, status2);
306        assert_ne!(status1, status3);
307    }
308
309    #[rstest]
310    fn test_instrument_status_partial_eq_different_fields() {
311        let status1 = create_test_instrument_status();
312        let mut status2 = create_test_instrument_status();
313        status2.action = MarketStatusAction::Halt;
314
315        let mut status3 = create_test_instrument_status();
316        status3.is_trading = Some(false);
317
318        let mut status4 = create_test_instrument_status();
319        status4.reason = Some(Ustr::from("Different reason"));
320
321        assert_ne!(status1, status2);
322        assert_ne!(status1, status3);
323        assert_ne!(status1, status4);
324    }
325
326    #[rstest]
327    fn test_instrument_status_eq_consistency() {
328        let status1 = create_test_instrument_status();
329        let status2 = create_test_instrument_status();
330
331        assert_eq!(status1, status2);
332        assert_eq!(status2, status1); // Symmetry
333        assert_eq!(status1, status1); // Reflexivity
334    }
335
336    #[rstest]
337    fn test_instrument_status_hash() {
338        let status1 = create_test_instrument_status();
339        let status2 = create_test_instrument_status();
340
341        let mut hasher1 = DefaultHasher::new();
342        let mut hasher2 = DefaultHasher::new();
343
344        status1.hash(&mut hasher1);
345        status2.hash(&mut hasher2);
346
347        assert_eq!(hasher1.finish(), hasher2.finish());
348    }
349
350    #[rstest]
351    fn test_instrument_status_hash_different_objects() {
352        let status1 = create_test_instrument_status();
353        let status2 = create_test_instrument_status_minimal();
354
355        let mut hasher1 = DefaultHasher::new();
356        let mut hasher2 = DefaultHasher::new();
357
358        status1.hash(&mut hasher1);
359        status2.hash(&mut hasher2);
360
361        assert_ne!(hasher1.finish(), hasher2.finish());
362    }
363
364    #[rstest]
365    fn test_instrument_status_clone() {
366        let status1 = create_test_instrument_status();
367        let status2 = status1;
368
369        assert_eq!(status1, status2);
370        assert_eq!(status1.instrument_id, status2.instrument_id);
371        assert_eq!(status1.action, status2.action);
372        assert_eq!(status1.ts_event, status2.ts_event);
373        assert_eq!(status1.ts_init, status2.ts_init);
374        assert_eq!(status1.reason, status2.reason);
375        assert_eq!(status1.trading_event, status2.trading_event);
376        assert_eq!(status1.is_trading, status2.is_trading);
377        assert_eq!(status1.is_quoting, status2.is_quoting);
378        assert_eq!(
379            status1.is_short_sell_restricted,
380            status2.is_short_sell_restricted
381        );
382    }
383
384    #[rstest]
385    fn test_instrument_status_debug() {
386        let status = create_test_instrument_status();
387        let debug_str = format!("{status:?}");
388
389        assert!(debug_str.contains("InstrumentStatus"));
390        assert!(debug_str.contains("EURUSD.SIM"));
391        assert!(debug_str.contains("Trading"));
392        assert!(debug_str.contains("Normal trading"));
393        assert!(debug_str.contains("MARKET_OPEN"));
394    }
395
396    #[rstest]
397    fn test_instrument_status_copy() {
398        let status1 = create_test_instrument_status();
399        let status2 = status1; // Copy, not clone
400
401        assert_eq!(status1, status2);
402        assert_eq!(status1.instrument_id, status2.instrument_id);
403        assert_eq!(status1.action, status2.action);
404    }
405
406    #[rstest]
407    fn test_instrument_status_has_ts_init() {
408        let status = create_test_instrument_status();
409        assert_eq!(status.ts_init(), UnixNanos::from(2_000_000_000));
410    }
411
412    #[rstest]
413    fn test_instrument_status_has_ts_init_different_values() {
414        let status1 = create_test_instrument_status();
415        let status2 = create_test_instrument_status_minimal();
416
417        assert_eq!(status1.ts_init(), UnixNanos::from(2_000_000_000));
418        assert_eq!(status2.ts_init(), UnixNanos::from(1_000_000_000));
419        assert_ne!(status1.ts_init(), status2.ts_init());
420    }
421
422    #[rstest]
423    fn test_instrument_status_display() {
424        let status = create_test_instrument_status();
425        let display_str = format!("{status}");
426
427        assert!(display_str.contains("EURUSD.SIM"));
428        assert!(display_str.contains("TRADING"));
429        assert!(display_str.contains("1000000000"));
430        assert!(display_str.contains("2000000000"));
431    }
432
433    #[rstest]
434    fn test_instrument_status_display_format() {
435        let status = create_test_instrument_status();
436        let expected = "EURUSD.SIM,TRADING,1000000000,2000000000";
437
438        assert_eq!(format!("{status}"), expected);
439    }
440
441    #[rstest]
442    fn test_instrument_status_display_different_actions() {
443        let halt_status = InstrumentStatus::new(
444            InstrumentId::from("TEST.SIM"),
445            MarketStatusAction::Halt,
446            UnixNanos::from(1_000_000_000),
447            UnixNanos::from(2_000_000_000),
448            None,
449            None,
450            None,
451            None,
452            None,
453        );
454
455        let display_str = format!("{halt_status}");
456        assert!(display_str.contains("HALT"));
457    }
458
459    #[rstest]
460    fn test_instrument_status_serialization() {
461        let status = create_test_instrument_status();
462
463        // Test serde JSON serialization
464        let json = serde_json::to_string(&status).unwrap();
465        let deserialized: InstrumentStatus = serde_json::from_str(&json).unwrap();
466
467        assert_eq!(status, deserialized);
468    }
469
470    #[rstest]
471    fn test_instrument_status_serialization_with_optional_fields() {
472        let status = create_test_instrument_status_minimal();
473
474        // Test serde JSON serialization with None values
475        let json = serde_json::to_string(&status).unwrap();
476        let deserialized: InstrumentStatus = serde_json::from_str(&json).unwrap();
477
478        assert_eq!(status, deserialized);
479        assert_eq!(deserialized.reason, None);
480        assert_eq!(deserialized.trading_event, None);
481        assert_eq!(deserialized.is_trading, None);
482        assert_eq!(deserialized.is_quoting, None);
483        assert_eq!(deserialized.is_short_sell_restricted, None);
484    }
485
486    #[rstest]
487    fn test_instrument_status_with_trading_flags() {
488        let status = InstrumentStatus::new(
489            InstrumentId::from("TEST.SIM"),
490            MarketStatusAction::Trading,
491            UnixNanos::from(1_000_000_000),
492            UnixNanos::from(2_000_000_000),
493            None,
494            None,
495            Some(true),
496            Some(true),
497            Some(false),
498        );
499
500        assert_eq!(status.is_trading, Some(true));
501        assert_eq!(status.is_quoting, Some(true));
502        assert_eq!(status.is_short_sell_restricted, Some(false));
503    }
504
505    #[rstest]
506    fn test_instrument_status_with_halt_flags() {
507        let status = InstrumentStatus::new(
508            InstrumentId::from("TEST.SIM"),
509            MarketStatusAction::Halt,
510            UnixNanos::from(1_000_000_000),
511            UnixNanos::from(2_000_000_000),
512            Some(Ustr::from("System maintenance")),
513            Some(Ustr::from("HALT_SYSTEM")),
514            Some(false),
515            Some(false),
516            Some(true),
517        );
518
519        assert_eq!(status.action, MarketStatusAction::Halt);
520        assert_eq!(status.is_trading, Some(false));
521        assert_eq!(status.is_quoting, Some(false));
522        assert_eq!(status.is_short_sell_restricted, Some(true));
523        assert_eq!(status.reason, Some(Ustr::from("System maintenance")));
524        assert_eq!(status.trading_event, Some(Ustr::from("HALT_SYSTEM")));
525    }
526
527    #[rstest]
528    fn test_instrument_status_with_short_sell_restriction() {
529        let status = InstrumentStatus::new(
530            InstrumentId::from("TEST.SIM"),
531            MarketStatusAction::ShortSellRestrictionChange,
532            UnixNanos::from(1_000_000_000),
533            UnixNanos::from(2_000_000_000),
534            Some(Ustr::from("Circuit breaker triggered")),
535            Some(Ustr::from("SSR_ACTIVATED")),
536            Some(true),
537            Some(true),
538            Some(true),
539        );
540
541        assert_eq!(
542            status.action,
543            MarketStatusAction::ShortSellRestrictionChange
544        );
545        assert_eq!(status.is_short_sell_restricted, Some(true));
546        assert_eq!(status.reason, Some(Ustr::from("Circuit breaker triggered")));
547        assert_eq!(status.trading_event, Some(Ustr::from("SSR_ACTIVATED")));
548    }
549
550    #[rstest]
551    fn test_instrument_status_with_mixed_optional_fields() {
552        let status = InstrumentStatus::new(
553            InstrumentId::from("TEST.SIM"),
554            MarketStatusAction::Quoting,
555            UnixNanos::from(1_000_000_000),
556            UnixNanos::from(2_000_000_000),
557            Some(Ustr::from("Pre-market")),
558            None,
559            Some(false),
560            Some(true),
561            None,
562        );
563
564        assert_eq!(status.reason, Some(Ustr::from("Pre-market")));
565        assert_eq!(status.trading_event, None);
566        assert_eq!(status.is_trading, Some(false));
567        assert_eq!(status.is_quoting, Some(true));
568        assert_eq!(status.is_short_sell_restricted, None);
569    }
570
571    #[rstest]
572    fn test_instrument_status_with_empty_reason() {
573        let status = InstrumentStatus::new(
574            InstrumentId::from("TEST.SIM"),
575            MarketStatusAction::Trading,
576            UnixNanos::from(1_000_000_000),
577            UnixNanos::from(2_000_000_000),
578            Some(Ustr::from("")),
579            None,
580            None,
581            None,
582            None,
583        );
584
585        assert_eq!(status.reason, Some(Ustr::from("")));
586    }
587
588    #[rstest]
589    fn test_instrument_status_with_long_reason() {
590        let long_reason = "This is a very long reason that explains in detail why the market status has changed and includes multiple sentences to test the handling of longer text strings.";
591        let status = InstrumentStatus::new(
592            InstrumentId::from("TEST.SIM"),
593            MarketStatusAction::Suspend,
594            UnixNanos::from(1_000_000_000),
595            UnixNanos::from(2_000_000_000),
596            Some(Ustr::from(long_reason)),
597            None,
598            None,
599            None,
600            None,
601        );
602
603        assert_eq!(status.reason, Some(Ustr::from(long_reason)));
604    }
605
606    #[rstest]
607    fn test_instrument_status_with_zero_timestamps() {
608        let status = InstrumentStatus::new(
609            InstrumentId::from("TEST.SIM"),
610            MarketStatusAction::None,
611            UnixNanos::from(0),
612            UnixNanos::from(0),
613            None,
614            None,
615            None,
616            None,
617            None,
618        );
619
620        assert_eq!(status.ts_event, UnixNanos::from(0));
621        assert_eq!(status.ts_init, UnixNanos::from(0));
622    }
623
624    #[rstest]
625    fn test_instrument_status_with_max_timestamps() {
626        let status = InstrumentStatus::new(
627            InstrumentId::from("TEST.SIM"),
628            MarketStatusAction::Trading,
629            UnixNanos::from(u64::MAX),
630            UnixNanos::from(u64::MAX),
631            None,
632            None,
633            None,
634            None,
635            None,
636        );
637
638        assert_eq!(status.ts_event, UnixNanos::from(u64::MAX));
639        assert_eq!(status.ts_init, UnixNanos::from(u64::MAX));
640    }
641
642    #[rstest]
643    fn test_to_string(stub_instrument_status: InstrumentStatus) {
644        assert_eq!(stub_instrument_status.to_string(), "MSFT.XNAS,TRADING,1,2");
645    }
646}