Skip to main content

dukascopy_fx/
models.rs

1//! Data models for currency pairs and exchange rates.
2
3use crate::core::instrument::{resolve_instrument_config, HasInstrumentConfig, InstrumentConfig};
4use crate::error::DukascopyError;
5use chrono::{DateTime, Utc};
6use rust_decimal::Decimal;
7use serde::{Deserialize, Serialize};
8use smol_str::SmolStr;
9use std::fmt;
10use std::str::FromStr;
11
12/// Represents a currency pair (e.g., USD/PLN).
13///
14/// # Examples
15///
16/// ```
17/// use dukascopy_fx::CurrencyPair;
18///
19/// // Using constructor
20/// let pair = CurrencyPair::new("USD", "PLN");
21///
22/// // Using FromStr
23/// let pair: CurrencyPair = "EUR/USD".parse().unwrap();
24///
25/// // Display
26/// assert_eq!(format!("{}", pair), "EUR/USD");
27/// ```
28#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
29pub struct CurrencyPair {
30    from: SmolStr,
31    to: SmolStr,
32}
33
34/// Unified request for rate queries.
35///
36/// Use [`RateRequest::Pair`] for explicit pair requests (e.g. `EUR/USD`)
37/// and [`RateRequest::Symbol`] for single-instrument requests (e.g. `AAPL`).
38#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
39pub enum RateRequest {
40    Pair(CurrencyPair),
41    Symbol(String),
42}
43
44/// Parsing strategy for request input.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
46#[serde(rename_all = "snake_case")]
47pub enum RequestParseMode {
48    /// Best-effort parsing: slash pairs first, then known FX shorthand, otherwise symbol.
49    #[default]
50    Auto,
51    /// Require pair parsing semantics.
52    PairOnly,
53    /// Require symbol parsing semantics.
54    SymbolOnly,
55}
56
57impl RateRequest {
58    /// Creates a pair request.
59    pub fn pair(from: impl Into<String>, to: impl Into<String>) -> Self {
60        Self::Pair(CurrencyPair::new(from, to))
61    }
62
63    /// Creates a symbol request with validation.
64    pub fn symbol(symbol: impl Into<String>) -> Result<Self, DukascopyError> {
65        let raw = symbol.into();
66        Self::symbol_from_trimmed(raw.trim())
67    }
68
69    /// Returns pair if this is a pair request.
70    pub fn as_pair(&self) -> Option<&CurrencyPair> {
71        match self {
72            Self::Pair(pair) => Some(pair),
73            Self::Symbol(_) => None,
74        }
75    }
76
77    /// Returns symbol if this is a symbol request.
78    pub fn as_symbol(&self) -> Option<&str> {
79        match self {
80            Self::Pair(_) => None,
81            Self::Symbol(symbol) => Some(symbol),
82        }
83    }
84
85    /// Parses a request with an explicit parse mode.
86    pub fn parse_with_mode(input: &str, mode: RequestParseMode) -> Result<Self, DukascopyError> {
87        let normalized = input.trim();
88        if normalized.is_empty() {
89            return Err(DukascopyError::InvalidRequest(
90                "Request cannot be empty".to_string(),
91            ));
92        }
93
94        match mode {
95            RequestParseMode::Auto => {
96                if let Some(pair) = parse_compact_pair_slash(normalized) {
97                    return Ok(Self::Pair(pair));
98                }
99
100                if normalized.as_bytes().contains(&b'/') {
101                    return Ok(Self::Pair(parse_pair_with_slash(normalized)?));
102                }
103
104                if let Some((from, to)) = split_known_fx_pair_shorthand(normalized) {
105                    // Parsed shorthand is guaranteed to be 6 ASCII letters, so this is a safe
106                    // normalization-only construction path.
107                    return Ok(Self::Pair(CurrencyPair::new(from, to)));
108                }
109
110                Self::symbol_from_trimmed(normalized)
111            }
112            RequestParseMode::PairOnly => {
113                if let Some(pair) = parse_compact_pair_slash(normalized) {
114                    return Ok(Self::Pair(pair));
115                }
116
117                if normalized.as_bytes().contains(&b'/') {
118                    return Ok(Self::Pair(parse_pair_with_slash(normalized)?));
119                }
120
121                if let Some((from, to)) = split_ascii_pair_shorthand(normalized) {
122                    return Ok(Self::Pair(CurrencyPair::new(from, to)));
123                }
124
125                Err(DukascopyError::InvalidRequest(format!(
126                    "PairOnly parsing expected 'BASE/QUOTE' or 6-letter pair shorthand, got '{}'",
127                    normalized
128                )))
129            }
130            RequestParseMode::SymbolOnly => Self::symbol_from_trimmed(normalized),
131        }
132    }
133
134    #[inline]
135    fn symbol_from_trimmed(trimmed: &str) -> Result<Self, DukascopyError> {
136        let normalized = normalize_code_checked(trimmed.to_string()).map_err(|err| match err {
137            DukascopyError::InvalidCurrencyCode { reason, .. } => {
138                DukascopyError::InvalidCurrencyCode {
139                    code: trimmed.to_ascii_uppercase(),
140                    reason,
141                }
142            }
143            other => other,
144        })?;
145        Ok(Self::Symbol(normalized))
146    }
147}
148
149impl fmt::Display for RateRequest {
150    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151        match self {
152            Self::Pair(pair) => write!(f, "{}", pair),
153            Self::Symbol(symbol) => write!(f, "{}", symbol),
154        }
155    }
156}
157
158impl From<CurrencyPair> for RateRequest {
159    fn from(value: CurrencyPair) -> Self {
160        Self::Pair(value)
161    }
162}
163
164impl FromStr for RateRequest {
165    type Err = DukascopyError;
166
167    /// Parses a request from input string.
168    ///
169    /// Rules:
170    /// - input containing `/` is parsed as explicit pair, e.g. `EUR/USD`
171    /// - 6-letter FX shorthand (e.g. `EURUSD`, `XAUUSD`) is parsed as pair
172    /// - otherwise input is parsed as symbol, e.g. `AAPL`, `USA500IDX`
173    fn from_str(input: &str) -> Result<Self, Self::Err> {
174        Self::parse_with_mode(input, RequestParseMode::Auto)
175    }
176}
177
178#[inline]
179fn split_known_fx_pair_shorthand(input: &str) -> Option<(&str, &str)> {
180    let (from, to) = split_ascii_pair_shorthand(input)?;
181
182    if is_known_fx_code_case_insensitive(from.as_bytes())
183        && is_known_fx_code_case_insensitive(to.as_bytes())
184    {
185        Some((from, to))
186    } else {
187        None
188    }
189}
190
191#[inline]
192fn split_ascii_pair_shorthand(input: &str) -> Option<(&str, &str)> {
193    let bytes = input.as_bytes();
194    if bytes.len() != 6 {
195        return None;
196    }
197
198    if !bytes[0].is_ascii_alphabetic()
199        || !bytes[1].is_ascii_alphabetic()
200        || !bytes[2].is_ascii_alphabetic()
201        || !bytes[3].is_ascii_alphabetic()
202        || !bytes[4].is_ascii_alphabetic()
203        || !bytes[5].is_ascii_alphabetic()
204    {
205        return None;
206    }
207
208    Some((&input[0..3], &input[3..6]))
209}
210
211#[inline]
212fn is_known_fx_code_case_insensitive(code: &[u8]) -> bool {
213    let Some(value) = code3_ascii_upper(code) else {
214        return false;
215    };
216
217    matches!(
218        value,
219        CODE_JPY
220            | CODE_RUB
221            | CODE_XAU
222            | CODE_XAG
223            | CODE_XPT
224            | CODE_XPD
225            | CODE_USD
226            | CODE_EUR
227            | CODE_GBP
228            | CODE_AUD
229            | CODE_NZD
230            | CODE_CAD
231            | CODE_CHF
232            | CODE_SEK
233            | CODE_NOK
234            | CODE_DKK
235            | CODE_SGD
236            | CODE_HKD
237            | CODE_MXN
238            | CODE_ZAR
239            | CODE_TRY
240            | CODE_PLN
241            | CODE_CZK
242            | CODE_HUF
243            | CODE_CNH
244            | CODE_CNY
245            | CODE_INR
246            | CODE_THB
247            | CODE_KRW
248            | CODE_TWD
249            | CODE_BRL
250            | CODE_ILS
251    )
252}
253
254#[inline]
255fn parse_compact_pair_slash(input: &str) -> Option<CurrencyPair> {
256    let bytes = input.as_bytes();
257    if bytes.len() != 7 || bytes[3] != b'/' {
258        return None;
259    }
260
261    if !is_ascii_alphanumeric3(&bytes[0..3]) || !is_ascii_alphanumeric3(&bytes[4..7]) {
262        return None;
263    }
264
265    Some(CurrencyPair::new(&input[0..3], &input[4..7]))
266}
267
268#[inline]
269fn parse_pair_with_slash(input: &str) -> Result<CurrencyPair, DukascopyError> {
270    let Some((from_raw, to_raw)) = input.split_once('/') else {
271        return Err(DukascopyError::InvalidCurrencyCode {
272            code: input.to_string(),
273            reason: "Invalid pair format. Expected 'BASE/QUOTE'".to_string(),
274        });
275    };
276
277    if to_raw.as_bytes().contains(&b'/') {
278        return Err(DukascopyError::InvalidCurrencyCode {
279            code: input.to_string(),
280            reason: "Invalid pair format. Expected 'BASE/QUOTE'".to_string(),
281        });
282    }
283
284    CurrencyPair::try_new(from_raw.trim(), to_raw.trim())
285}
286
287#[inline]
288fn is_ascii_alphanumeric3(bytes: &[u8]) -> bool {
289    bytes[0].is_ascii_alphanumeric()
290        && bytes[1].is_ascii_alphanumeric()
291        && bytes[2].is_ascii_alphanumeric()
292}
293
294#[inline]
295fn normalize_code_checked(mut code: String) -> Result<String, DukascopyError> {
296    let len = code.len();
297    if !(2..=12).contains(&len) {
298        return Err(DukascopyError::InvalidCurrencyCode {
299            code,
300            reason: "Instrument code must be between 2 and 12 characters".to_string(),
301        });
302    }
303
304    let mut has_lowercase = false;
305    for &b in code.as_bytes() {
306        if !b.is_ascii_alphanumeric() {
307            return Err(DukascopyError::InvalidCurrencyCode {
308                code,
309                reason: "Instrument code must contain only letters or digits".to_string(),
310            });
311        }
312        has_lowercase |= b.is_ascii_lowercase();
313    }
314
315    if has_lowercase {
316        code.make_ascii_uppercase();
317    }
318
319    Ok(code)
320}
321
322#[inline]
323fn normalize_code_checked_smol(code: &str) -> Result<SmolStr, DukascopyError> {
324    let len = code.len();
325    if !(2..=12).contains(&len) {
326        return Err(DukascopyError::InvalidCurrencyCode {
327            code: code.to_string(),
328            reason: "Instrument code must be between 2 and 12 characters".to_string(),
329        });
330    }
331
332    let bytes = code.as_bytes();
333    let mut has_lowercase = false;
334    for &b in bytes {
335        if !b.is_ascii_alphanumeric() {
336            return Err(DukascopyError::InvalidCurrencyCode {
337                code: code.to_string(),
338                reason: "Instrument code must contain only letters or digits".to_string(),
339            });
340        }
341        has_lowercase |= b.is_ascii_lowercase();
342    }
343
344    if has_lowercase {
345        return Ok(SmolStr::new(code.to_ascii_uppercase()));
346    }
347
348    Ok(SmolStr::new(code))
349}
350
351#[inline]
352fn normalize_ascii_upper(code: &str) -> SmolStr {
353    if code.as_bytes().iter().any(|b| b.is_ascii_lowercase()) {
354        return SmolStr::new(code.to_ascii_uppercase());
355    }
356    SmolStr::new(code)
357}
358
359#[inline]
360const fn code3(a: u8, b: u8, c: u8) -> u32 {
361    ((a as u32) << 16) | ((b as u32) << 8) | (c as u32)
362}
363
364#[inline]
365fn code3_ascii_upper(code: &[u8]) -> Option<u32> {
366    if code.len() != 3 {
367        return None;
368    }
369
370    Some(code3(
371        code[0].to_ascii_uppercase(),
372        code[1].to_ascii_uppercase(),
373        code[2].to_ascii_uppercase(),
374    ))
375}
376
377const CODE_JPY: u32 = code3(b'J', b'P', b'Y');
378const CODE_RUB: u32 = code3(b'R', b'U', b'B');
379const CODE_XAU: u32 = code3(b'X', b'A', b'U');
380const CODE_XAG: u32 = code3(b'X', b'A', b'G');
381const CODE_XPT: u32 = code3(b'X', b'P', b'T');
382const CODE_XPD: u32 = code3(b'X', b'P', b'D');
383const CODE_USD: u32 = code3(b'U', b'S', b'D');
384const CODE_EUR: u32 = code3(b'E', b'U', b'R');
385const CODE_GBP: u32 = code3(b'G', b'B', b'P');
386const CODE_AUD: u32 = code3(b'A', b'U', b'D');
387const CODE_NZD: u32 = code3(b'N', b'Z', b'D');
388const CODE_CAD: u32 = code3(b'C', b'A', b'D');
389const CODE_CHF: u32 = code3(b'C', b'H', b'F');
390const CODE_SEK: u32 = code3(b'S', b'E', b'K');
391const CODE_NOK: u32 = code3(b'N', b'O', b'K');
392const CODE_DKK: u32 = code3(b'D', b'K', b'K');
393const CODE_SGD: u32 = code3(b'S', b'G', b'D');
394const CODE_HKD: u32 = code3(b'H', b'K', b'D');
395const CODE_MXN: u32 = code3(b'M', b'X', b'N');
396const CODE_ZAR: u32 = code3(b'Z', b'A', b'R');
397const CODE_TRY: u32 = code3(b'T', b'R', b'Y');
398const CODE_PLN: u32 = code3(b'P', b'L', b'N');
399const CODE_CZK: u32 = code3(b'C', b'Z', b'K');
400const CODE_HUF: u32 = code3(b'H', b'U', b'F');
401const CODE_CNH: u32 = code3(b'C', b'N', b'H');
402const CODE_CNY: u32 = code3(b'C', b'N', b'Y');
403const CODE_INR: u32 = code3(b'I', b'N', b'R');
404const CODE_THB: u32 = code3(b'T', b'H', b'B');
405const CODE_KRW: u32 = code3(b'K', b'R', b'W');
406const CODE_TWD: u32 = code3(b'T', b'W', b'D');
407const CODE_BRL: u32 = code3(b'B', b'R', b'L');
408const CODE_ILS: u32 = code3(b'I', b'L', b'S');
409
410impl CurrencyPair {
411    /// Creates a new currency pair.
412    ///
413    /// # Arguments
414    /// * `from` - Source currency code (e.g., "USD")
415    /// * `to` - Target currency code (e.g., "PLN")
416    ///
417    /// # Examples
418    /// ```
419    /// use dukascopy_fx::CurrencyPair;
420    /// let pair = CurrencyPair::new("EUR", "USD");
421    /// ```
422    pub fn new(from: impl Into<String>, to: impl Into<String>) -> Self {
423        let from = from.into();
424        let to = to.into();
425        Self {
426            from: normalize_ascii_upper(&from),
427            to: normalize_ascii_upper(&to),
428        }
429    }
430
431    /// Creates a currency pair with validation.
432    ///
433    /// # Arguments
434    /// * `from` - Source instrument code
435    /// * `to` - Target instrument code
436    ///
437    /// # Returns
438    /// `Ok(CurrencyPair)` if valid, `Err(InvalidCurrencyCode)` otherwise
439    ///
440    /// # Examples
441    /// ```
442    /// use dukascopy_fx::CurrencyPair;
443    ///
444    /// let valid = CurrencyPair::try_new("USD", "EUR");
445    /// assert!(valid.is_ok());
446    ///
447    /// let invalid = CurrencyPair::try_new("U", "EUR");
448    /// assert!(invalid.is_err());
449    /// ```
450    pub fn try_new(from: impl Into<String>, to: impl Into<String>) -> Result<Self, DukascopyError> {
451        let from = from.into();
452        let to = to.into();
453        let from_norm = normalize_code_checked_smol(&from)?;
454        let to_norm = normalize_code_checked_smol(&to)?;
455
456        Ok(Self {
457            from: from_norm,
458            to: to_norm,
459        })
460    }
461
462    /// Returns the source currency code.
463    #[inline]
464    pub fn from(&self) -> &str {
465        &self.from
466    }
467
468    /// Returns the target currency code.
469    #[inline]
470    pub fn to(&self) -> &str {
471        &self.to
472    }
473
474    /// Returns the pair as a combined string (e.g., "EURUSD").
475    #[inline]
476    pub fn as_symbol(&self) -> String {
477        let mut symbol = String::with_capacity(self.from.len() + self.to.len());
478        symbol.push_str(&self.from);
479        symbol.push_str(&self.to);
480        symbol
481    }
482
483    /// Returns the inverse pair (e.g., USD/EUR -> EUR/USD).
484    pub fn inverse(&self) -> Self {
485        Self {
486            from: self.to.clone(),
487            to: self.from.clone(),
488        }
489    }
490
491    // ==================== Common Forex Pairs ====================
492
493    /// EUR/USD - Euro / US Dollar
494    pub fn eur_usd() -> Self {
495        Self::new("EUR", "USD")
496    }
497
498    /// GBP/USD - British Pound / US Dollar
499    pub fn gbp_usd() -> Self {
500        Self::new("GBP", "USD")
501    }
502
503    /// USD/JPY - US Dollar / Japanese Yen
504    pub fn usd_jpy() -> Self {
505        Self::new("USD", "JPY")
506    }
507
508    /// USD/CHF - US Dollar / Swiss Franc
509    pub fn usd_chf() -> Self {
510        Self::new("USD", "CHF")
511    }
512
513    /// AUD/USD - Australian Dollar / US Dollar
514    pub fn aud_usd() -> Self {
515        Self::new("AUD", "USD")
516    }
517
518    /// USD/CAD - US Dollar / Canadian Dollar
519    pub fn usd_cad() -> Self {
520        Self::new("USD", "CAD")
521    }
522
523    /// NZD/USD - New Zealand Dollar / US Dollar
524    pub fn nzd_usd() -> Self {
525        Self::new("NZD", "USD")
526    }
527
528    /// XAU/USD - Gold / US Dollar
529    pub fn xau_usd() -> Self {
530        Self::new("XAU", "USD")
531    }
532
533    /// XAG/USD - Silver / US Dollar
534    pub fn xag_usd() -> Self {
535        Self::new("XAG", "USD")
536    }
537}
538
539impl fmt::Display for CurrencyPair {
540    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
541        write!(f, "{}/{}", self.from, self.to)
542    }
543}
544
545impl FromStr for CurrencyPair {
546    type Err = DukascopyError;
547
548    /// Parse a currency pair from string.
549    ///
550    /// Accepts formats:
551    /// - "EUR/USD" (with slash)
552    /// - "EURUSD" (6 characters, no separator; forex shorthand)
553    ///
554    /// # Examples
555    /// ```
556    /// use dukascopy_fx::CurrencyPair;
557    ///
558    /// let pair1: CurrencyPair = "EUR/USD".parse().unwrap();
559    /// let pair2: CurrencyPair = "EURUSD".parse().unwrap();
560    /// assert_eq!(pair1, pair2);
561    /// ```
562    fn from_str(s: &str) -> Result<Self, Self::Err> {
563        let s = s.trim();
564
565        if s.len() == 6 && !s.as_bytes().contains(&b'/') {
566            Self::try_new(&s[0..3], &s[3..6])
567        } else if s.as_bytes().contains(&b'/') {
568            parse_pair_with_slash(s)
569        } else {
570            Err(DukascopyError::InvalidCurrencyCode {
571                code: s.to_string(),
572                reason: "Invalid pair format. Expected 'BASE/QUOTE' or 6-char forex shorthand like 'EURUSD'".to_string(),
573            })
574        }
575    }
576}
577
578impl HasInstrumentConfig for CurrencyPair {
579    fn instrument_config(&self) -> InstrumentConfig {
580        resolve_instrument_config(&self.from, &self.to)
581    }
582}
583
584/// Represents a currency exchange rate at a specific timestamp.
585#[derive(Debug, Clone, Serialize, Deserialize)]
586pub struct CurrencyExchange {
587    /// The currency pair
588    pub pair: CurrencyPair,
589    /// The exchange rate (mid price: average of ask and bid)
590    pub rate: Decimal,
591    /// Timestamp when the rate was recorded
592    pub timestamp: DateTime<Utc>,
593    /// Ask price
594    pub ask: Decimal,
595    /// Bid price
596    pub bid: Decimal,
597    /// Ask volume
598    pub ask_volume: f32,
599    /// Bid volume
600    pub bid_volume: f32,
601}
602
603impl CurrencyExchange {
604    /// Calculate the spread (ask - bid)
605    #[inline]
606    pub fn spread(&self) -> Decimal {
607        self.ask - self.bid
608    }
609
610    /// Calculate spread in pips based on the instrument configuration
611    pub fn spread_pips(&self) -> Decimal {
612        let config = self.pair.instrument_config();
613        let multiplier = Decimal::from(10u32.pow(config.decimal_places - 1));
614        self.spread() * multiplier
615    }
616}
617
618impl fmt::Display for CurrencyExchange {
619    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
620        write!(
621            f,
622            "{} @ {} (bid: {}, ask: {}) at {}",
623            self.pair, self.rate, self.bid, self.ask, self.timestamp
624        )
625    }
626}
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631
632    mod currency_pair {
633        use super::*;
634
635        #[test]
636        fn test_new() {
637            let pair = CurrencyPair::new("usd", "pln");
638            assert_eq!(pair.from(), "USD");
639            assert_eq!(pair.to(), "PLN");
640        }
641
642        #[test]
643        fn test_try_new_valid() {
644            let pair = CurrencyPair::try_new("EUR", "USD").unwrap();
645            assert_eq!(pair.from(), "EUR");
646            assert_eq!(pair.to(), "USD");
647        }
648
649        #[test]
650        fn test_try_new_invalid_length() {
651            let result = CurrencyPair::try_new("E", "USD");
652            assert!(result.is_err());
653
654            let result = CurrencyPair::try_new("TOO_LONG_INSTRUMENT_CODE", "USD");
655            assert!(result.is_err());
656        }
657
658        #[test]
659        fn test_try_new_invalid_chars() {
660            let result = CurrencyPair::try_new("US$", "EUR");
661            assert!(result.is_err());
662        }
663
664        #[test]
665        fn test_try_new_allows_alphanumeric_instrument_codes() {
666            let pair = CurrencyPair::try_new("DE40", "USD").unwrap();
667            assert_eq!(pair.from(), "DE40");
668            assert_eq!(pair.to(), "USD");
669        }
670
671        #[test]
672        fn test_display() {
673            let pair = CurrencyPair::new("EUR", "USD");
674            assert_eq!(format!("{}", pair), "EUR/USD");
675        }
676
677        #[test]
678        fn test_as_symbol() {
679            let pair = CurrencyPair::new("EUR", "USD");
680            assert_eq!(pair.as_symbol(), "EURUSD");
681        }
682
683        #[test]
684        fn test_inverse() {
685            let pair = CurrencyPair::new("EUR", "USD");
686            let inverse = pair.inverse();
687            assert_eq!(inverse.from(), "USD");
688            assert_eq!(inverse.to(), "EUR");
689        }
690
691        #[test]
692        fn test_from_str_with_slash() {
693            let pair: CurrencyPair = "EUR/USD".parse().unwrap();
694            assert_eq!(pair.from(), "EUR");
695            assert_eq!(pair.to(), "USD");
696        }
697
698        #[test]
699        fn test_from_str_without_slash() {
700            let pair: CurrencyPair = "EURUSD".parse().unwrap();
701            assert_eq!(pair.from(), "EUR");
702            assert_eq!(pair.to(), "USD");
703        }
704
705        #[test]
706        fn test_from_str_with_whitespace() {
707            let pair: CurrencyPair = "  EUR / USD  ".parse().unwrap();
708            assert_eq!(pair.from(), "EUR");
709            assert_eq!(pair.to(), "USD");
710        }
711
712        #[test]
713        fn test_from_str_lowercase() {
714            let pair: CurrencyPair = "eur/usd".parse().unwrap();
715            assert_eq!(pair.from(), "EUR");
716            assert_eq!(pair.to(), "USD");
717        }
718
719        #[test]
720        fn test_from_str_invalid() {
721            assert!("EUR".parse::<CurrencyPair>().is_err());
722            assert!("EUR/USD/GBP".parse::<CurrencyPair>().is_err());
723            assert!("EURUSDD".parse::<CurrencyPair>().is_err());
724        }
725
726        #[test]
727        fn test_from_str_with_non_fx_codes() {
728            let pair: CurrencyPair = "DE40/USD".parse().unwrap();
729            assert_eq!(pair.from(), "DE40");
730            assert_eq!(pair.to(), "USD");
731        }
732
733        #[test]
734        fn test_common_pairs() {
735            assert_eq!(CurrencyPair::eur_usd().as_symbol(), "EURUSD");
736            assert_eq!(CurrencyPair::gbp_usd().as_symbol(), "GBPUSD");
737            assert_eq!(CurrencyPair::usd_jpy().as_symbol(), "USDJPY");
738            assert_eq!(CurrencyPair::xau_usd().as_symbol(), "XAUUSD");
739        }
740
741        #[test]
742        fn test_equality() {
743            let pair1 = CurrencyPair::new("EUR", "USD");
744            let pair2 = CurrencyPair::new("eur", "usd");
745            assert_eq!(pair1, pair2);
746        }
747
748        #[test]
749        fn test_hash() {
750            use std::collections::HashSet;
751            let mut set = HashSet::new();
752            set.insert(CurrencyPair::new("EUR", "USD"));
753            assert!(set.contains(&CurrencyPair::new("eur", "usd")));
754        }
755
756        #[test]
757        fn test_instrument_config() {
758            let standard = CurrencyPair::new("EUR", "USD");
759            assert_eq!(standard.price_divisor(), 100_000.0);
760
761            let jpy = CurrencyPair::new("USD", "JPY");
762            assert_eq!(jpy.price_divisor(), 1_000.0);
763
764            let gold = CurrencyPair::new("XAU", "USD");
765            assert_eq!(gold.price_divisor(), 1_000.0);
766        }
767    }
768
769    mod rate_request {
770        use super::*;
771
772        #[test]
773        fn test_parse_pair_request() {
774            let request: RateRequest = "EUR/USD".parse().unwrap();
775            let pair = request.as_pair().unwrap();
776            assert_eq!(pair.from(), "EUR");
777            assert_eq!(pair.to(), "USD");
778        }
779
780        #[test]
781        fn test_parse_pair_request_with_whitespace() {
782            let request: RateRequest = "  eur / usd  ".parse().unwrap();
783            let pair = request.as_pair().unwrap();
784            assert_eq!(pair.from(), "EUR");
785            assert_eq!(pair.to(), "USD");
786        }
787
788        #[test]
789        fn test_parse_symbol_request() {
790            let request: RateRequest = "aapl".parse().unwrap();
791            assert_eq!(request.as_symbol(), Some("AAPL"));
792        }
793
794        #[test]
795        fn test_parse_forex_shorthand_without_slash() {
796            let request: RateRequest = "eurusd".parse().unwrap();
797            let pair = request.as_pair().unwrap();
798            assert_eq!(pair.from(), "EUR");
799            assert_eq!(pair.to(), "USD");
800        }
801
802        #[test]
803        fn test_parse_non_fx_six_char_code_as_symbol() {
804            let request: RateRequest = "aaplus".parse().unwrap();
805            assert_eq!(request.as_symbol(), Some("AAPLUS"));
806        }
807
808        #[test]
809        fn test_parse_with_mode_pair_only() {
810            let request =
811                RateRequest::parse_with_mode("EURUSD", RequestParseMode::PairOnly).unwrap();
812            let pair = request.as_pair().unwrap();
813            assert_eq!(pair.from(), "EUR");
814            assert_eq!(pair.to(), "USD");
815        }
816
817        #[test]
818        fn test_parse_with_mode_pair_only_rejects_symbol() {
819            let err = RateRequest::parse_with_mode("AAPL", RequestParseMode::PairOnly).unwrap_err();
820            assert!(matches!(err, DukascopyError::InvalidRequest(_)));
821        }
822
823        #[test]
824        fn test_parse_with_mode_symbol_only() {
825            let request =
826                RateRequest::parse_with_mode("aapl", RequestParseMode::SymbolOnly).unwrap();
827            assert_eq!(request.as_symbol(), Some("AAPL"));
828        }
829
830        #[test]
831        fn test_parse_with_mode_symbol_only_rejects_pair_format() {
832            let err =
833                RateRequest::parse_with_mode("EUR/USD", RequestParseMode::SymbolOnly).unwrap_err();
834            assert!(matches!(
835                err,
836                DukascopyError::InvalidCurrencyCode { code, .. } if code == "EUR/USD"
837            ));
838        }
839
840        #[test]
841        fn test_symbol_constructor_validation() {
842            assert!(RateRequest::symbol("AAPL").is_ok());
843            assert!(RateRequest::symbol("X").is_err());
844            assert!(RateRequest::symbol("BAD$").is_err());
845        }
846
847        #[test]
848        fn test_pair_constructor_normalizes_codes() {
849            let request = RateRequest::pair("eur", "usd");
850            let pair = request.as_pair().unwrap();
851            assert_eq!(pair.from(), "EUR");
852            assert_eq!(pair.to(), "USD");
853        }
854
855        #[test]
856        fn test_pair_variant_as_symbol_is_none() {
857            let request = RateRequest::pair("EUR", "USD");
858            assert_eq!(request.as_symbol(), None);
859        }
860
861        #[test]
862        fn test_symbol_variant_as_pair_is_none() {
863            let request = RateRequest::symbol("AAPL").unwrap();
864            assert_eq!(request.as_pair(), None);
865        }
866
867        #[test]
868        fn test_display_for_pair_and_symbol() {
869            let pair_request = RateRequest::pair("eur", "usd");
870            let symbol_request = RateRequest::symbol("msft").unwrap();
871
872            assert_eq!(pair_request.to_string(), "EUR/USD");
873            assert_eq!(symbol_request.to_string(), "MSFT");
874        }
875
876        #[test]
877        fn test_from_currency_pair_conversion() {
878            let pair = CurrencyPair::new("GBP", "JPY");
879            let request: RateRequest = pair.clone().into();
880            assert_eq!(request.as_pair(), Some(&pair));
881        }
882
883        #[test]
884        fn test_parse_empty_request() {
885            let err = "   ".parse::<RateRequest>().unwrap_err();
886            assert!(matches!(err, DukascopyError::InvalidRequest(_)));
887        }
888
889        #[test]
890        fn test_parse_invalid_pair_request_propagates_validation_error() {
891            let err = "EUR/US$".parse::<RateRequest>().unwrap_err();
892            assert!(matches!(
893                err,
894                DukascopyError::InvalidCurrencyCode { code, .. } if code == "US$"
895            ));
896        }
897    }
898
899    mod currency_exchange {
900        use super::*;
901        use rust_decimal::Decimal;
902        use std::str::FromStr;
903
904        #[test]
905        fn test_spread() {
906            let exchange = CurrencyExchange {
907                pair: CurrencyPair::new("EUR", "USD"),
908                rate: Decimal::from_str("1.10450").unwrap(),
909                timestamp: Utc::now(),
910                ask: Decimal::from_str("1.10500").unwrap(),
911                bid: Decimal::from_str("1.10400").unwrap(),
912                ask_volume: 1.0,
913                bid_volume: 1.0,
914            };
915            assert_eq!(exchange.spread(), Decimal::from_str("0.00100").unwrap());
916        }
917
918        #[test]
919        fn test_display() {
920            let exchange = CurrencyExchange {
921                pair: CurrencyPair::new("EUR", "USD"),
922                rate: Decimal::from_str("1.10450").unwrap(),
923                timestamp: Utc::now(),
924                ask: Decimal::from_str("1.10500").unwrap(),
925                bid: Decimal::from_str("1.10400").unwrap(),
926                ask_volume: 1.0,
927                bid_volume: 1.0,
928            };
929            let display = format!("{}", exchange);
930            assert!(display.contains("EUR/USD"));
931            assert!(display.contains("1.10450"));
932        }
933    }
934}