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
26const EXTERNAL_STRATEGY_ID: &str = "EXTERNAL";
28
29#[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#[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 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 pub fn new<T: AsRef<str>>(value: T) -> Self {
92 Self::new_checked(value).expect_display(FAILED)
93 }
94
95 #[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 #[must_use]
103 pub fn inner(&self) -> Ustr {
104 self.0
105 }
106
107 #[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 #[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 #[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}