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}