ccxt_core/symbol/
parser.rs

1//! Symbol parser implementation
2//!
3//! This module provides parsing functionality for unified symbol strings,
4//! converting them into `ParsedSymbol` structures.
5//!
6//! # Symbol Format
7//!
8//! The unified symbol format follows the CCXT standard:
9//! - **Spot**: `BASE/QUOTE` (e.g., "BTC/USDT")
10//! - **Perpetual Swap**: `BASE/QUOTE:SETTLE` (e.g., "BTC/USDT:USDT")
11//! - **Futures**: `BASE/QUOTE:SETTLE-YYMMDD` (e.g., "BTC/USDT:USDT-241231")
12//!
13//! # Example
14//!
15//! ```rust
16//! use ccxt_core::symbol::{SymbolParser, ParsedSymbol};
17//!
18//! // Parse a spot symbol
19//! let spot = SymbolParser::parse("BTC/USDT").unwrap();
20//! assert!(spot.is_spot());
21//!
22//! // Parse a swap symbol
23//! let swap = SymbolParser::parse("BTC/USDT:USDT").unwrap();
24//! assert!(swap.is_swap());
25//!
26//! // Parse a futures symbol
27//! let futures = SymbolParser::parse("BTC/USDT:USDT-241231").unwrap();
28//! assert!(futures.is_futures());
29//! ```
30
31use super::error::SymbolError;
32use crate::types::symbol::{ExpiryDate, ParsedSymbol};
33use std::str::FromStr;
34
35/// Symbol parser for unified symbol strings
36///
37/// Provides methods to parse and validate unified symbol strings into
38/// `ParsedSymbol` structures.
39pub struct SymbolParser;
40
41impl SymbolParser {
42    /// Parse a unified symbol string into a `ParsedSymbol`
43    ///
44    /// # Arguments
45    ///
46    /// * `symbol` - The unified symbol string to parse
47    ///
48    /// # Returns
49    ///
50    /// Returns `Ok(ParsedSymbol)` if parsing succeeds, or `Err(SymbolError)` if the
51    /// symbol format is invalid.
52    ///
53    /// # Examples
54    ///
55    /// ```rust
56    /// use ccxt_core::symbol::SymbolParser;
57    ///
58    /// // Spot symbol
59    /// let spot = SymbolParser::parse("BTC/USDT").unwrap();
60    /// assert_eq!(spot.base, "BTC");
61    /// assert_eq!(spot.quote, "USDT");
62    /// assert!(spot.settle.is_none());
63    ///
64    /// // Swap symbol
65    /// let swap = SymbolParser::parse("ETH/USDT:USDT").unwrap();
66    /// assert_eq!(swap.settle, Some("USDT".to_string()));
67    ///
68    /// // Futures symbol
69    /// let futures = SymbolParser::parse("BTC/USDT:USDT-241231").unwrap();
70    /// assert!(futures.expiry.is_some());
71    /// ```
72    pub fn parse(symbol: &str) -> Result<ParsedSymbol, SymbolError> {
73        // Trim whitespace
74        let symbol = symbol.trim();
75
76        // Check for empty symbol
77        if symbol.is_empty() {
78            return Err(SymbolError::EmptySymbol);
79        }
80
81        // Check for multiple colons
82        let colon_count = symbol.chars().filter(|&c| c == ':').count();
83        if colon_count > 1 {
84            return Err(SymbolError::MultipleColons);
85        }
86
87        // Determine symbol type based on structure
88        if colon_count == 0 {
89            // Spot symbol: BASE/QUOTE
90            Self::parse_spot(symbol)
91        } else {
92            // Derivative symbol: BASE/QUOTE:SETTLE or BASE/QUOTE:SETTLE-YYMMDD
93            Self::parse_derivative(symbol)
94        }
95    }
96
97    /// Parse a spot symbol (BASE/QUOTE format)
98    ///
99    /// # Arguments
100    ///
101    /// * `symbol` - The spot symbol string (already trimmed)
102    fn parse_spot(symbol: &str) -> Result<ParsedSymbol, SymbolError> {
103        // Validate no date suffix (should not contain hyphen followed by 6 digits)
104        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        // Split by forward slash
111        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        // Validate currency codes
123        Self::validate_currency(base)?;
124        Self::validate_currency(quote)?;
125
126        Ok(ParsedSymbol::spot(base.to_string(), quote.to_string()))
127    }
128
129    /// Parse a derivative symbol (swap or futures)
130    ///
131    /// # Arguments
132    ///
133    /// * `symbol` - The derivative symbol string (already trimmed)
134    fn parse_derivative(symbol: &str) -> Result<ParsedSymbol, SymbolError> {
135        // Split by colon to separate BASE/QUOTE from SETTLE[-YYMMDD]
136        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        // Parse BASE/QUOTE
148        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        // Validate base and quote
160        Self::validate_currency(base)?;
161        Self::validate_currency(quote)?;
162
163        // Check if settle part contains date suffix
164        if let Some(hyphen_pos) = settle_part.rfind('-') {
165            let potential_date = &settle_part[hyphen_pos + 1..];
166
167            // Check if it looks like a date (6 digits)
168            if potential_date.len() == 6 && potential_date.chars().all(|c| c.is_ascii_digit()) {
169                // Futures symbol: BASE/QUOTE:SETTLE-YYMMDD
170                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        // Swap symbol: BASE/QUOTE:SETTLE (no date suffix)
187        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    /// Validate a symbol string without full parsing
197    ///
198    /// # Arguments
199    ///
200    /// * `symbol` - The symbol string to validate
201    ///
202    /// # Returns
203    ///
204    /// Returns `Ok(())` if the symbol is valid, or `Err(SymbolError)` if invalid.
205    pub fn validate(symbol: &str) -> Result<(), SymbolError> {
206        Self::parse(symbol).map(|_| ())
207    }
208
209    /// Check if a string has a date suffix pattern (-YYMMDD)
210    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    /// Validate a currency code
220    ///
221    /// Currency codes must be:
222    /// - Non-empty
223    /// - 1-10 characters long
224    /// - Alphanumeric (letters and digits only)
225    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
248/// Implement FromStr for ParsedSymbol to enable string parsing
249impl 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    // ========================================================================
263    // Spot Symbol Tests
264    // ========================================================================
265
266    #[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    // ========================================================================
305    // Swap Symbol Tests
306    // ========================================================================
307
308    #[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    // ========================================================================
339    // Futures Symbol Tests
340    // ========================================================================
341
342    #[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    // ========================================================================
373    // Error Cases
374    // ========================================================================
375
376    #[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"); // month 13
403        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    // ========================================================================
425    // FromStr Implementation Tests
426    // ========================================================================
427
428    #[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    // ========================================================================
436    // Validate Tests
437    // ========================================================================
438
439    #[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}