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