1use 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#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
29pub struct CurrencyPair {
30 from: SmolStr,
31 to: SmolStr,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
39pub enum RateRequest {
40 Pair(CurrencyPair),
41 Symbol(String),
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
46#[serde(rename_all = "snake_case")]
47pub enum RequestParseMode {
48 #[default]
50 Auto,
51 PairOnly,
53 SymbolOnly,
55}
56
57impl RateRequest {
58 pub fn pair(from: impl Into<String>, to: impl Into<String>) -> Self {
60 Self::Pair(CurrencyPair::new(from, to))
61 }
62
63 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 pub fn as_pair(&self) -> Option<&CurrencyPair> {
71 match self {
72 Self::Pair(pair) => Some(pair),
73 Self::Symbol(_) => None,
74 }
75 }
76
77 pub fn as_symbol(&self) -> Option<&str> {
79 match self {
80 Self::Pair(_) => None,
81 Self::Symbol(symbol) => Some(symbol),
82 }
83 }
84
85 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 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 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 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 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 #[inline]
464 pub fn from(&self) -> &str {
465 &self.from
466 }
467
468 #[inline]
470 pub fn to(&self) -> &str {
471 &self.to
472 }
473
474 #[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 pub fn inverse(&self) -> Self {
485 Self {
486 from: self.to.clone(),
487 to: self.from.clone(),
488 }
489 }
490
491 pub fn eur_usd() -> Self {
495 Self::new("EUR", "USD")
496 }
497
498 pub fn gbp_usd() -> Self {
500 Self::new("GBP", "USD")
501 }
502
503 pub fn usd_jpy() -> Self {
505 Self::new("USD", "JPY")
506 }
507
508 pub fn usd_chf() -> Self {
510 Self::new("USD", "CHF")
511 }
512
513 pub fn aud_usd() -> Self {
515 Self::new("AUD", "USD")
516 }
517
518 pub fn usd_cad() -> Self {
520 Self::new("USD", "CAD")
521 }
522
523 pub fn nzd_usd() -> Self {
525 Self::new("NZD", "USD")
526 }
527
528 pub fn xau_usd() -> Self {
530 Self::new("XAU", "USD")
531 }
532
533 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
586pub struct CurrencyExchange {
587 pub pair: CurrencyPair,
589 pub rate: Decimal,
591 pub timestamp: DateTime<Utc>,
593 pub ask: Decimal,
595 pub bid: Decimal,
597 pub ask_volume: f32,
599 pub bid_volume: f32,
601}
602
603impl CurrencyExchange {
604 #[inline]
606 pub fn spread(&self) -> Decimal {
607 self.ask - self.bid
608 }
609
610 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}