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