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    /// Every provider variant compiled into this build, regardless of whether
95    /// it's actually configured/initialized. Used to compute error-message
96    /// hints (see [`Capability::candidate_providers`]) without needing a live
97    /// `ProviderSet`.
98    pub(crate) fn all() -> Vec<Self> {
99        let mut v = vec![Self::Yahoo];
100        #[cfg(feature = "polygon")]
101        v.push(Self::Polygon);
102        #[cfg(feature = "fmp")]
103        v.push(Self::Fmp);
104        #[cfg(feature = "alphavantage")]
105        v.push(Self::AlphaVantage);
106        #[cfg(feature = "crypto")]
107        v.push(Self::CoinGecko);
108        #[cfg(feature = "fred")]
109        v.push(Self::Fred);
110        v.push(Self::Edgar);
111        v
112    }
113
114    /// Static capability bitflags for this provider variant. Must be kept in
115    /// sync with the matching `src/providers/<name>.rs` adapter's
116    /// `ProviderAdapter::capabilities()` — see `provider_capabilities_match_adapters`
117    /// in the test module below.
118    pub(crate) fn capabilities(self) -> Capability {
119        match self {
120            Self::Yahoo => {
121                Capability::QUOTE
122                    | Capability::CHART
123                    | Capability::FUNDAMENTALS
124                    | Capability::CORPORATE
125                    | Capability::OPTIONS
126            }
127            #[cfg(feature = "polygon")]
128            Self::Polygon => {
129                Capability::QUOTE
130                    | Capability::CHART
131                    | Capability::FUNDAMENTALS
132                    | Capability::CORPORATE
133                    | Capability::OPTIONS
134                    | Capability::CRYPTO
135                    | Capability::FOREX
136                    | Capability::FUTURES
137                    | Capability::INDICES
138                    | Capability::FILINGS
139                    | Capability::ECONOMIC
140            }
141            #[cfg(feature = "fmp")]
142            Self::Fmp => {
143                Capability::QUOTE
144                    | Capability::CHART
145                    | Capability::FUNDAMENTALS
146                    | Capability::CORPORATE
147                    | Capability::INDICES
148                    | Capability::COMMODITIES
149                    | Capability::FOREX
150                    | Capability::CRYPTO
151            }
152            #[cfg(feature = "alphavantage")]
153            Self::AlphaVantage => {
154                Capability::QUOTE
155                    | Capability::CHART
156                    | Capability::FUNDAMENTALS
157                    | Capability::CORPORATE
158                    | Capability::OPTIONS
159                    | Capability::CRYPTO
160                    | Capability::FOREX
161                    | Capability::COMMODITIES
162                    | Capability::ECONOMIC
163            }
164            #[cfg(feature = "crypto")]
165            Self::CoinGecko => Capability::CRYPTO,
166            #[cfg(feature = "fred")]
167            Self::Fred => Capability::ECONOMIC,
168            Self::Edgar => Capability::FILINGS,
169        }
170    }
171}
172
173impl std::fmt::Display for Provider {
174    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175        f.write_str(self.as_str())
176    }
177}
178
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180/// How providers are queried.
181pub enum Fetch {
182    /// Try providers in priority order; first success wins.
183    Sequential,
184    /// Fire all providers concurrently; first success wins.
185    Parallel,
186}
187
188/// Capability bits that a provider can declare.
189///
190/// Route a capability to specific providers using `.route(Capability::QUOTE, [Provider::Fmp])`.
191/// If no route is configured for a capability, all providers declaring that capability are used.
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
193pub struct Capability(u32);
194
195impl Capability {
196    /// Equity quote data — price, volume, market cap, fundamentals summary.
197    pub const QUOTE: Self = Self(1 << 0);
198    /// Historical OHLCV chart data across intervals and ranges.
199    pub const CHART: Self = Self(1 << 1);
200    /// Financial statements — income, balance sheet, cash flow.
201    pub const FUNDAMENTALS: Self = Self(1 << 2);
202    /// Corporate events — news, recommendations, SEC filings metadata.
203    pub const CORPORATE: Self = Self(1 << 3);
204    /// Options chains and contract data.
205    pub const OPTIONS: Self = Self(1 << 4);
206    // bit 5 reserved for future use
207
208    /// Cryptocurrency quotes and market data.
209    pub const CRYPTO: Self = Self(1 << 6);
210    /// Macro-economic data series (FRED, GDP, CPI, etc.).
211    pub const ECONOMIC: Self = Self(1 << 7);
212    // bit 8 reserved for future use
213
214    /// Foreign exchange currency pair quotes.
215    pub const FOREX: Self = Self(1 << 9);
216    /// Stock market index quotes (S&P 500, NASDAQ, etc.).
217    pub const INDICES: Self = Self(1 << 10);
218    /// Futures contract quotes.
219    pub const FUTURES: Self = Self(1 << 11);
220    /// Commodity price quotes (gold, oil, etc.).
221    pub const COMMODITIES: Self = Self(1 << 12);
222    // bit 13 reserved for future use
223
224    /// SEC EDGAR filing data.
225    pub const FILINGS: Self = Self(1 << 14);
226
227    /// Returns `true` if this capability set includes all bits in `other`.
228    pub const fn contains(self, other: Self) -> bool {
229        (self.0 & other.0) == other.0
230    }
231
232    /// Returns a short lowercase name for this capability (e.g., `"quote"`, `"chart"`).
233    ///
234    /// Returns `"unknown"` for combined capability flags or unrecognised bits.
235    pub fn name(self) -> &'static str {
236        match self.0 {
237            x if x == Self::QUOTE.0 => "quote",
238            x if x == Self::CHART.0 => "chart",
239            x if x == Self::FUNDAMENTALS.0 => "fundamentals",
240            x if x == Self::CORPORATE.0 => "corporate",
241            x if x == Self::OPTIONS.0 => "options",
242            x if x == Self::CRYPTO.0 => "crypto",
243            x if x == Self::ECONOMIC.0 => "economic",
244            x if x == Self::FOREX.0 => "forex",
245            x if x == Self::INDICES.0 => "indices",
246            x if x == Self::FUTURES.0 => "futures",
247            x if x == Self::COMMODITIES.0 => "commodities",
248            x if x == Self::FILINGS.0 => "filings",
249            _ => "unknown",
250        }
251    }
252}
253
254impl std::fmt::Display for Capability {
255    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256        f.write_str(self.name())
257    }
258}
259
260impl Capability {
261    /// Providers whose `capabilities()` declare this capability, regardless of
262    /// which providers are actually configured/feature-enabled in this build.
263    ///
264    /// Purely informational — used to make [`crate::FinanceError::NotSupported`]/
265    /// [`crate::FinanceError::NoProviderAvailable`] point at what would need to
266    /// be enabled (feature flag) and/or routed (`Providers::builder().route(...)`).
267    pub(crate) fn candidate_providers(self) -> Vec<Provider> {
268        Provider::all()
269            .into_iter()
270            .filter(|p| p.capabilities().contains(self))
271            .collect()
272    }
273}
274
275impl std::ops::BitOr for Capability {
276    type Output = Self;
277    fn bitor(self, rhs: Self) -> Self {
278        Self(self.0 | rhs.0)
279    }
280}
281
282/// A single provider-adapter operation — finer-grained than [`Capability`]
283/// (e.g. `Chart`, `ChartRange`, and `Spark` all fall under `Capability::CHART`).
284///
285/// Used in [`crate::FinanceError::NotSupported`] to say exactly which method a
286/// provider doesn't implement; [`Operation::capability`] recovers the coarser
287/// bit for computing which other providers could satisfy it.
288#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
289#[non_exhaustive]
290pub enum Operation {
291    /// Single-symbol quote.
292    Quote,
293    /// Historical OHLCV chart over an interval/range.
294    Chart,
295    /// Historical OHLCV chart over a custom timestamp range.
296    ChartRange,
297    /// Financial statements (income/balance/cash flow).
298    Financials,
299    /// Symbol news.
300    News,
301    /// Similar-symbol recommendations.
302    Recommendations,
303    /// Options chain.
304    Options,
305    /// Corporate calendar events (earnings, dividends, splits).
306    Events,
307    /// Batch quotes for multiple symbols in one request.
308    QuotesBatch,
309    /// Lightweight sparkline data for multiple symbols in one request.
310    Spark,
311    /// Cryptocurrency quote.
312    CryptoQuote,
313    /// Macro-economic data series.
314    EconomicSeries,
315    /// Foreign exchange currency pair quote.
316    ForexQuote,
317    /// Stock market index quote.
318    IndicesQuote,
319    /// Futures contract quote.
320    FuturesQuote,
321    /// Commodity price quote.
322    CommoditiesQuote,
323    /// SEC EDGAR filing data.
324    Filings,
325}
326
327impl Operation {
328    /// Short lowercase identifier (e.g. `"chart_range"`, `"crypto_quote"`).
329    pub fn as_str(self) -> &'static str {
330        match self {
331            Self::Quote => "quote",
332            Self::Chart => "chart",
333            Self::ChartRange => "chart_range",
334            Self::Financials => "financials",
335            Self::News => "news",
336            Self::Recommendations => "recommendations",
337            Self::Options => "options",
338            Self::Events => "events",
339            Self::QuotesBatch => "quotes_batch",
340            Self::Spark => "spark",
341            Self::CryptoQuote => "crypto_quote",
342            Self::EconomicSeries => "economic_series",
343            Self::ForexQuote => "forex_quote",
344            Self::IndicesQuote => "indices_quote",
345            Self::FuturesQuote => "futures_quote",
346            Self::CommoditiesQuote => "commodities_quote",
347            Self::Filings => "filings",
348        }
349    }
350
351    /// The coarser [`Capability`] bit this operation falls under.
352    pub fn capability(self) -> Capability {
353        match self {
354            Self::Quote | Self::QuotesBatch => Capability::QUOTE,
355            Self::Chart | Self::ChartRange | Self::Spark => Capability::CHART,
356            Self::Financials => Capability::FUNDAMENTALS,
357            Self::News | Self::Recommendations | Self::Events => Capability::CORPORATE,
358            Self::Options => Capability::OPTIONS,
359            Self::CryptoQuote => Capability::CRYPTO,
360            Self::EconomicSeries => Capability::ECONOMIC,
361            Self::ForexQuote => Capability::FOREX,
362            Self::IndicesQuote => Capability::INDICES,
363            Self::FuturesQuote => Capability::FUTURES,
364            Self::CommoditiesQuote => Capability::COMMODITIES,
365            Self::Filings => Capability::FILINGS,
366        }
367    }
368}
369
370impl std::fmt::Display for Operation {
371    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
372        f.write_str(self.as_str())
373    }
374}
375
376/// Per-capability provider routing table.
377///
378/// Maps each [`Capability`] to an ordered list of [`Provider`]s to try.
379/// When a capability has no entry, all providers declaring that capability are used.
380#[derive(Debug)]
381pub struct Routes {
382    pub(crate) map: HashMap<Capability, Vec<Provider>>,
383    pub(crate) fetch: Fetch,
384}
385
386impl Routes {
387    pub fn new(fetch: Fetch) -> Self {
388        Self {
389            map: HashMap::new(),
390            fetch,
391        }
392    }
393}
394
395#[async_trait::async_trait]
396pub(crate) trait ProviderAdapter: Send + Sync {
397    fn id(&self) -> Provider;
398    fn capabilities(&self) -> Capability;
399
400    /// Initialize this provider. Called once during construction.
401    async fn initialize(&self) -> Result<()> {
402        Ok(())
403    }
404
405    fn not_supported(&self, operation: Operation) -> FinanceError {
406        FinanceError::NotSupported {
407            provider: self.id(),
408            operation,
409            candidates: operation.capability().candidate_providers(),
410        }
411    }
412
413    // Single-ticker quote routing; Ticker uses first_yahoo() directly for crumb auth.
414    // Wired up for future multi-provider single-ticker quote routing.
415    async fn fetch_quote(&self, _: &str) -> Result<QuoteSummaryResponse> {
416        Err(self.not_supported(Operation::Quote))
417    }
418    async fn fetch_chart(
419        &self,
420        _: &str,
421        _: crate::Interval,
422        _: crate::TimeRange,
423    ) -> Result<crate::models::chart::Chart> {
424        Err(self.not_supported(Operation::Chart))
425    }
426    async fn fetch_chart_range(
427        &self,
428        _: &str,
429        _: crate::Interval,
430        _: i64,
431        _: i64,
432    ) -> Result<crate::models::chart::Chart> {
433        Err(self.not_supported(Operation::ChartRange))
434    }
435    async fn fetch_financials(
436        &self,
437        _: &str,
438        _: crate::StatementType,
439        _: crate::Frequency,
440    ) -> Result<crate::models::fundamentals::FinancialStatement> {
441        Err(self.not_supported(Operation::Financials))
442    }
443    async fn fetch_news(&self, _: &str) -> Result<Vec<crate::models::corporate::news::News>> {
444        Err(self.not_supported(Operation::News))
445    }
446    async fn fetch_similar_symbols(
447        &self,
448        _: &str,
449        _: u32,
450    ) -> Result<Vec<crate::models::corporate::recommendation::SimilarSymbol>> {
451        Err(self.not_supported(Operation::Recommendations))
452    }
453    async fn fetch_options(
454        &self,
455        _: &str,
456        _: Option<i64>,
457    ) -> Result<crate::models::options::Options> {
458        Err(self.not_supported(Operation::Options))
459    }
460    async fn fetch_events(&self, _: &str) -> Result<crate::models::chart::events::ChartEvents> {
461        Err(self.not_supported(Operation::Events))
462    }
463    /// Fetch quotes for multiple symbols in a single request.
464    /// Returns `(symbol, QuoteSummaryResponse)` pairs — only partially populated
465    /// (price module only) since batch endpoints don't return full quoteSummary data.
466    async fn fetch_quotes_batch(&self, _: &[&str]) -> Result<Vec<(String, QuoteSummaryResponse)>> {
467        Err(self.not_supported(Operation::QuotesBatch))
468    }
469
470    /// Fetch lightweight sparkline data for multiple symbols in a single request.
471    /// Returns successfully-parsed `(symbol, Spark)` pairs; callers fill in
472    /// missing-symbol errors for any symbol absent from the result.
473    async fn fetch_spark(
474        &self,
475        _: &[&str],
476        _: crate::Interval,
477        _: crate::TimeRange,
478    ) -> Result<Vec<(String, crate::models::chart::spark::Spark)>> {
479        Err(self.not_supported(Operation::Spark))
480    }
481
482    #[cfg(any(
483        feature = "crypto",
484        feature = "alphavantage",
485        feature = "fmp",
486        feature = "polygon"
487    ))]
488    async fn fetch_crypto_quote(
489        &self,
490        _: &str,
491        _: &str,
492    ) -> Result<crate::models::crypto::CryptoQuote> {
493        Err(self.not_supported(Operation::CryptoQuote))
494    }
495
496    #[cfg(any(feature = "fred", feature = "alphavantage", feature = "polygon"))]
497    async fn fetch_economic_series(
498        &self,
499        _: &str,
500    ) -> Result<crate::models::economic::EconomicSeries> {
501        Err(self.not_supported(Operation::EconomicSeries))
502    }
503
504    #[cfg(any(feature = "polygon", feature = "fmp", feature = "alphavantage"))]
505    async fn fetch_forex_quote(
506        &self,
507        _from: &str,
508        _to: &str,
509    ) -> Result<crate::models::forex::ForexQuote> {
510        Err(self.not_supported(Operation::ForexQuote))
511    }
512
513    #[cfg(any(feature = "polygon", feature = "fmp"))]
514    async fn fetch_indices_quote(&self, _: &str) -> Result<crate::models::indices::IndexQuote> {
515        Err(self.not_supported(Operation::IndicesQuote))
516    }
517
518    #[cfg(feature = "polygon")]
519    async fn fetch_futures_quote(&self, _: &str) -> Result<crate::models::futures::FuturesQuote> {
520        Err(self.not_supported(Operation::FuturesQuote))
521    }
522
523    #[cfg(any(feature = "fmp", feature = "alphavantage"))]
524    async fn fetch_commodities_quote(
525        &self,
526        _: &str,
527    ) -> Result<crate::models::commodities::CommodityQuote> {
528        Err(self.not_supported(Operation::CommoditiesQuote))
529    }
530
531    async fn fetch_filings(&self, _: &str) -> Result<crate::models::filings::ProviderFilings> {
532        Err(self.not_supported(Operation::Filings))
533    }
534}
535
536pub(crate) struct ProviderSet {
537    providers: Vec<Arc<dyn ProviderAdapter>>,
538    yahoo_client: Option<Arc<YahooClient>>,
539    routes: Routes,
540}
541
542impl std::fmt::Debug for ProviderSet {
543    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
544        f.debug_struct("ProviderSet")
545            .field(
546                "providers",
547                &self.providers.iter().map(|p| p.id()).collect::<Vec<_>>(),
548            )
549            .field("has_yahoo_client", &self.yahoo_client.is_some())
550            .field("routes", &self.routes)
551            .finish()
552    }
553}
554
555impl ProviderSet {
556    pub fn new(
557        providers: Vec<Arc<dyn ProviderAdapter>>,
558        yahoo_client: Option<Arc<YahooClient>>,
559        routes: Routes,
560    ) -> Self {
561        Self {
562            providers,
563            yahoo_client,
564            routes,
565        }
566    }
567
568    /// Returns the providers to use for a given capability, respecting any
569    /// explicit route configured via `.route()`. When no route is configured,
570    /// defaults to Yahoo for all capabilities and EDGAR for filings.
571    fn candidates_for(&self, cap: Capability) -> Vec<&Arc<dyn ProviderAdapter>> {
572        if let Some(provider_ids) = self.routes.map.get(&cap) {
573            provider_ids
574                .iter()
575                .filter_map(|id| self.providers.iter().find(|p| p.id() == *id))
576                .collect()
577        } else if cap == Capability::FILINGS {
578            // Default: EDGAR (keyless SEC filings) first, then Yahoo
579            let mut v: Vec<&Arc<dyn ProviderAdapter>> = self
580                .providers
581                .iter()
582                .filter(|p| p.id() == Provider::Edgar)
583                .collect();
584            v.extend(self.providers.iter().filter(|p| p.id() == Provider::Yahoo));
585            v
586        } else {
587            // Default: Yahoo only
588            self.providers
589                .iter()
590                .filter(|p| p.id() == Provider::Yahoo)
591                .collect()
592        }
593    }
594
595    fn no_provider(cap: Capability) -> FinanceError {
596        FinanceError::NoProviderAvailable {
597            operation: cap,
598            candidates: cap.candidate_providers(),
599        }
600    }
601
602    fn finish_err(cap: Capability, last: Option<FinanceError>) -> FinanceError {
603        last.unwrap_or_else(|| Self::no_provider(cap))
604    }
605
606    pub(crate) async fn fetch<T, F, Fut>(&self, cap: Capability, f: F) -> Result<T>
607    where
608        F: Fn(&Arc<dyn ProviderAdapter>) -> Fut,
609        Fut: std::future::Future<Output = Result<T>>,
610    {
611        let candidates = self.candidates_for(cap);
612        if candidates.is_empty() {
613            return Err(Self::no_provider(cap));
614        }
615        match self.routes.fetch {
616            Fetch::Sequential => {
617                let mut last = None;
618                for p in &candidates {
619                    match f(p).await {
620                        Ok(v) => return Ok(v),
621                        Err(FinanceError::NotSupported { .. }) => continue,
622                        Err(e) => last = Some(e),
623                    }
624                }
625                Err(Self::finish_err(cap, last))
626            }
627            Fetch::Parallel => {
628                let mut futs = futures::stream::FuturesUnordered::new();
629                for p in &candidates {
630                    futs.push(f(p));
631                }
632                let mut last = None;
633                while let Some(r) = futs.next().await {
634                    match r {
635                        Ok(v) => return Ok(v),
636                        Err(FinanceError::NotSupported { .. }) => continue,
637                        Err(e) => last = Some(e),
638                    }
639                }
640                Err(Self::finish_err(cap, last))
641            }
642        }
643    }
644
645    pub(crate) fn first_yahoo(&self) -> Result<Arc<YahooClient>> {
646        self.yahoo_client.as_ref().map(Arc::clone).ok_or_else(|| {
647            FinanceError::NoProviderAvailable {
648                operation: Capability::QUOTE,
649                candidates: vec![Provider::Yahoo],
650            }
651        })
652    }
653}
654
655#[allow(dead_code)] // used by fmp, polygon, alphavantage feature-gated providers
656pub(crate) fn json_value_to_f64(value: serde_json::Value) -> Option<f64> {
657    value
658        .as_f64()
659        .or_else(|| value.as_i64().map(|v| v as f64))
660        .or_else(|| value.as_u64().map(|v| v as f64))
661        .or_else(|| value.as_str().and_then(|s| s.parse::<f64>().ok()))
662        .or_else(|| {
663            value
664                .get("raw")
665                .and_then(|raw| raw.as_f64().or_else(|| raw.as_i64().map(|v| v as f64)))
666        })
667}
668
669#[allow(dead_code)] // used by fmp, polygon, alphavantage feature-gated providers
670pub(crate) fn build_financial_statement(
671    symbol: String,
672    statement_type: String,
673    frequency: String,
674    provider_id: Provider,
675    data: std::collections::HashMap<String, std::collections::HashMap<String, serde_json::Value>>,
676) -> crate::models::fundamentals::FinancialStatement {
677    let statement = data
678        .into_iter()
679        .filter_map(|(metric, values)| {
680            let values: std::collections::HashMap<String, f64> = values
681                .into_iter()
682                .filter_map(|(date, value)| json_value_to_f64(value).map(|v| (date, v)))
683                .collect();
684            if values.is_empty() {
685                None
686            } else {
687                Some((metric, values))
688            }
689        })
690        .collect();
691    crate::models::fundamentals::FinancialStatement {
692        symbol,
693        statement_type,
694        frequency,
695        statement,
696        provider_id: Some(provider_id),
697    }
698}
699
700pub(crate) fn build_options(
701    symbol: String,
702    provider_id: Provider,
703    expiration_dates: Vec<i64>,
704    calls: Vec<crate::models::options::OptionContract>,
705    puts: Vec<crate::models::options::OptionContract>,
706) -> crate::models::options::Options {
707    use std::collections::BTreeMap;
708
709    let mut chains_by_expiration: BTreeMap<
710        i64,
711        (
712            Vec<crate::models::options::OptionContract>,
713            Vec<crate::models::options::OptionContract>,
714        ),
715    > = BTreeMap::new();
716
717    for contract in calls {
718        let exp = contract.expiration.unwrap_or(0);
719        chains_by_expiration
720            .entry(exp)
721            .or_default()
722            .0
723            .push(contract);
724    }
725    for contract in puts {
726        let exp = contract.expiration.unwrap_or(0);
727        chains_by_expiration
728            .entry(exp)
729            .or_default()
730            .1
731            .push(contract);
732    }
733
734    let option_chains: Vec<crate::models::options::response::OptionChainData> =
735        chains_by_expiration
736            .into_iter()
737            .map(
738                |(expiration, (c, p))| crate::models::options::response::OptionChainData {
739                    expiration_date: expiration,
740                    has_mini_options: None,
741                    calls: Some(c),
742                    puts: Some(p),
743                },
744            )
745            .collect();
746
747    let expiration_dates = if expiration_dates.is_empty() {
748        option_chains
749            .iter()
750            .map(|chain| chain.expiration_date)
751            .collect()
752    } else {
753        let mut v: Vec<i64> = expiration_dates;
754        v.sort_unstable();
755        v.dedup();
756        v
757    };
758
759    let mut strikes: Vec<f64> = option_chains
760        .iter()
761        .flat_map(|chain| {
762            chain
763                .calls
764                .as_deref()
765                .unwrap_or_default()
766                .iter()
767                .map(|c| c.strike)
768                .chain(
769                    chain
770                        .puts
771                        .as_deref()
772                        .unwrap_or_default()
773                        .iter()
774                        .map(|p| p.strike),
775                )
776        })
777        .collect();
778    strikes.sort_by(|a, b| a.total_cmp(b));
779    strikes.dedup_by(|a, b| a.total_cmp(b).is_eq());
780
781    let result = crate::models::options::response::OptionChainResult {
782        underlying_symbol: Some(symbol),
783        expiration_dates: Some(expiration_dates),
784        strikes: Some(strikes),
785        has_mini_options: None,
786        quote: None,
787        options: option_chains,
788    };
789
790    crate::models::options::Options {
791        option_chain: crate::models::options::response::OptionChainContainer {
792            result: vec![result],
793            error: None,
794        },
795        provider_id: Some(provider_id),
796    }
797}
798
799#[allow(dead_code)] // used by fmp feature-gated provider
800pub(crate) fn range_to_dates(range: crate::TimeRange) -> (String, String) {
801    use chrono::{Datelike, Utc};
802    let end = Utc::now();
803    if range == crate::TimeRange::YearToDate {
804        let year = end.year();
805        let start = chrono::NaiveDate::from_ymd_opt(year, 1, 1)
806            .and_then(|d| d.and_hms_opt(0, 0, 0))
807            .map(|dt| dt.and_utc())
808            .unwrap_or(end);
809        return (
810            start.format("%Y-%m-%d").to_string(),
811            end.format("%Y-%m-%d").to_string(),
812        );
813    }
814    let days = match range {
815        crate::TimeRange::OneDay => 1,
816        crate::TimeRange::FiveDays => 5,
817        crate::TimeRange::OneMonth => 30,
818        crate::TimeRange::ThreeMonths => 90,
819        crate::TimeRange::SixMonths => 180,
820        crate::TimeRange::OneYear => 365,
821        crate::TimeRange::TwoYears => 730,
822        crate::TimeRange::FiveYears => 1825,
823        crate::TimeRange::TenYears => 3650,
824        crate::TimeRange::Max => 36500,
825        crate::TimeRange::YearToDate => unreachable!("YTD handled by early return above"),
826    };
827    let start = end - chrono::Duration::days(days);
828    (
829        start.format("%Y-%m-%d").to_string(),
830        end.format("%Y-%m-%d").to_string(),
831    )
832}
833
834pub(crate) async fn build_providers(
835    ids: &[Provider],
836    config: &ClientConfig,
837    routes: Routes,
838) -> Result<ProviderSet> {
839    use yahoo::YahooProvider;
840    let mut providers: Vec<Arc<dyn ProviderAdapter>> = Vec::new();
841    let mut yahoo_client: Option<Arc<YahooClient>> = None;
842    for &id in ids {
843        match id {
844            Provider::Yahoo => {
845                let yp = YahooProvider::new(config).await?;
846                yahoo_client = Some(yp.client_arc());
847                providers.push(Arc::new(yp));
848            }
849            #[cfg(feature = "polygon")]
850            Provider::Polygon => {
851                let pp = polygon::PolygonProvider;
852                pp.initialize().await?;
853                providers.push(Arc::new(pp));
854            }
855            #[cfg(feature = "fmp")]
856            Provider::Fmp => {
857                let fp = fmp::FmpProvider;
858                fp.initialize().await?;
859                providers.push(Arc::new(fp));
860            }
861            #[cfg(feature = "alphavantage")]
862            Provider::AlphaVantage => {
863                let av = alphavantage::AlphaVantageProvider;
864                av.initialize().await?;
865                providers.push(Arc::new(av));
866            }
867            #[cfg(feature = "crypto")]
868            Provider::CoinGecko => providers.push(Arc::new(coingecko::CoinGeckoProvider)),
869            #[cfg(feature = "fred")]
870            Provider::Fred => {
871                let fp = fred::FredProvider;
872                fp.initialize().await?;
873                providers.push(Arc::new(fp));
874            }
875            Provider::Edgar => providers.push(Arc::new(edgar::EdgarProvider)),
876        }
877    }
878    // Auto-inject EDGAR if no other FILINGS-capable provider was configured
879    let has_filings = providers
880        .iter()
881        .any(|p| p.capabilities().contains(Capability::FILINGS));
882    if !has_filings {
883        providers.push(Arc::new(edgar::EdgarProvider));
884    }
885    Ok(ProviderSet::new(providers, yahoo_client, routes))
886}
887
888#[cfg(test)]
889mod tests {
890    use super::*;
891
892    /// A CHART-capable provider that does not implement spark — exercises the
893    /// default trait method and proves spark now dispatches through the set.
894    struct NoSparkProvider;
895
896    #[async_trait::async_trait]
897    impl ProviderAdapter for NoSparkProvider {
898        fn id(&self) -> Provider {
899            Provider::Yahoo
900        }
901        fn capabilities(&self) -> Capability {
902            Capability::CHART
903        }
904    }
905
906    #[tokio::test]
907    async fn fetch_spark_defaults_to_not_supported() {
908        let err = NoSparkProvider
909            .fetch_spark(
910                &["AAPL"],
911                crate::Interval::OneDay,
912                crate::TimeRange::FiveDays,
913            )
914            .await
915            .unwrap_err();
916        assert!(matches!(
917            err,
918            FinanceError::NotSupported {
919                operation: Operation::Spark,
920                ..
921            }
922        ));
923    }
924
925    #[tokio::test]
926    async fn spark_routes_through_provider_set() {
927        // The CHART default route resolves to the "yahoo"-id provider; routing a
928        // provider that lacks spark must surface an error rather than silently
929        // hitting a hardcoded Yahoo client.
930        let set = ProviderSet::new(
931            vec![Arc::new(NoSparkProvider)],
932            None,
933            Routes::new(Fetch::Sequential),
934        );
935        let result = set
936            .fetch(Capability::CHART, |p| {
937                let p = p.clone();
938                async move {
939                    p.fetch_spark(
940                        &["AAPL"],
941                        crate::Interval::OneDay,
942                        crate::TimeRange::FiveDays,
943                    )
944                    .await
945                }
946            })
947            .await;
948        assert!(result.is_err());
949    }
950
951    // Guards `Provider::capabilities()` (the static table used to compute
952    // error-message candidates) against drifting from each adapter's real
953    // `ProviderAdapter::capabilities()`. Yahoo is exempted — constructing
954    // `YahooProvider` requires a live auth handshake — but every other
955    // adapter is a zero-cost unit struct, so this is a cheap sync check.
956    #[cfg(feature = "polygon")]
957    #[test]
958    fn polygon_capabilities_match_static_table() {
959        assert_eq!(
960            ProviderAdapter::capabilities(&polygon::PolygonProvider),
961            Provider::Polygon.capabilities()
962        );
963    }
964
965    #[cfg(feature = "fmp")]
966    #[test]
967    fn fmp_capabilities_match_static_table() {
968        assert_eq!(
969            ProviderAdapter::capabilities(&fmp::FmpProvider),
970            Provider::Fmp.capabilities()
971        );
972    }
973
974    #[cfg(feature = "alphavantage")]
975    #[test]
976    fn alphavantage_capabilities_match_static_table() {
977        assert_eq!(
978            ProviderAdapter::capabilities(&alphavantage::AlphaVantageProvider),
979            Provider::AlphaVantage.capabilities()
980        );
981    }
982
983    #[cfg(feature = "crypto")]
984    #[test]
985    fn coingecko_capabilities_match_static_table() {
986        assert_eq!(
987            ProviderAdapter::capabilities(&coingecko::CoinGeckoProvider),
988            Provider::CoinGecko.capabilities()
989        );
990    }
991
992    #[cfg(feature = "fred")]
993    #[test]
994    fn fred_capabilities_match_static_table() {
995        assert_eq!(
996            ProviderAdapter::capabilities(&fred::FredProvider),
997            Provider::Fred.capabilities()
998        );
999    }
1000
1001    #[test]
1002    fn edgar_capabilities_match_static_table() {
1003        assert_eq!(
1004            ProviderAdapter::capabilities(&edgar::EdgarProvider),
1005            Provider::Edgar.capabilities()
1006        );
1007    }
1008}