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: {}",
115 symbol
116 )));
117 }
118
119 let base = parts[0].trim();
120 let quote = parts[1].trim();
121
122 Self::validate_currency(base)?;
124 Self::validate_currency(quote)?;
125
126 Ok(ParsedSymbol::spot(base.to_string(), quote.to_string()))
127 }
128
129 fn parse_derivative(symbol: &str) -> Result<ParsedSymbol, SymbolError> {
135 let colon_parts: Vec<&str> = symbol.split(':').collect();
137 if colon_parts.len() != 2 {
138 return Err(SymbolError::InvalidFormat(format!(
139 "Expected BASE/QUOTE:SETTLE format, got: {}",
140 symbol
141 )));
142 }
143
144 let base_quote_part = colon_parts[0].trim();
145 let settle_part = colon_parts[1].trim();
146
147 let slash_parts: Vec<&str> = base_quote_part.split('/').collect();
149 if slash_parts.len() != 2 {
150 return Err(SymbolError::InvalidFormat(format!(
151 "Expected BASE/QUOTE format before colon, got: {}",
152 base_quote_part
153 )));
154 }
155
156 let base = slash_parts[0].trim();
157 let quote = slash_parts[1].trim();
158
159 Self::validate_currency(base)?;
161 Self::validate_currency(quote)?;
162
163 if let Some(hyphen_pos) = settle_part.rfind('-') {
165 let potential_date = &settle_part[hyphen_pos + 1..];
166
167 if potential_date.len() == 6 && potential_date.chars().all(|c| c.is_ascii_digit()) {
169 let settle = &settle_part[..hyphen_pos];
171 Self::validate_currency(settle)?;
172
173 let expiry = ExpiryDate::from_str(potential_date).map_err(|e| {
174 SymbolError::InvalidDateFormat(format!("{}: {}", potential_date, e))
175 })?;
176
177 return Ok(ParsedSymbol::futures(
178 base.to_string(),
179 quote.to_string(),
180 settle.to_string(),
181 expiry,
182 ));
183 }
184 }
185
186 Self::validate_currency(settle_part)?;
188
189 Ok(ParsedSymbol::swap(
190 base.to_string(),
191 quote.to_string(),
192 settle_part.to_string(),
193 ))
194 }
195
196 pub fn validate(symbol: &str) -> Result<(), SymbolError> {
206 Self::parse(symbol).map(|_| ())
207 }
208
209 fn has_date_suffix(s: &str) -> bool {
211 if let Some(hyphen_pos) = s.rfind('-') {
212 let potential_date = &s[hyphen_pos + 1..];
213 potential_date.len() == 6 && potential_date.chars().all(|c| c.is_ascii_digit())
214 } else {
215 false
216 }
217 }
218
219 fn validate_currency(code: &str) -> Result<(), SymbolError> {
226 if code.is_empty() {
227 return Err(SymbolError::MissingComponent("currency code".to_string()));
228 }
229
230 if code.len() > 10 {
231 return Err(SymbolError::InvalidCurrency(format!(
232 "Currency code too long: {}",
233 code
234 )));
235 }
236
237 if !code.chars().all(|c| c.is_ascii_alphanumeric()) {
238 return Err(SymbolError::InvalidCurrency(format!(
239 "Currency code contains invalid characters: {}",
240 code
241 )));
242 }
243
244 Ok(())
245 }
246}
247
248impl FromStr for ParsedSymbol {
250 type Err = SymbolError;
251
252 fn from_str(s: &str) -> Result<Self, Self::Err> {
253 SymbolParser::parse(s)
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260 use crate::types::symbol::SymbolMarketType;
261
262 #[test]
267 fn test_parse_spot_basic() {
268 let symbol = SymbolParser::parse("BTC/USDT").unwrap();
269 assert_eq!(symbol.base, "BTC");
270 assert_eq!(symbol.quote, "USDT");
271 assert!(symbol.settle.is_none());
272 assert!(symbol.expiry.is_none());
273 assert_eq!(symbol.market_type(), SymbolMarketType::Spot);
274 }
275
276 #[test]
277 fn test_parse_spot_lowercase() {
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_mixed_case() {
285 let symbol = SymbolParser::parse("Btc/UsDt").unwrap();
286 assert_eq!(symbol.base, "BTC");
287 assert_eq!(symbol.quote, "USDT");
288 }
289
290 #[test]
291 fn test_parse_spot_with_whitespace() {
292 let symbol = SymbolParser::parse(" BTC/USDT ").unwrap();
293 assert_eq!(symbol.base, "BTC");
294 assert_eq!(symbol.quote, "USDT");
295 }
296
297 #[test]
298 fn test_parse_spot_numeric_currency() {
299 let symbol = SymbolParser::parse("1INCH/USDT").unwrap();
300 assert_eq!(symbol.base, "1INCH");
301 assert_eq!(symbol.quote, "USDT");
302 }
303
304 #[test]
309 fn test_parse_linear_swap() {
310 let symbol = SymbolParser::parse("BTC/USDT:USDT").unwrap();
311 assert_eq!(symbol.base, "BTC");
312 assert_eq!(symbol.quote, "USDT");
313 assert_eq!(symbol.settle, Some("USDT".to_string()));
314 assert!(symbol.expiry.is_none());
315 assert_eq!(symbol.market_type(), SymbolMarketType::Swap);
316 assert!(symbol.is_linear());
317 }
318
319 #[test]
320 fn test_parse_inverse_swap() {
321 let symbol = SymbolParser::parse("BTC/USD:BTC").unwrap();
322 assert_eq!(symbol.base, "BTC");
323 assert_eq!(symbol.quote, "USD");
324 assert_eq!(symbol.settle, Some("BTC".to_string()));
325 assert!(symbol.expiry.is_none());
326 assert_eq!(symbol.market_type(), SymbolMarketType::Swap);
327 assert!(symbol.is_inverse());
328 }
329
330 #[test]
331 fn test_parse_swap_lowercase() {
332 let symbol = SymbolParser::parse("eth/usdt:usdt").unwrap();
333 assert_eq!(symbol.base, "ETH");
334 assert_eq!(symbol.quote, "USDT");
335 assert_eq!(symbol.settle, Some("USDT".to_string()));
336 }
337
338 #[test]
343 fn test_parse_futures_basic() {
344 let symbol = SymbolParser::parse("BTC/USDT:USDT-241231").unwrap();
345 assert_eq!(symbol.base, "BTC");
346 assert_eq!(symbol.quote, "USDT");
347 assert_eq!(symbol.settle, Some("USDT".to_string()));
348 assert!(symbol.expiry.is_some());
349
350 let expiry = symbol.expiry.unwrap();
351 assert_eq!(expiry.year, 24);
352 assert_eq!(expiry.month, 12);
353 assert_eq!(expiry.day, 31);
354 assert_eq!(symbol.market_type(), SymbolMarketType::Futures);
355 }
356
357 #[test]
358 fn test_parse_futures_inverse() {
359 let symbol = SymbolParser::parse("BTC/USD:BTC-250315").unwrap();
360 assert_eq!(symbol.base, "BTC");
361 assert_eq!(symbol.quote, "USD");
362 assert_eq!(symbol.settle, Some("BTC".to_string()));
363 assert!(symbol.expiry.is_some());
364
365 let expiry = symbol.expiry.unwrap();
366 assert_eq!(expiry.year, 25);
367 assert_eq!(expiry.month, 3);
368 assert_eq!(expiry.day, 15);
369 assert!(symbol.is_inverse());
370 }
371
372 #[test]
377 fn test_parse_empty_symbol() {
378 let result = SymbolParser::parse("");
379 assert!(matches!(result, Err(SymbolError::EmptySymbol)));
380 }
381
382 #[test]
383 fn test_parse_whitespace_only() {
384 let result = SymbolParser::parse(" ");
385 assert!(matches!(result, Err(SymbolError::EmptySymbol)));
386 }
387
388 #[test]
389 fn test_parse_multiple_colons() {
390 let result = SymbolParser::parse("BTC/USDT:USDT:EXTRA");
391 assert!(matches!(result, Err(SymbolError::MultipleColons)));
392 }
393
394 #[test]
395 fn test_parse_missing_slash() {
396 let result = SymbolParser::parse("BTCUSDT");
397 assert!(matches!(result, Err(SymbolError::InvalidFormat(_))));
398 }
399
400 #[test]
401 fn test_parse_invalid_date() {
402 let result = SymbolParser::parse("BTC/USDT:USDT-241301"); assert!(matches!(result, Err(SymbolError::InvalidDateFormat(_))));
404 }
405
406 #[test]
407 fn test_parse_invalid_currency_special_chars() {
408 let result = SymbolParser::parse("BTC$/USDT");
409 assert!(matches!(result, Err(SymbolError::InvalidCurrency(_))));
410 }
411
412 #[test]
413 fn test_parse_empty_base() {
414 let result = SymbolParser::parse("/USDT");
415 assert!(matches!(result, Err(SymbolError::MissingComponent(_))));
416 }
417
418 #[test]
419 fn test_parse_empty_quote() {
420 let result = SymbolParser::parse("BTC/");
421 assert!(matches!(result, Err(SymbolError::MissingComponent(_))));
422 }
423
424 #[test]
429 fn test_from_str() {
430 let symbol: ParsedSymbol = "BTC/USDT".parse().unwrap();
431 assert_eq!(symbol.base, "BTC");
432 assert_eq!(symbol.quote, "USDT");
433 }
434
435 #[test]
440 fn test_validate_valid_symbols() {
441 assert!(SymbolParser::validate("BTC/USDT").is_ok());
442 assert!(SymbolParser::validate("ETH/USDT:USDT").is_ok());
443 assert!(SymbolParser::validate("BTC/USDT:USDT-241231").is_ok());
444 }
445
446 #[test]
447 fn test_validate_invalid_symbols() {
448 assert!(SymbolParser::validate("").is_err());
449 assert!(SymbolParser::validate("BTCUSDT").is_err());
450 assert!(SymbolParser::validate("BTC/USDT:USDT:EXTRA").is_err());
451 }
452}