Skip to main content

finance_query/providers/
mod.rs

1//! Multi-provider financial data aggregation.
2
3pub mod config;
4
5#[cfg(feature = "alphavantage")]
6pub(crate) mod alphavantage;
7#[cfg(feature = "crypto")]
8pub(crate) mod coingecko;
9pub(crate) mod edgar;
10#[cfg(feature = "fmp")]
11pub(crate) mod fmp;
12#[cfg(feature = "fred")]
13pub(crate) mod fred;
14#[cfg(feature = "polygon")]
15pub(crate) mod polygon;
16pub(crate) mod types;
17pub(crate) mod yahoo;
18
19use crate::adapters::yahoo::client::{ClientConfig, YahooClient};
20use crate::error::{FinanceError, Result};
21use crate::models::quote::QuoteSummaryResponse;
22use futures::stream::StreamExt;
23use serde::{Deserialize, Serialize};
24use std::collections::HashMap;
25use std::sync::Arc;
26
27/// Typed identifier for a financial data provider.
28///
29/// Variants are feature-gated: unavailable providers are excluded at compile time.
30#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
31pub enum Provider {
32    #[default]
33    /// Yahoo Finance (always available).
34    Yahoo,
35    /// Polygon.io (requires `polygon` feature).
36    #[cfg(feature = "polygon")]
37    Polygon,
38    /// Financial Modeling Prep (requires `fmp` feature).
39    #[cfg(feature = "fmp")]
40    Fmp,
41    /// Alpha Vantage (requires `alphavantage` feature).
42    #[cfg(feature = "alphavantage")]
43    AlphaVantage,
44    /// CoinGecko cryptocurrency data (requires `crypto` feature).
45    #[cfg(feature = "crypto")]
46    CoinGecko,
47    /// FRED economic data (requires `fred` feature).
48    #[cfg(feature = "fred")]
49    Fred,
50    /// SEC EDGAR filings (always available, keyless).
51    Edgar,
52}
53
54impl Provider {
55    /// Parse a provider id string back to the typed variant.
56    /// Returns `None` if the string doesn't match any known provider.
57    /// Prefer this over string conversion to avoid panics.
58    pub fn from_id_str(s: &str) -> Option<Self> {
59        match s {
60            "yahoo" => Some(Self::Yahoo),
61            #[cfg(feature = "polygon")]
62            "polygon" => Some(Self::Polygon),
63            #[cfg(feature = "fmp")]
64            "fmp" => Some(Self::Fmp),
65            #[cfg(feature = "alphavantage")]
66            "alphavantage" => Some(Self::AlphaVantage),
67            #[cfg(feature = "crypto")]
68            "coingecko" => Some(Self::CoinGecko),
69            #[cfg(feature = "fred")]
70            "fred" => Some(Self::Fred),
71            "edgar" => Some(Self::Edgar),
72            _ => None,
73        }
74    }
75
76    /// String identifier matching [`ProviderAdapter::id`].
77    pub fn as_str(self) -> &'static str {
78        match self {
79            Self::Yahoo => "yahoo",
80            #[cfg(feature = "polygon")]
81            Self::Polygon => "polygon",
82            #[cfg(feature = "fmp")]
83            Self::Fmp => "fmp",
84            #[cfg(feature = "alphavantage")]
85            Self::AlphaVantage => "alphavantage",
86            #[cfg(feature = "crypto")]
87            Self::CoinGecko => "coingecko",
88            #[cfg(feature = "fred")]
89            Self::Fred => "fred",
90            Self::Edgar => "edgar",
91        }
92    }
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96/// How providers are queried.
97pub enum Fetch {
98    /// Try providers in priority order; first success wins.
99    Sequential,
100    /// Fire all providers concurrently; first success wins.
101    Parallel,
102}
103
104/// Capability bits that a provider can declare.
105///
106/// Route a capability to specific providers using `.route(Capability::QUOTE, &[Provider::Fmp])`.
107/// If no route is configured for a capability, all providers declaring that capability are used.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
109pub struct Capability(u32);
110
111impl Capability {
112    /// Equity quote data — price, volume, market cap, fundamentals summary.
113    pub const QUOTE: Self = Self(1 << 0);
114    /// Historical OHLCV chart data across intervals and ranges.
115    pub const CHART: Self = Self(1 << 1);
116    /// Financial statements — income, balance sheet, cash flow.
117    pub const FUNDAMENTALS: Self = Self(1 << 2);
118    /// Corporate events — news, recommendations, SEC filings metadata.
119    pub const CORPORATE: Self = Self(1 << 3);
120    /// Options chains and contract data.
121    pub const OPTIONS: Self = Self(1 << 4);
122    // bit 5 reserved for future use
123
124    /// Cryptocurrency quotes and market data.
125    pub const CRYPTO: Self = Self(1 << 6);
126    /// Macro-economic data series (FRED, GDP, CPI, etc.).
127    pub const ECONOMIC: Self = Self(1 << 7);
128    // bit 8 reserved for future use
129
130    /// Foreign exchange currency pair quotes.
131    pub const FOREX: Self = Self(1 << 9);
132    /// Stock market index quotes (S&P 500, NASDAQ, etc.).
133    pub const INDICES: Self = Self(1 << 10);
134    /// Futures contract quotes.
135    pub const FUTURES: Self = Self(1 << 11);
136    /// Commodity price quotes (gold, oil, etc.).
137    pub const COMMODITIES: Self = Self(1 << 12);
138    // bit 13 reserved for future use
139
140    /// SEC EDGAR filing data.
141    pub const FILINGS: Self = Self(1 << 14);
142
143    /// Returns `true` if this capability set includes all bits in `other`.
144    pub const fn contains(self, other: Self) -> bool {
145        (self.0 & other.0) == other.0
146    }
147
148    /// Returns a short lowercase name for this capability (e.g., `"quote"`, `"chart"`).
149    ///
150    /// Returns `"unknown"` for combined capability flags or unrecognised bits.
151    pub fn name(self) -> &'static str {
152        match self.0 {
153            x if x == Self::QUOTE.0 => "quote",
154            x if x == Self::CHART.0 => "chart",
155            x if x == Self::FUNDAMENTALS.0 => "fundamentals",
156            x if x == Self::CORPORATE.0 => "corporate",
157            x if x == Self::OPTIONS.0 => "options",
158            x if x == Self::CRYPTO.0 => "crypto",
159            x if x == Self::ECONOMIC.0 => "economic",
160            x if x == Self::FOREX.0 => "forex",
161            x if x == Self::INDICES.0 => "indices",
162            x if x == Self::FUTURES.0 => "futures",
163            x if x == Self::COMMODITIES.0 => "commodities",
164            x if x == Self::FILINGS.0 => "filings",
165            _ => "unknown",
166        }
167    }
168}
169
170impl std::ops::BitOr for Capability {
171    type Output = Self;
172    fn bitor(self, rhs: Self) -> Self {
173        Self(self.0 | rhs.0)
174    }
175}
176
177/// Per-capability provider routing table.
178///
179/// Maps each [`Capability`] to an ordered list of [`Provider`]s to try.
180/// When a capability has no entry, all providers declaring that capability are used.
181pub struct Routes {
182    pub(crate) map: HashMap<Capability, Vec<Provider>>,
183    pub(crate) fetch: Fetch,
184}
185
186impl Routes {
187    pub fn new(fetch: Fetch) -> Self {
188        Self {
189            map: HashMap::new(),
190            fetch,
191        }
192    }
193}
194
195#[async_trait::async_trait]
196pub(crate) trait ProviderAdapter: Send + Sync {
197    fn id(&self) -> &'static str;
198    fn capabilities(&self) -> Capability;
199
200    /// Initialize this provider. Called once during construction.
201    async fn initialize(&self) -> Result<()> {
202        Ok(())
203    }
204
205    fn not_supported(&self, operation: &'static str) -> FinanceError {
206        FinanceError::NotSupported {
207            provider: self.id(),
208            operation,
209        }
210    }
211
212    // Single-ticker quote routing; Ticker uses first_yahoo() directly for crumb auth.
213    // Wired up for future multi-provider single-ticker quote routing.
214    async fn fetch_quote(&self, _: &str) -> Result<QuoteSummaryResponse> {
215        Err(self.not_supported("quote"))
216    }
217    async fn fetch_chart(
218        &self,
219        _: &str,
220        _: crate::Interval,
221        _: crate::TimeRange,
222    ) -> Result<crate::models::chart::Chart> {
223        Err(self.not_supported("chart"))
224    }
225    async fn fetch_chart_range(
226        &self,
227        _: &str,
228        _: crate::Interval,
229        _: i64,
230        _: i64,
231    ) -> Result<crate::models::chart::Chart> {
232        Err(self.not_supported("chart_range"))
233    }
234    async fn fetch_financials(
235        &self,
236        _: &str,
237        _: crate::StatementType,
238        _: crate::Frequency,
239    ) -> Result<crate::models::fundamentals::FinancialStatement> {
240        Err(self.not_supported("financials"))
241    }
242    async fn fetch_news(&self, _: &str) -> Result<Vec<crate::models::corporate::news::News>> {
243        Err(self.not_supported("news"))
244    }
245    async fn fetch_similar_symbols(
246        &self,
247        _: &str,
248        _: u32,
249    ) -> Result<Vec<crate::models::corporate::recommendation::SimilarSymbol>> {
250        Err(self.not_supported("recommendations"))
251    }
252    async fn fetch_options(
253        &self,
254        _: &str,
255        _: Option<i64>,
256    ) -> Result<crate::models::options::Options> {
257        Err(self.not_supported("options"))
258    }
259    async fn fetch_events(&self, _: &str) -> Result<crate::models::chart::events::ChartEvents> {
260        Err(self.not_supported("events"))
261    }
262    /// Fetch quotes for multiple symbols in a single request.
263    /// Returns `(symbol, QuoteSummaryResponse)` pairs — only partially populated
264    /// (price module only) since batch endpoints don't return full quoteSummary data.
265    async fn fetch_quotes_batch(&self, _: &[&str]) -> Result<Vec<(String, QuoteSummaryResponse)>> {
266        Err(self.not_supported("quotes_batch"))
267    }
268
269    /// Fetch lightweight sparkline data for multiple symbols in a single request.
270    /// Returns successfully-parsed `(symbol, Spark)` pairs; callers fill in
271    /// missing-symbol errors for any symbol absent from the result.
272    async fn fetch_spark(
273        &self,
274        _: &[&str],
275        _: crate::Interval,
276        _: crate::TimeRange,
277    ) -> Result<Vec<(String, crate::models::chart::spark::Spark)>> {
278        Err(self.not_supported("spark"))
279    }
280
281    #[cfg(any(
282        feature = "crypto",
283        feature = "alphavantage",
284        feature = "fmp",
285        feature = "polygon"
286    ))]
287    async fn fetch_crypto_quote(
288        &self,
289        _: &str,
290        _: &str,
291    ) -> Result<crate::models::crypto::CryptoQuote> {
292        Err(self.not_supported("crypto_quote"))
293    }
294
295    #[cfg(any(feature = "fred", feature = "alphavantage", feature = "polygon"))]
296    async fn fetch_economic_series(
297        &self,
298        _: &str,
299    ) -> Result<crate::models::economic::EconomicSeries> {
300        Err(self.not_supported("economic_series"))
301    }
302
303    #[cfg(any(feature = "polygon", feature = "fmp", feature = "alphavantage"))]
304    async fn fetch_forex_quote(
305        &self,
306        _from: &str,
307        _to: &str,
308    ) -> Result<crate::models::forex::ForexQuote> {
309        Err(self.not_supported("forex_quote"))
310    }
311
312    #[cfg(any(feature = "polygon", feature = "fmp"))]
313    async fn fetch_indices_quote(&self, _: &str) -> Result<crate::models::indices::IndexQuote> {
314        Err(self.not_supported("indices_quote"))
315    }
316
317    #[cfg(feature = "polygon")]
318    async fn fetch_futures_quote(&self, _: &str) -> Result<crate::models::futures::FuturesQuote> {
319        Err(self.not_supported("futures_quote"))
320    }
321
322    #[cfg(any(feature = "fmp", feature = "alphavantage"))]
323    async fn fetch_commodities_quote(
324        &self,
325        _: &str,
326    ) -> Result<crate::models::commodities::CommodityQuote> {
327        Err(self.not_supported("commodities_quote"))
328    }
329
330    async fn fetch_filings(&self, _: &str) -> Result<crate::models::filings::ProviderFilings> {
331        Err(self.not_supported("filings"))
332    }
333}
334
335pub(crate) struct ProviderSet {
336    providers: Vec<Arc<dyn ProviderAdapter>>,
337    yahoo_client: Option<Arc<YahooClient>>,
338    routes: Routes,
339}
340
341impl ProviderSet {
342    pub fn new(
343        providers: Vec<Arc<dyn ProviderAdapter>>,
344        yahoo_client: Option<Arc<YahooClient>>,
345        routes: Routes,
346    ) -> Self {
347        Self {
348            providers,
349            yahoo_client,
350            routes,
351        }
352    }
353
354    /// Returns the providers to use for a given capability, respecting any
355    /// explicit route configured via `.route()`. When no route is configured,
356    /// defaults to Yahoo for all capabilities and EDGAR for filings.
357    fn candidates_for(&self, cap: Capability) -> Vec<&Arc<dyn ProviderAdapter>> {
358        if let Some(provider_ids) = self.routes.map.get(&cap) {
359            provider_ids
360                .iter()
361                .filter_map(|id| self.providers.iter().find(|p| p.id() == id.as_str()))
362                .collect()
363        } else if cap == Capability::FILINGS {
364            // Default: EDGAR (keyless SEC filings) first, then Yahoo
365            let mut v: Vec<&Arc<dyn ProviderAdapter>> = self
366                .providers
367                .iter()
368                .filter(|p| p.id() == "edgar")
369                .collect();
370            v.extend(self.providers.iter().filter(|p| p.id() == "yahoo"));
371            v
372        } else {
373            // Default: Yahoo only
374            self.providers
375                .iter()
376                .filter(|p| p.id() == "yahoo")
377                .collect()
378        }
379    }
380
381    fn no_provider(cap: Capability) -> FinanceError {
382        FinanceError::NoProviderAvailable {
383            operation: cap.name(),
384        }
385    }
386
387    fn finish_err(cap: Capability, last: Option<FinanceError>) -> FinanceError {
388        last.unwrap_or_else(|| Self::no_provider(cap))
389    }
390
391    pub(crate) async fn fetch<T, F, Fut>(&self, cap: Capability, f: F) -> Result<T>
392    where
393        F: Fn(&Arc<dyn ProviderAdapter>) -> Fut,
394        Fut: std::future::Future<Output = Result<T>>,
395    {
396        let candidates = self.candidates_for(cap);
397        if candidates.is_empty() {
398            return Err(Self::no_provider(cap));
399        }
400        match self.routes.fetch {
401            Fetch::Sequential => {
402                let mut last = None;
403                for p in &candidates {
404                    match f(p).await {
405                        Ok(v) => return Ok(v),
406                        Err(FinanceError::NotSupported { .. }) => continue,
407                        Err(e) => last = Some(e),
408                    }
409                }
410                Err(Self::finish_err(cap, last))
411            }
412            Fetch::Parallel => {
413                let mut futs = futures::stream::FuturesUnordered::new();
414                for p in &candidates {
415                    futs.push(f(p));
416                }
417                let mut last = None;
418                while let Some(r) = futs.next().await {
419                    match r {
420                        Ok(v) => return Ok(v),
421                        Err(FinanceError::NotSupported { .. }) => continue,
422                        Err(e) => last = Some(e),
423                    }
424                }
425                Err(Self::finish_err(cap, last))
426            }
427        }
428    }
429
430    pub(crate) fn first_yahoo(&self) -> Result<Arc<YahooClient>> {
431        self.yahoo_client
432            .as_ref()
433            .map(Arc::clone)
434            .ok_or_else(|| FinanceError::NoProviderAvailable { operation: "yahoo" })
435    }
436}
437
438#[allow(dead_code)] // used by fmp, polygon, alphavantage feature-gated providers
439pub(crate) fn json_value_to_f64(value: serde_json::Value) -> Option<f64> {
440    value
441        .as_f64()
442        .or_else(|| value.as_i64().map(|v| v as f64))
443        .or_else(|| value.as_u64().map(|v| v as f64))
444        .or_else(|| value.as_str().and_then(|s| s.parse::<f64>().ok()))
445        .or_else(|| {
446            value
447                .get("raw")
448                .and_then(|raw| raw.as_f64().or_else(|| raw.as_i64().map(|v| v as f64)))
449        })
450}
451
452#[allow(dead_code)] // used by fmp, polygon, alphavantage feature-gated providers
453pub(crate) fn build_financial_statement(
454    symbol: String,
455    statement_type: String,
456    frequency: String,
457    provider_id: Provider,
458    data: std::collections::HashMap<String, std::collections::HashMap<String, serde_json::Value>>,
459) -> crate::models::fundamentals::FinancialStatement {
460    let statement = data
461        .into_iter()
462        .filter_map(|(metric, values)| {
463            let values: std::collections::HashMap<String, f64> = values
464                .into_iter()
465                .filter_map(|(date, value)| json_value_to_f64(value).map(|v| (date, v)))
466                .collect();
467            if values.is_empty() {
468                None
469            } else {
470                Some((metric, values))
471            }
472        })
473        .collect();
474    crate::models::fundamentals::FinancialStatement {
475        symbol,
476        statement_type,
477        frequency,
478        statement,
479        provider_id: Some(provider_id),
480    }
481}
482
483pub(crate) fn build_options(
484    symbol: String,
485    provider_id: Provider,
486    expiration_dates: Vec<i64>,
487    calls: Vec<crate::models::options::OptionContract>,
488    puts: Vec<crate::models::options::OptionContract>,
489) -> crate::models::options::Options {
490    use std::collections::BTreeMap;
491
492    let mut chains_by_expiration: BTreeMap<
493        i64,
494        (
495            Vec<crate::models::options::OptionContract>,
496            Vec<crate::models::options::OptionContract>,
497        ),
498    > = BTreeMap::new();
499
500    for contract in calls {
501        let exp = contract.expiration.unwrap_or(0);
502        chains_by_expiration
503            .entry(exp)
504            .or_default()
505            .0
506            .push(contract);
507    }
508    for contract in puts {
509        let exp = contract.expiration.unwrap_or(0);
510        chains_by_expiration
511            .entry(exp)
512            .or_default()
513            .1
514            .push(contract);
515    }
516
517    let option_chains: Vec<crate::models::options::response::OptionChainData> =
518        chains_by_expiration
519            .into_iter()
520            .map(
521                |(expiration, (c, p))| crate::models::options::response::OptionChainData {
522                    expiration_date: expiration,
523                    has_mini_options: None,
524                    calls: Some(c),
525                    puts: Some(p),
526                },
527            )
528            .collect();
529
530    let expiration_dates = if expiration_dates.is_empty() {
531        option_chains
532            .iter()
533            .map(|chain| chain.expiration_date)
534            .collect()
535    } else {
536        let mut v: Vec<i64> = expiration_dates;
537        v.sort_unstable();
538        v.dedup();
539        v
540    };
541
542    let mut strikes: Vec<f64> = option_chains
543        .iter()
544        .flat_map(|chain| {
545            chain
546                .calls
547                .as_deref()
548                .unwrap_or_default()
549                .iter()
550                .map(|c| c.strike)
551                .chain(
552                    chain
553                        .puts
554                        .as_deref()
555                        .unwrap_or_default()
556                        .iter()
557                        .map(|p| p.strike),
558                )
559        })
560        .collect();
561    strikes.sort_by(|a, b| a.total_cmp(b));
562    strikes.dedup_by(|a, b| a.total_cmp(b).is_eq());
563
564    let result = crate::models::options::response::OptionChainResult {
565        underlying_symbol: Some(symbol),
566        expiration_dates: Some(expiration_dates),
567        strikes: Some(strikes),
568        has_mini_options: None,
569        quote: None,
570        options: option_chains,
571    };
572
573    crate::models::options::Options {
574        option_chain: crate::models::options::response::OptionChainContainer {
575            result: vec![result],
576            error: None,
577        },
578        provider_id: Some(provider_id),
579    }
580}
581
582#[allow(dead_code)] // used by fmp feature-gated provider
583pub(crate) fn range_to_dates(range: crate::TimeRange) -> (String, String) {
584    use chrono::{Datelike, Utc};
585    let end = Utc::now();
586    if range == crate::TimeRange::YearToDate {
587        let year = end.year();
588        let start = chrono::NaiveDate::from_ymd_opt(year, 1, 1)
589            .and_then(|d| d.and_hms_opt(0, 0, 0))
590            .map(|dt| dt.and_utc())
591            .unwrap_or(end);
592        return (
593            start.format("%Y-%m-%d").to_string(),
594            end.format("%Y-%m-%d").to_string(),
595        );
596    }
597    let days = match range {
598        crate::TimeRange::OneDay => 1,
599        crate::TimeRange::FiveDays => 5,
600        crate::TimeRange::OneMonth => 30,
601        crate::TimeRange::ThreeMonths => 90,
602        crate::TimeRange::SixMonths => 180,
603        crate::TimeRange::OneYear => 365,
604        crate::TimeRange::TwoYears => 730,
605        crate::TimeRange::FiveYears => 1825,
606        crate::TimeRange::TenYears => 3650,
607        crate::TimeRange::Max => 36500,
608        crate::TimeRange::YearToDate => unreachable!("YTD handled by early return above"),
609    };
610    let start = end - chrono::Duration::days(days);
611    (
612        start.format("%Y-%m-%d").to_string(),
613        end.format("%Y-%m-%d").to_string(),
614    )
615}
616
617pub(crate) async fn build_providers(
618    ids: &[Provider],
619    config: &ClientConfig,
620    routes: Routes,
621) -> Result<ProviderSet> {
622    use yahoo::YahooProvider;
623    let mut providers: Vec<Arc<dyn ProviderAdapter>> = Vec::new();
624    let mut yahoo_client: Option<Arc<YahooClient>> = None;
625    for &id in ids {
626        match id {
627            Provider::Yahoo => {
628                let yp = YahooProvider::new(config).await?;
629                yahoo_client = Some(yp.client_arc());
630                providers.push(Arc::new(yp));
631            }
632            #[cfg(feature = "polygon")]
633            Provider::Polygon => {
634                let pp = polygon::PolygonProvider;
635                pp.initialize().await?;
636                providers.push(Arc::new(pp));
637            }
638            #[cfg(feature = "fmp")]
639            Provider::Fmp => {
640                let fp = fmp::FmpProvider;
641                fp.initialize().await?;
642                providers.push(Arc::new(fp));
643            }
644            #[cfg(feature = "alphavantage")]
645            Provider::AlphaVantage => {
646                let av = alphavantage::AlphaVantageProvider;
647                av.initialize().await?;
648                providers.push(Arc::new(av));
649            }
650            #[cfg(feature = "crypto")]
651            Provider::CoinGecko => providers.push(Arc::new(coingecko::CoinGeckoProvider)),
652            #[cfg(feature = "fred")]
653            Provider::Fred => {
654                let fp = fred::FredProvider;
655                fp.initialize().await?;
656                providers.push(Arc::new(fp));
657            }
658            Provider::Edgar => providers.push(Arc::new(edgar::EdgarProvider)),
659        }
660    }
661    // Auto-inject EDGAR if no other FILINGS-capable provider was configured
662    let has_filings = providers
663        .iter()
664        .any(|p| p.capabilities().contains(Capability::FILINGS));
665    if !has_filings {
666        providers.push(Arc::new(edgar::EdgarProvider));
667    }
668    Ok(ProviderSet::new(providers, yahoo_client, routes))
669}
670
671#[cfg(test)]
672mod tests {
673    use super::*;
674
675    /// A CHART-capable provider that does not implement spark — exercises the
676    /// default trait method and proves spark now dispatches through the set.
677    struct NoSparkProvider;
678
679    #[async_trait::async_trait]
680    impl ProviderAdapter for NoSparkProvider {
681        fn id(&self) -> &'static str {
682            "yahoo"
683        }
684        fn capabilities(&self) -> Capability {
685            Capability::CHART
686        }
687    }
688
689    #[tokio::test]
690    async fn fetch_spark_defaults_to_not_supported() {
691        let err = NoSparkProvider
692            .fetch_spark(
693                &["AAPL"],
694                crate::Interval::OneDay,
695                crate::TimeRange::FiveDays,
696            )
697            .await
698            .unwrap_err();
699        assert!(matches!(
700            err,
701            FinanceError::NotSupported {
702                operation: "spark",
703                ..
704            }
705        ));
706    }
707
708    #[tokio::test]
709    async fn spark_routes_through_provider_set() {
710        // The CHART default route resolves to the "yahoo"-id provider; routing a
711        // provider that lacks spark must surface an error rather than silently
712        // hitting a hardcoded Yahoo client.
713        let set = ProviderSet::new(
714            vec![Arc::new(NoSparkProvider)],
715            None,
716            Routes::new(Fetch::Sequential),
717        );
718        let result = set
719            .fetch(Capability::CHART, |p| {
720                let p = p.clone();
721                async move {
722                    p.fetch_spark(
723                        &["AAPL"],
724                        crate::Interval::OneDay,
725                        crate::TimeRange::FiveDays,
726                    )
727                    .await
728                }
729            })
730            .await;
731        assert!(result.is_err());
732    }
733}