Skip to main content

finance_query/providers/
config.rs

1use crate::adapters::yahoo::client::ClientConfig;
2use crate::error::Result;
3use crate::providers::{
4    Fetch, Provider, ProviderAdapter, ProviderHealth, ProviderSet, RetryPolicy, Routes,
5    build_providers,
6};
7use std::sync::Arc;
8use std::time::Duration;
9
10/// Central provider configuration shared across query handles.
11///
12/// Build once with [`Providers::builder`], then create lightweight
13/// [`Ticker`](crate::Ticker) handles that share the same underlying
14/// provider connections and authentication.
15///
16/// # Example
17///
18/// ```no_run
19/// use finance_query::{Providers, Provider, Fetch, Capability};
20///
21/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
22/// let providers = Providers::builder()
23///     .route(Capability::QUOTE, [Provider::Yahoo])
24///     .fetch(Fetch::Sequential)
25///     .build().await?;
26///
27/// // All Ticker handles share the same Arc<ProviderSet>
28/// let aapl = providers.ticker("AAPL").build().await?;
29/// let nvda = providers.ticker("NVDA").logo().build().await?;
30/// # Ok(())
31/// # }
32/// ```
33pub struct Providers {
34    pub(crate) set: Arc<ProviderSet>,
35    lang: String,
36}
37
38impl std::fmt::Debug for Providers {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        f.debug_struct("Providers")
41            .field("set", &self.set)
42            .field("lang", &self.lang)
43            .finish()
44    }
45}
46
47impl Providers {
48    /// Create a builder for configuring providers.
49    pub fn builder() -> ProvidersBuilder {
50        ProvidersBuilder::default()
51    }
52
53    /// Wrap a [`ProviderSet`] assembled by hand.
54    ///
55    /// The lower-level counterpart to [`builder`](Self::builder), for a caller
56    /// that has already built its own adapters and route table. Nothing is
57    /// initialised: [`ProviderAdapter::initialize`](crate::ProviderAdapter::initialize)
58    /// is the builder's job, so a hand-built set must be ready to use.
59    pub fn from_set(set: Arc<ProviderSet>) -> Self {
60        Self::from_set_with_lang(set, ClientConfig::default().lang)
61    }
62
63    /// [`from_set`](Self::from_set) with an explicit language for the handles
64    /// this set creates.
65    pub fn from_set_with_lang(set: Arc<ProviderSet>, lang: impl Into<String>) -> Self {
66        Self {
67            set,
68            lang: lang.into(),
69        }
70    }
71
72    /// The underlying set, for `TickerBuilder::with_provider_set` and friends.
73    pub fn provider_set(&self) -> &Arc<ProviderSet> {
74        &self.set
75    }
76
77    /// Create a [`TickerBuilder`](crate::TickerBuilder) pre-wired to this provider set.
78    ///
79    /// The returned builder accepts the same optional configuration as
80    /// [`Ticker::builder`](crate::Ticker::builder) (`.cache()`, `.logo()`,
81    /// `.format()`) before calling `.build()`.
82    ///
83    /// The language configured via [`ProvidersBuilder::lang`] or
84    /// [`ProvidersBuilder::region`] is inherited (override with `.lang()` on
85    /// the returned builder). With the `translation` feature, a non-English
86    /// language translates text fields automatically.
87    pub fn ticker(&self, symbol: impl Into<String>) -> crate::TickerBuilder {
88        crate::Ticker::builder(symbol)
89            .lang(self.lang.clone())
90            .with_provider_set(Arc::clone(&self.set))
91    }
92
93    /// Create a [`TickersBuilder`](crate::TickersBuilder) pre-wired to this provider set.
94    ///
95    /// The returned builder accepts the same optional configuration as
96    /// [`Tickers::builder`](crate::Tickers::builder) (`.cache()`,
97    /// `.max_concurrency()`, `.logo()`, `.format()`) before calling `.build()`.
98    ///
99    /// The language configured via [`ProvidersBuilder::lang`] or
100    /// [`ProvidersBuilder::region`] is inherited (override with `.lang()` on
101    /// the returned builder). With the `translation` feature, a non-English
102    /// language translates text fields automatically.
103    pub fn tickers<S, I>(&self, symbols: I) -> crate::TickersBuilder
104    where
105        S: Into<String>,
106        I: IntoIterator<Item = S>,
107    {
108        crate::Tickers::builder(symbols)
109            .lang(self.lang.clone())
110            .with_provider_set(Arc::clone(&self.set))
111    }
112
113    /// Create a [`CryptoCoin`](crate::CryptoCoin) handle backed by this provider set.
114    ///
115    /// Compiled in unconditionally. With no built-in provider for this
116    /// capability, register one with [`ProvidersBuilder::with_adapter`] or
117    /// enable its feature, or these calls return `NoProviderAvailable`.
118    pub fn crypto(&self, id: impl Into<String>) -> crate::domains::CryptoCoin {
119        crate::domains::CryptoCoin::with_providers(id.into().into(), Arc::clone(&self.set))
120    }
121
122    /// Create a [`ForexPair`](crate::ForexPair) handle backed by this provider set.
123    ///
124    /// Compiled in unconditionally. With no built-in provider for this
125    /// capability, register one with [`ProvidersBuilder::with_adapter`] or
126    /// enable its feature, or these calls return `NoProviderAvailable`.
127    pub fn forex(
128        &self,
129        from: impl Into<String>,
130        to: impl Into<String>,
131    ) -> crate::domains::ForexPair {
132        crate::domains::ForexPair::with_providers(
133            from.into().into(),
134            to.into().into(),
135            Arc::clone(&self.set),
136        )
137    }
138
139    /// Create an [`EconomicIndicator`](crate::EconomicIndicator) handle backed by this provider set.
140    ///
141    /// Compiled in unconditionally. With no built-in provider for this
142    /// capability, register one with [`ProvidersBuilder::with_adapter`] or
143    /// enable its feature, or these calls return `NoProviderAvailable`.
144    pub fn economic(&self, series_id: impl Into<String>) -> crate::domains::EconomicIndicator {
145        crate::domains::EconomicIndicator::with_providers(
146            series_id.into().into(),
147            Arc::clone(&self.set),
148        )
149    }
150
151    /// Create an [`Index`](crate::Index) handle backed by this provider set.
152    pub fn index(&self, symbol: impl Into<String>) -> crate::domains::Index {
153        crate::domains::Index::with_providers(symbol.into().into(), Arc::clone(&self.set))
154    }
155
156    /// Create a [`FuturesContract`](crate::FuturesContract) handle backed by this provider set.
157    pub fn futures(&self, symbol: impl Into<String>) -> crate::domains::FuturesContract {
158        crate::domains::FuturesContract::with_providers(symbol.into().into(), Arc::clone(&self.set))
159    }
160
161    /// Create a [`Commodity`](crate::Commodity) handle backed by this provider set.
162    pub fn commodity(&self, symbol: impl Into<String>) -> crate::domains::Commodity {
163        crate::domains::Commodity::with_providers(symbol.into().into(), Arc::clone(&self.set))
164    }
165
166    /// Create a [`Discovery`](crate::Discovery) handle backed by this provider set.
167    ///
168    /// Routes symbol search, reference data, and screening through
169    /// [`Capability::DISCOVERY`](crate::Capability::DISCOVERY). Distinct from
170    /// [`crate::finance::search`], which is a Yahoo-only shortcut.
171    pub fn discovery(&self) -> crate::domains::Discovery {
172        crate::domains::Discovery::with_providers(Arc::clone(&self.set))
173    }
174
175    /// Create a [`MarketCalendar`](crate::MarketCalendar) handle backed by this provider set.
176    ///
177    /// Routes market-wide earnings/IPO/dividend/split/economic calendars
178    /// through [`Capability::CALENDAR`](crate::Capability::CALENDAR).
179    pub fn calendar(&self) -> crate::domains::MarketCalendar {
180        crate::domains::MarketCalendar::with_providers(Arc::clone(&self.set))
181    }
182
183    /// Create a [`Market`](crate::Market) handle backed by this provider set.
184    ///
185    /// Routes sector/industry performance and movers through
186    /// [`Capability::MARKET`](crate::Capability::MARKET). Movers work on the
187    /// default keyless route (Yahoo screeners); the sector/industry
188    /// statistics need a keyed provider (FMP).
189    pub fn market(&self) -> crate::domains::Market {
190        crate::domains::Market::with_providers(Arc::clone(&self.set))
191    }
192
193    /// Create an [`EconomicCatalog`](crate::EconomicCatalog) handle backed by
194    /// this provider set.
195    ///
196    /// Routes series search and category/release browsing through
197    /// [`Capability::ECONOMIC`](crate::Capability::ECONOMIC). Unlike
198    /// [`economic`](Self::economic) it takes no series id — it is how you find
199    /// one.
200    ///
201    /// Compiled in unconditionally. With no built-in provider for this
202    /// capability, register one with [`ProvidersBuilder::with_adapter`] or
203    /// enable its feature, or these calls return `NoProviderAvailable`.
204    pub fn economic_catalog(&self) -> crate::domains::EconomicCatalog {
205        crate::domains::EconomicCatalog::with_providers(Arc::clone(&self.set))
206    }
207
208    /// Create a [`Snapshot`](crate::Snapshot) handle backed by this provider set.
209    ///
210    /// Routes cross-market snapshots through
211    /// [`Capability::QUOTE`](crate::Capability::QUOTE). Needs a provider whose
212    /// snapshot endpoint spans asset classes, currently Polygon alone.
213    ///
214    /// Compiled in unconditionally. With no built-in provider for this
215    /// capability, register one with [`ProvidersBuilder::with_adapter`] or
216    /// enable its feature, or these calls return `NoProviderAvailable`.
217    pub fn snapshot(&self) -> crate::domains::Snapshot {
218        crate::domains::Snapshot::with_providers(Arc::clone(&self.set))
219    }
220
221    /// Create a [`Filings`](crate::Filings) handle backed by this provider set.
222    ///
223    /// Always available — EDGAR is auto-injected when no other FILINGS provider
224    /// is configured.
225    pub fn filings(&self, symbol: impl Into<String>) -> crate::domains::Filings {
226        crate::domains::Filings::with_providers(symbol.into().into(), Arc::clone(&self.set))
227    }
228
229    /// Snapshot recent health for every configured provider.
230    ///
231    /// Each [`ProviderHealth`] entry reflects up to the last 20 dispatch
232    /// outcomes recorded in-process for that provider (recency window is
233    /// internal and unspecified beyond "recent"), plus a best-effort
234    /// rate-limit budget estimate where the provider exposes one. Purely
235    /// observational — it does not affect routing or retries.
236    ///
237    /// # Example
238    ///
239    /// ```no_run
240    /// use finance_query::Providers;
241    ///
242    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
243    /// let providers = Providers::builder().build().await?;
244    /// for health in providers.health() {
245    ///     println!("{:?}: healthy={}", health.provider, health.is_healthy);
246    /// }
247    /// # Ok(())
248    /// # }
249    /// ```
250    pub fn health(&self) -> Vec<ProviderHealth> {
251        self.set.health()
252    }
253}
254
255/// Builder for [`Providers`].
256pub struct ProvidersBuilder {
257    provider_ids: Vec<Provider>,
258    adapters: Vec<Arc<dyn crate::ProviderAdapter>>,
259    config: ClientConfig,
260    routes: Routes,
261    retry: Option<RetryPolicy>,
262    api_keys: std::collections::HashMap<&'static str, String>,
263    request_limits: std::collections::HashMap<&'static str, u32>,
264    endpoints: std::collections::HashMap<&'static str, String>,
265}
266
267impl std::fmt::Debug for ProvidersBuilder {
268    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
269        f.debug_struct("ProvidersBuilder")
270            .field("provider_ids", &self.provider_ids)
271            .field(
272                "adapters",
273                &self.adapters.iter().map(|a| a.id()).collect::<Vec<_>>(),
274            )
275            .field("routes", &self.routes)
276            .field("retry", &self.retry)
277            .field(
278                "api_keys",
279                &self.api_keys.keys().copied().collect::<Vec<_>>(),
280            )
281            .finish()
282    }
283}
284
285impl Default for ProvidersBuilder {
286    fn default() -> Self {
287        Self {
288            provider_ids: vec![Provider::Yahoo],
289            adapters: Vec::new(),
290            config: ClientConfig::default(),
291            routes: Routes::new(Fetch::Sequential),
292            retry: None,
293            api_keys: std::collections::HashMap::new(),
294            request_limits: std::collections::HashMap::new(),
295            endpoints: std::collections::HashMap::new(),
296        }
297    }
298}
299
300/// Reject a builder option set for a provider that is not configured or would ignore it.
301fn check_scoped_options(
302    set: &ProviderSet,
303    option: &str,
304    providers: &[&str],
305    accepts: impl Fn(&dyn ProviderAdapter) -> bool,
306) -> Result<()> {
307    for key in providers {
308        let adapter = Provider::from_id_str(key).and_then(|id| set.adapter(id));
309        if !adapter.is_some_and(|p| accepts(p.as_ref())) {
310            return Err(crate::FinanceError::InvalidParameter {
311                param: option.into(),
312                reason: format!("provider `{key}` is not configured or does not support {option}"),
313            });
314        }
315    }
316    Ok(())
317}
318
319impl ProvidersBuilder {
320    /// Select initialized providers instead of the default Yahoo provider.
321    /// Providers already named in routes are retained.
322    pub fn providers(mut self, providers: impl IntoIterator<Item = Provider>) -> Self {
323        self.provider_ids = providers.into_iter().collect();
324        for route in self.routes.map.values() {
325            for provider in &route.providers {
326                if !matches!(provider, Provider::Custom(_)) && !self.provider_ids.contains(provider)
327                {
328                    self.provider_ids.push(*provider);
329                }
330            }
331        }
332        self
333    }
334    /// Set the request budget for this provider's API key. Zero is rejected by build.
335    ///
336    /// Instances using the same key share one bucket: the most recently built
337    /// explicit budget sets its rate, and instances without one never change it.
338    /// Requires an explicit [`api_key`](Self::api_key) and a provider whose
339    /// adapter [accepts a budget](crate::ProviderAdapter::accepts_request_budget).
340    pub fn requests_per_minute(mut self, provider: Provider, limit: u32) -> Self {
341        self.request_limits.insert(provider.as_str(), limit);
342        self
343    }
344
345    /// Send this provider's requests to a compatible service or local HTTP fixture.
346    ///
347    /// Only HTTPS and loopback HTTP origins are accepted, and continuations cannot
348    /// leave the origin. No global endpoint is modified. Requires an explicit
349    /// [`api_key`](Self::api_key) and a provider whose adapter
350    /// [accepts an endpoint](crate::ProviderAdapter::accepts_endpoint).
351    pub fn endpoint(mut self, provider: Provider, base_url: impl Into<String>) -> Self {
352        self.endpoints.insert(provider.as_str(), base_url.into());
353        self
354    }
355    /// Configure how providers are queried. Default: `Sequential`.
356    ///
357    /// Use [`Fetch::Sequential`] or [`Fetch::Parallel`].
358    pub fn fetch(mut self, mode: Fetch) -> Self {
359        self.routes.fetch = mode;
360        self
361    }
362
363    /// Set `provider`'s API key for the [`Providers`] this builds, instead of
364    /// the process-global key from its `init` function.
365    ///
366    /// Two `Providers` can hold different keys for the same provider, and a
367    /// key can be replaced without restarting. Each distinct key gets its own
368    /// rate-limit budget. Providers left unset fall back to the process-global
369    /// key, then to the environment variable.
370    pub fn api_key(mut self, provider: Provider, key: impl Into<String>) -> Self {
371        self.api_keys.insert(provider.as_str(), key.into());
372        self
373    }
374
375    /// Route a capability to a specific provider priority list.
376    ///
377    /// Providers referenced in the route are automatically added to the
378    /// initialisation list if not already present. If omitted for a capability,
379    /// Yahoo is used as default.
380    pub fn route(
381        self,
382        cap: crate::providers::Capability,
383        providers: impl IntoIterator<Item = Provider>,
384    ) -> Self {
385        self.insert_route(cap, providers, None)
386    }
387
388    /// Route a capability, overriding [`fetch`](Self::fetch) for it alone.
389    ///
390    /// Lets a quota-limited capability stay sequential while another races
391    /// its providers.
392    pub fn route_with(
393        self,
394        cap: crate::providers::Capability,
395        providers: impl IntoIterator<Item = Provider>,
396        fetch: Fetch,
397    ) -> Self {
398        self.insert_route(cap, providers, Some(fetch))
399    }
400
401    fn insert_route(
402        mut self,
403        cap: crate::providers::Capability,
404        providers: impl IntoIterator<Item = Provider>,
405        fetch: Option<Fetch>,
406    ) -> Self {
407        let providers: Vec<Provider> = providers.into_iter().collect();
408        for provider in &providers {
409            // A Custom id has no arm in `build_providers`; it arrives only
410            // through `with_adapter`.
411            if matches!(provider, Provider::Custom(_)) {
412                continue;
413            }
414            if !self.provider_ids.contains(provider) {
415                self.provider_ids.push(*provider);
416            }
417        }
418        self.routes
419            .map
420            .insert(cap, super::routes::Route { providers, fetch });
421        self
422    }
423
424    /// Set the region (automatically sets lang and region code).
425    pub fn region(mut self, region: crate::constants::Region) -> Self {
426        self.config.lang = region.lang().to_string();
427        self.config.region = region.region().to_string();
428        self
429    }
430
431    /// Set the language code (e.g., "en-US", "ja-JP").
432    ///
433    /// Inherited by every `Ticker`/`Tickers` handle created from the built
434    /// [`Providers`]. With the `translation` feature, a non-English language
435    /// translates text fields on those handles automatically.
436    pub fn lang(mut self, lang: impl Into<String>) -> Self {
437        self.config.lang = lang.into();
438        self
439    }
440
441    /// Set the region code (e.g., "US", "JP", "DE").
442    ///
443    /// For standard countries, prefer `.region()` instead to ensure correct
444    /// lang/region pairing.
445    pub fn region_code(mut self, region: impl Into<String>) -> Self {
446        self.config.region = region.into();
447        self
448    }
449
450    /// Set the HTTP request timeout.
451    pub fn timeout(mut self, t: Duration) -> Self {
452        self.config.timeout = t;
453        self
454    }
455
456    /// Set the proxy URL.
457    pub fn proxy(mut self, p: impl Into<String>) -> Self {
458        self.config.proxy = Some(p.into());
459        self
460    }
461
462    /// Opt into retrying `FinanceError::RateLimited` errors during dispatch
463    /// See [`RetryPolicy`] for the exact semantics.
464    ///
465    /// **Default is no retry** — omitting this call preserves the exact
466    /// prior behavior: a `RateLimited` error is treated like any other
467    /// failure and dispatch moves straight to the next routed provider.
468    pub fn retry(mut self, policy: RetryPolicy) -> Self {
469        self.retry = Some(policy);
470        self
471    }
472
473    /// Register an adapter this crate does not build itself.
474    ///
475    /// Route to it by the id its [`ProviderCore::id`](crate::ProviderCore::id)
476    /// returns, usually [`Provider::Custom`]. Registering alone does not route
477    /// anything: a capability with no explicit route still falls back to its
478    /// default provider.
479    ///
480    /// ```no_run
481    /// # use std::sync::Arc;
482    /// # use finance_query::{Capability, Provider, Providers, ProviderAdapter};
483    /// # async fn f(my_adapter: Arc<dyn ProviderAdapter>) -> finance_query::Result<()> {
484    /// let providers = Providers::builder()
485    ///     .with_adapter(my_adapter)
486    ///     .route(Capability::ECONOMIC, [Provider::custom("my-source")])
487    ///     .build()
488    ///     .await?;
489    /// # let _ = providers;
490    /// # Ok(())
491    /// # }
492    /// ```
493    #[must_use]
494    pub fn with_adapter(mut self, adapter: Arc<dyn crate::ProviderAdapter>) -> Self {
495        self.adapters.push(adapter);
496        self
497    }
498
499    /// Build the [`Providers`] instance, initialising all configured providers.
500    pub async fn build(self) -> Result<Providers> {
501        for provider in self.request_limits.keys().chain(self.endpoints.keys()) {
502            if !self.api_keys.contains_key(provider) {
503                return Err(crate::FinanceError::InvalidParameter {
504                    param: "provider_options".into(),
505                    reason: format!("options for `{provider}` require an explicit API key"),
506                });
507            }
508        }
509        let budgets: Vec<&'static str> = self.request_limits.keys().copied().collect();
510        let endpoints: Vec<&'static str> = self.endpoints.keys().copied().collect();
511        if self.request_limits.values().any(|limit| *limit == 0) {
512            return Err(crate::FinanceError::InvalidParameter {
513                param: "requests_per_minute".into(),
514                reason: "limit must be positive".into(),
515            });
516        }
517        for endpoint in self.endpoints.values() {
518            let valid = reqwest::Url::parse(endpoint).is_ok_and(|url| {
519                (url.scheme() == "https"
520                    || (url.scheme() == "http"
521                        && matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "[::1]"))))
522                    && url.host_str().is_some()
523                    && url.username().is_empty()
524                    && url.password().is_none()
525                    && url.query().is_none()
526                    && url.fragment().is_none()
527                    && url.path() == "/"
528            });
529            if !valid {
530                return Err(crate::FinanceError::InvalidParameter {
531                    param: "endpoint".into(),
532                    reason: "expected an HTTPS or loopback HTTP origin without credentials".into(),
533                });
534            }
535        }
536        #[cfg(feature = "translation")]
537        crate::translation::Lang::parse(&self.config.lang)?;
538        for adapter in &self.adapters {
539            let id = adapter.id();
540            // `from_id_str` resolves interned custom ids too, so ask the
541            // built-in list directly.
542            if let Provider::Custom(custom) = id
543                && Provider::all()
544                    .iter()
545                    .any(|p| p.as_str() == custom.as_str())
546            {
547                return Err(crate::FinanceError::InvalidParameter {
548                    param: "adapter".to_string(),
549                    reason: format!(
550                        "custom provider id `{}` collides with a built-in",
551                        custom.as_str()
552                    ),
553                });
554            }
555            let duplicate = self.provider_ids.contains(&id)
556                || self.adapters.iter().filter(|a| a.id() == id).count() > 1;
557            if duplicate {
558                return Err(crate::FinanceError::InvalidParameter {
559                    param: "adapter".to_string(),
560                    reason: format!("provider id `{id}` is already configured"),
561                });
562            }
563        }
564        let lang = self.config.lang.clone();
565        let mut keys = crate::adapters::keys::KeyMap::new();
566        for (provider_key, api_key) in self.api_keys {
567            if api_key.trim().is_empty() {
568                return Err(crate::FinanceError::InvalidParameter {
569                    param: provider_key.to_string(),
570                    reason: "API key must not be empty".to_string(),
571                });
572            }
573            let mut key = crate::adapters::keys::ScopedKey::new(api_key, self.config.timeout);
574            key.rpm = self.request_limits.get(provider_key).copied();
575            key.base_url = self
576                .endpoints
577                .get(provider_key)
578                .map(|s| s.trim_end_matches('/').to_string());
579            keys.insert(provider_key, key);
580        }
581        // `initialize` builds each keyed client, so the scope has to cover
582        // construction as well as dispatch.
583        let set = crate::adapters::keys::scope(
584            Arc::new(keys.clone()),
585            build_providers(&self.provider_ids, self.adapters, &self.config, self.routes),
586        )
587        .await?
588        .with_api_keys(keys)
589        .with_retry_policy(self.retry);
590        check_scoped_options(&set, "requests_per_minute", &budgets, |p| {
591            p.accepts_request_budget()
592        })?;
593        check_scoped_options(&set, "endpoint", &endpoints, |p| p.accepts_endpoint())?;
594        Ok(Providers {
595            set: Arc::new(set),
596            lang,
597        })
598    }
599}