Skip to main content

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