nautilus_model/identifiers/
symbol.rs1use std::{
19 fmt::{Debug, Display},
20 hash::Hash,
21};
22
23use nautilus_core::correctness::{
24 CorrectnessResult, CorrectnessResultExt, FAILED, check_valid_string_utf8,
25};
26use ustr::Ustr;
27
28#[repr(C)]
30#[derive(Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord)]
31#[cfg_attr(
32 feature = "python",
33 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
34)]
35#[cfg_attr(
36 feature = "python",
37 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
38)]
39pub struct Symbol(Ustr);
40
41impl Symbol {
42 pub fn new_checked<T: AsRef<str>>(value: T) -> CorrectnessResult<Self> {
52 let value = value.as_ref();
53 check_valid_string_utf8(value, stringify!(value))?;
54 Ok(Self(Ustr::from(value)))
55 }
56
57 pub fn new<T: AsRef<str>>(value: T) -> Self {
63 Self::new_checked(value).expect_display(FAILED)
64 }
65
66 #[cfg_attr(not(feature = "python"), allow(dead_code))]
68 pub(crate) fn set_inner(&mut self, value: &str) {
69 self.0 = Ustr::from(value);
70 }
71
72 #[must_use]
73 pub fn from_str_unchecked<T: AsRef<str>>(s: T) -> Self {
74 Self(Ustr::from(s.as_ref()))
75 }
76
77 #[must_use]
78 pub const fn from_ustr_unchecked(s: Ustr) -> Self {
79 Self(s)
80 }
81
82 #[must_use]
84 pub fn inner(&self) -> Ustr {
85 self.0
86 }
87
88 #[must_use]
90 pub fn as_str(&self) -> &str {
91 self.0.as_str()
92 }
93
94 #[must_use]
96 pub fn is_composite(&self) -> bool {
97 self.as_str().contains('.')
98 }
99
100 #[must_use]
107 pub fn root(&self) -> &str {
108 let symbol_str = self.as_str();
109 if let Some(index) = symbol_str.find('.') {
110 &symbol_str[..index]
111 } else {
112 symbol_str
113 }
114 }
115
116 #[must_use]
121 pub fn topic(&self) -> String {
122 let root_str = self.root();
123 if root_str == self.as_str() {
124 root_str.to_string()
125 } else {
126 format!("{root_str}*")
127 }
128 }
129}
130
131impl Debug for Symbol {
132 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133 write!(f, "\"{}\"", self.0)
134 }
135}
136
137impl Display for Symbol {
138 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139 write!(f, "{}", self.0)
140 }
141}
142
143impl From<Ustr> for Symbol {
144 fn from(value: Ustr) -> Self {
145 Self(value)
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use nautilus_core::correctness::CorrectnessError;
152 use rstest::rstest;
153
154 use crate::identifiers::{Symbol, stubs::*};
155
156 #[rstest]
157 fn test_string_reprs(symbol_eth_perp: Symbol) {
158 assert_eq!(symbol_eth_perp.as_str(), "ETH-PERP");
159 assert_eq!(format!("{symbol_eth_perp}"), "ETH-PERP");
160 }
161
162 #[rstest]
163 #[case("AUDUSD", false)]
164 #[case("AUD/USD", false)]
165 #[case("CL.FUT", true)]
166 #[case("LO.OPT", true)]
167 #[case("ES.c.0", true)]
168 fn test_symbol_is_composite(#[case] input: &str, #[case] expected: bool) {
169 let symbol = Symbol::new(input);
170 assert_eq!(symbol.is_composite(), expected);
171 }
172
173 #[rstest]
174 #[case("AUDUSD", "AUDUSD")]
175 #[case("AUD/USD", "AUD/USD")]
176 #[case("CL.FUT", "CL")]
177 #[case("LO.OPT", "LO")]
178 #[case("ES.c.0", "ES")]
179 fn test_symbol_root(#[case] input: &str, #[case] expected_root: &str) {
180 let symbol = Symbol::new(input);
181 assert_eq!(symbol.root(), expected_root);
182 }
183
184 #[rstest]
185 #[case("AUDUSD", "AUDUSD")]
186 #[case("AUD/USD", "AUD/USD")]
187 #[case("CL.FUT", "CL*")]
188 #[case("LO.OPT", "LO*")]
189 #[case("ES.c.0", "ES*")]
190 fn test_symbol_topic(#[case] input: &str, #[case] expected_topic: &str) {
191 let symbol = Symbol::new(input);
192 assert_eq!(symbol.topic(), expected_topic);
193 }
194
195 #[rstest]
196 #[case("")] #[case(" ")] fn test_symbol_with_invalid_values(#[case] input: &str) {
199 assert!(Symbol::new_checked(input).is_err());
200 }
201
202 #[rstest]
203 fn test_symbol_new_checked_returns_typed_error_with_stable_display() {
204 let error = Symbol::new_checked("").unwrap_err();
205
206 assert_eq!(
207 error,
208 CorrectnessError::EmptyString {
209 param: "value".to_string(),
210 }
211 );
212 assert_eq!(error.to_string(), "invalid string for 'value', was empty");
213 }
214
215 #[rstest]
216 #[should_panic(expected = "Condition failed: invalid string for 'value', was empty")]
217 fn test_symbol_new_with_empty_string_panics_with_display_format() {
218 let _ = Symbol::new("");
219 }
220
221 #[rstest]
222 fn test_symbol_deserialize_json_with_unicode_escapes() {
223 let symbol: Symbol = serde_json::from_str(r#""\u9f99\u867eUSDT""#).unwrap();
224 assert_eq!(symbol.as_str(), "\u{9f99}\u{867e}USDT");
225 }
226
227 #[rstest]
228 fn test_symbol_deserialize_from_owned_value_with_non_ascii() {
229 let value = serde_json::Value::String("\u{9f99}\u{867e}USDT".to_string());
230 let symbol: Symbol = serde_json::from_value(value).unwrap();
231 assert_eq!(symbol.as_str(), "\u{9f99}\u{867e}USDT");
232 }
233
234 #[rstest]
235 fn test_symbol_serialization_roundtrip_non_ascii() {
236 let symbol = Symbol::new("\u{9f99}\u{867e}USDT");
237 let json = serde_json::to_string(&symbol).unwrap();
238 assert_eq!(json, "\"\u{9f99}\u{867e}USDT\"");
239
240 let deserialized: Symbol = serde_json::from_str(&json).unwrap();
241 assert_eq!(deserialized, symbol);
242 }
243
244 #[rstest]
245 fn test_symbol_deserialize_rejects_empty_string() {
246 let result: Result<Symbol, _> = serde_json::from_str(r#""""#);
247 assert!(result.is_err());
248 }
249}