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: {symbol}"
115            )));
116        }
117
118        let base = parts[0].trim();
119        let quote = parts[1].trim();
120
121        // Validate currency codes
122        Self::validate_currency(base)?;
123        Self::validate_currency(quote)?;
124
125        Ok(ParsedSymbol::spot(base, quote))
126    }
127
128    /// Parse a derivative symbol (swap or futures)
129    ///
130    /// # Arguments
131    ///
132    /// * `symbol` - The derivative symbol string (already trimmed)
133    fn parse_derivative(symbol: &str) -> Result<ParsedSymbol, SymbolError> {
134        // Split by colon to separate BASE/QUOTE from SETTLE[-YYMMDD]
135        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        // Parse BASE/QUOTE
146        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        // Validate base and quote
157        Self::validate_currency(base)?;
158        Self::validate_currency(quote)?;
159
160        // Check if settle part contains date suffix
161        if let Some(hyphen_pos) = settle_part.rfind('-') {
162            let potential_date = &settle_part[hyphen_pos + 1..];
163
164            // Check if it looks like a date (6 digits)
165            if potential_date.len() == 6 && potential_date.chars().all(|c| c.is_ascii_digit()) {
166                // Futures symbol: BASE/QUOTE:SETTLE-YYMMDD
167                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        // Swap symbol: BASE/QUOTE:SETTLE (no date suffix)
179        Self::validate_currency(settle_part)?;
180
181        Ok(ParsedSymbol::swap(base, quote, settle_part))
182    }
183
184    /// Validate a symbol string without full parsing
185    ///
186    /// # Arguments
187    ///
188    /// * `symbol` - The symbol string to validate
189    ///
190    /// # Returns
191    ///
192    /// Returns `Ok(())` if the symbol is valid, or `Err(SymbolError)` if invalid.
193    pub fn validate(symbol: &str) -> Result<(), SymbolError> {
194        Self::parse(symbol).map(|_| ())
195    }
196
197    /// Check if a string has a date suffix pattern (-YYMMDD)
198    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    /// Validate a currency code
208    ///
209    /// Currency codes must be:
210    /// - Non-empty
211    /// - 1-10 characters long
212    /// - Alphanumeric (letters and digits only)
213    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
234/// Implement FromStr for ParsedSymbol to enable string parsing
235impl 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    // ========================================================================
249    // Spot Symbol Tests
250    // ========================================================================
251
252    #[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    // ========================================================================
291    // Swap Symbol Tests
292    // ========================================================================
293
294    #[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    // ========================================================================
325    // Futures Symbol Tests
326    // ========================================================================
327
328    #[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    // ========================================================================
359    // Error Cases
360    // ========================================================================
361
362    #[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"); // month 13
389        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    // ========================================================================
411    // FromStr Implementation Tests
412    // ========================================================================
413
414    #[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    // ========================================================================
422    // Validate Tests
423    // ========================================================================
424
425    #[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}