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