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    /// Behaves identically to [`Fetch::Parallel`]. Retained for backward compatibility;
103    /// prefer [`Fetch::Parallel`] for new code.
104    #[deprecated(since = "2.6.0", note = "Use `Fetch::Parallel` instead")]
105    All,
106}
107
108/// Capability bits that a provider can declare.
109///
110/// Route a capability to specific providers using `.route(Capability::QUOTE, &[Provider::Fmp])`.
111/// If no route is configured for a capability, all providers declaring that capability are used.
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
113pub struct Capability(u32);
114
115impl Capability {
116    /// Equity quote data — price, volume, market cap, fundamentals summary.
117    pub const QUOTE: Self = Self(1 << 0);
118    /// Historical OHLCV chart data across intervals and ranges.
119    pub const CHART: Self = Self(1 << 1);
120    /// Financial statements — income, balance sheet, cash flow.
121    pub const FUNDAMENTALS: Self = Self(1 << 2);
122    /// Corporate events — news, recommendations, SEC filings metadata.
123    pub const CORPORATE: Self = Self(1 << 3);
124    /// Options chains and contract data.
125    pub const OPTIONS: Self = Self(1 << 4);
126    // bit 5 reserved for future use
127
128    /// Cryptocurrency quotes and market data.
129    pub const CRYPTO: Self = Self(1 << 6);
130    /// Macro-economic data series (FRED, GDP, CPI, etc.).
131    pub const ECONOMIC: Self = Self(1 << 7);
132    // bit 8 reserved for future use
133
134    /// Foreign exchange currency pair quotes.
135    pub const FOREX: Self = Self(1 << 9);
136    /// Stock market index quotes (S&P 500, NASDAQ, etc.).
137    pub const INDICES: Self = Self(1 << 10);
138    /// Futures contract quotes.
139    pub const FUTURES: Self = Self(1 << 11);
140    /// Commodity price quotes (gold, oil, etc.).
141    pub const COMMODITIES: Self = Self(1 << 12);
142    // bit 13 reserved for future use
143
144    /// SEC EDGAR filing data.
145    pub const FILINGS: Self = Self(1 << 14);
146
147    /// Returns `true` if this capability set includes all bits in `other`.
148    pub const fn contains(self, other: Self) -> bool {
149        (self.0 & other.0) == other.0
150    }
151
152    /// Returns a short lowercase name for this capability (e.g., `"quote"`, `"chart"`).
153    ///
154    /// Returns `"unknown"` for combined capability flags or unrecognised bits.
155    pub fn name(self) -> &'static str {
156        match self.0 {
157            x if x == Self::QUOTE.0 => "quote",
158            x if x == Self::CHART.0 => "chart",
159            x if x == Self::FUNDAMENTALS.0 => "fundamentals",
160            x if x == Self::CORPORATE.0 => "corporate",
161            x if x == Self::OPTIONS.0 => "options",
162            x if x == Self::CRYPTO.0 => "crypto",
163            x if x == Self::ECONOMIC.0 => "economic",
164            x if x == Self::FOREX.0 => "forex",
165            x if x == Self::INDICES.0 => "indices",
166            x if x == Self::FUTURES.0 => "futures",
167            x if x == Self::COMMODITIES.0 => "commodities",
168            x if x == Self::FILINGS.0 => "filings",
169            _ => "unknown",
170        }
171    }
172}
173
174impl std::ops::BitOr for Capability {
175    type Output = Self;
176    fn bitor(self, rhs: Self) -> Self {
177        Self(self.0 | rhs.0)
178    }
179}
180
181/// Per-capability provider routing table.
182///
183/// Maps each [`Capability`] to an ordered list of [`Provider`]s to try.
184/// When a capability has no entry, all providers declaring that capability are used.
185pub struct Routes {
186    pub(crate) map: HashMap<Capability, Vec<Provider>>,
187    pub(crate) fetch: Fetch,
188}
189
190impl Routes {
191    pub fn new(fetch: Fetch) -> Self {
192        Self {
193            map: HashMap::new(),
194            fetch,
195        }
196    }
197}
198
199#[async_trait::async_trait]
200pub(crate) trait ProviderAdapter: Send + Sync {
201    fn id(&self) -> &'static str;
202    fn capabilities(&self) -> Capability;
203
204    /// Initialize this provider. Called once during construction.
205    async fn initialize(&self) -> Result<()> {
206        Ok(())
207    }
208
209    fn not_supported(&self, operation: &'static str) -> FinanceError {
210        FinanceError::NotSupported {
211            provider: self.id(),
212            operation,
213        }
214    }
215
216    // Single-ticker quote routing; Ticker uses first_yahoo() directly for crumb auth.
217    // Wired up for future multi-provider single-ticker quote routing.
218    async fn fetch_quote(&self, _: &str) -> Result<QuoteSummaryResponse> {
219        Err(self.not_supported("quote"))
220    }
221    async fn fetch_chart(
222        &self,
223        _: &str,
224        _: crate::Interval,
225        _: crate::TimeRange,
226    ) -> Result<crate::models::chart::Chart> {
227        Err(self.not_supported("chart"))
228    }
229    async fn fetch_chart_range(
230        &self,
231        _: &str,
232        _: crate::Interval,
233        _: i64,
234        _: i64,
235    ) -> Result<crate::models::chart::Chart> {
236        Err(self.not_supported("chart_range"))
237    }
238    async fn fetch_financials(
239        &self,
240        _: &str,
241        _: crate::StatementType,
242        _: crate::Frequency,
243    ) -> Result<crate::models::fundamentals::FinancialStatement> {
244        Err(self.not_supported("financials"))
245    }
246    async fn fetch_news(&self, _: &str) -> Result<Vec<crate::models::corporate::news::News>> {
247        Err(self.not_supported("news"))
248    }
249    async fn fetch_similar_symbols(
250        &self,
251        _: &str,
252        _: u32,
253    ) -> Result<Vec<crate::models::corporate::recommendation::SimilarSymbol>> {
254        Err(self.not_supported("recommendations"))
255    }
256    async fn fetch_options(
257        &self,
258        _: &str,
259        _: Option<i64>,
260    ) -> Result<crate::models::options::Options> {
261        Err(self.not_supported("options"))
262    }
263    async fn fetch_events(&self, _: &str) -> Result<crate::models::chart::events::ChartEvents> {
264        Err(self.not_supported("events"))
265    }
266    /// Fetch quotes for multiple symbols in a single request.
267    /// Returns `(symbol, QuoteSummaryResponse)` pairs — only partially populated
268    /// (price module only) since batch endpoints don't return full quoteSummary data.
269    async fn fetch_quotes_batch(&self, _: &[&str]) -> Result<Vec<(String, QuoteSummaryResponse)>> {
270        Err(self.not_supported("quotes_batch"))
271    }
272
273    #[cfg(any(
274        feature = "crypto",
275        feature = "alphavantage",
276        feature = "fmp",
277        feature = "polygon"
278    ))]
279    async fn fetch_crypto_quote(
280        &self,
281        _: &str,
282        _: &str,
283    ) -> Result<crate::models::crypto::CryptoQuote> {
284        Err(self.not_supported("crypto_quote"))
285    }
286
287    #[cfg(any(feature = "fred", feature = "alphavantage", feature = "polygon"))]
288    async fn fetch_economic_series(
289        &self,
290        _: &str,
291    ) -> Result<crate::models::economic::EconomicSeries> {
292        Err(self.not_supported("economic_series"))
293    }
294
295    #[cfg(any(feature = "polygon", feature = "fmp", feature = "alphavantage"))]
296    async fn fetch_forex_quote(
297        &self,
298        _from: &str,
299        _to: &str,
300    ) -> Result<crate::models::forex::ForexQuote> {
301        Err(self.not_supported("forex_quote"))
302    }
303
304    #[cfg(any(feature = "polygon", feature = "fmp"))]
305    async fn fetch_indices_quote(&self, _: &str) -> Result<crate::models::indices::IndexQuote> {
306        Err(self.not_supported("indices_quote"))
307    }
308
309    #[cfg(feature = "polygon")]
310    async fn fetch_futures_quote(&self, _: &str) -> Result<crate::models::futures::FuturesQuote> {
311        Err(self.not_supported("futures_quote"))
312    }
313
314    #[cfg(any(feature = "fmp", feature = "alphavantage"))]
315    async fn fetch_commodities_quote(
316        &self,
317        _: &str,
318    ) -> Result<crate::models::commodities::CommodityQuote> {
319        Err(self.not_supported("commodities_quote"))
320    }
321
322    async fn fetch_filings(&self, _: &str) -> Result<crate::models::filings::ProviderFilings> {
323        Err(self.not_supported("filings"))
324    }
325}
326
327pub(crate) struct ProviderSet {
328    providers: Vec<Arc<dyn ProviderAdapter>>,
329    yahoo_client: Option<Arc<YahooClient>>,
330    routes: Routes,
331}
332
333impl ProviderSet {
334    pub fn new(
335        providers: Vec<Arc<dyn ProviderAdapter>>,
336        yahoo_client: Option<Arc<YahooClient>>,
337        routes: Routes,
338    ) -> Self {
339        Self {
340            providers,
341            yahoo_client,
342            routes,
343        }
344    }
345
346    /// Returns the providers to use for a given capability, respecting any
347    /// explicit route configured via `.route()`. When no route is configured,
348    /// defaults to Yahoo for all capabilities and EDGAR for filings.
349    fn candidates_for(&self, cap: Capability) -> Vec<&Arc<dyn ProviderAdapter>> {
350        if let Some(provider_ids) = self.routes.map.get(&cap) {
351            provider_ids
352                .iter()
353                .filter_map(|id| self.providers.iter().find(|p| p.id() == id.as_str()))
354                .collect()
355        } else if cap == Capability::FILINGS {
356            // Default: EDGAR (keyless SEC filings) first, then Yahoo
357            let mut v: Vec<&Arc<dyn ProviderAdapter>> = self
358                .providers
359                .iter()
360                .filter(|p| p.id() == "edgar")
361                .collect();
362            v.extend(self.providers.iter().filter(|p| p.id() == "yahoo"));
363            v
364        } else {
365            // Default: Yahoo only
366            self.providers
367                .iter()
368                .filter(|p| p.id() == "yahoo")
369                .collect()
370        }
371    }
372
373    fn no_provider(cap: Capability) -> FinanceError {
374        FinanceError::NoProviderAvailable {
375            operation: cap.name(),
376        }
377    }
378
379    fn finish_err(cap: Capability, last: Option<FinanceError>) -> FinanceError {
380        last.unwrap_or_else(|| Self::no_provider(cap))
381    }
382
383    #[allow(deprecated)] // must handle Fetch::All internally until it is removed
384    pub(crate) async fn fetch<T, F, Fut>(&self, cap: Capability, f: F) -> Result<T>
385    where
386        F: Fn(&Arc<dyn ProviderAdapter>) -> Fut,
387        Fut: std::future::Future<Output = Result<T>>,
388    {
389        let candidates = self.candidates_for(cap);
390        if candidates.is_empty() {
391            return Err(Self::no_provider(cap));
392        }
393        match self.routes.fetch {
394            Fetch::Sequential => {
395                let mut last = None;
396                for p in &candidates {
397                    match f(p).await {
398                        Ok(v) => return Ok(v),
399                        Err(FinanceError::NotSupported { .. }) => continue,
400                        Err(e) => last = Some(e),
401                    }
402                }
403                Err(Self::finish_err(cap, last))
404            }
405            Fetch::Parallel | Fetch::All => {
406                let mut futs = futures::stream::FuturesUnordered::new();
407                for p in &candidates {
408                    futs.push(f(p));
409                }
410                let mut last = None;
411                while let Some(r) = futs.next().await {
412                    match r {
413                        Ok(v) => return Ok(v),
414                        Err(FinanceError::NotSupported { .. }) => continue,
415                        Err(e) => last = Some(e),
416                    }
417                }
418                Err(Self::finish_err(cap, last))
419            }
420        }
421    }
422
423    pub(crate) fn first_yahoo(&self) -> Result<Arc<YahooClient>> {
424        self.yahoo_client
425            .as_ref()
426            .map(Arc::clone)
427            .ok_or_else(|| FinanceError::NoProviderAvailable { operation: "yahoo" })
428    }
429}
430
431#[allow(dead_code)] // used by fmp, polygon, alphavantage feature-gated providers
432pub(crate) fn json_value_to_f64(value: serde_json::Value) -> Option<f64> {
433    value
434        .as_f64()
435        .or_else(|| value.as_i64().map(|v| v as f64))
436        .or_else(|| value.as_u64().map(|v| v as f64))
437        .or_else(|| value.as_str().and_then(|s| s.parse::<f64>().ok()))
438        .or_else(|| {
439            value
440                .get("raw")
441                .and_then(|raw| raw.as_f64().or_else(|| raw.as_i64().map(|v| v as f64)))
442        })
443}
444
445#[allow(dead_code)] // used by fmp, polygon, alphavantage feature-gated providers
446pub(crate) fn build_financial_statement(
447    symbol: String,
448    statement_type: String,
449    frequency: String,
450    provider_id: Provider,
451    data: std::collections::HashMap<String, std::collections::HashMap<String, serde_json::Value>>,
452) -> crate::models::fundamentals::FinancialStatement {
453    let statement = data
454        .into_iter()
455        .filter_map(|(metric, values)| {
456            let values: std::collections::HashMap<String, f64> = values
457                .into_iter()
458                .filter_map(|(date, value)| json_value_to_f64(value).map(|v| (date, v)))
459                .collect();
460            if values.is_empty() {
461                None
462            } else {
463                Some((metric, values))
464            }
465        })
466        .collect();
467    crate::models::fundamentals::FinancialStatement {
468        symbol,
469        statement_type,
470        frequency,
471        statement,
472        provider_id: Some(provider_id),
473    }
474}
475
476pub(crate) fn build_options(
477    symbol: String,
478    provider_id: Provider,
479    expiration_dates: Vec<i64>,
480    calls: Vec<crate::models::options::OptionContract>,
481    puts: Vec<crate::models::options::OptionContract>,
482) -> crate::models::options::Options {
483    use std::collections::BTreeMap;
484
485    let mut chains_by_expiration: BTreeMap<
486        i64,
487        (
488            Vec<crate::models::options::OptionContract>,
489            Vec<crate::models::options::OptionContract>,
490        ),
491    > = BTreeMap::new();
492
493    for contract in calls {
494        let exp = contract.expiration.unwrap_or(0);
495        chains_by_expiration
496            .entry(exp)
497            .or_default()
498            .0
499            .push(contract);
500    }
501    for contract in puts {
502        let exp = contract.expiration.unwrap_or(0);
503        chains_by_expiration
504            .entry(exp)
505            .or_default()
506            .1
507            .push(contract);
508    }
509
510    let option_chains: Vec<crate::models::options::response::OptionChainData> =
511        chains_by_expiration
512            .into_iter()
513            .map(
514                |(expiration, (c, p))| crate::models::options::response::OptionChainData {
515                    expiration_date: expiration,
516                    has_mini_options: None,
517                    calls: Some(c),
518                    puts: Some(p),
519                },
520            )
521            .collect();
522
523    let expiration_dates = if expiration_dates.is_empty() {
524        option_chains
525            .iter()
526            .map(|chain| chain.expiration_date)
527            .collect()
528    } else {
529        let mut v: Vec<i64> = expiration_dates;
530        v.sort_unstable();
531        v.dedup();
532        v
533    };
534
535    let mut strikes: Vec<f64> = option_chains
536        .iter()
537        .flat_map(|chain| {
538            chain
539                .calls
540                .as_deref()
541                .unwrap_or_default()
542                .iter()
543                .map(|c| c.strike)
544                .chain(
545                    chain
546                        .puts
547                        .as_deref()
548                        .unwrap_or_default()
549                        .iter()
550                        .map(|p| p.strike),
551                )
552        })
553        .collect();
554    strikes.sort_by(|a, b| a.total_cmp(b));
555    strikes.dedup_by(|a, b| a.total_cmp(b).is_eq());
556
557    let result = crate::models::options::response::OptionChainResult {
558        underlying_symbol: Some(symbol),
559        expiration_dates: Some(expiration_dates),
560        strikes: Some(strikes),
561        has_mini_options: None,
562        quote: None,
563        options: option_chains,
564    };
565
566    crate::models::options::Options {
567        option_chain: crate::models::options::response::OptionChainContainer {
568            result: vec![result],
569            error: None,
570        },
571        provider_id: Some(provider_id),
572    }
573}
574
575#[allow(dead_code)] // used by fmp feature-gated provider
576pub(crate) fn range_to_dates(range: crate::TimeRange) -> (String, String) {
577    use chrono::{Datelike, Utc};
578    let end = Utc::now();
579    if range == crate::TimeRange::YearToDate {
580        let year = end.year();
581        let start = chrono::NaiveDate::from_ymd_opt(year, 1, 1)
582            .and_then(|d| d.and_hms_opt(0, 0, 0))
583            .map(|dt| dt.and_utc())
584            .unwrap_or(end);
585        return (
586            start.format("%Y-%m-%d").to_string(),
587            end.format("%Y-%m-%d").to_string(),
588        );
589    }
590    let days = match range {
591        crate::TimeRange::OneDay => 1,
592        crate::TimeRange::FiveDays => 5,
593        crate::TimeRange::OneMonth => 30,
594        crate::TimeRange::ThreeMonths => 90,
595        crate::TimeRange::SixMonths => 180,
596        crate::TimeRange::OneYear => 365,
597        crate::TimeRange::TwoYears => 730,
598        crate::TimeRange::FiveYears => 1825,
599        crate::TimeRange::TenYears => 3650,
600        crate::TimeRange::Max => 36500,
601        crate::TimeRange::YearToDate => unreachable!("YTD handled by early return above"),
602    };
603    let start = end - chrono::Duration::days(days);
604    (
605        start.format("%Y-%m-%d").to_string(),
606        end.format("%Y-%m-%d").to_string(),
607    )
608}
609
610pub(crate) async fn build_providers(
611    ids: &[Provider],
612    config: &ClientConfig,
613    routes: Routes,
614) -> Result<ProviderSet> {
615    use yahoo::YahooProvider;
616    let mut providers: Vec<Arc<dyn ProviderAdapter>> = Vec::new();
617    let mut yahoo_client: Option<Arc<YahooClient>> = None;
618    for &id in ids {
619        match id {
620            Provider::Yahoo => {
621                let yp = YahooProvider::new(config).await?;
622                yahoo_client = Some(yp.client_arc());
623                providers.push(Arc::new(yp));
624            }
625            #[cfg(feature = "polygon")]
626            Provider::Polygon => {
627                let pp = polygon::PolygonProvider;
628                pp.initialize().await?;
629                providers.push(Arc::new(pp));
630            }
631            #[cfg(feature = "fmp")]
632            Provider::Fmp => {
633                let fp = fmp::FmpProvider;
634                fp.initialize().await?;
635                providers.push(Arc::new(fp));
636            }
637            #[cfg(feature = "alphavantage")]
638            Provider::AlphaVantage => {
639                let av = alphavantage::AlphaVantageProvider;
640                av.initialize().await?;
641                providers.push(Arc::new(av));
642            }
643            #[cfg(feature = "crypto")]
644            Provider::CoinGecko => providers.push(Arc::new(coingecko::CoinGeckoProvider)),
645            #[cfg(feature = "fred")]
646            Provider::Fred => {
647                let fp = fred::FredProvider;
648                fp.initialize().await?;
649                providers.push(Arc::new(fp));
650            }
651            Provider::Edgar => providers.push(Arc::new(edgar::EdgarProvider)),
652        }
653    }
654    // Auto-inject EDGAR if no other FILINGS-capable provider was configured
655    let has_filings = providers
656        .iter()
657        .any(|p| p.capabilities().contains(Capability::FILINGS));
658    if !has_filings {
659        providers.push(Arc::new(edgar::EdgarProvider));
660    }
661    Ok(ProviderSet::new(providers, yahoo_client, routes))
662}