Skip to main content

dukascopy_fx/core/
client.rs

1//! HTTP client for fetching tick data from Dukascopy.
2
3use 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
45// ============================================================================
46// Constants
47// ============================================================================
48
49/// Default LRU cache size for decompressed tick data
50pub const DEFAULT_CACHE_SIZE: usize = 100;
51
52/// Default maximum idle connections per host
53pub const DEFAULT_MAX_IDLE_CONNECTIONS: usize = 10;
54
55/// Default HTTP request timeout in seconds
56pub const DEFAULT_TIMEOUT_SECS: u64 = 30;
57
58/// Default retry attempts for transient HTTP/network failures
59pub const DEFAULT_MAX_RETRIES: u32 = 3;
60
61/// Default base delay for exponential retry backoff in milliseconds
62pub const DEFAULT_RETRY_BASE_DELAY_MS: u64 = 200;
63
64/// Default maximum number of in-flight HTTP requests per client
65pub const DEFAULT_MAX_IN_FLIGHT_REQUESTS: usize = 8;
66
67/// Default maximum number of concurrent batch-download tasks.
68pub const DEFAULT_MAX_DOWNLOAD_CONCURRENCY: usize = 8;
69
70/// Default maximum number of parallel LZMA decompression jobs.
71pub const DEFAULT_MAX_DECOMPRESSION_JOBS: usize = 4;
72
73/// Default LRU cache size for parsed ticks
74pub const DEFAULT_PARSED_TICK_CACHE_SIZE: usize = 50;
75
76/// Maximum number of hours to backtrack when resolving at-or-before tick.
77pub const DEFAULT_MAX_AT_OR_BEFORE_BACKTRACK_HOURS: usize = 72;
78
79/// Dukascopy API base URL
80pub const DUKASCOPY_BASE_URL: &str = "https://datafeed.dukascopy.com/datafeed";
81
82/// Default quote currency used by global convenience symbol API.
83pub const GLOBAL_DEFAULT_QUOTE_CURRENCY: &str = "USD";
84
85/// How symbol-only requests should be resolved.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
87pub enum PairResolutionMode {
88    /// Symbol-only requests are disabled.
89    ExplicitOnly,
90    /// Symbol-only requests use configured default quote currency.
91    #[default]
92    ExplicitOrDefaultQuote,
93}
94
95/// How cross-currency conversion should be performed.
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
97pub enum ConversionMode {
98    /// Use only direct instrument/quote pairs.
99    #[default]
100    DirectOnly,
101    /// Try direct pair, then fallback to synthetic route through bridge currencies.
102    DirectThenSynthetic,
103}
104
105/// Conversion path type for symbol/quote resolution.
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum ConversionPathType {
108    Direct,
109    Synthetic,
110}
111
112/// Rich output for symbol/quote resolution.
113#[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
129// Global default client instance
130static DEFAULT_CLIENT: OnceCell<ConfiguredClient> = OnceCell::const_new();
131
132/// Gets or initializes the global default client
133async 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// ============================================================================
144// Client Configuration
145// ============================================================================
146
147/// Configuration for a Dukascopy client instance.
148///
149/// This is usually created through [`DukascopyClientBuilder`], then accessed
150/// via [`ConfiguredClient::config`] for inspection.
151///
152/// # Example
153///
154/// ```no_run
155/// use dukascopy_fx::advanced::{DukascopyClientBuilder, PairResolutionMode};
156///
157/// let client = DukascopyClientBuilder::new()
158///     .default_quote_currency("USD")
159///     .pair_resolution_mode(PairResolutionMode::ExplicitOrDefaultQuote)
160///     .max_in_flight_requests(16)
161///     .max_download_concurrency(16)
162///     .build();
163///
164/// assert_eq!(client.config().max_in_flight_requests, 16);
165/// assert_eq!(client.config().max_download_concurrency, 16);
166/// ```
167#[derive(Debug, Clone)]
168pub struct ClientConfig {
169    /// Cache size (number of hourly data files to cache)
170    pub cache_size: usize,
171    /// Parsed tick cache size (number of hourly parsed entries to cache)
172    pub parsed_tick_cache_size: usize,
173    /// HTTP request timeout in seconds
174    pub timeout_secs: u64,
175    /// Maximum idle connections per host
176    pub max_idle_connections: usize,
177    /// Maximum retries for transient failures
178    pub max_retries: u32,
179    /// Base delay for exponential retry backoff in milliseconds
180    pub retry_base_delay_ms: u64,
181    /// Maximum number of concurrent in-flight HTTP requests
182    pub max_in_flight_requests: usize,
183    /// Maximum number of concurrent batch download tasks.
184    pub max_download_concurrency: usize,
185    /// Maximum number of concurrent LZMA decompression jobs.
186    pub max_decompression_jobs: usize,
187    /// Maximum number of hours to scan backward for at-or-before tick lookup.
188    pub max_at_or_before_backtrack_hours: usize,
189    /// Whether market-hours filtering should be applied (FX-style).
190    pub respect_market_hours: bool,
191    /// Optional default quote currency used for symbol-only requests.
192    pub default_quote_currency: Option<String>,
193    /// Symbol resolution policy.
194    pub pair_resolution_mode: PairResolutionMode,
195    /// Conversion policy for quote resolution.
196    pub conversion_mode: ConversionMode,
197    /// Bridge currencies used by synthetic conversion.
198    pub bridge_currencies: Vec<String>,
199    /// Optional alias mapping for instrument codes, e.g. `AAPL -> AAPLUS`.
200    pub code_aliases: HashMap<String, String>,
201    /// Base URL for the Dukascopy API
202    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// ============================================================================
230// Client Builder
231// ============================================================================
232
233/// Builder for creating configured Dukascopy client instances.
234///
235/// # Example
236///
237/// ```no_run
238/// use dukascopy_fx::advanced::{DukascopyClientBuilder, PairResolutionMode};
239///
240/// let client = DukascopyClientBuilder::new()
241///     .cache_size(500)
242///     .timeout_secs(60)
243///     .default_quote_currency("USD")
244///     .pair_resolution_mode(PairResolutionMode::ExplicitOrDefaultQuote)
245///     .build();
246/// ```
247#[derive(Debug, Clone, Default)]
248pub struct DukascopyClientBuilder {
249    config: ClientConfig,
250    instrument_provider: Option<OverrideInstrumentProvider>,
251}
252
253impl DukascopyClientBuilder {
254    /// Creates a new builder with default settings.
255    pub fn new() -> Self {
256        Self::default()
257    }
258
259    /// Sets the cache size (number of hourly data files to cache).
260    pub fn cache_size(mut self, size: usize) -> Self {
261        self.config.cache_size = size;
262        self
263    }
264
265    /// Sets parsed tick cache size (number of parsed hourly entries).
266    pub fn parsed_tick_cache_size(mut self, size: usize) -> Self {
267        self.config.parsed_tick_cache_size = size;
268        self
269    }
270
271    /// Sets the HTTP request timeout in seconds.
272    pub fn timeout_secs(mut self, timeout: u64) -> Self {
273        self.config.timeout_secs = timeout;
274        self
275    }
276
277    /// Sets the maximum idle connections per host.
278    pub fn max_idle_connections(mut self, connections: usize) -> Self {
279        self.config.max_idle_connections = connections;
280        self
281    }
282
283    /// Sets the maximum number of retries for transient failures.
284    pub fn max_retries(mut self, retries: u32) -> Self {
285        self.config.max_retries = retries;
286        self
287    }
288
289    /// Sets the base delay for exponential retry backoff in milliseconds.
290    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    /// Sets the maximum number of in-flight HTTP requests.
296    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    /// Sets the maximum number of concurrent batch download tasks.
302    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    /// Sets the maximum number of concurrent LZMA decompression jobs.
308    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    /// Sets maximum backtrack horizon in hours for at-or-before tick lookup.
314    ///
315    /// Use `0` to disable multi-hour backtracking.
316    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    /// Enables or disables FX market-hours filtering.
322    pub fn respect_market_hours(mut self, enabled: bool) -> Self {
323        self.config.respect_market_hours = enabled;
324        self
325    }
326
327    /// Sets default quote currency for symbol-only requests.
328    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    /// Clears default quote currency.
334    pub fn clear_default_quote_currency(mut self) -> Self {
335        self.config.default_quote_currency = None;
336        self
337    }
338
339    /// Sets symbol resolution policy.
340    pub fn pair_resolution_mode(mut self, mode: PairResolutionMode) -> Self {
341        self.config.pair_resolution_mode = mode;
342        self
343    }
344
345    /// Sets conversion policy.
346    pub fn conversion_mode(mut self, mode: ConversionMode) -> Self {
347        self.config.conversion_mode = mode;
348        self
349    }
350
351    /// Sets bridge currencies used by synthetic conversion fallback.
352    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    /// Adds a code alias mapping used by request resolution, e.g. `AAPL -> AAPLUS`.
368    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    /// Adds multiple code alias mappings.
378    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    /// Sets a custom base URL for the Dukascopy API.
391    pub fn base_url(mut self, url: impl Into<String>) -> Self {
392        self.config.base_url = url.into();
393        self
394    }
395
396    /// Adds a custom instrument configuration override.
397    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    /// Imports instrument configs and aliases from a catalog.
411    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    /// Builds the configured client instance.
434    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// ============================================================================
486// Configured Client
487// ============================================================================
488
489/// A configured Dukascopy client instance with its own cache and settings.
490#[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    /// Returns the client configuration.
567    pub fn config(&self) -> &ClientConfig {
568        &self.config
569    }
570
571    /// Returns configured default quote currency if set.
572    pub fn default_quote_currency(&self) -> Option<&str> {
573        self.config.default_quote_currency.as_deref()
574    }
575
576    /// Returns the instrument configuration for a currency pair.
577    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    /// Fetches tick data for a currency pair at a specific time.
588    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    /// Fetches the exchange rate for a currency pair at a specific timestamp.
605    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    /// Fetches exchange rate for unified request type (pair or symbol).
676    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    /// Fetches exchange rate for a symbol using configured default quote currency.
690    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    /// Fetches exchange rate for a symbol and returns conversion path metadata.
701    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    /// Fetches exchange rate for a symbol in target quote currency.
721    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    /// Fetches exchange rate for a symbol in target quote currency with path metadata.
733    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, &quote, 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 == &quote || 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, &quote, timestamp)
795                .await?
796            {
797                Some(rate) => rate,
798                None => continue,
799            };
800
801            let exchange = DukascopyClient::build_synthetic_exchange(
802                &symbol,
803                &quote,
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(&current) else {
842            return current;
843        };
844        if next == &current {
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(&current) {
852            if next == &current || !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    /// Fetches exchange rates over a time range.
868    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    /// Builds a URL for fetching tick data.
1001    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    /// Fetches data from URL with caching.
1058    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            // Singleflight: only one task fetches/decompresses URL on cache miss.
1066            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        // Decompress in blocking task
1179        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    /// Clears the cache.
1226    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    /// Returns the current number of cached entries.
1234    pub fn cache_len(&self) -> Result<usize, DukascopyError> {
1235        Ok(self.cache.lock().len())
1236    }
1237}
1238
1239// ============================================================================
1240// Static Client API
1241// ============================================================================
1242
1243/// Static convenience API using a global default client.
1244pub struct DukascopyClient;
1245
1246impl DukascopyClient {
1247    /// Fetches the exchange rate for a currency pair at a specific timestamp.
1248    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    /// Fetches exchange rate for unified request type (pair or symbol).
1259    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    /// Fetches exchange rates over a time range.
1270    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    /// Fetches exchange rate for a symbol using global client's default quote currency.
1283    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    /// Fetches exchange rate for a symbol in target quote currency.
1294    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    /// Builds a URL for fetching tick data.
1306    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    /// Fetches cached data from URL.
1311    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    /// Clears the global cache.
1316    pub async fn clear_cache() -> Result<(), DukascopyError> {
1317        get_default_client().await.clear_cache()
1318    }
1319
1320    /// Gets current cache size.
1321    pub async fn cache_len() -> Result<usize, DukascopyError> {
1322        get_default_client().await.cache_len()
1323    }
1324
1325    // Private helpers
1326
1327    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
1472// ============================================================================
1473// Helper Functions
1474// ============================================================================
1475
1476fn 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// ============================================================================
1522// Tests
1523// ============================================================================
1524
1525#[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()); // ms
1793        data.extend_from_slice(&110_100u32.to_be_bytes()); // ask raw
1794        data.extend_from_slice(&110_000u32.to_be_bytes()); // bid raw
1795        data.extend_from_slice(&1.0f32.to_be_bytes()); // ask volume
1796        data.extend_from_slice(&1.0f32.to_be_bytes()); // bid volume
1797
1798        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), // 1.2
1843            timestamp: Utc.with_ymd_and_hms(2025, 1, 3, 14, 45, 0).unwrap(),
1844            ask: Decimal::new(121, 2), // 1.21
1845            bid: Decimal::new(119, 2), // 1.19
1846            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}