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/// Returns a usable order ID tag, filtering unset sentinel values.
30#[must_use]
31pub fn normalize_order_id_tag(order_id_tag: Option<&str>) -> Option<&str> {
32    order_id_tag.filter(|tag| !tag.is_empty() && *tag != "None")
33}
34
35/// Represents a valid strategy ID.
36#[repr(C)]
37#[derive(Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord)]
38#[cfg_attr(
39    feature = "python",
40    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
41)]
42#[cfg_attr(
43    feature = "python",
44    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
45)]
46pub struct StrategyId(Ustr);
47
48impl StrategyId {
49    /// Creates a new [`StrategyId`] instance.
50    ///
51    /// Must be correctly formatted with two valid strings either side of a hyphen.
52    /// It is expected a strategy ID is the class name of the strategy,
53    /// with an order ID tag separated by a hyphen.
54    ///
55    /// Example: "EMACross-001".
56    ///
57    /// The reason for the tag component of the ID is so that order and position IDs
58    /// do not collide with those from another strategy within the node instance.
59    ///
60    /// # Errors
61    ///
62    /// Returns an error if:
63    /// - `value` is not a valid ASCII string.
64    /// - `value` is not "EXTERNAL" and does not contain a hyphen '-' separator.
65    /// - Either the name or tag part (before/after the hyphen) is empty.
66    pub fn new_checked<T: AsRef<str>>(value: T) -> CorrectnessResult<Self> {
67        let value = value.as_ref();
68        check_valid_string_ascii(value, stringify!(value))?;
69        if value != EXTERNAL_STRATEGY_ID {
70            check_string_contains(value, "-", stringify!(value))?;
71
72            if let Some((name, tag)) = value.rsplit_once('-') {
73                check_predicate_false(
74                    name.is_empty(),
75                    "`value` name part (before '-') cannot be empty",
76                )?;
77                check_predicate_false(
78                    tag.is_empty(),
79                    "`value` tag part (after '-') cannot be empty",
80                )?;
81            }
82        }
83        Ok(Self(Ustr::from(value)))
84    }
85
86    /// Creates a new [`StrategyId`] instance.
87    ///
88    /// # Panics
89    ///
90    /// Panics if `value` is not a valid string.
91    pub fn new<T: AsRef<str>>(value: T) -> Self {
92        Self::new_checked(value).expect_display(FAILED)
93    }
94
95    /// Sets the inner identifier value.
96    #[cfg_attr(not(feature = "python"), allow(dead_code))]
97    pub(crate) fn set_inner(&mut self, value: &str) {
98        self.0 = Ustr::from(value);
99    }
100
101    /// Returns the inner identifier value.
102    #[must_use]
103    pub fn inner(&self) -> Ustr {
104        self.0
105    }
106
107    /// Returns the inner identifier value as a string slice.
108    #[must_use]
109    pub fn as_str(&self) -> &str {
110        self.0.as_str()
111    }
112
113    #[must_use]
114    pub fn external() -> Self {
115        Self::new(EXTERNAL_STRATEGY_ID)
116    }
117
118    #[must_use]
119    pub fn is_external(&self) -> bool {
120        self.0 == EXTERNAL_STRATEGY_ID
121    }
122
123    /// Returns the numerical tag portion of the strategy ID.
124    ///
125    /// For external strategy IDs (no separator), returns the full ID string.
126    #[must_use]
127    pub fn get_tag(&self) -> &str {
128        self.0
129            .rsplit_once('-')
130            .map_or(self.0.as_str(), |(_, tag)| tag)
131    }
132}
133
134impl Debug for StrategyId {
135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136        write!(f, "\"{}\"", self.0)
137    }
138}
139
140impl Display for StrategyId {
141    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142        write!(f, "{}", self.0)
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use nautilus_core::correctness::CorrectnessError;
149    use rstest::rstest;
150
151    use super::{StrategyId, normalize_order_id_tag};
152    use crate::identifiers::stubs::*;
153
154    #[rstest]
155    fn test_string_reprs(strategy_id_ema_cross: StrategyId) {
156        assert_eq!(strategy_id_ema_cross.as_str(), "EMACross-001");
157        assert_eq!(format!("{strategy_id_ema_cross}"), "EMACross-001");
158    }
159
160    #[rstest]
161    fn test_get_external() {
162        assert_eq!(StrategyId::external().as_str(), "EXTERNAL");
163    }
164
165    #[rstest]
166    fn test_is_external() {
167        assert!(StrategyId::external().is_external());
168    }
169
170    #[rstest]
171    fn test_get_tag(strategy_id_ema_cross: StrategyId) {
172        assert_eq!(strategy_id_ema_cross.get_tag(), "001");
173    }
174
175    #[rstest]
176    fn test_get_tag_external() {
177        assert_eq!(StrategyId::external().get_tag(), "EXTERNAL");
178    }
179
180    #[rstest]
181    #[case(None, None)]
182    #[case(Some(""), None)]
183    #[case(Some("None"), None)]
184    #[case(Some("001"), Some("001"))]
185    #[case(Some("ABC"), Some("ABC"))]
186    fn test_normalize_order_id_tag(
187        #[case] order_id_tag: Option<&str>,
188        #[case] expected: Option<&str>,
189    ) {
190        assert_eq!(normalize_order_id_tag(order_id_tag), expected);
191    }
192
193    #[rstest]
194    #[should_panic(expected = "name part (before '-') cannot be empty")]
195    fn test_new_with_empty_name_panics() {
196        let _ = StrategyId::new("-001");
197    }
198
199    #[rstest]
200    #[should_panic(expected = "tag part (after '-') cannot be empty")]
201    fn test_new_with_empty_tag_panics() {
202        let _ = StrategyId::new("EMACross-");
203    }
204
205    #[rstest]
206    fn test_new_checked_with_empty_name_returns_error() {
207        assert!(StrategyId::new_checked("-001").is_err());
208    }
209
210    #[rstest]
211    fn test_new_checked_with_empty_tag_returns_error() {
212        assert!(StrategyId::new_checked("EMACross-").is_err());
213    }
214
215    #[rstest]
216    fn test_new_checked_with_empty_name_returns_typed_error_with_stable_display() {
217        let error = StrategyId::new_checked("-001").unwrap_err();
218
219        match error {
220            CorrectnessError::PredicateViolation { ref message } => {
221                assert_eq!(message, "`value` name part (before '-') cannot be empty");
222            }
223            other => panic!("Expected typed predicate violation, was: {other:?}"),
224        }
225
226        assert_eq!(
227            error.to_string(),
228            "`value` name part (before '-') cannot be empty"
229        );
230    }
231
232    #[rstest]
233    fn test_new_checked_with_empty_tag_returns_typed_error_with_stable_display() {
234        let error = StrategyId::new_checked("EMACross-").unwrap_err();
235
236        match error {
237            CorrectnessError::PredicateViolation { ref message } => {
238                assert_eq!(message, "`value` tag part (after '-') cannot be empty");
239            }
240            other => panic!("Expected typed predicate violation, was: {other:?}"),
241        }
242
243        assert_eq!(
244            error.to_string(),
245            "`value` tag part (after '-') cannot be empty"
246        );
247    }
248
249    // Tagged enums force serde to buffer the content and replay it, which
250    // can only feed owned strings to the inner deserializer. The `&str`
251    // impl previously rejected this with "expected a borrowed string".
252    #[rstest]
253    fn test_deserialize_inside_tagged_enum() {
254        #[derive(serde::Deserialize)]
255        #[serde(tag = "type")]
256        enum Wrapper {
257            Strategy { id: StrategyId },
258        }
259
260        let json = r#"{"type":"Strategy","id":"EMACross-001"}"#;
261        let Wrapper::Strategy { id } = serde_json::from_str(json).unwrap();
262        assert_eq!(id.as_str(), "EMACross-001");
263    }
264
265    #[rstest]
266    fn test_deserialize_from_serde_json_value() {
267        let value = serde_json::json!("EMACross-001");
268        let id: StrategyId = serde_json::from_value(value).unwrap();
269        assert_eq!(id.as_str(), "EMACross-001");
270    }
271}