ccxt_core/symbol/
parser.rs1use super::error::SymbolError;
32use crate::types::symbol::{ExpiryDate, ParsedSymbol};
33use std::str::FromStr;
34
35pub struct SymbolParser;
40
41impl SymbolParser {
42 pub fn parse(symbol: &str) -> Result<ParsedSymbol, SymbolError> {
73 let symbol = symbol.trim();
75
76 if symbol.is_empty() {
78 return Err(SymbolError::EmptySymbol);
79 }
80
81 let colon_count = symbol.chars().filter(|&c| c == ':').count();
83 if colon_count > 1 {
84 return Err(SymbolError::MultipleColons);
85 }
86
87 if colon_count == 0 {
89 Self::parse_spot(symbol)
91 } else {
92 Self::parse_derivative(symbol)
94 }
95 }
96
97 fn parse_spot(symbol: &str) -> Result<ParsedSymbol, SymbolError> {
103 if Self::has_date_suffix(symbol) {
105 return Err(SymbolError::InvalidFormat(
106 "Spot symbol should not contain date suffix".to_string(),
107 ));
108 }
109
110 let parts: Vec<&str> = symbol.split('/').collect();
112 if parts.len() != 2 {
113 return Err(SymbolError::InvalidFormat(format!(
114 "Expected BASE/QUOTE format, got: {symbol}"
115 )));
116 }
117
118 let base = parts[0].trim();
119 let quote = parts[1].trim();
120
121 Self::validate_currency(base)?;
123 Self::validate_currency(quote)?;
124
125 Ok(ParsedSymbol::spot(base, quote))
126 }
127
128 fn parse_derivative(symbol: &str) -> Result<ParsedSymbol, SymbolError> {
134 let colon_parts: Vec<&str> = symbol.split(':').collect();
136 if colon_parts.len() != 2 {
137 return Err(SymbolError::InvalidFormat(format!(
138 "Expected BASE/QUOTE:SETTLE format, got: {symbol}"
139 )));
140 }
141
142 let base_quote_part = colon_parts[0].trim();
143 let settle_part = colon_parts[1].trim();
144
145 let slash_parts: Vec<&str> = base_quote_part.split('/').collect();
147 if slash_parts.len() != 2 {
148 return Err(SymbolError::InvalidFormat(format!(
149 "Expected BASE/QUOTE format before colon, got: {base_quote_part}"
150 )));
151 }
152
153 let base = slash_parts[0].trim();
154 let quote = slash_parts[1].trim();
155
156 Self::validate_currency(base)?;
158 Self::validate_currency(quote)?;
159
160 if let Some(hyphen_pos) = settle_part.rfind('-') {
162 let potential_date = &settle_part[hyphen_pos + 1..];
163
164 if potential_date.len() == 6 && potential_date.chars().all(|c| c.is_ascii_digit()) {
166 let settle = &settle_part[..hyphen_pos];
168 Self::validate_currency(settle)?;
169
170 let expiry = ExpiryDate::from_str(potential_date).map_err(|e| {
171 SymbolError::InvalidDateFormat(format!("{potential_date}: {e}"))
172 })?;
173
174 return Ok(ParsedSymbol::futures(base, quote, settle, expiry));
175 }
176 }
177
178 Self::validate_currency(settle_part)?;
180
181 Ok(ParsedSymbol::swap(base, quote, settle_part))
182 }
183
184 pub fn validate(symbol: &str) -> Result<(), SymbolError> {
194 Self::parse(symbol).map(|_| ())
195 }
196
197 fn has_date_suffix(s: &str) -> bool {
199 if let Some(hyphen_pos) = s.rfind('-') {
200 let potential_date = &s[hyphen_pos + 1..];
201 potential_date.len() == 6 && potential_date.chars().all(|c| c.is_ascii_digit())
202 } else {
203 false
204 }
205 }
206
207 fn validate_currency(code: &str) -> Result<(), SymbolError> {
214 if code.is_empty() {
215 return Err(SymbolError::MissingComponent("currency code".to_string()));
216 }
217
218 if code.len() > 10 {
219 return Err(SymbolError::InvalidCurrency(format!(
220 "Currency code too long: {code}"
221 )));
222 }
223
224 if !code.chars().all(|c| c.is_ascii_alphanumeric()) {
225 return Err(SymbolError::InvalidCurrency(format!(
226 "Currency code contains invalid characters: {code}"
227 )));
228 }
229
230 Ok(())
231 }
232}
233
234impl FromStr for ParsedSymbol {
236 type Err = SymbolError;
237
238 fn from_str(s: &str) -> Result<Self, Self::Err> {
239 SymbolParser::parse(s)
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246 use crate::types::symbol::SymbolMarketType;
247
248 #[test]
253 fn test_parse_spot_basic() {
254 let symbol = SymbolParser::parse("BTC/USDT").unwrap();
255 assert_eq!(symbol.base, "BTC");
256 assert_eq!(symbol.quote, "USDT");
257 assert!(symbol.settle.is_none());
258 assert!(symbol.expiry.is_none());
259 assert_eq!(symbol.market_type(), SymbolMarketType::Spot);
260 }
261
262 #[test]
263 fn test_parse_spot_lowercase() {
264 let symbol = SymbolParser::parse("btc/usdt").unwrap();
265 assert_eq!(symbol.base, "BTC");
266 assert_eq!(symbol.quote, "USDT");
267 }
268
269 #[test]
270 fn test_parse_spot_mixed_case() {
271 let symbol = SymbolParser::parse("Btc/UsDt").unwrap();
272 assert_eq!(symbol.base, "BTC");
273 assert_eq!(symbol.quote, "USDT");
274 }
275
276 #[test]
277 fn test_parse_spot_with_whitespace() {
278 let symbol = SymbolParser::parse(" BTC/USDT ").unwrap();
279 assert_eq!(symbol.base, "BTC");
280 assert_eq!(symbol.quote, "USDT");
281 }
282
283 #[test]
284 fn test_parse_spot_numeric_currency() {
285 let symbol = SymbolParser::parse("1INCH/USDT").unwrap();
286 assert_eq!(symbol.base, "1INCH");
287 assert_eq!(symbol.quote, "USDT");
288 }
289
290 #[test]
295 fn test_parse_linear_swap() {
296 let symbol = SymbolParser::parse("BTC/USDT:USDT").unwrap();
297 assert_eq!(symbol.base, "BTC");
298 assert_eq!(symbol.quote, "USDT");
299 assert_eq!(symbol.settle, Some("USDT".to_string()));
300 assert!(symbol.expiry.is_none());
301 assert_eq!(symbol.market_type(), SymbolMarketType::Swap);
302 assert!(symbol.is_linear());
303 }
304
305 #[test]
306 fn test_parse_inverse_swap() {
307 let symbol = SymbolParser::parse("BTC/USD:BTC").unwrap();
308 assert_eq!(symbol.base, "BTC");
309 assert_eq!(symbol.quote, "USD");
310 assert_eq!(symbol.settle, Some("BTC".to_string()));
311 assert!(symbol.expiry.is_none());
312 assert_eq!(symbol.market_type(), SymbolMarketType::Swap);
313 assert!(symbol.is_inverse());
314 }
315
316 #[test]
317 fn test_parse_swap_lowercase() {
318 let symbol = SymbolParser::parse("eth/usdt:usdt").unwrap();
319 assert_eq!(symbol.base, "ETH");
320 assert_eq!(symbol.quote, "USDT");
321 assert_eq!(symbol.settle, Some("USDT".to_string()));
322 }
323
324 #[test]
329 fn test_parse_futures_basic() {
330 let symbol = SymbolParser::parse("BTC/USDT:USDT-241231").unwrap();
331 assert_eq!(symbol.base, "BTC");
332 assert_eq!(symbol.quote, "USDT");
333 assert_eq!(symbol.settle, Some("USDT".to_string()));
334 assert!(symbol.expiry.is_some());
335
336 let expiry = symbol.expiry.unwrap();
337 assert_eq!(expiry.year, 24);
338 assert_eq!(expiry.month, 12);
339 assert_eq!(expiry.day, 31);
340 assert_eq!(symbol.market_type(), SymbolMarketType::Futures);
341 }
342
343 #[test]
344 fn test_parse_futures_inverse() {
345 let symbol = SymbolParser::parse("BTC/USD:BTC-250315").unwrap();
346 assert_eq!(symbol.base, "BTC");
347 assert_eq!(symbol.quote, "USD");
348 assert_eq!(symbol.settle, Some("BTC".to_string()));
349 assert!(symbol.expiry.is_some());
350
351 let expiry = symbol.expiry.unwrap();
352 assert_eq!(expiry.year, 25);
353 assert_eq!(expiry.month, 3);
354 assert_eq!(expiry.day, 15);
355 assert!(symbol.is_inverse());
356 }
357
358 #[test]
363 fn test_parse_empty_symbol() {
364 let result = SymbolParser::parse("");
365 assert!(matches!(result, Err(SymbolError::EmptySymbol)));
366 }
367
368 #[test]
369 fn test_parse_whitespace_only() {
370 let result = SymbolParser::parse(" ");
371 assert!(matches!(result, Err(SymbolError::EmptySymbol)));
372 }
373
374 #[test]
375 fn test_parse_multiple_colons() {
376 let result = SymbolParser::parse("BTC/USDT:USDT:EXTRA");
377 assert!(matches!(result, Err(SymbolError::MultipleColons)));
378 }
379
380 #[test]
381 fn test_parse_missing_slash() {
382 let result = SymbolParser::parse("BTCUSDT");
383 assert!(matches!(result, Err(SymbolError::InvalidFormat(_))));
384 }
385
386 #[test]
387 fn test_parse_invalid_date() {
388 let result = SymbolParser::parse("BTC/USDT:USDT-241301"); assert!(matches!(result, Err(SymbolError::InvalidDateFormat(_))));
390 }
391
392 #[test]
393 fn test_parse_invalid_currency_special_chars() {
394 let result = SymbolParser::parse("BTC$/USDT");
395 assert!(matches!(result, Err(SymbolError::InvalidCurrency(_))));
396 }
397
398 #[test]
399 fn test_parse_empty_base() {
400 let result = SymbolParser::parse("/USDT");
401 assert!(matches!(result, Err(SymbolError::MissingComponent(_))));
402 }
403
404 #[test]
405 fn test_parse_empty_quote() {
406 let result = SymbolParser::parse("BTC/");
407 assert!(matches!(result, Err(SymbolError::MissingComponent(_))));
408 }
409
410 #[test]
415 fn test_from_str() {
416 let symbol: ParsedSymbol = "BTC/USDT".parse().unwrap();
417 assert_eq!(symbol.base, "BTC");
418 assert_eq!(symbol.quote, "USDT");
419 }
420
421 #[test]
426 fn test_validate_valid_symbols() {
427 assert!(SymbolParser::validate("BTC/USDT").is_ok());
428 assert!(SymbolParser::validate("ETH/USDT:USDT").is_ok());
429 assert!(SymbolParser::validate("BTC/USDT:USDT-241231").is_ok());
430 }
431
432 #[test]
433 fn test_validate_invalid_symbols() {
434 assert!(SymbolParser::validate("").is_err());
435 assert!(SymbolParser::validate("BTCUSDT").is_err());
436 assert!(SymbolParser::validate("BTC/USDT:USDT:EXTRA").is_err());
437 }
438}