nautilus_model/identifiers/
strategy_id.rs1use 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
26pub const UNASSIGNED_ORDER_ID_TAG: &str = "None";
28
29const EXTERNAL_STRATEGY_ID: &str = "EXTERNAL";
31
32#[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 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 pub fn new<T: AsRef<str>>(value: T) -> Self {
89 Self::new_checked(value).expect_display(FAILED)
90 }
91
92 #[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 #[must_use]
100 pub fn inner(&self) -> Ustr {
101 self.0
102 }
103
104 #[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 #[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#[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
149pub 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 #[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}