Skip to main content

finance_query/tickers/core/
mod.rs

1//! Tickers implementation for batch operations on multiple symbols.
2//!
3//! Optimizes data fetching by using batch endpoints and concurrent requests.
4
5use super::macros::define_batch_response;
6use crate::adapters::yahoo::client::ClientConfig;
7use crate::constants::{Frequency, Interval, Region, StatementType, TimeRange};
8use crate::error::Result;
9#[cfg(any(feature = "backtesting", feature = "indicators"))]
10use crate::indicators;
11use crate::models::chart::events::ChartEvents;
12use crate::models::chart::spark::Spark;
13use crate::models::chart::{CapitalGain, Chart, Dividend, Split};
14use crate::models::corporate::news::News;
15use crate::models::corporate::recommendation::Recommendation;
16use crate::models::fundamentals::FinancialStatement;
17use crate::models::options::Options;
18use crate::models::quote::Quote;
19use crate::providers::yahoo::YahooProvider;
20use crate::providers::{Fetch, Provider, ProviderAdapter, ProviderSet, Routes, build_providers};
21use crate::ticker::ClientHandle;
22use crate::utils::{CacheEntry, CacheMode};
23use std::collections::HashMap;
24use std::sync::Arc;
25use std::time::Duration;
26use tokio::sync::RwLock;
27
28#[cfg(any(feature = "backtesting", feature = "indicators"))]
29mod analysis;
30mod charts;
31mod corporate;
32mod fundamentals;
33mod membership;
34mod quotes;
35
36// Type aliases — MapCache wraps values in CacheEntry for TTL support.
37type MapCache<K, V> = Arc<RwLock<HashMap<K, CacheEntry<V>>>>;
38type ChartCacheKey = (Arc<str>, Interval, TimeRange);
39type QuoteCache = MapCache<Arc<str>, Quote>;
40type ChartCache = MapCache<ChartCacheKey, Chart>;
41type EventsCache = MapCache<Arc<str>, ChartEvents>;
42type FinancialsCache = MapCache<(Arc<str>, StatementType, Frequency), FinancialStatement>;
43type NewsCache = MapCache<Arc<str>, Vec<News>>;
44type RecommendationsCache = MapCache<(Arc<str>, u32), Recommendation>;
45type OptionsCache = MapCache<(Arc<str>, Option<i64>), Options>;
46type SparkCacheKey = (Arc<str>, Interval, TimeRange);
47type SparkCache = MapCache<SparkCacheKey, Spark>;
48#[cfg(feature = "indicators")]
49type IndicatorsCache = MapCache<(Arc<str>, Interval, TimeRange), indicators::IndicatorsSummary>;
50
51// Fetch guards for request deduplication — prevent concurrent duplicate fetches
52type FetchGuard = Arc<tokio::sync::Mutex<()>>;
53type FetchGuardMap<K> = Arc<RwLock<HashMap<K, FetchGuard>>>;
54
55// Generate all batch response types
56define_batch_response! {
57    /// Response containing quotes for multiple symbols.
58    BatchQuotesResponse => quotes: Quote
59}
60
61define_batch_response! {
62    /// Response containing charts for multiple symbols.
63    BatchChartsResponse => charts: Chart
64}
65
66define_batch_response! {
67    /// Response containing spark data for multiple symbols.
68    ///
69    /// Spark data is optimized for sparkline rendering with only close prices.
70    /// Unlike charts, spark data is fetched in a single batch request.
71    BatchSparksResponse => sparks: Spark
72}
73
74define_batch_response! {
75    /// Response containing dividends for multiple symbols.
76    BatchDividendsResponse => dividends: Vec<Dividend>
77}
78
79define_batch_response! {
80    /// Response containing splits for multiple symbols.
81    BatchSplitsResponse => splits: Vec<Split>
82}
83
84define_batch_response! {
85    /// Response containing capital gains for multiple symbols.
86    BatchCapitalGainsResponse => capital_gains: Vec<CapitalGain>
87}
88
89define_batch_response! {
90    /// Response containing financial statements for multiple symbols.
91    BatchFinancialsResponse => financials: FinancialStatement
92}
93
94define_batch_response! {
95    /// Response containing news articles for multiple symbols.
96    BatchNewsResponse => news: Vec<News>
97}
98
99define_batch_response! {
100    /// Response containing recommendations for multiple symbols.
101    BatchRecommendationsResponse => recommendations: Recommendation
102}
103
104define_batch_response! {
105    /// Response containing options chains for multiple symbols.
106    BatchOptionsResponse => options: Options
107}
108
109#[cfg(feature = "indicators")]
110define_batch_response! {
111    /// Response containing technical indicators for multiple symbols.
112    BatchIndicatorsResponse => indicators: indicators::IndicatorsSummary
113}
114
115/// Default maximum concurrent requests for batch operations.
116const DEFAULT_MAX_CONCURRENCY: usize = 10;
117
118/// Builder for Tickers
119pub struct TickersBuilder {
120    symbols: Vec<Arc<str>>,
121    config: ClientConfig,
122    shared_client: Option<ClientHandle>,
123    injected_providers: Option<Arc<ProviderSet>>,
124    max_concurrency: usize,
125    cache_mode: CacheMode,
126    include_logo: bool,
127}
128
129impl TickersBuilder {
130    fn new<S, I>(symbols: I) -> Self
131    where
132        S: Into<String>,
133        I: IntoIterator<Item = S>,
134    {
135        Self {
136            symbols: symbols.into_iter().map(|s| s.into().into()).collect(),
137            config: ClientConfig::default(),
138            shared_client: None,
139            injected_providers: None,
140            max_concurrency: DEFAULT_MAX_CONCURRENCY,
141            cache_mode: CacheMode::default(),
142            include_logo: false,
143        }
144    }
145
146    /// Set the region (automatically sets correct lang and region code)
147    pub fn region(mut self, region: Region) -> Self {
148        self.config.lang = region.lang().to_string();
149        self.config.region = region.region().to_string();
150        self
151    }
152
153    /// Set the language code (e.g., "en-US", "ja-JP", "de-DE")
154    pub fn lang(mut self, lang: impl Into<String>) -> Self {
155        self.config.lang = lang.into();
156        self
157    }
158
159    /// Set the region code (e.g., "US", "JP", "DE")
160    pub fn region_code(mut self, region: impl Into<String>) -> Self {
161        self.config.region = region.into();
162        self
163    }
164
165    /// Set the HTTP request timeout
166    pub fn timeout(mut self, timeout: Duration) -> Self {
167        self.config.timeout = timeout;
168        self
169    }
170
171    /// Set the proxy URL
172    pub fn proxy(mut self, proxy: impl Into<String>) -> Self {
173        self.config.proxy = Some(proxy.into());
174        self
175    }
176
177    #[allow(dead_code)]
178    pub(crate) fn config(mut self, config: ClientConfig) -> Self {
179        self.config = config;
180        self
181    }
182
183    /// Set the maximum number of concurrent requests for batch operations.
184    ///
185    /// Controls how many HTTP requests run in parallel when methods like
186    /// `charts()`, `financials()`, or `news()` fetch data for each symbol.
187    /// Default is 10.
188    ///
189    /// Lower values reduce the risk of rate limiting from Yahoo Finance.
190    /// Higher values increase throughput for large symbol lists.
191    pub fn max_concurrency(mut self, n: usize) -> Self {
192        self.max_concurrency = n.max(1);
193        self
194    }
195
196    /// Cache responses for `ttl` instead of the default 60 seconds.
197    ///
198    /// Responses are reused until the TTL expires; stale entries are evicted
199    /// on a later write.
200    ///
201    /// # Example
202    ///
203    /// ```no_run
204    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
205    /// use finance_query::Tickers;
206    /// use std::time::Duration;
207    ///
208    /// let tickers = Tickers::builder(["AAPL", "MSFT"])
209    ///     .cache(Duration::from_secs(30))
210    ///     .build()
211    ///     .await?;
212    /// # Ok(())
213    /// # }
214    /// ```
215    pub fn cache(mut self, ttl: Duration) -> Self {
216        self.cache_mode = CacheMode::Ttl(ttl);
217        self
218    }
219
220    /// Cache responses for the handle's lifetime instead of the default 60
221    /// seconds.
222    pub fn cache_forever(mut self) -> Self {
223        self.cache_mode = CacheMode::Lifetime;
224        self
225    }
226
227    /// Disable caching — every call fetches fresh data.
228    ///
229    /// By default a `Tickers` handle caches each response for 60 seconds,
230    /// so repeated calls within that window reuse one fetch.
231    pub fn no_cache(mut self) -> Self {
232        self.cache_mode = CacheMode::Off;
233        self
234    }
235
236    /// Include company logo URLs in quote responses.
237    ///
238    /// When enabled, `quotes()` will fetch logo URLs in parallel with the
239    /// quote batch request, adding a small extra request.
240    pub fn logo(mut self) -> Self {
241        self.include_logo = true;
242        self
243    }
244
245    /// Pre-inject a shared provider set (used by [`Providers::tickers`]).
246    ///
247    /// Not part of the stable public API — see [`ProviderAdapter`](crate::ProviderAdapter).
248    #[doc(hidden)]
249    pub fn with_provider_set(mut self, set: Arc<ProviderSet>) -> Self {
250        self.injected_providers = Some(set);
251        self
252    }
253
254    /// Share an existing authenticated session instead of creating a new one.
255    ///
256    /// Avoids redundant auth handshakes when combining `Tickers` with other
257    /// `Ticker` instances. Obtain a handle from any existing `Ticker` or
258    /// `Tickers` via `.client_handle()`.
259    pub fn client(mut self, handle: ClientHandle) -> Self {
260        self.shared_client = Some(handle);
261        self
262    }
263
264    /// Build the Tickers instance
265    pub async fn build(self) -> Result<Tickers> {
266        #[cfg(feature = "translation")]
267        let translate_lang = {
268            let lang = crate::translation::Lang::parse(&self.config.lang)?;
269            (!lang.is_english()).then_some(lang)
270        };
271        let providers = if let Some(set) = self.injected_providers {
272            set
273        } else if let Some(handle) = self.shared_client {
274            let yahoo = YahooProvider::from_client(handle.0);
275            let client = yahoo.client_arc();
276            Arc::new(
277                ProviderSet::new(
278                    vec![Arc::new(yahoo) as Arc<dyn ProviderAdapter>],
279                    Routes::new(Fetch::Sequential),
280                )
281                .with_yahoo_client(Some(client)),
282            )
283        } else {
284            Arc::new(
285                build_providers(
286                    &[Provider::Yahoo],
287                    Vec::new(),
288                    &self.config,
289                    Routes::new(Fetch::Sequential),
290                )
291                .await?,
292            )
293        };
294
295        Ok(Tickers {
296            symbols: self.symbols,
297            providers,
298            max_concurrency: self.max_concurrency,
299            cache_mode: self.cache_mode,
300            include_logo: self.include_logo,
301            #[cfg(feature = "translation")]
302            translate_lang,
303            quote_cache: Default::default(),
304            chart_cache: Default::default(),
305            events_cache: Default::default(),
306            financials_cache: Default::default(),
307            news_cache: Default::default(),
308            recommendations_cache: Default::default(),
309            options_cache: Default::default(),
310            spark_cache: Default::default(),
311            #[cfg(feature = "indicators")]
312            indicators_cache: Default::default(),
313
314            // Initialize fetch guards for request deduplication
315            quotes_fetch: Arc::new(tokio::sync::Mutex::new(())),
316            events_fetch: Arc::new(tokio::sync::Mutex::new(())),
317            charts_fetch: Default::default(),
318            financials_fetch: Default::default(),
319            news_fetch: Arc::new(tokio::sync::Mutex::new(())),
320            recommendations_fetch: Default::default(),
321            options_fetch: Default::default(),
322            spark_fetch: Default::default(),
323            #[cfg(feature = "indicators")]
324            indicators_fetch: Default::default(),
325        })
326    }
327}
328
329/// Multi-symbol ticker for efficient batch operations.
330///
331/// `Tickers` optimizes data fetching for multiple symbols by:
332/// - Using batch endpoints where available (e.g., /v7/finance/quote)
333/// - Fetching concurrently when batch endpoints don't exist
334/// - Sharing a single authenticated client across all symbols
335/// - Caching results per symbol
336///
337/// # Example
338///
339/// ```no_run
340/// use finance_query::Tickers;
341///
342/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
343/// // Create tickers for multiple symbols
344/// let tickers = Tickers::new(["AAPL", "MSFT", "GOOGL"]).await?;
345///
346/// // Batch fetch all quotes (single API call)
347/// let quotes = tickers.quotes().await?;
348/// for (symbol, quote) in &quotes.quotes {
349///     let price = quote.regular_market_price.as_ref().and_then(|v| v.raw).unwrap_or(0.0);
350///     println!("{}: ${:.2}", symbol, price);
351/// }
352///
353/// // Fetch charts concurrently
354/// use finance_query::{Interval, TimeRange};
355/// let charts = tickers.charts(Interval::OneDay, TimeRange::OneMonth).await?;
356/// # Ok(())
357/// # }
358/// ```
359pub struct Tickers {
360    symbols: Vec<Arc<str>>,
361    providers: Arc<ProviderSet>,
362    max_concurrency: usize,
363    cache_mode: CacheMode,
364    include_logo: bool,
365    #[cfg(feature = "translation")]
366    translate_lang: Option<crate::translation::Lang>,
367    quote_cache: QuoteCache,
368    chart_cache: ChartCache,
369    events_cache: EventsCache,
370    financials_cache: FinancialsCache,
371    news_cache: NewsCache,
372    recommendations_cache: RecommendationsCache,
373    options_cache: OptionsCache,
374    spark_cache: SparkCache,
375    #[cfg(feature = "indicators")]
376    indicators_cache: IndicatorsCache,
377
378    // Fetch guards prevent duplicate concurrent requests
379    quotes_fetch: FetchGuard,
380    events_fetch: FetchGuard,
381    charts_fetch: FetchGuardMap<(Interval, TimeRange)>,
382    financials_fetch: FetchGuardMap<(StatementType, Frequency)>,
383    news_fetch: FetchGuard,
384    recommendations_fetch: FetchGuardMap<u32>,
385    options_fetch: FetchGuardMap<Option<i64>>,
386    spark_fetch: FetchGuardMap<(Interval, TimeRange)>,
387    #[cfg(feature = "indicators")]
388    indicators_fetch: FetchGuardMap<(Interval, TimeRange)>,
389}
390
391impl std::fmt::Debug for Tickers {
392    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
393        f.debug_struct("Tickers")
394            .field("symbols", &self.symbols)
395            .field("max_concurrency", &self.max_concurrency)
396            .field("cache_mode", &self.cache_mode)
397            .finish_non_exhaustive()
398    }
399}
400
401impl Tickers {
402    /// Creates new tickers with default configuration
403    ///
404    /// # Arguments
405    ///
406    /// * `symbols` - Iterable of stock symbols (e.g., `["AAPL", "MSFT"]`)
407    ///
408    /// # Example
409    ///
410    /// ```no_run
411    /// use finance_query::Tickers;
412    ///
413    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
414    /// let tickers = Tickers::new(["AAPL", "MSFT", "GOOGL"]).await?;
415    /// # Ok(())
416    /// # }
417    /// ```
418    pub async fn new<S, I>(symbols: I) -> Result<Self>
419    where
420        S: Into<String>,
421        I: IntoIterator<Item = S>,
422    {
423        Self::builder(symbols).build().await
424    }
425
426    /// Creates a new builder for Tickers
427    pub fn builder<S, I>(symbols: I) -> TickersBuilder
428    where
429        S: Into<String>,
430        I: IntoIterator<Item = S>,
431    {
432        TickersBuilder::new(symbols)
433    }
434
435    /// Returns the symbols this tickers instance manages
436    pub fn symbols(&self) -> Vec<&str> {
437        self.symbols.iter().map(|s| &**s).collect()
438    }
439
440    /// Number of symbols
441    pub fn len(&self) -> usize {
442        self.symbols.len()
443    }
444
445    /// Check if empty
446    pub fn is_empty(&self) -> bool {
447        self.symbols.is_empty()
448    }
449
450    /// Returns a handle to the underlying Yahoo Finance session.
451    ///
452    /// Pass to [`Ticker::builder`](crate::Ticker::builder) or other
453    /// [`Tickers::builder`] calls via `.client(handle)` to share the
454    /// authenticated session without a new auth handshake.
455    ///
456    /// # Panics
457    ///
458    /// Panics if these tickers were created via [`Providers`](crate::Providers) with
459    /// no Yahoo provider configured. For session sharing across multiple tickers,
460    /// prefer [`Providers::tickers`](crate::Providers::tickers) instead.
461    pub fn client_handle(&self) -> ClientHandle {
462        ClientHandle(
463            self.providers
464                .first_yahoo()
465                .expect("client_handle requires a Yahoo session; use Providers::tickers() for multi-provider tickers"),
466        )
467    }
468
469    /// Returns `true` if a cache entry exists and is still usable.
470    #[inline]
471    fn is_cache_fresh<T>(&self, entry: Option<&CacheEntry<T>>) -> bool {
472        CacheEntry::is_fresh_entry(entry, self.cache_mode)
473    }
474
475    /// Translate a response value when a non-English language is configured
476    /// (no-op otherwise).
477    #[cfg(feature = "translation")]
478    pub(crate) async fn translate_response<T: crate::translation::Translatable>(
479        &self,
480        value: &mut T,
481    ) -> Result<()> {
482        if let Some(lang) = &self.translate_lang {
483            crate::translation::translate_with(value, lang).await?;
484        }
485        Ok(())
486    }
487
488    /// Returns `true` if all keys are present and fresh in a map cache.
489    fn all_cached<K: Eq + std::hash::Hash, V>(
490        &self,
491        map: &HashMap<K, CacheEntry<V>>,
492        keys: impl Iterator<Item = K>,
493    ) -> bool {
494        if !self.cache_mode.enabled() {
495            return false;
496        }
497        keys.into_iter()
498            .all(|k| map.get(&k).is_some_and(|e| e.is_fresh(self.cache_mode)))
499    }
500
501    /// Insert into a map cache, amortizing eviction.
502    ///
503    /// The eviction threshold scales with the basket size. These caches key one
504    /// entry per symbol and `all_cached` only serves a hit when *every* symbol is
505    /// fresh, so a fixed cap below the basket size would evict part of the basket
506    /// on every pass and the handle would never register a single cache hit.
507    #[inline]
508    fn cache_insert<K: Eq + std::hash::Hash, V>(
509        &self,
510        map: &mut HashMap<K, CacheEntry<V>>,
511        key: K,
512        value: V,
513    ) {
514        crate::utils::cache_insert(
515            map,
516            key,
517            value,
518            self.cache_mode,
519            crate::utils::eviction_threshold_for(self.symbols.len()),
520        );
521    }
522
523    /// Helper to get or create a fetch guard for a given key.
524    ///
525    /// Returns the guard from the map, never a locally-created copy that
526    /// could diverge under contention.
527    async fn get_fetch_guard<K: Clone + Eq + std::hash::Hash>(
528        guard_map: &FetchGuardMap<K>,
529        key: K,
530    ) -> FetchGuard {
531        {
532            let guards = guard_map.read().await;
533            if let Some(guard) = guards.get(&key) {
534                return Arc::clone(guard);
535            }
536        }
537
538        let mut guards = guard_map.write().await;
539        Arc::clone(
540            guards
541                .entry(key)
542                .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))),
543        )
544    }
545}
546
547#[cfg(test)]
548mod tests {
549    use super::*;
550
551    #[tokio::test]
552    async fn default_caches_quotes_across_calls() {
553        use crate::providers::mock::{CountingProvider, provider_set};
554
555        let provider = CountingProvider::new();
556        let tickers = Tickers::builder(["AAPL", "MSFT"])
557            .with_provider_set(provider_set(Arc::clone(&provider)))
558            .build()
559            .await
560            .unwrap();
561
562        let _ = tickers.quotes().await.unwrap();
563        let _ = tickers.quotes().await.unwrap();
564
565        assert_eq!(provider.quotes(), 2, "one per symbol, fetched once");
566    }
567
568    #[tokio::test]
569    async fn no_cache_refetches_quotes() {
570        use crate::providers::mock::{CountingProvider, provider_set};
571
572        let provider = CountingProvider::new();
573        let tickers = Tickers::builder(["AAPL", "MSFT"])
574            .with_provider_set(provider_set(Arc::clone(&provider)))
575            .no_cache()
576            .build()
577            .await
578            .unwrap();
579
580        let _ = tickers.quotes().await.unwrap();
581        let _ = tickers.quotes().await.unwrap();
582
583        assert_eq!(provider.quotes(), 4);
584    }
585
586    #[tokio::test]
587    async fn concurrent_event_accessors_fetch_once() {
588        use crate::providers::mock::{CountingProvider, provider_set};
589
590        let provider = CountingProvider::new();
591        let tickers = Tickers::builder(["AAPL", "MSFT"])
592            .with_provider_set(provider_set(Arc::clone(&provider)))
593            .build()
594            .await
595            .unwrap();
596
597        let (d, s) = tokio::join!(
598            tickers.dividends(TimeRange::OneYear),
599            tickers.splits(TimeRange::OneYear)
600        );
601        assert!(d.is_ok() && s.is_ok());
602        assert_eq!(provider.events(), 2, "one event fetch per symbol, not two");
603    }
604
605    #[tokio::test]
606    async fn calendar_reuses_the_options_cache() {
607        use crate::providers::mock::{CountingProvider, provider_set};
608
609        let provider = CountingProvider::new();
610        let tickers = Tickers::builder(["AAPL"])
611            .with_provider_set(provider_set(Arc::clone(&provider)))
612            .build()
613            .await
614            .unwrap();
615
616        let _ = tickers.options(None).await;
617        let before = provider.options();
618        let _ = tickers.calendar(TimeRange::OneMonth).await;
619        assert_eq!(
620            provider.options(),
621            before,
622            "calendar refetched a cached chain"
623        );
624    }
625}