1use crate::core::instrument::{InstrumentConfig, InstrumentProvider, OverrideInstrumentProvider};
4use crate::core::parser::{DukascopyParser, ParsedTick, TICK_SIZE_BYTES};
5use crate::error::{DukascopyError, TransportErrorKind};
6use crate::market::{is_market_open, last_available_tick_time};
7use crate::models::{CurrencyExchange, CurrencyPair, RateRequest};
8
9use chrono::{DateTime, Datelike, Duration, Timelike, Utc};
10#[cfg(feature = "logging")]
11use log::{debug, info, warn};
12use lru::LruCache;
13use parking_lot::Mutex;
14use reqwest::Client;
15use rust_decimal::prelude::FromPrimitive;
16use rust_decimal::{Decimal, RoundingStrategy};
17use std::collections::{HashMap, HashSet};
18use std::io::Cursor;
19use std::num::NonZeroUsize;
20use std::sync::Arc;
21use std::time::Duration as StdDuration;
22use tokio::sync::{Notify, OnceCell, Semaphore};
23
24#[cfg(not(feature = "logging"))]
25macro_rules! debug {
26 ($($arg:tt)*) => {{
27 let _ = format_args!($($arg)*);
28 }};
29}
30
31#[cfg(not(feature = "logging"))]
32macro_rules! info {
33 ($($arg:tt)*) => {{
34 let _ = format_args!($($arg)*);
35 }};
36}
37
38#[cfg(not(feature = "logging"))]
39macro_rules! warn {
40 ($($arg:tt)*) => {{
41 let _ = format_args!($($arg)*);
42 }};
43}
44
45pub const DEFAULT_CACHE_SIZE: usize = 100;
51
52pub const DEFAULT_MAX_IDLE_CONNECTIONS: usize = 10;
54
55pub const DEFAULT_TIMEOUT_SECS: u64 = 30;
57
58pub const DEFAULT_MAX_RETRIES: u32 = 3;
60
61pub const DEFAULT_RETRY_BASE_DELAY_MS: u64 = 200;
63
64pub const DEFAULT_MAX_IN_FLIGHT_REQUESTS: usize = 8;
66
67pub const DEFAULT_MAX_DOWNLOAD_CONCURRENCY: usize = 8;
69
70pub const DEFAULT_MAX_DECOMPRESSION_JOBS: usize = 4;
72
73pub const DEFAULT_PARSED_TICK_CACHE_SIZE: usize = 50;
75
76pub const DEFAULT_MAX_AT_OR_BEFORE_BACKTRACK_HOURS: usize = 72;
78
79pub const DUKASCOPY_BASE_URL: &str = "https://datafeed.dukascopy.com/datafeed";
81
82pub const GLOBAL_DEFAULT_QUOTE_CURRENCY: &str = "USD";
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
87pub enum PairResolutionMode {
88 ExplicitOnly,
90 #[default]
92 ExplicitOrDefaultQuote,
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
97pub enum ConversionMode {
98 #[default]
100 DirectOnly,
101 DirectThenSynthetic,
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum ConversionPathType {
108 Direct,
109 Synthetic,
110}
111
112#[derive(Debug, Clone)]
114pub struct ResolvedExchange {
115 pub exchange: CurrencyExchange,
116 pub path_type: ConversionPathType,
117 pub legs: Vec<CurrencyExchange>,
118}
119
120fn normalize_code(code: &str) -> String {
121 let trimmed = code.trim();
122 if trimmed.as_bytes().iter().any(|b| b.is_ascii_lowercase()) {
123 trimmed.to_ascii_uppercase()
124 } else {
125 trimmed.to_string()
126 }
127}
128
129static DEFAULT_CLIENT: OnceCell<ConfiguredClient> = OnceCell::const_new();
131
132async fn get_default_client() -> &'static ConfiguredClient {
134 DEFAULT_CLIENT
135 .get_or_init(|| async {
136 DukascopyClientBuilder::new()
137 .default_quote_currency(GLOBAL_DEFAULT_QUOTE_CURRENCY)
138 .build()
139 })
140 .await
141}
142
143#[derive(Debug, Clone)]
168pub struct ClientConfig {
169 pub cache_size: usize,
171 pub parsed_tick_cache_size: usize,
173 pub timeout_secs: u64,
175 pub max_idle_connections: usize,
177 pub max_retries: u32,
179 pub retry_base_delay_ms: u64,
181 pub max_in_flight_requests: usize,
183 pub max_download_concurrency: usize,
185 pub max_decompression_jobs: usize,
187 pub max_at_or_before_backtrack_hours: usize,
189 pub respect_market_hours: bool,
191 pub default_quote_currency: Option<String>,
193 pub pair_resolution_mode: PairResolutionMode,
195 pub conversion_mode: ConversionMode,
197 pub bridge_currencies: Vec<String>,
199 pub code_aliases: HashMap<String, String>,
201 pub base_url: String,
203}
204
205impl Default for ClientConfig {
206 fn default() -> Self {
207 Self {
208 cache_size: DEFAULT_CACHE_SIZE,
209 parsed_tick_cache_size: DEFAULT_PARSED_TICK_CACHE_SIZE,
210 timeout_secs: DEFAULT_TIMEOUT_SECS,
211 max_idle_connections: DEFAULT_MAX_IDLE_CONNECTIONS,
212 max_retries: DEFAULT_MAX_RETRIES,
213 retry_base_delay_ms: DEFAULT_RETRY_BASE_DELAY_MS,
214 max_in_flight_requests: DEFAULT_MAX_IN_FLIGHT_REQUESTS,
215 max_download_concurrency: DEFAULT_MAX_DOWNLOAD_CONCURRENCY,
216 max_decompression_jobs: DEFAULT_MAX_DECOMPRESSION_JOBS,
217 max_at_or_before_backtrack_hours: DEFAULT_MAX_AT_OR_BEFORE_BACKTRACK_HOURS,
218 respect_market_hours: true,
219 default_quote_currency: None,
220 pair_resolution_mode: PairResolutionMode::default(),
221 conversion_mode: ConversionMode::default(),
222 bridge_currencies: vec!["USD".to_string(), "EUR".to_string()],
223 code_aliases: HashMap::new(),
224 base_url: DUKASCOPY_BASE_URL.to_string(),
225 }
226 }
227}
228
229#[derive(Debug, Clone, Default)]
248pub struct DukascopyClientBuilder {
249 config: ClientConfig,
250 instrument_provider: Option<OverrideInstrumentProvider>,
251}
252
253impl DukascopyClientBuilder {
254 pub fn new() -> Self {
256 Self::default()
257 }
258
259 pub fn cache_size(mut self, size: usize) -> Self {
261 self.config.cache_size = size;
262 self
263 }
264
265 pub fn parsed_tick_cache_size(mut self, size: usize) -> Self {
267 self.config.parsed_tick_cache_size = size;
268 self
269 }
270
271 pub fn timeout_secs(mut self, timeout: u64) -> Self {
273 self.config.timeout_secs = timeout;
274 self
275 }
276
277 pub fn max_idle_connections(mut self, connections: usize) -> Self {
279 self.config.max_idle_connections = connections;
280 self
281 }
282
283 pub fn max_retries(mut self, retries: u32) -> Self {
285 self.config.max_retries = retries;
286 self
287 }
288
289 pub fn retry_base_delay_ms(mut self, delay_ms: u64) -> Self {
291 self.config.retry_base_delay_ms = delay_ms;
292 self
293 }
294
295 pub fn max_in_flight_requests(mut self, max_requests: usize) -> Self {
297 self.config.max_in_flight_requests = max_requests;
298 self
299 }
300
301 pub fn max_download_concurrency(mut self, max_download_concurrency: usize) -> Self {
303 self.config.max_download_concurrency = max_download_concurrency;
304 self
305 }
306
307 pub fn max_decompression_jobs(mut self, max_decompression_jobs: usize) -> Self {
309 self.config.max_decompression_jobs = max_decompression_jobs;
310 self
311 }
312
313 pub fn max_at_or_before_backtrack_hours(mut self, hours: usize) -> Self {
317 self.config.max_at_or_before_backtrack_hours = hours;
318 self
319 }
320
321 pub fn respect_market_hours(mut self, enabled: bool) -> Self {
323 self.config.respect_market_hours = enabled;
324 self
325 }
326
327 pub fn default_quote_currency(mut self, quote: &str) -> Self {
329 self.config.default_quote_currency = Some(normalize_code(quote));
330 self
331 }
332
333 pub fn clear_default_quote_currency(mut self) -> Self {
335 self.config.default_quote_currency = None;
336 self
337 }
338
339 pub fn pair_resolution_mode(mut self, mode: PairResolutionMode) -> Self {
341 self.config.pair_resolution_mode = mode;
342 self
343 }
344
345 pub fn conversion_mode(mut self, mode: ConversionMode) -> Self {
347 self.config.conversion_mode = mode;
348 self
349 }
350
351 pub fn bridge_currencies(mut self, currencies: &[&str]) -> Self {
353 let mut bridges = Vec::with_capacity(currencies.len());
354 for currency in currencies {
355 let normalized = normalize_code(currency);
356 if normalized.is_empty() || bridges.contains(&normalized) {
357 continue;
358 }
359 bridges.push(normalized);
360 }
361 if !bridges.is_empty() {
362 self.config.bridge_currencies = bridges;
363 }
364 self
365 }
366
367 pub fn code_alias(mut self, alias: &str, canonical: &str) -> Self {
369 let alias = normalize_code(alias);
370 let canonical = normalize_code(canonical);
371 if !alias.is_empty() && !canonical.is_empty() && alias != canonical {
372 self.config.code_aliases.insert(alias, canonical);
373 }
374 self
375 }
376
377 pub fn code_aliases(mut self, aliases: &[(&str, &str)]) -> Self {
379 for (alias, canonical) in aliases {
380 let alias = normalize_code(alias);
381 let canonical = normalize_code(canonical);
382 if alias.is_empty() || canonical.is_empty() || alias == canonical {
383 continue;
384 }
385 self.config.code_aliases.insert(alias, canonical);
386 }
387 self
388 }
389
390 pub fn base_url(mut self, url: impl Into<String>) -> Self {
392 self.config.base_url = url.into();
393 self
394 }
395
396 pub fn with_instrument_config(
398 mut self,
399 from: &str,
400 to: &str,
401 config: InstrumentConfig,
402 ) -> Self {
403 let provider = self
404 .instrument_provider
405 .get_or_insert_with(OverrideInstrumentProvider::new);
406 provider.add_override(from, to, config);
407 self
408 }
409
410 pub fn with_instrument_catalog(
412 mut self,
413 catalog: &crate::core::catalog::InstrumentCatalog,
414 ) -> Self {
415 let provider = self
416 .instrument_provider
417 .get_or_insert_with(OverrideInstrumentProvider::new);
418 for instrument in &catalog.instruments {
419 provider.add_override(
420 &instrument.base,
421 &instrument.quote,
422 InstrumentConfig::new(instrument.price_divisor, instrument.decimal_places),
423 );
424 }
425
426 for (alias, canonical) in catalog.normalized_code_aliases() {
427 self.config.code_aliases.insert(alias, canonical);
428 }
429
430 self
431 }
432
433 pub fn build(self) -> ConfiguredClient {
435 let config = self.config;
436 let cache_size = config.cache_size.max(1);
437 let parsed_tick_cache_size = config.parsed_tick_cache_size.max(1);
438 let max_in_flight_requests = config.max_in_flight_requests.max(1);
439 let max_decompression_jobs = config.max_decompression_jobs.max(1);
440 let cache_capacity = NonZeroUsize::new(cache_size).unwrap_or(NonZeroUsize::MIN);
441 let parsed_tick_cache_capacity =
442 NonZeroUsize::new(parsed_tick_cache_size).unwrap_or(NonZeroUsize::MIN);
443 let cache = Arc::new(Mutex::new(LruCache::new(cache_capacity)));
444 let parsed_tick_cache = Arc::new(Mutex::new(LruCache::new(parsed_tick_cache_capacity)));
445
446 let http_client = match Client::builder()
447 .pool_max_idle_per_host(config.max_idle_connections)
448 .tcp_nodelay(true)
449 .pool_idle_timeout(None)
450 .timeout(StdDuration::from_secs(config.timeout_secs))
451 .no_proxy()
452 .build()
453 {
454 Ok(client) => client,
455 Err(err) => {
456 warn!(
457 "Failed to create custom HTTP client config (falling back to minimal no-proxy client): {}",
458 err
459 );
460 Client::builder()
461 .no_proxy()
462 .build()
463 .unwrap_or_else(|fallback_err| {
464 panic!(
465 "Failed to construct reqwest client (primary='{}', fallback='{}')",
466 err, fallback_err
467 )
468 })
469 }
470 };
471
472 ConfiguredClient {
473 config,
474 http_client,
475 cache,
476 parsed_tick_cache,
477 request_limiter: Arc::new(Semaphore::new(max_in_flight_requests)),
478 decompress_limiter: Arc::new(Semaphore::new(max_decompression_jobs)),
479 in_flight_fetches: Arc::new(Mutex::new(HashMap::new())),
480 instrument_provider: self.instrument_provider,
481 }
482 }
483}
484
485#[derive(Debug)]
491pub struct ConfiguredClient {
492 config: ClientConfig,
493 http_client: Client,
494 cache: Arc<Mutex<LruCache<String, Arc<[u8]>>>>,
495 parsed_tick_cache: Arc<Mutex<LruCache<String, Arc<[ParsedTick]>>>>,
496 request_limiter: Arc<Semaphore>,
497 decompress_limiter: Arc<Semaphore>,
498 in_flight_fetches: Arc<Mutex<HashMap<String, Arc<InFlightFetch>>>>,
499 instrument_provider: Option<OverrideInstrumentProvider>,
500}
501
502#[derive(Debug)]
503struct InFlightFetch {
504 notify: Notify,
505 result: Mutex<Option<Result<Arc<[u8]>, DukascopyError>>>,
506}
507
508impl InFlightFetch {
509 #[inline]
510 fn new() -> Self {
511 Self {
512 notify: Notify::new(),
513 result: Mutex::new(None),
514 }
515 }
516}
517
518struct InFlightLeaderGuard {
519 key: String,
520 in_flight_fetches: Arc<Mutex<HashMap<String, Arc<InFlightFetch>>>>,
521 state: Arc<InFlightFetch>,
522 completed: bool,
523}
524
525impl InFlightLeaderGuard {
526 #[inline]
527 fn new(
528 key: String,
529 in_flight_fetches: Arc<Mutex<HashMap<String, Arc<InFlightFetch>>>>,
530 state: Arc<InFlightFetch>,
531 ) -> Self {
532 Self {
533 key,
534 in_flight_fetches,
535 state,
536 completed: false,
537 }
538 }
539
540 fn finish(
541 mut self,
542 result: Result<Arc<[u8]>, DukascopyError>,
543 ) -> Result<Arc<[u8]>, DukascopyError> {
544 *self.state.result.lock() = Some(result.clone());
545 self.in_flight_fetches.lock().remove(&self.key);
546 self.state.notify.notify_waiters();
547 self.completed = true;
548 result
549 }
550}
551
552impl Drop for InFlightLeaderGuard {
553 fn drop(&mut self) {
554 if self.completed {
555 return;
556 }
557 *self.state.result.lock() = Some(Err(DukascopyError::Unknown(
558 "In-flight fetch cancelled".to_string(),
559 )));
560 self.in_flight_fetches.lock().remove(&self.key);
561 self.state.notify.notify_waiters();
562 }
563}
564
565impl ConfiguredClient {
566 pub fn config(&self) -> &ClientConfig {
568 &self.config
569 }
570
571 pub fn default_quote_currency(&self) -> Option<&str> {
573 self.config.default_quote_currency.as_deref()
574 }
575
576 pub fn get_instrument_config(&self, from: &str, to: &str) -> InstrumentConfig {
578 let from = self.resolve_code_alias(from);
579 let to = self.resolve_code_alias(to);
580 if let Some(ref provider) = self.instrument_provider {
581 provider.get_config(&from, &to)
582 } else {
583 crate::core::instrument::resolve_instrument_config(&from, &to)
584 }
585 }
586
587 pub async fn get_tick_data(
589 &self,
590 pair: &CurrencyPair,
591 timestamp: DateTime<Utc>,
592 ) -> Result<Vec<u8>, DukascopyError> {
593 let resolved_pair = self.resolve_pair_alias(pair)?;
594 let url = self.build_url(
595 &resolved_pair.as_symbol(),
596 timestamp.year(),
597 timestamp.month(),
598 timestamp.day(),
599 timestamp.hour(),
600 );
601 Ok(self.fetch_cached(&url).await?.to_vec())
602 }
603
604 pub async fn get_exchange_rate(
606 &self,
607 pair: &CurrencyPair,
608 timestamp: DateTime<Utc>,
609 ) -> Result<CurrencyExchange, DukascopyError> {
610 let resolved_pair = self.resolve_pair_alias(pair)?;
611 DukascopyClient::validate_pair(&resolved_pair)?;
612
613 let effective_timestamp = if self.config.respect_market_hours && !is_market_open(timestamp)
614 {
615 last_available_tick_time(timestamp)
616 } else {
617 timestamp
618 };
619
620 let config = self.get_instrument_config(resolved_pair.from(), resolved_pair.to());
621 let mut query_timestamp = effective_timestamp;
622 let mut fallback_attempts = 0usize;
623 let not_found_for = |query_time: DateTime<Utc>| DukascopyError::DataNotFoundFor {
624 pair: format!("{}/{}", resolved_pair.from(), resolved_pair.to()),
625 timestamp: query_time.to_rfc3339(),
626 };
627
628 loop {
629 let hour_start = DukascopyClient::hour_start(query_timestamp)?;
630 let url = self.build_url(
631 &resolved_pair.as_symbol(),
632 hour_start.year(),
633 hour_start.month(),
634 hour_start.day(),
635 hour_start.hour(),
636 );
637
638 let data = match self.fetch_cached(&url).await {
639 Ok(data) => data,
640 Err(DukascopyError::DataNotFound) if fallback_attempts > 0 => {
641 if fallback_attempts >= self.config.max_at_or_before_backtrack_hours {
642 return Err(not_found_for(query_timestamp));
643 }
644 query_timestamp = hour_start
645 .checked_sub_signed(Duration::milliseconds(1))
646 .ok_or_else(|| not_found_for(query_timestamp))?;
647 fallback_attempts += 1;
648 continue;
649 }
650 Err(err) => return Err(err),
651 };
652 DukascopyParser::validate_decompressed_data(data.as_ref())?;
653
654 let target_ms = DukascopyClient::timestamp_to_ms_from_hour(query_timestamp);
655 match DukascopyClient::find_tick_at_or_before(data.as_ref(), target_ms, config) {
656 Ok(tick) => {
657 return DukascopyClient::build_exchange_response(
658 pair, hour_start, tick, config,
659 );
660 }
661 Err(DukascopyError::DataNotFound) => {
662 if fallback_attempts >= self.config.max_at_or_before_backtrack_hours {
663 return Err(not_found_for(query_timestamp));
664 }
665 query_timestamp = hour_start
666 .checked_sub_signed(Duration::milliseconds(1))
667 .ok_or_else(|| not_found_for(query_timestamp))?;
668 fallback_attempts += 1;
669 }
670 Err(err) => return Err(err),
671 }
672 }
673 }
674
675 pub async fn get_exchange_rate_for_request(
677 &self,
678 request: &RateRequest,
679 timestamp: DateTime<Utc>,
680 ) -> Result<CurrencyExchange, DukascopyError> {
681 match request {
682 RateRequest::Pair(pair) => self.get_exchange_rate(pair, timestamp).await,
683 RateRequest::Symbol(symbol) => {
684 self.get_exchange_rate_for_symbol(symbol, timestamp).await
685 }
686 }
687 }
688
689 pub async fn get_exchange_rate_for_symbol(
691 &self,
692 symbol: &str,
693 timestamp: DateTime<Utc>,
694 ) -> Result<CurrencyExchange, DukascopyError> {
695 self.get_exchange_rate_for_symbol_with_path(symbol, timestamp)
696 .await
697 .map(|resolved| resolved.exchange)
698 }
699
700 pub async fn get_exchange_rate_for_symbol_with_path(
702 &self,
703 symbol: &str,
704 timestamp: DateTime<Utc>,
705 ) -> Result<ResolvedExchange, DukascopyError> {
706 if self.config.pair_resolution_mode == PairResolutionMode::ExplicitOnly {
707 return Err(DukascopyError::PairResolutionDisabled);
708 }
709
710 let quote = self
711 .config
712 .default_quote_currency
713 .as_deref()
714 .ok_or(DukascopyError::MissingDefaultQuoteCurrency)?;
715
716 self.get_exchange_rate_in_quote_with_path(symbol, quote, timestamp)
717 .await
718 }
719
720 pub async fn get_exchange_rate_in_quote(
722 &self,
723 symbol: &str,
724 quote: &str,
725 timestamp: DateTime<Utc>,
726 ) -> Result<CurrencyExchange, DukascopyError> {
727 self.get_exchange_rate_in_quote_with_path(symbol, quote, timestamp)
728 .await
729 .map(|resolved| resolved.exchange)
730 }
731
732 pub async fn get_exchange_rate_in_quote_with_path(
734 &self,
735 symbol: &str,
736 quote: &str,
737 timestamp: DateTime<Utc>,
738 ) -> Result<ResolvedExchange, DukascopyError> {
739 let symbol = self.resolve_code_alias(symbol);
740 let quote = self.resolve_code_alias(quote);
741 if symbol == quote {
742 let pair = CurrencyPair::try_new(symbol.clone(), quote.clone())?;
743 let effective_timestamp =
744 if self.config.respect_market_hours && !is_market_open(timestamp) {
745 last_available_tick_time(timestamp)
746 } else {
747 timestamp
748 };
749
750 return Ok(ResolvedExchange {
751 exchange: CurrencyExchange {
752 pair,
753 rate: Decimal::ONE,
754 timestamp: effective_timestamp,
755 ask: Decimal::ONE,
756 bid: Decimal::ONE,
757 ask_volume: 0.0,
758 bid_volume: 0.0,
759 },
760 path_type: ConversionPathType::Direct,
761 legs: Vec::new(),
762 });
763 }
764
765 if let Some(exchange) = self
766 .get_exchange_rate_direct_or_inverse(&symbol, "e, timestamp)
767 .await?
768 {
769 return Ok(ResolvedExchange {
770 exchange: exchange.clone(),
771 path_type: ConversionPathType::Direct,
772 legs: vec![exchange],
773 });
774 }
775
776 if self.config.conversion_mode == ConversionMode::DirectOnly {
777 return Err(DukascopyError::NoConversionRoute { symbol, quote });
778 }
779
780 for bridge in &self.config.bridge_currencies {
781 if bridge == "e || bridge == &symbol {
782 continue;
783 }
784
785 let first_leg = match self
786 .get_exchange_rate_direct_or_inverse(&symbol, bridge, timestamp)
787 .await?
788 {
789 Some(rate) => rate,
790 None => continue,
791 };
792
793 let second_leg = match self
794 .get_exchange_rate_direct_or_inverse(bridge, "e, timestamp)
795 .await?
796 {
797 Some(rate) => rate,
798 None => continue,
799 };
800
801 let exchange = DukascopyClient::build_synthetic_exchange(
802 &symbol,
803 "e,
804 &first_leg,
805 &second_leg,
806 )?;
807
808 return Ok(ResolvedExchange {
809 exchange,
810 path_type: ConversionPathType::Synthetic,
811 legs: vec![first_leg, second_leg],
812 });
813 }
814
815 Err(DukascopyError::NoConversionRoute { symbol, quote })
816 }
817
818 async fn get_exchange_rate_direct_or_inverse(
819 &self,
820 from: &str,
821 to: &str,
822 timestamp: DateTime<Utc>,
823 ) -> Result<Option<CurrencyExchange>, DukascopyError> {
824 let direct_pair = CurrencyPair::try_new(from, to)?;
825 match self.get_exchange_rate(&direct_pair, timestamp).await {
826 Ok(exchange) => return Ok(Some(exchange)),
827 Err(err) if err.is_not_found() => {}
828 Err(err) => return Err(err),
829 }
830
831 let inverse_pair = CurrencyPair::try_new(to, from)?;
832 match self.get_exchange_rate(&inverse_pair, timestamp).await {
833 Ok(exchange) => Ok(Some(DukascopyClient::invert_exchange(&exchange)?)),
834 Err(err) if err.is_not_found() => Ok(None),
835 Err(err) => Err(err),
836 }
837 }
838
839 fn resolve_code_alias(&self, code: &str) -> String {
840 let mut current = normalize_code(code);
841 let Some(next) = self.config.code_aliases.get(¤t) else {
842 return current;
843 };
844 if next == ¤t {
845 return current;
846 }
847 let mut visited = HashSet::new();
848 visited.insert(current.clone());
849 current = next.clone();
850
851 while let Some(next) = self.config.code_aliases.get(¤t) {
852 if next == ¤t || !visited.insert(current.clone()) {
853 break;
854 }
855 current = next.clone();
856 }
857
858 current
859 }
860
861 fn resolve_pair_alias(&self, pair: &CurrencyPair) -> Result<CurrencyPair, DukascopyError> {
862 let from = self.resolve_code_alias(pair.from());
863 let to = self.resolve_code_alias(pair.to());
864 CurrencyPair::try_new(from, to)
865 }
866
867 pub async fn get_exchange_rates_range(
869 &self,
870 pair: &CurrencyPair,
871 start: DateTime<Utc>,
872 end: DateTime<Utc>,
873 interval: Duration,
874 ) -> Result<Vec<CurrencyExchange>, DukascopyError> {
875 let resolved_pair = self.resolve_pair_alias(pair)?;
876 DukascopyClient::validate_pair(&resolved_pair)?;
877
878 if start >= end {
879 return Err(DukascopyError::InvalidRequest(
880 "Start time must be before end time".to_string(),
881 ));
882 }
883
884 if interval <= Duration::zero() {
885 return Err(DukascopyError::InvalidRequest(
886 "Interval must be a positive duration".to_string(),
887 ));
888 }
889
890 let mut results: Vec<CurrencyExchange> = Vec::new();
891 let mut hour_cache: Option<(DateTime<Utc>, Arc<[ParsedTick]>)> = None;
892 let mut hour_fallback_cache: Option<(
893 DateTime<Utc>,
894 Option<u32>,
895 Option<CurrencyExchange>,
896 )> = None;
897 let pair_symbol = resolved_pair.as_symbol();
898 let config = self.get_instrument_config(resolved_pair.from(), resolved_pair.to());
899 let mut current = start;
900
901 while current <= end {
902 if self.config.respect_market_hours && !is_market_open(current) {
903 current += interval;
904 continue;
905 }
906
907 let hour_start = DukascopyClient::hour_start(current)?;
908 let cache_miss = hour_cache
909 .as_ref()
910 .map(|(cached_hour, _)| *cached_hour != hour_start)
911 .unwrap_or(true);
912
913 if cache_miss {
914 let url = self.build_url(
915 &pair_symbol,
916 hour_start.year(),
917 hour_start.month(),
918 hour_start.day(),
919 hour_start.hour(),
920 );
921
922 match self.fetch_cached(&url).await {
923 Ok(data) => {
924 let parsed_ticks = self.get_or_parse_ticks(&url, &data, config)?;
925 hour_cache = Some((hour_start, parsed_ticks));
926 }
927 Err(DukascopyError::DataNotFound) => {
928 hour_cache = Some((hour_start, Arc::from(Vec::<ParsedTick>::new())));
929 }
930 Err(e) => return Err(e),
931 }
932 }
933
934 let target_ms = DukascopyClient::timestamp_to_ms_from_hour(current);
935 let first_tick_ms = hour_cache
936 .as_ref()
937 .and_then(|(_, ticks)| ticks.first().map(|tick| tick.ms_from_hour));
938 let mut exchange = match hour_cache.as_ref() {
939 Some((_, ticks)) => {
940 if let Some(tick) =
941 DukascopyClient::find_tick_at_or_before_parsed(ticks, target_ms)
942 {
943 Some(DukascopyClient::build_exchange_response(
944 pair, current, tick, config,
945 )?)
946 } else {
947 None
948 }
949 }
950 None => None,
951 };
952
953 if exchange.is_none() {
954 if let Some(previous) = results.last() {
955 if previous.timestamp <= current {
956 exchange = Some(previous.clone());
957 }
958 }
959 }
960
961 if exchange.is_none() {
962 if let Some((cached_hour, cached_first_tick_ms, cached_exchange)) =
963 &hour_fallback_cache
964 {
965 if *cached_hour == hour_start && *cached_first_tick_ms == first_tick_ms {
966 exchange = cached_exchange.clone();
967 }
968 }
969 }
970
971 if exchange.is_none() {
972 let fallback_ts = current
973 .checked_sub_signed(Duration::milliseconds(1))
974 .ok_or(DukascopyError::DataNotFound)?;
975 let computed_fallback = match self.get_exchange_rate(pair, fallback_ts).await {
976 Ok(value) => Some(value),
977 Err(err) if err.is_not_found() => None,
978 Err(err) => return Err(err),
979 };
980 hour_fallback_cache = Some((hour_start, first_tick_ms, computed_fallback.clone()));
981 exchange = computed_fallback;
982 }
983
984 if let Some(exchange) = exchange {
985 let duplicate_ts = results
986 .last()
987 .map(|last| last.timestamp == exchange.timestamp)
988 .unwrap_or(false);
989 if !duplicate_ts {
990 results.push(exchange);
991 }
992 }
993
994 current += interval;
995 }
996
997 Ok(results)
998 }
999
1000 pub fn build_url(
1002 &self,
1003 pair_symbol: &str,
1004 year: i32,
1005 month: u32,
1006 day: u32,
1007 hour: u32,
1008 ) -> String {
1009 build_tick_url(&self.config.base_url, pair_symbol, year, month, day, hour)
1010 }
1011
1012 #[inline]
1013 fn cache_get_decompressed(&self, url: &str) -> Option<Arc<[u8]>> {
1014 let mut cache_guard = self.cache.lock();
1015 cache_guard.get(url).cloned()
1016 }
1017
1018 #[inline]
1019 fn cache_put_decompressed(&self, url: &str, data: Arc<[u8]>) {
1020 let mut cache_guard = self.cache.lock();
1021 cache_guard.put(url.to_string(), data);
1022 }
1023
1024 #[inline]
1025 fn parsed_cache_get(&self, url: &str) -> Option<Arc<[ParsedTick]>> {
1026 let mut cache_guard = self.parsed_tick_cache.lock();
1027 cache_guard.get(url).cloned()
1028 }
1029
1030 #[inline]
1031 fn parsed_cache_put(&self, url: &str, ticks: Arc<[ParsedTick]>) {
1032 let mut cache_guard = self.parsed_tick_cache.lock();
1033 cache_guard.put(url.to_string(), ticks);
1034 }
1035
1036 fn get_or_parse_ticks(
1037 &self,
1038 url: &str,
1039 data: &[u8],
1040 config: InstrumentConfig,
1041 ) -> Result<Arc<[ParsedTick]>, DukascopyError> {
1042 if let Some(ticks) = self.parsed_cache_get(url) {
1043 return Ok(ticks);
1044 }
1045
1046 DukascopyParser::validate_decompressed_data(data)?;
1047 let mut parsed_ticks = Vec::with_capacity(data.len() / TICK_SIZE_BYTES);
1048 for tick in DukascopyParser::iter_ticks(data, config) {
1049 parsed_ticks.push(tick?);
1050 }
1051
1052 let parsed_ticks: Arc<[ParsedTick]> = parsed_ticks.into();
1053 self.parsed_cache_put(url, Arc::clone(&parsed_ticks));
1054 Ok(parsed_ticks)
1055 }
1056
1057 async fn fetch_cached(&self, url: &str) -> Result<Arc<[u8]>, DukascopyError> {
1059 loop {
1060 if let Some(data) = self.cache_get_decompressed(url) {
1061 debug!("Cache hit for: {}", url);
1062 return Ok(data);
1063 }
1064
1065 let (state, is_leader) = {
1067 let mut inflight = self.in_flight_fetches.lock();
1068 if let Some(state) = inflight.get(url) {
1069 (Arc::clone(state), false)
1070 } else {
1071 let state = Arc::new(InFlightFetch::new());
1072 inflight.insert(url.to_string(), Arc::clone(&state));
1073 (state, true)
1074 }
1075 };
1076
1077 if !is_leader {
1078 state.notify.notified().await;
1079 if let Some(result) = state.result.lock().as_ref() {
1080 return result.clone();
1081 }
1082 continue;
1083 }
1084
1085 let guard = InFlightLeaderGuard::new(
1086 url.to_string(),
1087 Arc::clone(&self.in_flight_fetches),
1088 Arc::clone(&state),
1089 );
1090 let fetch_result = self.fetch_uncached_and_store(url).await;
1091 return guard.finish(fetch_result);
1092 }
1093 }
1094
1095 async fn fetch_uncached_and_store(&self, url: &str) -> Result<Arc<[u8]>, DukascopyError> {
1096 debug!("Cache miss for: {}", url);
1097 info!("Fetching data from: {}", url);
1098
1099 let _request_permit = self.request_limiter.acquire().await.map_err(|_| {
1100 DukascopyError::Unknown("Request limiter was closed unexpectedly".to_string())
1101 })?;
1102
1103 let mut attempt = 0;
1104 let bytes = loop {
1105 match self.http_client.get(url).send().await {
1106 Ok(response) => {
1107 let status = response.status();
1108 if !status.is_success() {
1109 let error = map_http_error(status);
1110 if error.is_retryable() && attempt < self.config.max_retries {
1111 let delay_ms = retry_delay_ms(self.config.retry_base_delay_ms, attempt);
1112 warn!(
1113 "Request failed with {} for: {} (attempt {}/{}, retrying in {} ms)",
1114 status,
1115 url,
1116 attempt + 1,
1117 self.config.max_retries + 1,
1118 delay_ms
1119 );
1120 tokio::time::sleep(StdDuration::from_millis(delay_ms)).await;
1121 attempt += 1;
1122 continue;
1123 }
1124
1125 warn!("HTTP error {} for: {}", status, url);
1126 return Err(error);
1127 }
1128
1129 match response.bytes().await {
1130 Ok(bytes) => break bytes,
1131 Err(err) => {
1132 let error = self.map_reqwest_error(err);
1133 if error.is_retryable() && attempt < self.config.max_retries {
1134 let delay_ms =
1135 retry_delay_ms(self.config.retry_base_delay_ms, attempt);
1136 warn!(
1137 "Failed to read response body for: {} (attempt {}/{}, retrying in {} ms): {}",
1138 url,
1139 attempt + 1,
1140 self.config.max_retries + 1,
1141 delay_ms,
1142 error
1143 );
1144 tokio::time::sleep(StdDuration::from_millis(delay_ms)).await;
1145 attempt += 1;
1146 continue;
1147 }
1148 return Err(error);
1149 }
1150 }
1151 }
1152 Err(err) => {
1153 let error = self.map_reqwest_error(err);
1154 if error.is_retryable() && attempt < self.config.max_retries {
1155 let delay_ms = retry_delay_ms(self.config.retry_base_delay_ms, attempt);
1156 warn!(
1157 "Network request failed for: {} (attempt {}/{}, retrying in {} ms): {}",
1158 url,
1159 attempt + 1,
1160 self.config.max_retries + 1,
1161 delay_ms,
1162 error
1163 );
1164 tokio::time::sleep(StdDuration::from_millis(delay_ms)).await;
1165 attempt += 1;
1166 continue;
1167 }
1168 return Err(error);
1169 }
1170 }
1171 };
1172
1173 if bytes.is_empty() {
1174 warn!("Empty response for: {}", url);
1175 return Err(DukascopyError::DataNotFound);
1176 }
1177
1178 let _decompression_permit = self.decompress_limiter.acquire().await.map_err(|_| {
1180 DukascopyError::Unknown("Decompression limiter was closed unexpectedly".to_string())
1181 })?;
1182 let decompressed = tokio::task::spawn_blocking(move || {
1183 let mut output = Vec::with_capacity(bytes.len() * 4);
1184 lzma_rs::lzma_decompress(&mut Cursor::new(&bytes), &mut output)?;
1185 Ok::<_, DukascopyError>(output)
1186 })
1187 .await??;
1188
1189 if decompressed.is_empty() {
1190 return Err(DukascopyError::DataNotFound);
1191 }
1192
1193 debug!("Fetched and decompressed {} bytes", decompressed.len());
1194 let decompressed: Arc<[u8]> = decompressed.into();
1195
1196 self.cache_put_decompressed(url, Arc::clone(&decompressed));
1197
1198 Ok(decompressed)
1199 }
1200
1201 fn map_reqwest_error(&self, err: reqwest::Error) -> DukascopyError {
1202 if err.is_timeout() {
1203 DukascopyError::Timeout(self.config.timeout_secs)
1204 } else if err.is_connect() {
1205 DukascopyError::Transport {
1206 kind: TransportErrorKind::Connect,
1207 status: None,
1208 message: err.to_string(),
1209 }
1210 } else if err.is_body() {
1211 DukascopyError::Transport {
1212 kind: TransportErrorKind::ResponseBody,
1213 status: err.status().map(|status| status.as_u16()),
1214 message: err.to_string(),
1215 }
1216 } else {
1217 DukascopyError::Transport {
1218 kind: TransportErrorKind::Other,
1219 status: err.status().map(|status| status.as_u16()),
1220 message: err.to_string(),
1221 }
1222 }
1223 }
1224
1225 pub fn clear_cache(&self) -> Result<(), DukascopyError> {
1227 self.cache.lock().clear();
1228 self.parsed_tick_cache.lock().clear();
1229 debug!("Cache cleared");
1230 Ok(())
1231 }
1232
1233 pub fn cache_len(&self) -> Result<usize, DukascopyError> {
1235 Ok(self.cache.lock().len())
1236 }
1237}
1238
1239pub struct DukascopyClient;
1245
1246impl DukascopyClient {
1247 pub async fn get_exchange_rate(
1249 pair: &CurrencyPair,
1250 timestamp: DateTime<Utc>,
1251 ) -> Result<CurrencyExchange, DukascopyError> {
1252 get_default_client()
1253 .await
1254 .get_exchange_rate(pair, timestamp)
1255 .await
1256 }
1257
1258 pub async fn get_exchange_rate_for_request(
1260 request: &RateRequest,
1261 timestamp: DateTime<Utc>,
1262 ) -> Result<CurrencyExchange, DukascopyError> {
1263 get_default_client()
1264 .await
1265 .get_exchange_rate_for_request(request, timestamp)
1266 .await
1267 }
1268
1269 pub async fn get_exchange_rates_range(
1271 pair: &CurrencyPair,
1272 start: DateTime<Utc>,
1273 end: DateTime<Utc>,
1274 interval: Duration,
1275 ) -> Result<Vec<CurrencyExchange>, DukascopyError> {
1276 get_default_client()
1277 .await
1278 .get_exchange_rates_range(pair, start, end, interval)
1279 .await
1280 }
1281
1282 pub async fn get_exchange_rate_for_symbol(
1284 symbol: &str,
1285 timestamp: DateTime<Utc>,
1286 ) -> Result<CurrencyExchange, DukascopyError> {
1287 get_default_client()
1288 .await
1289 .get_exchange_rate_for_symbol(symbol, timestamp)
1290 .await
1291 }
1292
1293 pub async fn get_exchange_rate_in_quote(
1295 symbol: &str,
1296 quote: &str,
1297 timestamp: DateTime<Utc>,
1298 ) -> Result<CurrencyExchange, DukascopyError> {
1299 get_default_client()
1300 .await
1301 .get_exchange_rate_in_quote(symbol, quote, timestamp)
1302 .await
1303 }
1304
1305 pub fn build_url(pair_symbol: &str, year: i32, month: u32, day: u32, hour: u32) -> String {
1307 build_tick_url(DUKASCOPY_BASE_URL, pair_symbol, year, month, day, hour)
1308 }
1309
1310 pub async fn get_cached_data(url: &str) -> Result<Vec<u8>, DukascopyError> {
1312 Ok(get_default_client().await.fetch_cached(url).await?.to_vec())
1313 }
1314
1315 pub async fn clear_cache() -> Result<(), DukascopyError> {
1317 get_default_client().await.clear_cache()
1318 }
1319
1320 pub async fn cache_len() -> Result<usize, DukascopyError> {
1322 get_default_client().await.cache_len()
1323 }
1324
1325 fn validate_pair(pair: &CurrencyPair) -> Result<(), DukascopyError> {
1328 if !DukascopyClient::is_valid_instrument_code(pair.from()) {
1329 return Err(DukascopyError::InvalidCurrencyCode {
1330 code: pair.from().to_string(),
1331 reason: "Instrument code must be 2-12 ASCII alphanumeric characters".to_string(),
1332 });
1333 }
1334 if !DukascopyClient::is_valid_instrument_code(pair.to()) {
1335 return Err(DukascopyError::InvalidCurrencyCode {
1336 code: pair.to().to_string(),
1337 reason: "Instrument code must be 2-12 ASCII alphanumeric characters".to_string(),
1338 });
1339 }
1340 Ok(())
1341 }
1342
1343 fn is_valid_instrument_code(code: &str) -> bool {
1344 let len = code.len();
1345 (2..=12).contains(&len) && code.chars().all(|ch| ch.is_ascii_alphanumeric())
1346 }
1347
1348 fn hour_start(timestamp: DateTime<Utc>) -> Result<DateTime<Utc>, DukascopyError> {
1349 timestamp
1350 .with_minute(0)
1351 .and_then(|t| t.with_second(0))
1352 .and_then(|t| t.with_nanosecond(0))
1353 .ok_or_else(|| DukascopyError::Unknown("Invalid timestamp".to_string()))
1354 }
1355
1356 fn timestamp_to_ms_from_hour(timestamp: DateTime<Utc>) -> u32 {
1357 timestamp.minute() * 60_000 + timestamp.second() * 1_000
1358 }
1359
1360 fn find_tick_at_or_before(
1361 data: &[u8],
1362 target_ms: u32,
1363 config: InstrumentConfig,
1364 ) -> Result<ParsedTick, DukascopyError> {
1365 let mut best_tick: Option<ParsedTick> = None;
1366
1367 for chunk in data.chunks_exact(TICK_SIZE_BYTES) {
1368 let tick = DukascopyParser::parse_tick_with_config(chunk, config)?;
1369
1370 if tick.ms_from_hour <= target_ms {
1371 best_tick = Some(tick);
1372 } else {
1373 break;
1374 }
1375 }
1376
1377 best_tick.ok_or(DukascopyError::DataNotFound)
1378 }
1379
1380 fn find_tick_at_or_before_parsed(ticks: &[ParsedTick], target_ms: u32) -> Option<ParsedTick> {
1381 if ticks.is_empty() {
1382 return None;
1383 }
1384
1385 match ticks.binary_search_by_key(&target_ms, |tick| tick.ms_from_hour) {
1386 Ok(index) => Some(ticks[index]),
1387 Err(0) => None,
1388 Err(index) => Some(ticks[index - 1]),
1389 }
1390 }
1391
1392 fn build_exchange_response(
1393 pair: &CurrencyPair,
1394 base_timestamp: DateTime<Utc>,
1395 tick: ParsedTick,
1396 config: InstrumentConfig,
1397 ) -> Result<CurrencyExchange, DukascopyError> {
1398 let decimal_places = config.decimal_places;
1399 let mid_price = tick.mid_price();
1400
1401 let rate = Decimal::from_f64(mid_price)
1402 .ok_or_else(|| DukascopyError::Unknown("Invalid price conversion".to_string()))?;
1403 let rate =
1404 rate.round_dp_with_strategy(decimal_places, RoundingStrategy::MidpointNearestEven);
1405
1406 let ask = Decimal::from_f64(tick.ask)
1407 .ok_or_else(|| DukascopyError::Unknown("Invalid ask price conversion".to_string()))?;
1408 let ask = ask.round_dp_with_strategy(decimal_places, RoundingStrategy::MidpointNearestEven);
1409
1410 let bid = Decimal::from_f64(tick.bid)
1411 .ok_or_else(|| DukascopyError::Unknown("Invalid bid price conversion".to_string()))?;
1412 let bid = bid.round_dp_with_strategy(decimal_places, RoundingStrategy::MidpointNearestEven);
1413
1414 let tick_time = base_timestamp
1415 .with_minute(0)
1416 .and_then(|t| t.with_second(0))
1417 .and_then(|t| t.with_nanosecond(0))
1418 .ok_or_else(|| DukascopyError::Unknown("Invalid timestamp".to_string()))?
1419 + Duration::milliseconds(tick.ms_from_hour as i64);
1420
1421 Ok(CurrencyExchange {
1422 pair: pair.clone(),
1423 rate,
1424 timestamp: tick_time,
1425 ask,
1426 bid,
1427 ask_volume: tick.ask_volume,
1428 bid_volume: tick.bid_volume,
1429 })
1430 }
1431
1432 fn build_synthetic_exchange(
1433 symbol: &str,
1434 quote: &str,
1435 first_leg: &CurrencyExchange,
1436 second_leg: &CurrencyExchange,
1437 ) -> Result<CurrencyExchange, DukascopyError> {
1438 let pair = CurrencyPair::try_new(symbol, quote)?;
1439 let rate = first_leg.rate * second_leg.rate;
1440 let bid = (first_leg.bid * second_leg.bid).min(first_leg.ask * second_leg.ask);
1441 let ask = (first_leg.ask * second_leg.ask).max(first_leg.bid * second_leg.bid);
1442 let timestamp = first_leg.timestamp.min(second_leg.timestamp);
1443
1444 Ok(CurrencyExchange {
1445 pair,
1446 rate,
1447 timestamp,
1448 ask,
1449 bid,
1450 ask_volume: first_leg.ask_volume.min(second_leg.ask_volume),
1451 bid_volume: first_leg.bid_volume.min(second_leg.bid_volume),
1452 })
1453 }
1454
1455 fn invert_exchange(exchange: &CurrencyExchange) -> Result<CurrencyExchange, DukascopyError> {
1456 if exchange.rate.is_zero() || exchange.ask.is_zero() || exchange.bid.is_zero() {
1457 return Err(DukascopyError::InvalidTickData);
1458 }
1459
1460 Ok(CurrencyExchange {
1461 pair: exchange.pair.inverse(),
1462 rate: Decimal::ONE / exchange.rate,
1463 timestamp: exchange.timestamp,
1464 ask: Decimal::ONE / exchange.bid,
1465 bid: Decimal::ONE / exchange.ask,
1466 ask_volume: exchange.bid_volume,
1467 bid_volume: exchange.ask_volume,
1468 })
1469 }
1470}
1471
1472fn build_tick_url(
1477 base_url: &str,
1478 pair_symbol: &str,
1479 year: i32,
1480 month: u32,
1481 day: u32,
1482 hour: u32,
1483) -> String {
1484 let month = month.clamp(1, 12);
1485 format!(
1486 "{}/{}/{}/{:02}/{:02}/{}h_ticks.bi5",
1487 base_url,
1488 pair_symbol,
1489 year,
1490 month - 1,
1491 day,
1492 hour
1493 )
1494}
1495
1496fn map_http_error(status: reqwest::StatusCode) -> DukascopyError {
1497 match status {
1498 reqwest::StatusCode::NOT_FOUND => DukascopyError::DataNotFound,
1499 reqwest::StatusCode::TOO_MANY_REQUESTS => DukascopyError::RateLimitExceeded,
1500 reqwest::StatusCode::UNAUTHORIZED => DukascopyError::Unauthorized,
1501 reqwest::StatusCode::FORBIDDEN => DukascopyError::Forbidden,
1502 reqwest::StatusCode::BAD_REQUEST => {
1503 DukascopyError::InvalidRequest("Bad request".to_string())
1504 }
1505 status => DukascopyError::Transport {
1506 kind: TransportErrorKind::HttpStatus,
1507 status: Some(status.as_u16()),
1508 message: status
1509 .canonical_reason()
1510 .unwrap_or("Unknown HTTP status")
1511 .to_string(),
1512 },
1513 }
1514}
1515
1516fn retry_delay_ms(base_delay_ms: u64, attempt: u32) -> u64 {
1517 let backoff_factor = 2u64.saturating_pow(attempt.min(16));
1518 base_delay_ms.saturating_mul(backoff_factor).max(1)
1519}
1520
1521#[cfg(test)]
1526mod tests {
1527 use super::*;
1528
1529 #[test]
1530 fn test_build_url() {
1531 let url = DukascopyClient::build_url("EURUSD", 2024, 1, 15, 14);
1532 assert_eq!(
1533 url,
1534 "https://datafeed.dukascopy.com/datafeed/EURUSD/2024/00/15/14h_ticks.bi5"
1535 );
1536 }
1537
1538 #[test]
1539 fn test_build_url_december() {
1540 let url = DukascopyClient::build_url("USDJPY", 2024, 12, 31, 23);
1541 assert_eq!(
1542 url,
1543 "https://datafeed.dukascopy.com/datafeed/USDJPY/2024/11/31/23h_ticks.bi5"
1544 );
1545 }
1546
1547 #[test]
1548 fn test_build_url_clamps_invalid_months() {
1549 let below_range = DukascopyClient::build_url("EURUSD", 2024, 0, 15, 14);
1550 assert_eq!(
1551 below_range,
1552 "https://datafeed.dukascopy.com/datafeed/EURUSD/2024/00/15/14h_ticks.bi5"
1553 );
1554
1555 let above_range = DukascopyClient::build_url("EURUSD", 2024, 13, 15, 14);
1556 assert_eq!(
1557 above_range,
1558 "https://datafeed.dukascopy.com/datafeed/EURUSD/2024/11/15/14h_ticks.bi5"
1559 );
1560 }
1561
1562 #[test]
1563 fn test_map_http_error() {
1564 assert!(matches!(
1565 map_http_error(reqwest::StatusCode::NOT_FOUND),
1566 DukascopyError::DataNotFound
1567 ));
1568 assert!(matches!(
1569 map_http_error(reqwest::StatusCode::TOO_MANY_REQUESTS),
1570 DukascopyError::RateLimitExceeded
1571 ));
1572 }
1573
1574 #[test]
1575 fn test_default_config() {
1576 let config = ClientConfig::default();
1577 assert_eq!(config.cache_size, DEFAULT_CACHE_SIZE);
1578 assert_eq!(
1579 config.parsed_tick_cache_size,
1580 DEFAULT_PARSED_TICK_CACHE_SIZE
1581 );
1582 assert_eq!(config.timeout_secs, DEFAULT_TIMEOUT_SECS);
1583 assert_eq!(config.max_retries, DEFAULT_MAX_RETRIES);
1584 assert_eq!(config.retry_base_delay_ms, DEFAULT_RETRY_BASE_DELAY_MS);
1585 assert_eq!(
1586 config.max_in_flight_requests,
1587 DEFAULT_MAX_IN_FLIGHT_REQUESTS
1588 );
1589 assert_eq!(
1590 config.max_download_concurrency,
1591 DEFAULT_MAX_DOWNLOAD_CONCURRENCY
1592 );
1593 assert_eq!(
1594 config.max_decompression_jobs,
1595 DEFAULT_MAX_DECOMPRESSION_JOBS
1596 );
1597 assert_eq!(
1598 config.max_at_or_before_backtrack_hours,
1599 DEFAULT_MAX_AT_OR_BEFORE_BACKTRACK_HOURS
1600 );
1601 assert!(config.respect_market_hours);
1602 assert_eq!(
1603 config.pair_resolution_mode,
1604 PairResolutionMode::ExplicitOrDefaultQuote
1605 );
1606 assert_eq!(config.conversion_mode, ConversionMode::DirectOnly);
1607 assert_eq!(
1608 config.bridge_currencies,
1609 vec!["USD".to_string(), "EUR".to_string()]
1610 );
1611 assert!(config.code_aliases.is_empty());
1612 }
1613
1614 #[tokio::test]
1615 async fn test_global_default_client_quote_currency() {
1616 let client = get_default_client().await;
1617 assert_eq!(
1618 client.default_quote_currency(),
1619 Some(GLOBAL_DEFAULT_QUOTE_CURRENCY)
1620 );
1621 }
1622
1623 #[test]
1624 fn test_builder_chaining() {
1625 let client = DukascopyClientBuilder::new()
1626 .cache_size(200)
1627 .parsed_tick_cache_size(150)
1628 .timeout_secs(45)
1629 .max_retries(5)
1630 .retry_base_delay_ms(150)
1631 .max_in_flight_requests(4)
1632 .max_download_concurrency(3)
1633 .max_decompression_jobs(2)
1634 .max_at_or_before_backtrack_hours(24)
1635 .respect_market_hours(false)
1636 .default_quote_currency("pln")
1637 .pair_resolution_mode(PairResolutionMode::ExplicitOrDefaultQuote)
1638 .conversion_mode(ConversionMode::DirectThenSynthetic)
1639 .bridge_currencies(&["usd", "eur", "usd"])
1640 .code_alias("aapl", "aaplus")
1641 .build();
1642
1643 assert_eq!(client.config().cache_size, 200);
1644 assert_eq!(client.config().parsed_tick_cache_size, 150);
1645 assert_eq!(client.config().timeout_secs, 45);
1646 assert_eq!(client.config().max_retries, 5);
1647 assert_eq!(client.config().retry_base_delay_ms, 150);
1648 assert_eq!(client.config().max_in_flight_requests, 4);
1649 assert_eq!(client.config().max_download_concurrency, 3);
1650 assert_eq!(client.config().max_decompression_jobs, 2);
1651 assert_eq!(client.config().max_at_or_before_backtrack_hours, 24);
1652 assert!(!client.config().respect_market_hours);
1653 assert_eq!(client.default_quote_currency(), Some("PLN"));
1654 assert_eq!(
1655 client.config().pair_resolution_mode,
1656 PairResolutionMode::ExplicitOrDefaultQuote
1657 );
1658 assert_eq!(
1659 client.config().conversion_mode,
1660 ConversionMode::DirectThenSynthetic
1661 );
1662 assert_eq!(
1663 client.config().bridge_currencies,
1664 vec!["USD".to_string(), "EUR".to_string()]
1665 );
1666 assert_eq!(
1667 client.config().code_aliases.get("AAPL"),
1668 Some(&"AAPLUS".to_string())
1669 );
1670 }
1671
1672 #[test]
1673 fn test_code_alias_resolves_pair() {
1674 let client = DukascopyClientBuilder::new()
1675 .code_alias("aapl", "AAPLUS")
1676 .build();
1677 let requested = CurrencyPair::new("AAPL", "USD");
1678 let resolved = client.resolve_pair_alias(&requested).unwrap();
1679 assert_eq!(resolved.as_symbol(), "AAPLUSUSD");
1680 }
1681
1682 #[test]
1683 fn test_code_alias_chain_resolves_pair() {
1684 let client = DukascopyClientBuilder::new()
1685 .code_alias("sp500", "us500")
1686 .code_alias("us500", "USA500IDX")
1687 .build();
1688 let requested = CurrencyPair::new("SP500", "USD");
1689 let resolved = client.resolve_pair_alias(&requested).unwrap();
1690 assert_eq!(resolved.as_symbol(), "USA500IDXUSD");
1691 }
1692
1693 #[test]
1694 fn test_code_alias_cycle_returns_stable_value() {
1695 let client = DukascopyClientBuilder::new()
1696 .code_alias("a", "b")
1697 .code_alias("b", "a")
1698 .build();
1699 assert_eq!(client.resolve_code_alias("a"), "A");
1700 assert_eq!(client.resolve_code_alias("b"), "B");
1701 }
1702
1703 #[test]
1704 fn test_with_instrument_catalog_imports_aliases_and_configs() {
1705 let catalog_json = r#"
1706 {
1707 "instruments": [
1708 {
1709 "symbol": "AAPLUSUSD",
1710 "base": "AAPLUS",
1711 "quote": "USD",
1712 "asset_class": "equity",
1713 "price_divisor": 1000.0,
1714 "decimal_places": 2,
1715 "active": true
1716 }
1717 ],
1718 "code_aliases": {
1719 "AAPL": "AAPLUS"
1720 }
1721 }
1722 "#;
1723 let catalog = crate::core::catalog::InstrumentCatalog::from_json_str(catalog_json).unwrap();
1724 let client = DukascopyClientBuilder::new()
1725 .with_instrument_catalog(&catalog)
1726 .build();
1727
1728 assert_eq!(
1729 client.config().code_aliases.get("AAPL"),
1730 Some(&"AAPLUS".to_string())
1731 );
1732 let config = client.get_instrument_config("AAPL", "USD");
1733 assert_eq!(config.price_divisor, 1000.0);
1734 assert_eq!(config.decimal_places, 2);
1735 }
1736
1737 #[test]
1738 fn test_timestamp_to_ms() {
1739 use chrono::TimeZone;
1740 let ts = Utc.with_ymd_and_hms(2024, 1, 1, 14, 30, 15).unwrap();
1741 assert_eq!(
1742 DukascopyClient::timestamp_to_ms_from_hour(ts),
1743 30 * 60_000 + 15 * 1_000
1744 );
1745 }
1746
1747 #[test]
1748 fn test_retry_delay_ms() {
1749 assert_eq!(retry_delay_ms(100, 0), 100);
1750 assert_eq!(retry_delay_ms(100, 1), 200);
1751 assert_eq!(retry_delay_ms(100, 2), 400);
1752 }
1753
1754 #[test]
1755 fn test_validate_pair_accepts_non_three_char_codes() {
1756 let pair = CurrencyPair::new("DE40", "USD");
1757 assert!(DukascopyClient::validate_pair(&pair).is_ok());
1758 }
1759
1760 #[test]
1761 fn test_find_tick_at_or_before_parsed() {
1762 let ticks = vec![
1763 ParsedTick {
1764 ms_from_hour: 100,
1765 ask: 1.1010,
1766 bid: 1.1000,
1767 ask_volume: 1.0,
1768 bid_volume: 1.0,
1769 },
1770 ParsedTick {
1771 ms_from_hour: 1_000,
1772 ask: 1.1020,
1773 bid: 1.1010,
1774 ask_volume: 1.0,
1775 bid_volume: 1.0,
1776 },
1777 ];
1778
1779 let first = DukascopyClient::find_tick_at_or_before_parsed(&ticks, 50);
1780 assert!(first.is_none());
1781
1782 let second = DukascopyClient::find_tick_at_or_before_parsed(&ticks, 1_000).unwrap();
1783 assert_eq!(second.ms_from_hour, 1_000);
1784
1785 let last = DukascopyClient::find_tick_at_or_before_parsed(&ticks, 3_000).unwrap();
1786 assert_eq!(last.ms_from_hour, 1_000);
1787 }
1788
1789 #[test]
1790 fn test_find_tick_at_or_before_rejects_lookahead() {
1791 let mut data = Vec::new();
1792 data.extend_from_slice(&100u32.to_be_bytes()); data.extend_from_slice(&110_100u32.to_be_bytes()); data.extend_from_slice(&110_000u32.to_be_bytes()); data.extend_from_slice(&1.0f32.to_be_bytes()); data.extend_from_slice(&1.0f32.to_be_bytes()); let result = DukascopyClient::find_tick_at_or_before(&data, 50, InstrumentConfig::STANDARD);
1799 assert!(matches!(result, Err(DukascopyError::DataNotFound)));
1800 }
1801
1802 #[test]
1803 fn test_build_synthetic_exchange() {
1804 use chrono::TimeZone;
1805 use rust_decimal::Decimal;
1806
1807 let leg1 = CurrencyExchange {
1808 pair: CurrencyPair::new("AAPL", "USD"),
1809 rate: Decimal::new(150, 0),
1810 timestamp: Utc.with_ymd_and_hms(2025, 1, 3, 14, 45, 0).unwrap(),
1811 ask: Decimal::new(151, 0),
1812 bid: Decimal::new(149, 0),
1813 ask_volume: 10.0,
1814 bid_volume: 8.0,
1815 };
1816 let leg2 = CurrencyExchange {
1817 pair: CurrencyPair::new("USD", "PLN"),
1818 rate: Decimal::new(4, 0),
1819 timestamp: Utc.with_ymd_and_hms(2025, 1, 3, 14, 44, 0).unwrap(),
1820 ask: Decimal::new(41, 1),
1821 bid: Decimal::new(39, 1),
1822 ask_volume: 7.0,
1823 bid_volume: 6.0,
1824 };
1825
1826 let synthetic =
1827 DukascopyClient::build_synthetic_exchange("AAPL", "PLN", &leg1, &leg2).unwrap();
1828
1829 assert_eq!(synthetic.pair.as_symbol(), "AAPLPLN");
1830 assert!(synthetic.rate > Decimal::ZERO);
1831 assert!(synthetic.bid <= synthetic.ask);
1832 assert_eq!(synthetic.timestamp, leg2.timestamp);
1833 }
1834
1835 #[test]
1836 fn test_invert_exchange() {
1837 use chrono::TimeZone;
1838 use rust_decimal::Decimal;
1839
1840 let original = CurrencyExchange {
1841 pair: CurrencyPair::new("EUR", "USD"),
1842 rate: Decimal::new(12, 1), timestamp: Utc.with_ymd_and_hms(2025, 1, 3, 14, 45, 0).unwrap(),
1844 ask: Decimal::new(121, 2), bid: Decimal::new(119, 2), ask_volume: 10.0,
1847 bid_volume: 7.0,
1848 };
1849
1850 let inverted = DukascopyClient::invert_exchange(&original).unwrap();
1851 assert_eq!(inverted.pair.as_symbol(), "USDEUR");
1852 assert_eq!(inverted.rate, Decimal::ONE / original.rate);
1853 assert_eq!(inverted.ask, Decimal::ONE / original.bid);
1854 assert_eq!(inverted.bid, Decimal::ONE / original.ask);
1855 assert!(inverted.bid <= inverted.ask);
1856 assert_eq!(inverted.ask_volume, original.bid_volume);
1857 assert_eq!(inverted.bid_volume, original.ask_volume);
1858 }
1859
1860 #[tokio::test]
1861 async fn test_get_exchange_rate_for_symbol_requires_default_quote() {
1862 let client = DukascopyClientBuilder::new().build();
1863 let ts = Utc::now();
1864 let result = client.get_exchange_rate_for_symbol("AAPL", ts).await;
1865 assert!(matches!(
1866 result,
1867 Err(DukascopyError::MissingDefaultQuoteCurrency)
1868 ));
1869 }
1870
1871 #[tokio::test]
1872 async fn test_get_exchange_rate_for_symbol_respects_resolution_mode() {
1873 let client = DukascopyClientBuilder::new()
1874 .default_quote_currency("USD")
1875 .pair_resolution_mode(PairResolutionMode::ExplicitOnly)
1876 .build();
1877 let ts = Utc::now();
1878 let result = client.get_exchange_rate_for_symbol("AAPL", ts).await;
1879 assert!(matches!(
1880 result,
1881 Err(DukascopyError::PairResolutionDisabled)
1882 ));
1883 }
1884
1885 #[tokio::test]
1886 async fn test_get_exchange_rate_for_request_symbol_requires_default_quote() {
1887 let client = DukascopyClientBuilder::new().build();
1888 let ts = Utc::now();
1889 let request = RateRequest::symbol("AAPL").unwrap();
1890 let result = client.get_exchange_rate_for_request(&request, ts).await;
1891 assert!(matches!(
1892 result,
1893 Err(DukascopyError::MissingDefaultQuoteCurrency)
1894 ));
1895 }
1896
1897 #[tokio::test]
1898 async fn test_get_exchange_rate_for_request_symbol_respects_resolution_mode() {
1899 let client = DukascopyClientBuilder::new()
1900 .default_quote_currency("USD")
1901 .pair_resolution_mode(PairResolutionMode::ExplicitOnly)
1902 .build();
1903 let ts = Utc::now();
1904 let request = RateRequest::symbol("AAPL").unwrap();
1905 let result = client.get_exchange_rate_for_request(&request, ts).await;
1906 assert!(matches!(
1907 result,
1908 Err(DukascopyError::PairResolutionDisabled)
1909 ));
1910 }
1911
1912 #[tokio::test]
1913 async fn test_get_exchange_rate_for_request_pair_validates_before_network() {
1914 let client = DukascopyClientBuilder::new().build();
1915 let ts = Utc::now();
1916 let invalid_pair = CurrencyPair::new("BAD$", "USD");
1917 let request = RateRequest::Pair(invalid_pair);
1918 let result = client.get_exchange_rate_for_request(&request, ts).await;
1919
1920 assert!(matches!(
1921 result,
1922 Err(DukascopyError::InvalidCurrencyCode { code, .. }) if code == "BAD$"
1923 ));
1924 }
1925
1926 #[tokio::test]
1927 async fn test_get_exchange_rate_in_quote_same_symbol_returns_identity() {
1928 let client = DukascopyClientBuilder::new()
1929 .conversion_mode(ConversionMode::DirectOnly)
1930 .build();
1931 let ts = Utc::now();
1932 let exchange = client
1933 .get_exchange_rate_in_quote("USD", "USD", ts)
1934 .await
1935 .unwrap();
1936
1937 assert_eq!(exchange.pair.as_symbol(), "USDUSD");
1938 assert_eq!(exchange.rate, Decimal::ONE);
1939 assert_eq!(exchange.ask, Decimal::ONE);
1940 assert_eq!(exchange.bid, Decimal::ONE);
1941 }
1942}