Skip to main content

nautilus_model/identifiers/
strategy_id.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//! Represents a valid strategy ID.
17
18use std::fmt::{Debug, Display};
19
20use nautilus_core::correctness::{
21    CorrectnessResult, CorrectnessResultExt, FAILED, check_predicate_false, check_string_contains,
22    check_valid_string_ascii,
23};
24use ustr::Ustr;
25
26/// The identifier for all 'external' strategy IDs (not local to this system instance).
27const EXTERNAL_STRATEGY_ID: &str = "EXTERNAL";
28
29/// Represents a valid strategy ID.
30#[repr(C)]
31#[derive(Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord)]
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 StrategyId(Ustr);
41
42impl StrategyId {
43    /// Creates a new [`StrategyId`] instance.
44    ///
45    /// Must be correctly formatted with two valid strings either side of a hyphen.
46    /// It is expected a strategy ID is the class name of the strategy,
47    /// with an order ID tag number separated by a hyphen.
48    ///
49    /// Example: "EMACross-001".
50    ///
51    /// The reason for the numerical component of the ID is so that order and position IDs
52    /// do not collide with those from another strategy within the node instance.
53    ///
54    /// # Errors
55    ///
56    /// Returns an error if:
57    /// - `value` is not a valid ASCII string.
58    /// - `value` is not "EXTERNAL" and does not contain a hyphen '-' separator.
59    /// - Either the name or tag part (before/after the hyphen) is empty.
60    pub fn new_checked<T: AsRef<str>>(value: T) -> CorrectnessResult<Self> {
61        let value = value.as_ref();
62        check_valid_string_ascii(value, stringify!(value))?;
63        if value != EXTERNAL_STRATEGY_ID {
64            check_string_contains(value, "-", stringify!(value))?;
65
66            if let Some((name, tag)) = value.rsplit_once('-') {
67                check_predicate_false(
68                    name.is_empty(),
69                    "`value` name part (before '-') cannot be empty",
70                )?;
71                check_predicate_false(
72                    tag.is_empty(),
73                    "`value` tag part (after '-') cannot be empty",
74                )?;
75            }
76        }
77        Ok(Self(Ustr::from(value)))
78    }
79
80    /// Creates a new [`StrategyId`] instance.
81    ///
82    /// # Panics
83    ///
84    /// Panics if `value` is not a valid string.
85    pub fn new<T: AsRef<str>>(value: T) -> Self {
86        Self::new_checked(value).expect_display(FAILED)
87    }
88
89    /// Sets the inner identifier value.
90    #[cfg_attr(not(feature = "python"), allow(dead_code))]
91    pub(crate) fn set_inner(&mut self, value: &str) {
92        self.0 = Ustr::from(value);
93    }
94
95    /// Returns the inner identifier value.
96    #[must_use]
97    pub fn inner(&self) -> Ustr {
98        self.0
99    }
100
101    /// Returns the inner identifier value as a string slice.
102    #[must_use]
103    pub fn as_str(&self) -> &str {
104        self.0.as_str()
105    }
106
107    #[must_use]
108    pub fn external() -> Self {
109        Self::new(EXTERNAL_STRATEGY_ID)
110    }
111
112    #[must_use]
113    pub fn is_external(&self) -> bool {
114        self.0 == EXTERNAL_STRATEGY_ID
115    }
116
117    /// Returns the numerical tag portion of the strategy ID.
118    ///
119    /// For external strategy IDs (no separator), returns the full ID string.
120    #[must_use]
121    pub fn get_tag(&self) -> &str {
122        self.0
123            .rsplit_once('-')
124            .map_or(self.0.as_str(), |(_, tag)| tag)
125    }
126}
127
128impl Debug for StrategyId {
129    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130        write!(f, "\"{}\"", self.0)
131    }
132}
133
134impl Display for StrategyId {
135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136        write!(f, "{}", self.0)
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use nautilus_core::correctness::CorrectnessError;
143    use rstest::rstest;
144
145    use super::StrategyId;
146    use crate::identifiers::stubs::*;
147
148    #[rstest]
149    fn test_string_reprs(strategy_id_ema_cross: StrategyId) {
150        assert_eq!(strategy_id_ema_cross.as_str(), "EMACross-001");
151        assert_eq!(format!("{strategy_id_ema_cross}"), "EMACross-001");
152    }
153
154    #[rstest]
155    fn test_get_external() {
156        assert_eq!(StrategyId::external().as_str(), "EXTERNAL");
157    }
158
159    #[rstest]
160    fn test_is_external() {
161        assert!(StrategyId::external().is_external());
162    }
163
164    #[rstest]
165    fn test_get_tag(strategy_id_ema_cross: StrategyId) {
166        assert_eq!(strategy_id_ema_cross.get_tag(), "001");
167    }
168
169    #[rstest]
170    fn test_get_tag_external() {
171        assert_eq!(StrategyId::external().get_tag(), "EXTERNAL");
172    }
173
174    #[rstest]
175    #[should_panic(expected = "name part (before '-') cannot be empty")]
176    fn test_new_with_empty_name_panics() {
177        let _ = StrategyId::new("-001");
178    }
179
180    #[rstest]
181    #[should_panic(expected = "tag part (after '-') cannot be empty")]
182    fn test_new_with_empty_tag_panics() {
183        let _ = StrategyId::new("EMACross-");
184    }
185
186    #[rstest]
187    fn test_new_checked_with_empty_name_returns_error() {
188        assert!(StrategyId::new_checked("-001").is_err());
189    }
190
191    #[rstest]
192    fn test_new_checked_with_empty_tag_returns_error() {
193        assert!(StrategyId::new_checked("EMACross-").is_err());
194    }
195
196    #[rstest]
197    fn test_new_checked_with_empty_name_returns_typed_error_with_stable_display() {
198        let error = StrategyId::new_checked("-001").unwrap_err();
199
200        match error {
201            CorrectnessError::PredicateViolation { ref message } => {
202                assert_eq!(message, "`value` name part (before '-') cannot be empty");
203            }
204            other => panic!("Expected typed predicate violation, was: {other:?}"),
205        }
206
207        assert_eq!(
208            error.to_string(),
209            "`value` name part (before '-') cannot be empty"
210        );
211    }
212
213    #[rstest]
214    fn test_new_checked_with_empty_tag_returns_typed_error_with_stable_display() {
215        let error = StrategyId::new_checked("EMACross-").unwrap_err();
216
217        match error {
218            CorrectnessError::PredicateViolation { ref message } => {
219                assert_eq!(message, "`value` tag part (after '-') cannot be empty");
220            }
221            other => panic!("Expected typed predicate violation, was: {other:?}"),
222        }
223
224        assert_eq!(
225            error.to_string(),
226            "`value` tag part (after '-') cannot be empty"
227        );
228    }
229}