Skip to main content

finance_query/tickers/
core.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::{batch_fetch_cached, define_batch_response};
6use crate::adapters::yahoo::client::ClientConfig;
7#[cfg(feature = "backtesting")]
8use crate::backtesting;
9use crate::constants::{Frequency, Interval, Region, StatementType, TimeRange};
10use crate::error::{FinanceError, Result};
11use crate::format::Both;
12#[cfg(any(feature = "backtesting", feature = "indicators"))]
13use crate::indicators;
14use crate::models::chart::events::ChartEvents;
15use crate::models::chart::spark::Spark;
16use crate::models::chart::spark::response::SparkResponse;
17use crate::models::chart::{CapitalGain, Chart, Dividend, Split};
18use crate::models::corporate::news::News;
19use crate::models::corporate::recommendation::Recommendation;
20use crate::models::format::Format;
21use crate::models::fundamentals::FinancialStatement;
22use crate::models::options::Options;
23use crate::models::quote::{Quote, QuoteSummaryResponse};
24
25use crate::providers::types::recommendation_from_similar;
26use crate::providers::yahoo::YahooProvider;
27use crate::providers::{
28    Capability, Fetch, Provider, ProviderAdapter, ProviderSet, Routes, build_providers,
29};
30use crate::ticker::ClientHandle;
31use crate::utils::{CacheEntry, EVICTION_THRESHOLD, filter_by_range};
32use futures::stream::{self, StreamExt};
33use std::collections::HashMap;
34use std::sync::Arc;
35use std::time::Duration;
36use tokio::sync::RwLock;
37
38// Type aliases — MapCache wraps values in CacheEntry for TTL support.
39type MapCache<K, V> = Arc<RwLock<HashMap<K, CacheEntry<V>>>>;
40type ChartCacheKey = (Arc<str>, Interval, TimeRange);
41type QuoteCache = MapCache<Arc<str>, Quote>;
42type ChartCache = MapCache<ChartCacheKey, Chart>;
43type EventsCache = MapCache<Arc<str>, ChartEvents>;
44type FinancialsCache = MapCache<(Arc<str>, StatementType, Frequency), FinancialStatement>;
45type NewsCache = MapCache<Arc<str>, Vec<News>>;
46type RecommendationsCache = MapCache<(Arc<str>, u32), Recommendation>;
47type OptionsCache = MapCache<(Arc<str>, Option<i64>), Options>;
48type SparkCacheKey = (Arc<str>, Interval, TimeRange);
49type SparkCache = MapCache<SparkCacheKey, Spark>;
50#[cfg(feature = "indicators")]
51type IndicatorsCache = MapCache<(Arc<str>, Interval, TimeRange), indicators::IndicatorsSummary>;
52
53// Fetch guards for request deduplication — prevent concurrent duplicate fetches
54type FetchGuard = Arc<tokio::sync::Mutex<()>>;
55type FetchGuardMap<K> = Arc<RwLock<HashMap<K, FetchGuard>>>;
56
57// Generate all batch response types
58define_batch_response! {
59    /// Response containing quotes for multiple symbols.
60    BatchQuotesResponse => quotes: Quote
61}
62
63define_batch_response! {
64    /// Response containing charts for multiple symbols.
65    BatchChartsResponse => charts: Chart
66}
67
68define_batch_response! {
69    /// Response containing spark data for multiple symbols.
70    ///
71    /// Spark data is optimized for sparkline rendering with only close prices.
72    /// Unlike charts, spark data is fetched in a single batch request.
73    BatchSparksResponse => sparks: Spark
74}
75
76define_batch_response! {
77    /// Response containing dividends for multiple symbols.
78    BatchDividendsResponse => dividends: Vec<Dividend>
79}
80
81define_batch_response! {
82    /// Response containing splits for multiple symbols.
83    BatchSplitsResponse => splits: Vec<Split>
84}
85
86define_batch_response! {
87    /// Response containing capital gains for multiple symbols.
88    BatchCapitalGainsResponse => capital_gains: Vec<CapitalGain>
89}
90
91define_batch_response! {
92    /// Response containing financial statements for multiple symbols.
93    BatchFinancialsResponse => financials: FinancialStatement
94}
95
96define_batch_response! {
97    /// Response containing news articles for multiple symbols.
98    BatchNewsResponse => news: Vec<News>
99}
100
101define_batch_response! {
102    /// Response containing recommendations for multiple symbols.
103    BatchRecommendationsResponse => recommendations: Recommendation
104}
105
106define_batch_response! {
107    /// Response containing options chains for multiple symbols.
108    BatchOptionsResponse => options: Options
109}
110
111#[cfg(feature = "indicators")]
112define_batch_response! {
113    /// Response containing technical indicators for multiple symbols.
114    BatchIndicatorsResponse => indicators: indicators::IndicatorsSummary
115}
116
117/// Default maximum concurrent requests for batch operations.
118const DEFAULT_MAX_CONCURRENCY: usize = 10;
119
120/// Builder for Tickers
121pub struct TickersBuilder {
122    symbols: Vec<Arc<str>>,
123    config: ClientConfig,
124    shared_client: Option<ClientHandle>,
125    injected_providers: Option<Arc<ProviderSet>>,
126    max_concurrency: usize,
127    cache_ttl: Option<Duration>,
128    include_logo: bool,
129}
130
131impl TickersBuilder {
132    fn new<S, I>(symbols: I) -> Self
133    where
134        S: Into<String>,
135        I: IntoIterator<Item = S>,
136    {
137        Self {
138            symbols: symbols.into_iter().map(|s| s.into().into()).collect(),
139            config: ClientConfig::default(),
140            shared_client: None,
141            injected_providers: None,
142            max_concurrency: DEFAULT_MAX_CONCURRENCY,
143            cache_ttl: None,
144            include_logo: false,
145        }
146    }
147
148    /// Set the region (automatically sets correct lang and region code)
149    pub fn region(mut self, region: Region) -> Self {
150        self.config.lang = region.lang().to_string();
151        self.config.region = region.region().to_string();
152        self
153    }
154
155    /// Set the language code (e.g., "en-US", "ja-JP", "de-DE")
156    pub fn lang(mut self, lang: impl Into<String>) -> Self {
157        self.config.lang = lang.into();
158        self
159    }
160
161    /// Set the region code (e.g., "US", "JP", "DE")
162    pub fn region_code(mut self, region: impl Into<String>) -> Self {
163        self.config.region = region.into();
164        self
165    }
166
167    /// Set the HTTP request timeout
168    pub fn timeout(mut self, timeout: Duration) -> Self {
169        self.config.timeout = timeout;
170        self
171    }
172
173    /// Set the proxy URL
174    pub fn proxy(mut self, proxy: impl Into<String>) -> Self {
175        self.config.proxy = Some(proxy.into());
176        self
177    }
178
179    /// Set a complete ClientConfig
180    pub fn config(mut self, config: ClientConfig) -> Self {
181        self.config = config;
182        self
183    }
184
185    /// Set the maximum number of concurrent requests for batch operations.
186    ///
187    /// Controls how many HTTP requests run in parallel when methods like
188    /// `charts()`, `financials()`, or `news()` fetch data for each symbol.
189    /// Default is 10.
190    ///
191    /// Lower values reduce the risk of rate limiting from Yahoo Finance.
192    /// Higher values increase throughput for large symbol lists.
193    pub fn max_concurrency(mut self, n: usize) -> Self {
194        self.max_concurrency = n.max(1);
195        self
196    }
197
198    /// Enable response caching with a time-to-live.
199    ///
200    /// By default caching is **disabled** — every call fetches fresh data.
201    /// When enabled, responses are reused until the TTL expires. Stale
202    /// entries are evicted on the next write.
203    ///
204    /// # Example
205    ///
206    /// ```no_run
207    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
208    /// use finance_query::Tickers;
209    /// use std::time::Duration;
210    ///
211    /// let tickers = Tickers::builder(["AAPL", "MSFT"])
212    ///     .cache(Duration::from_secs(30))
213    ///     .build()
214    ///     .await?;
215    /// # Ok(())
216    /// # }
217    /// ```
218    pub fn cache(mut self, ttl: Duration) -> Self {
219        self.cache_ttl = Some(ttl);
220        self
221    }
222
223    /// Include company logo URLs in quote responses.
224    ///
225    /// When enabled, `quotes()` will fetch logo URLs in parallel with the
226    /// quote batch request, adding a small extra request.
227    pub fn logo(mut self) -> Self {
228        self.include_logo = true;
229        self
230    }
231
232    /// Pre-inject a shared provider set (used by [`Providers::tickers`]).
233    pub(crate) fn with_provider_set(mut self, set: Arc<ProviderSet>) -> Self {
234        self.injected_providers = Some(set);
235        self
236    }
237
238    /// Share an existing authenticated session instead of creating a new one.
239    ///
240    /// Avoids redundant auth handshakes when combining `Tickers` with other
241    /// `Ticker` instances. Obtain a handle from any existing `Ticker` or
242    /// `Tickers` via `.client_handle()`.
243    pub fn client(mut self, handle: ClientHandle) -> Self {
244        self.shared_client = Some(handle);
245        self
246    }
247
248    /// Build the Tickers instance
249    pub async fn build(self) -> Result<Tickers> {
250        #[cfg(feature = "translation")]
251        let translate_lang = {
252            let lang = crate::translation::Lang::parse(&self.config.lang)?;
253            (!lang.is_english()).then_some(lang)
254        };
255        let providers = if let Some(set) = self.injected_providers {
256            set
257        } else if let Some(handle) = self.shared_client {
258            let yahoo = YahooProvider::from_client(handle.0);
259            let client = yahoo.client_arc();
260            Arc::new(ProviderSet::new(
261                vec![Arc::new(yahoo) as Arc<dyn ProviderAdapter>],
262                Some(client),
263                Routes::new(Fetch::Sequential),
264            ))
265        } else {
266            Arc::new(
267                build_providers(
268                    &[Provider::Yahoo],
269                    &self.config,
270                    Routes::new(Fetch::Sequential),
271                )
272                .await?,
273            )
274        };
275
276        Ok(Tickers {
277            symbols: self.symbols,
278            providers,
279            max_concurrency: self.max_concurrency,
280            cache_ttl: self.cache_ttl,
281            include_logo: self.include_logo,
282            #[cfg(feature = "translation")]
283            translate_lang,
284            quote_cache: Default::default(),
285            chart_cache: Default::default(),
286            events_cache: Default::default(),
287            financials_cache: Default::default(),
288            news_cache: Default::default(),
289            recommendations_cache: Default::default(),
290            options_cache: Default::default(),
291            spark_cache: Default::default(),
292            #[cfg(feature = "indicators")]
293            indicators_cache: Default::default(),
294
295            // Initialize fetch guards for request deduplication
296            quotes_fetch: Arc::new(tokio::sync::Mutex::new(())),
297            charts_fetch: Default::default(),
298            financials_fetch: Default::default(),
299            news_fetch: Arc::new(tokio::sync::Mutex::new(())),
300            recommendations_fetch: Default::default(),
301            options_fetch: Default::default(),
302            spark_fetch: Default::default(),
303            #[cfg(feature = "indicators")]
304            indicators_fetch: Default::default(),
305        })
306    }
307}
308
309/// Multi-symbol ticker for efficient batch operations.
310///
311/// `Tickers` optimizes data fetching for multiple symbols by:
312/// - Using batch endpoints where available (e.g., /v7/finance/quote)
313/// - Fetching concurrently when batch endpoints don't exist
314/// - Sharing a single authenticated client across all symbols
315/// - Caching results per symbol
316///
317/// # Example
318///
319/// ```no_run
320/// use finance_query::Tickers;
321///
322/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
323/// // Create tickers for multiple symbols
324/// let tickers = Tickers::new(["AAPL", "MSFT", "GOOGL"]).await?;
325///
326/// // Batch fetch all quotes (single API call)
327/// let quotes = tickers.quotes().await?;
328/// for (symbol, quote) in &quotes.quotes {
329///     let price = quote.regular_market_price.as_ref().and_then(|v| v.raw).unwrap_or(0.0);
330///     println!("{}: ${:.2}", symbol, price);
331/// }
332///
333/// // Fetch charts concurrently
334/// use finance_query::{Interval, TimeRange};
335/// let charts = tickers.charts(Interval::OneDay, TimeRange::OneMonth).await?;
336/// # Ok(())
337/// # }
338/// ```
339pub struct Tickers {
340    symbols: Vec<Arc<str>>,
341    providers: Arc<ProviderSet>,
342    max_concurrency: usize,
343    cache_ttl: Option<Duration>,
344    include_logo: bool,
345    #[cfg(feature = "translation")]
346    translate_lang: Option<crate::translation::Lang>,
347    quote_cache: QuoteCache,
348    chart_cache: ChartCache,
349    events_cache: EventsCache,
350    financials_cache: FinancialsCache,
351    news_cache: NewsCache,
352    recommendations_cache: RecommendationsCache,
353    options_cache: OptionsCache,
354    spark_cache: SparkCache,
355    #[cfg(feature = "indicators")]
356    indicators_cache: IndicatorsCache,
357
358    // Fetch guards prevent duplicate concurrent requests
359    quotes_fetch: FetchGuard,
360    charts_fetch: FetchGuardMap<(Interval, TimeRange)>,
361    financials_fetch: FetchGuardMap<(StatementType, Frequency)>,
362    news_fetch: FetchGuard,
363    recommendations_fetch: FetchGuardMap<u32>,
364    options_fetch: FetchGuardMap<Option<i64>>,
365    spark_fetch: FetchGuardMap<(Interval, TimeRange)>,
366    #[cfg(feature = "indicators")]
367    indicators_fetch: FetchGuardMap<(Interval, TimeRange)>,
368}
369
370impl std::fmt::Debug for Tickers {
371    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
372        f.debug_struct("Tickers")
373            .field("symbols", &self.symbols)
374            .field("max_concurrency", &self.max_concurrency)
375            .field("cache_ttl", &self.cache_ttl)
376            .finish_non_exhaustive()
377    }
378}
379
380impl Tickers {
381    /// Creates new tickers with default configuration
382    ///
383    /// # Arguments
384    ///
385    /// * `symbols` - Iterable of stock symbols (e.g., `["AAPL", "MSFT"]`)
386    ///
387    /// # Example
388    ///
389    /// ```no_run
390    /// use finance_query::Tickers;
391    ///
392    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
393    /// let tickers = Tickers::new(["AAPL", "MSFT", "GOOGL"]).await?;
394    /// # Ok(())
395    /// # }
396    /// ```
397    pub async fn new<S, I>(symbols: I) -> Result<Self>
398    where
399        S: Into<String>,
400        I: IntoIterator<Item = S>,
401    {
402        Self::builder(symbols).build().await
403    }
404
405    /// Creates a new builder for Tickers
406    pub fn builder<S, I>(symbols: I) -> TickersBuilder
407    where
408        S: Into<String>,
409        I: IntoIterator<Item = S>,
410    {
411        TickersBuilder::new(symbols)
412    }
413
414    /// Returns the symbols this tickers instance manages
415    pub fn symbols(&self) -> Vec<&str> {
416        self.symbols.iter().map(|s| &**s).collect()
417    }
418
419    /// Number of symbols
420    pub fn len(&self) -> usize {
421        self.symbols.len()
422    }
423
424    /// Check if empty
425    pub fn is_empty(&self) -> bool {
426        self.symbols.is_empty()
427    }
428
429    /// Returns a handle to the underlying Yahoo Finance session.
430    ///
431    /// Pass to [`Ticker::builder`](crate::Ticker::builder) or other
432    /// [`Tickers::builder`] calls via `.client(handle)` to share the
433    /// authenticated session without a new auth handshake.
434    pub fn client_handle(&self) -> ClientHandle {
435        ClientHandle(
436            self.providers
437                .first_yahoo()
438                .expect("Tickers always uses a Yahoo session"),
439        )
440    }
441
442    /// Returns `true` if a cache entry exists and has not exceeded the TTL.
443    #[inline]
444    fn is_cache_fresh<T>(&self, entry: Option<&CacheEntry<T>>) -> bool {
445        CacheEntry::is_fresh_with_ttl(entry, self.cache_ttl)
446    }
447
448    /// Translate a response value when a non-English language is configured
449    /// (no-op otherwise).
450    #[cfg(feature = "translation")]
451    pub(crate) async fn translate_response<T: crate::translation::Translatable>(
452        &self,
453        value: &mut T,
454    ) -> Result<()> {
455        if let Some(lang) = &self.translate_lang {
456            crate::translation::translate_with(value, lang).await?;
457        }
458        Ok(())
459    }
460
461    /// Returns `true` if all keys are present and fresh in a map cache.
462    fn all_cached<K: Eq + std::hash::Hash, V>(
463        &self,
464        map: &HashMap<K, CacheEntry<V>>,
465        keys: impl Iterator<Item = K>,
466    ) -> bool {
467        let Some(ttl) = self.cache_ttl else {
468            return false;
469        };
470        keys.into_iter()
471            .all(|k| map.get(&k).map(|e| e.is_fresh(ttl)).unwrap_or(false))
472    }
473
474    /// Insert into a map cache, amortizing stale-entry eviction.
475    ///
476    /// Only sweeps stale entries when the map exceeds [`EVICTION_THRESHOLD`],
477    /// avoiding O(n) scans on every write.
478    #[inline]
479    fn cache_insert<K: Eq + std::hash::Hash, V>(
480        &self,
481        map: &mut HashMap<K, CacheEntry<V>>,
482        key: K,
483        value: V,
484    ) {
485        if let Some(ttl) = self.cache_ttl {
486            if map.len() >= EVICTION_THRESHOLD {
487                map.retain(|_, entry| entry.is_fresh(ttl));
488            }
489            map.insert(key, CacheEntry::new(value));
490        }
491    }
492
493    /// Batch fetch quotes for all symbols.
494    ///
495    /// Dispatches through the configured provider set. When logos are enabled,
496    /// fetches logo URLs in parallel via the Yahoo client.
497    ///
498    /// Use [`TickersBuilder::logo()`](TickersBuilder::logo) to enable logo fetching
499    /// for this tickers instance.
500    pub async fn quotes(&self) -> Result<BatchQuotesResponse> {
501        // Fast path: check if all symbols are cached
502        {
503            let cache = self.quote_cache.read().await;
504            if self.all_cached(&cache, self.symbols.iter().cloned()) {
505                let mut response = BatchQuotesResponse::with_capacity(self.symbols.len());
506                for symbol in &self.symbols {
507                    if let Some(entry) = cache.get(symbol) {
508                        response
509                            .quotes
510                            .insert(symbol.to_string(), entry.value.clone());
511                    }
512                }
513                return Ok(response);
514            }
515        }
516
517        let _fetch_guard = self.quotes_fetch.lock().await;
518
519        // Double-check: another task may have fetched while we waited
520        {
521            let cache = self.quote_cache.read().await;
522            if self.all_cached(&cache, self.symbols.iter().cloned()) {
523                let mut response = BatchQuotesResponse::with_capacity(self.symbols.len());
524                for symbol in &self.symbols {
525                    if let Some(entry) = cache.get(symbol) {
526                        response
527                            .quotes
528                            .insert(symbol.to_string(), entry.value.clone());
529                    }
530                }
531                return Ok(response);
532            }
533        }
534
535        let symbol_strings: Vec<String> = self.symbols.iter().map(|s| s.to_string()).collect();
536        let mut response = BatchQuotesResponse::with_capacity(self.symbols.len());
537
538        let (quote_data, logos) = if self.include_logo {
539            // Fire logo fetch in parallel with quote fetch; logos are Yahoo-only
540            let providers_logo = Arc::clone(&self.providers);
541            let syms_logo = symbol_strings.clone();
542            let logo_future = async move {
543                if let Ok(client) = providers_logo.first_yahoo() {
544                    let syms_ref: Vec<&str> = syms_logo.iter().map(String::as_str).collect();
545                    crate::adapters::yahoo::quote::quotes::fetch_with_fields(
546                        &client,
547                        &syms_ref,
548                        Some(&["logoUrl", "companyLogoUrl"]),
549                        true,
550                        true,
551                    )
552                    .await
553                    .ok()
554                } else {
555                    None
556                }
557            };
558
559            let providers_quote = Arc::clone(&self.providers);
560            let syms_quote = symbol_strings.clone();
561            let quote_future = async move {
562                providers_quote
563                    .fetch(Capability::QUOTE, |p| {
564                        let syms = syms_quote.clone();
565                        let p = p.clone();
566                        async move {
567                            let syms_ref: Vec<&str> = syms.iter().map(String::as_str).collect();
568                            p.fetch_quotes_batch(&syms_ref).await
569                        }
570                    })
571                    .await
572            };
573
574            let (batch_result, logo_result) = tokio::join!(quote_future, logo_future);
575            let quote_data = match batch_result {
576                Ok(data) => data,
577                Err(_) => {
578                    self.fetch_quotes_per_symbol(&symbol_strings, &mut response)
579                        .await
580                }
581            };
582            (quote_data, logo_result)
583        } else {
584            let providers = Arc::clone(&self.providers);
585            let syms = symbol_strings.clone();
586            let batch_result = providers
587                .fetch(Capability::QUOTE, |p| {
588                    let syms = syms.clone();
589                    let p = p.clone();
590                    async move {
591                        let syms_ref: Vec<&str> = syms.iter().map(String::as_str).collect();
592                        p.fetch_quotes_batch(&syms_ref).await
593                    }
594                })
595                .await;
596            let data = match batch_result {
597                Ok(data) => data,
598                Err(_) => {
599                    self.fetch_quotes_per_symbol(&symbol_strings, &mut response)
600                        .await
601                }
602            };
603            (data, None)
604        };
605
606        let logo_map: HashMap<String, (Option<String>, Option<String>)> = logos
607            .and_then(|l| l.get("quoteResponse")?.get("result")?.as_array().cloned())
608            .map(|results| {
609                results
610                    .iter()
611                    .filter_map(|r| {
612                        let symbol = r.get("symbol")?.as_str()?.to_string();
613                        let logo_url = r.get("logoUrl").and_then(|v| v.as_str()).map(String::from);
614                        let company_logo_url = r
615                            .get("companyLogoUrl")
616                            .and_then(|v| v.as_str())
617                            .map(String::from);
618                        Some((symbol, (logo_url, company_logo_url)))
619                    })
620                    .collect()
621            })
622            .unwrap_or_default();
623
624        let mut parsed_quotes: Vec<(String, Quote)> = Vec::new();
625
626        for (symbol, summary) in quote_data {
627            let logo_url = logo_map.get(&symbol).and_then(|(l, _)| l.clone());
628            let company_logo_url = logo_map.get(&symbol).and_then(|(_, c)| c.clone());
629            let quote = Quote::from_response(&summary, logo_url, company_logo_url);
630            parsed_quotes.push((symbol, quote));
631        }
632
633        for (symbol, quote) in parsed_quotes {
634            response.quotes.insert(symbol, quote);
635        }
636
637        // Translate before caching so cached quotes are already localized
638        // and repeat reads don't re-run the translation backend.
639        #[cfg(feature = "translation")]
640        self.translate_response(&mut response).await?;
641
642        if self.cache_ttl.is_some() {
643            let mut cache = self.quote_cache.write().await;
644            for (symbol, quote) in &response.quotes {
645                self.cache_insert(&mut cache, symbol.as_str().into(), quote.clone());
646            }
647        }
648
649        // Track missing symbols
650        for symbol in &self.symbols {
651            let s = &**symbol;
652            if !response.quotes.contains_key(s) && !response.errors.contains_key(s) {
653                response.errors.insert(
654                    symbol.to_string(),
655                    "Symbol not found in response".to_string(),
656                );
657            }
658        }
659
660        Ok(response)
661    }
662
663    /// Fallback for when no provider supports `fetch_quotes_batch`.
664    /// Fetches each symbol individually; failures go into `response.errors`.
665    async fn fetch_quotes_per_symbol(
666        &self,
667        symbols: &[String],
668        response: &mut BatchQuotesResponse,
669    ) -> Vec<(String, QuoteSummaryResponse)> {
670        let futures: Vec<_> = symbols
671            .iter()
672            .map(|sym| {
673                let providers = Arc::clone(&self.providers);
674                let sym = sym.clone();
675                async move {
676                    let result = providers
677                        .fetch(Capability::QUOTE, |p| {
678                            let sym = sym.clone();
679                            let p = p.clone();
680                            async move { p.fetch_quote(&sym).await }
681                        })
682                        .await;
683                    (sym, result)
684                }
685            })
686            .collect();
687
688        let results: Vec<_> = stream::iter(futures)
689            .buffer_unordered(self.max_concurrency)
690            .collect()
691            .await;
692
693        let mut successes = Vec::new();
694        for (sym, result) in results {
695            match result {
696                Ok(resp) => successes.push((sym, resp)),
697                Err(e) => {
698                    response.errors.insert(sym, e.to_string());
699                }
700            }
701        }
702        successes
703    }
704
705    /// Get a specific quote by symbol (from cache or fetch all)
706    pub async fn quote<F>(&self, symbol: &str) -> Result<Quote<F>>
707    where
708        F: Format,
709        Quote<Both>: Into<Quote<F>>,
710    {
711        {
712            let cache = self.quote_cache.read().await;
713            if let Some(entry) = cache.get(symbol)
714                && self.is_cache_fresh(Some(entry))
715            {
716                return Ok(entry.value.clone().into());
717            }
718        }
719
720        let response = self.quotes().await?;
721
722        response
723            .quotes
724            .get(symbol)
725            .cloned()
726            .map(Into::into)
727            .ok_or_else(|| FinanceError::SymbolNotFound {
728                symbol: Some(symbol.to_string()),
729                context: response
730                    .errors
731                    .get(symbol)
732                    .cloned()
733                    .unwrap_or_else(|| "Symbol not found".to_string()),
734            })
735    }
736
737    /// Helper to get or create a fetch guard for a given key.
738    ///
739    /// Returns the guard from the map, never a locally-created copy that
740    /// could diverge under contention.
741    async fn get_fetch_guard<K: Clone + Eq + std::hash::Hash>(
742        guard_map: &FetchGuardMap<K>,
743        key: K,
744    ) -> FetchGuard {
745        {
746            let guards = guard_map.read().await;
747            if let Some(guard) = guards.get(&key) {
748                return Arc::clone(guard);
749            }
750        }
751
752        let mut guards = guard_map.write().await;
753        Arc::clone(
754            guards
755                .entry(key)
756                .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))),
757        )
758    }
759
760    /// Batch fetch charts for all symbols concurrently
761    ///
762    /// Chart data cannot be batched in a single request, so this fetches
763    /// all charts concurrently using tokio for maximum performance.
764    pub async fn charts(
765        &self,
766        interval: Interval,
767        range: TimeRange,
768    ) -> Result<BatchChartsResponse> {
769        // Fast path: check if all symbols are cached
770        {
771            let cache = self.chart_cache.read().await;
772            if self.all_cached(
773                &cache,
774                self.symbols.iter().map(|s| (s.clone(), interval, range)),
775            ) {
776                let mut response = BatchChartsResponse::with_capacity(self.symbols.len());
777                for symbol in &self.symbols {
778                    if let Some(entry) = cache.get(&(symbol.clone(), interval, range)) {
779                        response
780                            .charts
781                            .insert(symbol.to_string(), entry.value.clone());
782                    }
783                }
784                return Ok(response);
785            }
786        }
787
788        // Slow path: acquire fetch guard to prevent duplicate concurrent requests
789        let fetch_guard = Self::get_fetch_guard(&self.charts_fetch, (interval, range)).await;
790        let _guard = fetch_guard.lock().await;
791
792        // Double-check: another task may have fetched while we waited
793        {
794            let cache = self.chart_cache.read().await;
795            if self.all_cached(
796                &cache,
797                self.symbols.iter().map(|s| (s.clone(), interval, range)),
798            ) {
799                let mut response = BatchChartsResponse::with_capacity(self.symbols.len());
800                for symbol in &self.symbols {
801                    if let Some(entry) = cache.get(&(symbol.clone(), interval, range)) {
802                        response
803                            .charts
804                            .insert(symbol.to_string(), entry.value.clone());
805                    }
806                }
807                return Ok(response);
808            }
809        }
810
811        // Fetch all charts concurrently via provider dispatch (no lock held during I/O)
812        let futures: Vec<_> = self
813            .symbols
814            .iter()
815            .map(|symbol| {
816                let providers = Arc::clone(&self.providers);
817                let symbol = Arc::clone(symbol);
818                async move {
819                    let sym = symbol.to_string();
820                    let result = providers
821                        .fetch(Capability::CHART, |p| {
822                            let sym = sym.clone();
823                            let p = p.clone();
824                            async move { p.fetch_chart(&sym, interval, range).await }
825                        })
826                        .await;
827                    (symbol, result)
828                }
829            })
830            .collect();
831
832        let results: Vec<_> = stream::iter(futures)
833            .buffer_unordered(self.max_concurrency)
834            .collect()
835            .await;
836
837        let mut response = BatchChartsResponse::with_capacity(self.symbols.len());
838        let mut parsed_charts: Vec<(Arc<str>, Chart)> = Vec::new();
839
840        for (symbol, result) in results {
841            match result {
842                Ok(data) => {
843                    let chart = data;
844                    parsed_charts.push((symbol, chart));
845                }
846                Err(e) => {
847                    response.errors.insert(symbol.to_string(), e.to_string());
848                }
849            }
850        }
851
852        // Move into cache, then clone for response — avoids double-clone
853        if self.cache_ttl.is_some() {
854            let mut cache = self.chart_cache.write().await;
855            let cache_keys: Vec<_> = parsed_charts
856                .into_iter()
857                .map(|(symbol, chart)| {
858                    self.cache_insert(&mut cache, (symbol.clone(), interval, range), chart);
859                    symbol
860                })
861                .collect();
862            for symbol in cache_keys {
863                if let Some(cached) = cache.get(&(symbol.clone(), interval, range)) {
864                    response
865                        .charts
866                        .insert(symbol.to_string(), cached.value.clone());
867                }
868            }
869        } else {
870            for (symbol, chart) in parsed_charts {
871                response.charts.insert(symbol.to_string(), chart);
872            }
873        }
874
875        Ok(response)
876    }
877
878    /// Get a specific chart by symbol
879    pub async fn chart(&self, symbol: &str, interval: Interval, range: TimeRange) -> Result<Chart> {
880        {
881            let cache = self.chart_cache.read().await;
882            let key: Arc<str> = symbol.into();
883            if let Some(entry) = cache.get(&(key, interval, range))
884                && self.is_cache_fresh(Some(entry))
885            {
886                return Ok(entry.value.clone());
887            }
888        }
889
890        let response = self.charts(interval, range).await?;
891
892        response
893            .charts
894            .get(symbol)
895            .cloned()
896            .ok_or_else(|| FinanceError::SymbolNotFound {
897                symbol: Some(symbol.to_string()),
898                context: response
899                    .errors
900                    .get(symbol)
901                    .cloned()
902                    .unwrap_or_else(|| "Symbol not found".to_string()),
903            })
904    }
905
906    /// Batch fetch chart data for a custom date range for all symbols concurrently.
907    ///
908    /// Unlike [`charts()`](Self::charts) which uses predefined time ranges,
909    /// this method accepts absolute start/end timestamps. Results are **not cached**
910    /// since custom ranges have unbounded key space.
911    ///
912    /// # Arguments
913    ///
914    /// * `interval` - Time interval between data points
915    /// * `start` - Start date as Unix timestamp (seconds since epoch)
916    /// * `end` - End date as Unix timestamp (seconds since epoch)
917    pub async fn charts_range(
918        &self,
919        interval: Interval,
920        start: i64,
921        end: i64,
922    ) -> Result<BatchChartsResponse> {
923        let futures: Vec<_> = self
924            .symbols
925            .iter()
926            .map(|symbol| {
927                let providers = Arc::clone(&self.providers);
928                let symbol = Arc::clone(symbol);
929                async move {
930                    let sym = symbol.to_string();
931                    let result = providers
932                        .fetch(Capability::CHART, |p| {
933                            let sym = sym.clone();
934                            let p = p.clone();
935                            async move { p.fetch_chart_range(&sym, interval, start, end).await }
936                        })
937                        .await;
938                    (symbol, result)
939                }
940            })
941            .collect();
942
943        let results: Vec<_> = stream::iter(futures)
944            .buffer_unordered(self.max_concurrency)
945            .collect()
946            .await;
947
948        let mut response = BatchChartsResponse::with_capacity(self.symbols.len());
949
950        for (symbol, result) in results {
951            match result {
952                Ok(data) => {
953                    let chart = data;
954                    response.charts.insert(symbol.to_string(), chart);
955                }
956                Err(e) => {
957                    response.errors.insert(symbol.to_string(), e.to_string());
958                }
959            }
960        }
961
962        Ok(response)
963    }
964
965    /// Ensures events are loaded for all symbols using chart requests.
966    ///
967    /// Fetches events concurrently for symbols that don't have cached events.
968    /// Uses `TimeRange::Max` to get full event history (Yahoo returns all
969    /// dividends/splits/capital gains regardless of chart range).
970    ///
971    /// Events are always stored regardless of `cache_ttl` because they are
972    /// derived data (not a TTL-bounded cache). When `cache_ttl` is `None`,
973    /// events persist for the lifetime of the `Tickers` instance.
974    async fn ensure_events_loaded(&self) -> Result<()> {
975        // Check which symbols need event data (existence check, not TTL-based)
976        let symbols_to_fetch: Vec<Arc<str>> = {
977            let cache = self.events_cache.read().await;
978            self.symbols
979                .iter()
980                .filter(|sym| !cache.contains_key(*sym))
981                .cloned()
982                .collect()
983        };
984
985        if symbols_to_fetch.is_empty() {
986            return Ok(());
987        }
988
989        // Fetch events concurrently for all symbols that need it via provider dispatch
990        let futures: Vec<_> = symbols_to_fetch
991            .iter()
992            .map(|symbol| {
993                let providers = Arc::clone(&self.providers);
994                let symbol = Arc::clone(symbol);
995                async move {
996                    let sym = symbol.to_string();
997                    let result = providers
998                        .fetch(Capability::CORPORATE, |p| {
999                            let sym = sym.clone();
1000                            let p = p.clone();
1001                            async move { p.fetch_events(&sym).await }
1002                        })
1003                        .await;
1004                    (symbol, result)
1005                }
1006            })
1007            .collect();
1008
1009        let results: Vec<_> = stream::iter(futures)
1010            .buffer_unordered(self.max_concurrency)
1011            .collect()
1012            .await;
1013
1014        let mut parsed_events: Vec<(Arc<str>, ChartEvents)> = Vec::new();
1015
1016        for (symbol, result) in results {
1017            if let Ok(events_data) = result {
1018                parsed_events.push((symbol, events_data));
1019            }
1020        }
1021
1022        // Always store events — they are derived data, not TTL-bounded cache
1023        if !parsed_events.is_empty() {
1024            let mut events_cache = self.events_cache.write().await;
1025            for (symbol, events) in parsed_events {
1026                events_cache.insert(symbol, CacheEntry::new(events));
1027            }
1028        }
1029
1030        Ok(())
1031    }
1032
1033    /// Batch fetch spark data for all symbols in a single request.
1034    ///
1035    /// Spark data is optimized for sparkline rendering, returning only close prices.
1036    /// Unlike `charts()`, this fetches all symbols in ONE API call, making it
1037    /// much more efficient for displaying price trends on dashboards or watchlists.
1038    ///
1039    /// # Arguments
1040    ///
1041    /// * `interval` - Time interval between data points (e.g., `Interval::FiveMinutes`)
1042    /// * `range` - Time range to fetch (e.g., `TimeRange::OneDay`)
1043    ///
1044    /// # Example
1045    ///
1046    /// ```no_run
1047    /// use finance_query::{Tickers, Interval, TimeRange};
1048    ///
1049    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1050    /// let tickers = Tickers::new(["AAPL", "MSFT", "GOOGL"]).await?;
1051    /// let sparks = tickers.spark(Interval::FiveMinutes, TimeRange::OneDay).await?;
1052    ///
1053    /// for (symbol, spark) in &sparks.sparks {
1054    ///     if let Some(change) = spark.percent_change() {
1055    ///         println!("{}: {:.2}%", symbol, change);
1056    ///     }
1057    /// }
1058    /// # Ok(())
1059    /// # }
1060    /// ```
1061    pub async fn spark(&self, interval: Interval, range: TimeRange) -> Result<BatchSparksResponse> {
1062        // Fast path: check if all symbols are cached
1063        {
1064            let cache = self.spark_cache.read().await;
1065            if self.all_cached(
1066                &cache,
1067                self.symbols.iter().map(|s| (s.clone(), interval, range)),
1068            ) {
1069                let mut response = BatchSparksResponse::with_capacity(self.symbols.len());
1070                for symbol in &self.symbols {
1071                    if let Some(entry) = cache.get(&(symbol.clone(), interval, range)) {
1072                        response
1073                            .sparks
1074                            .insert(symbol.to_string(), entry.value.clone());
1075                    }
1076                }
1077                return Ok(response);
1078            }
1079        }
1080
1081        // Slow path: acquire fetch guard
1082        let fetch_guard = Self::get_fetch_guard(&self.spark_fetch, (interval, range)).await;
1083        let _guard = fetch_guard.lock().await;
1084
1085        // Double-check after guard
1086        {
1087            let cache = self.spark_cache.read().await;
1088            if self.all_cached(
1089                &cache,
1090                self.symbols.iter().map(|s| (s.clone(), interval, range)),
1091            ) {
1092                let mut response = BatchSparksResponse::with_capacity(self.symbols.len());
1093                for symbol in &self.symbols {
1094                    if let Some(entry) = cache.get(&(symbol.clone(), interval, range)) {
1095                        response
1096                            .sparks
1097                            .insert(symbol.to_string(), entry.value.clone());
1098                    }
1099                }
1100                return Ok(response);
1101            }
1102        }
1103
1104        // Spark is a Yahoo-specific batch endpoint with no provider abstraction equivalent
1105        let client = self.providers.first_yahoo()?;
1106        let symbols_ref: Vec<&str> = self.symbols.iter().map(|s| &**s).collect();
1107        let json =
1108            crate::adapters::yahoo::quote::spark::fetch(&client, &symbols_ref, interval, range)
1109                .await?;
1110
1111        let mut response = BatchSparksResponse::with_capacity(self.symbols.len());
1112
1113        match SparkResponse::from_json(json) {
1114            Ok(spark_response) => {
1115                let mut parsed_sparks: Vec<(Arc<str>, Spark)> = Vec::new();
1116
1117                if let Some(results) = spark_response.spark.result {
1118                    for result in &results {
1119                        if let Some(spark) = Spark::from_response(
1120                            result,
1121                            Some(interval.as_str().to_string()),
1122                            Some(range.as_str().to_string()),
1123                        ) {
1124                            let sym: Arc<str> = result.symbol.as_str().into();
1125                            parsed_sparks.push((sym, spark));
1126                        } else {
1127                            response.errors.insert(
1128                                result.symbol.to_string(),
1129                                "Failed to parse spark data".to_string(),
1130                            );
1131                        }
1132                    }
1133                }
1134
1135                // Cache all parsed sparks
1136                if self.cache_ttl.is_some() {
1137                    let mut cache = self.spark_cache.write().await;
1138                    for (symbol, spark) in &parsed_sparks {
1139                        self.cache_insert(
1140                            &mut cache,
1141                            (symbol.clone(), interval, range),
1142                            spark.clone(),
1143                        );
1144                    }
1145                }
1146
1147                // Build response
1148                for (symbol, spark) in parsed_sparks {
1149                    response.sparks.insert(symbol.to_string(), spark);
1150                }
1151
1152                // Track missing symbols
1153                for symbol in &self.symbols {
1154                    let symbol_str = &**symbol;
1155                    if !response.sparks.contains_key(symbol_str)
1156                        && !response.errors.contains_key(symbol_str)
1157                    {
1158                        response.errors.insert(
1159                            symbol.to_string(),
1160                            "Symbol not found in response".to_string(),
1161                        );
1162                    }
1163                }
1164            }
1165            Err(e) => {
1166                for symbol in &self.symbols {
1167                    response.errors.insert(symbol.to_string(), e.to_string());
1168                }
1169            }
1170        }
1171
1172        Ok(response)
1173    }
1174
1175    /// Batch fetch dividends for all symbols
1176    ///
1177    /// Returns dividend history for all symbols, filtered by the specified time range.
1178    /// Dividends are cached per symbol after the first chart fetch.
1179    ///
1180    /// # Arguments
1181    ///
1182    /// * `range` - Time range to filter dividends
1183    ///
1184    /// # Example
1185    ///
1186    /// ```no_run
1187    /// use finance_query::{Tickers, TimeRange};
1188    ///
1189    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1190    /// let tickers = Tickers::new(["AAPL", "MSFT"]).await?;
1191    /// let dividends = tickers.dividends(TimeRange::OneYear).await?;
1192    ///
1193    /// for (symbol, divs) in &dividends.dividends {
1194    ///     println!("{}: {} dividends", symbol, divs.len());
1195    /// }
1196    /// # Ok(())
1197    /// # }
1198    /// ```
1199    pub async fn dividends(&self, range: TimeRange) -> Result<BatchDividendsResponse> {
1200        let mut response = BatchDividendsResponse::with_capacity(self.symbols.len());
1201
1202        // Fetch events efficiently (1-day chart request per symbol)
1203        self.ensure_events_loaded().await?;
1204
1205        let events_cache = self.events_cache.read().await;
1206
1207        for symbol in &self.symbols {
1208            if let Some(entry) = events_cache.get(symbol) {
1209                let all_dividends = entry.value.to_dividends();
1210                let filtered = filter_by_range(all_dividends, range);
1211                response.dividends.insert(symbol.to_string(), filtered);
1212            } else {
1213                response
1214                    .errors
1215                    .insert(symbol.to_string(), "No events data available".to_string());
1216            }
1217        }
1218
1219        Ok(response)
1220    }
1221
1222    /// Batch fetch stock splits for all symbols
1223    ///
1224    /// Returns stock split history for all symbols, filtered by the specified time range.
1225    /// Splits are cached per symbol after the first chart fetch.
1226    ///
1227    /// # Arguments
1228    ///
1229    /// * `range` - Time range to filter splits
1230    ///
1231    /// # Example
1232    ///
1233    /// ```no_run
1234    /// use finance_query::{Tickers, TimeRange};
1235    ///
1236    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1237    /// let tickers = Tickers::new(["NVDA", "TSLA"]).await?;
1238    /// let splits = tickers.splits(TimeRange::FiveYears).await?;
1239    ///
1240    /// for (symbol, sp) in &splits.splits {
1241    ///     for split in sp {
1242    ///         println!("{}: {}", symbol, split.ratio);
1243    ///     }
1244    /// }
1245    /// # Ok(())
1246    /// # }
1247    /// ```
1248    pub async fn splits(&self, range: TimeRange) -> Result<BatchSplitsResponse> {
1249        let mut response = BatchSplitsResponse::with_capacity(self.symbols.len());
1250
1251        // Fetch events efficiently (1-day chart request per symbol)
1252        self.ensure_events_loaded().await?;
1253
1254        let events_cache = self.events_cache.read().await;
1255
1256        for symbol in &self.symbols {
1257            if let Some(entry) = events_cache.get(symbol) {
1258                let all_splits = entry.value.to_splits();
1259                let filtered = filter_by_range(all_splits, range);
1260                response.splits.insert(symbol.to_string(), filtered);
1261            } else {
1262                response
1263                    .errors
1264                    .insert(symbol.to_string(), "No events data available".to_string());
1265            }
1266        }
1267
1268        Ok(response)
1269    }
1270
1271    /// Batch fetch capital gains for all symbols
1272    ///
1273    /// Returns capital gain distribution history for all symbols, filtered by the
1274    /// specified time range. This is primarily relevant for mutual funds and ETFs.
1275    /// Capital gains are cached per symbol after the first chart fetch.
1276    ///
1277    /// # Arguments
1278    ///
1279    /// * `range` - Time range to filter capital gains
1280    ///
1281    /// # Example
1282    ///
1283    /// ```no_run
1284    /// use finance_query::{Tickers, TimeRange};
1285    ///
1286    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1287    /// let tickers = Tickers::new(["VFIAX", "VTI"]).await?;
1288    /// let gains = tickers.capital_gains(TimeRange::TwoYears).await?;
1289    ///
1290    /// for (symbol, cg) in &gains.capital_gains {
1291    ///     println!("{}: {} distributions", symbol, cg.len());
1292    /// }
1293    /// # Ok(())
1294    /// # }
1295    /// ```
1296    pub async fn capital_gains(&self, range: TimeRange) -> Result<BatchCapitalGainsResponse> {
1297        let mut response = BatchCapitalGainsResponse::with_capacity(self.symbols.len());
1298
1299        // Fetch events efficiently (1-day chart request per symbol)
1300        self.ensure_events_loaded().await?;
1301
1302        let events_cache = self.events_cache.read().await;
1303
1304        for symbol in &self.symbols {
1305            if let Some(entry) = events_cache.get(symbol) {
1306                let all_gains = entry.value.to_capital_gains();
1307                let filtered = filter_by_range(all_gains, range);
1308                response.capital_gains.insert(symbol.to_string(), filtered);
1309            } else {
1310                response
1311                    .errors
1312                    .insert(symbol.to_string(), "No events data available".to_string());
1313            }
1314        }
1315
1316        Ok(response)
1317    }
1318
1319    /// Batch fetch financial statements for all symbols
1320    ///
1321    /// Fetches the specified financial statement type for all symbols concurrently.
1322    /// Financial statements are cached per (symbol, statement_type, frequency) tuple.
1323    ///
1324    /// # Arguments
1325    ///
1326    /// * `statement_type` - Type of statement (Income, Balance, CashFlow)
1327    /// * `frequency` - Annual or Quarterly
1328    ///
1329    /// # Example
1330    ///
1331    /// ```no_run
1332    /// use finance_query::{Tickers, StatementType, Frequency};
1333    ///
1334    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1335    /// let tickers = Tickers::new(["AAPL", "MSFT", "GOOGL"]).await?;
1336    /// let financials = tickers.financials(StatementType::Income, Frequency::Annual).await?;
1337    ///
1338    /// for (symbol, stmt) in &financials.financials {
1339    ///     if let Some(revenue) = stmt.statement.get("TotalRevenue") {
1340    ///         println!("{}: {:?}", symbol, revenue);
1341    ///     }
1342    /// }
1343    /// # Ok(())
1344    /// # }
1345    /// ```
1346    pub async fn financials(
1347        &self,
1348        statement_type: StatementType,
1349        frequency: Frequency,
1350    ) -> Result<BatchFinancialsResponse> {
1351        batch_fetch_cached!(self;
1352            cache: financials_cache,
1353            guard: map(financials_fetch, (statement_type, frequency)),
1354            key: |s| (s.clone(), statement_type, frequency),
1355            response: BatchFinancialsResponse.financials,
1356            fetch: |providers, symbol| {
1357                let sym = symbol.to_string();
1358                providers.fetch(Capability::FUNDAMENTALS, move |p| {
1359                    let sym = sym.clone();
1360                    let p = p.clone();
1361                    async move {
1362                        p.fetch_financials(&sym, statement_type, frequency)
1363                            .await
1364                    }
1365                }).await
1366            },
1367        )
1368    }
1369
1370    /// Batch fetch news articles for all symbols
1371    ///
1372    /// Fetches recent news articles for all symbols concurrently using scrapers.
1373    /// News articles are cached per symbol.
1374    ///
1375    /// # Example
1376    ///
1377    /// ```no_run
1378    /// use finance_query::Tickers;
1379    ///
1380    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1381    /// let tickers = Tickers::new(["AAPL", "MSFT"]).await?;
1382    /// let news = tickers.news().await?;
1383    ///
1384    /// for (symbol, articles) in &news.news {
1385    ///     println!("{}: {} articles", symbol, articles.len());
1386    ///     for article in articles.iter().take(3) {
1387    ///         println!("  - {}", article.title);
1388    ///     }
1389    /// }
1390    /// # Ok(())
1391    /// # }
1392    /// ```
1393    pub async fn news(&self) -> Result<BatchNewsResponse> {
1394        batch_fetch_cached!(self;
1395            cache: news_cache,
1396            guard: simple(news_fetch),
1397            key: |s| s.clone(),
1398            response: BatchNewsResponse.news,
1399            fetch: |providers, symbol| {
1400                let sym = symbol.to_string();
1401                providers.fetch(Capability::CORPORATE, move |p| {
1402                    let sym = sym.clone();
1403                    let p = p.clone();
1404                    async move {
1405                        p.fetch_news(&sym)
1406                            .await
1407                            .map(|data| data.into_iter().collect::<Vec<News>>())
1408                    }
1409                }).await
1410            },
1411        )
1412    }
1413
1414    /// Batch fetch recommendations for all symbols
1415    ///
1416    /// Fetches analyst recommendations and similar stocks for all symbols concurrently.
1417    /// Recommendations are cached per (symbol, limit) tuple — different limits
1418    /// produce different API responses and are cached independently.
1419    ///
1420    /// # Arguments
1421    ///
1422    /// * `limit` - Maximum number of similar stocks to return per symbol
1423    ///
1424    /// # Example
1425    ///
1426    /// ```no_run
1427    /// use finance_query::Tickers;
1428    ///
1429    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1430    /// let tickers = Tickers::new(["AAPL", "MSFT"]).await?;
1431    /// let recommendations = tickers.recommendations(10).await?;
1432    ///
1433    /// for (symbol, rec) in &recommendations.recommendations {
1434    ///     println!("{}: {} recommendations", symbol, rec.count());
1435    ///     for similar in &rec.recommendations {
1436    ///         println!("  - {}: score {}", similar.symbol, similar.score);
1437    ///     }
1438    /// }
1439    /// # Ok(())
1440    /// # }
1441    /// ```
1442    pub async fn recommendations(&self, limit: u32) -> Result<BatchRecommendationsResponse> {
1443        batch_fetch_cached!(self;
1444            cache: recommendations_cache,
1445            guard: map(recommendations_fetch, limit),
1446            key: |s| (s.clone(), limit),
1447            response: BatchRecommendationsResponse.recommendations,
1448            fetch: |providers, symbol| {
1449                let sym = symbol.to_string();
1450                providers.fetch(Capability::CORPORATE, move |p| {
1451                    let sym = sym.clone();
1452                    let p = p.clone();
1453                    async move {
1454                        let items = p.fetch_similar_symbols(&sym, limit).await?;
1455                        Ok(recommendation_from_similar(
1456                            sym,
1457                            Some(Provider::from_id_str(p.id()).ok_or_else(|| {
1458                                FinanceError::InternalError(format!("unknown provider id: {}", p.id()))
1459                            })?),
1460                            items,
1461                            Some(limit),
1462                        ))
1463                    }
1464                }).await
1465            },
1466        )
1467    }
1468
1469    /// Batch fetch options chains for all symbols
1470    ///
1471    /// Fetches options chains for the specified expiration date for all symbols concurrently.
1472    /// Options are cached per (symbol, date) tuple.
1473    ///
1474    /// # Arguments
1475    ///
1476    /// * `date` - Optional expiration date (Unix timestamp). If None, fetches nearest expiration.
1477    ///
1478    /// # Example
1479    ///
1480    /// ```no_run
1481    /// use finance_query::Tickers;
1482    ///
1483    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1484    /// let tickers = Tickers::new(["AAPL", "MSFT"]).await?;
1485    /// let options = tickers.options(None).await?;
1486    ///
1487    /// for (symbol, opts) in &options.options {
1488    ///     println!("{}: {} expirations", symbol, opts.expiration_dates().len());
1489    /// }
1490    /// # Ok(())
1491    /// # }
1492    /// ```
1493    pub async fn options(&self, date: Option<i64>) -> Result<BatchOptionsResponse> {
1494        batch_fetch_cached!(self;
1495            cache: options_cache,
1496            guard: map(options_fetch, date),
1497            key: |s| (s.clone(), date),
1498            response: BatchOptionsResponse.options,
1499            fetch: |providers, symbol| {
1500                let sym = symbol.to_string();
1501                providers.fetch(Capability::OPTIONS, move |p| {
1502                    let sym = sym.clone();
1503                    let p = p.clone();
1504                    async move {
1505                        p.fetch_options(&sym, date).await
1506                    }
1507                }).await
1508            },
1509        )
1510    }
1511
1512    /// Batch calculate all technical indicators for all symbols
1513    ///
1514    /// Calculates complete indicator summaries for all symbols from their chart data.
1515    /// Indicators are cached per (symbol, interval, range) tuple.
1516    ///
1517    /// # Arguments
1518    ///
1519    /// * `interval` - The time interval for each candle
1520    /// * `range` - The time range to fetch data for
1521    ///
1522    /// # Example
1523    ///
1524    /// ```no_run
1525    /// use finance_query::{Tickers, Interval, TimeRange};
1526    ///
1527    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1528    /// let tickers = Tickers::new(["AAPL", "MSFT"]).await?;
1529    /// let indicators = tickers.indicators(Interval::OneDay, TimeRange::ThreeMonths).await?;
1530    ///
1531    /// for (symbol, ind) in &indicators.indicators {
1532    ///     println!("{}: RSI(14) = {:?}, SMA(20) = {:?}", symbol, ind.rsi_14, ind.sma_20);
1533    /// }
1534    /// # Ok(())
1535    /// # }
1536    /// ```
1537    #[cfg(feature = "indicators")]
1538    pub async fn indicators(
1539        &self,
1540        interval: Interval,
1541        range: TimeRange,
1542    ) -> Result<BatchIndicatorsResponse> {
1543        let cache_key_for = |symbol: &Arc<str>| (symbol.clone(), interval, range);
1544
1545        // Fast path: check if all symbols are cached
1546        {
1547            let cache = self.indicators_cache.read().await;
1548            if self.all_cached(&cache, self.symbols.iter().map(&cache_key_for)) {
1549                let mut response = BatchIndicatorsResponse::with_capacity(self.symbols.len());
1550                for symbol in &self.symbols {
1551                    if let Some(entry) = cache.get(&cache_key_for(symbol)) {
1552                        response
1553                            .indicators
1554                            .insert(symbol.to_string(), entry.value.clone());
1555                    }
1556                }
1557                return Ok(response);
1558            }
1559        }
1560
1561        // Slow path: acquire fetch guard to prevent duplicate concurrent calculations
1562        let fetch_guard = Self::get_fetch_guard(&self.indicators_fetch, (interval, range)).await;
1563        let _guard = fetch_guard.lock().await;
1564
1565        // Double-check: another task may have computed while we waited
1566        {
1567            let cache = self.indicators_cache.read().await;
1568            if self.all_cached(&cache, self.symbols.iter().map(&cache_key_for)) {
1569                let mut response = BatchIndicatorsResponse::with_capacity(self.symbols.len());
1570                for symbol in &self.symbols {
1571                    if let Some(entry) = cache.get(&cache_key_for(symbol)) {
1572                        response
1573                            .indicators
1574                            .insert(symbol.to_string(), entry.value.clone());
1575                    }
1576                }
1577                return Ok(response);
1578            }
1579        }
1580
1581        // Fetch charts first (which may already be cached, has its own deduplication)
1582        let charts_response = self.charts(interval, range).await?;
1583
1584        let mut response = BatchIndicatorsResponse::with_capacity(self.symbols.len());
1585
1586        // Calculate all indicators first (no lock held)
1587        let mut calculated_indicators: Vec<(String, indicators::IndicatorsSummary)> = Vec::new();
1588
1589        for (symbol, chart) in &charts_response.charts {
1590            let indicators = indicators::summary::calculate_indicators(&chart.candles);
1591            calculated_indicators.push((symbol.to_string(), indicators));
1592        }
1593
1594        // Now acquire write lock briefly for batch cache insertion
1595        if self.cache_ttl.is_some() {
1596            let mut cache = self.indicators_cache.write().await;
1597            for (symbol, indicators) in &calculated_indicators {
1598                let key: Arc<str> = symbol.as_str().into();
1599                self.cache_insert(&mut cache, cache_key_for(&key), indicators.clone());
1600            }
1601        }
1602
1603        // Populate response (no lock needed)
1604        for (symbol, indicators) in calculated_indicators {
1605            response.indicators.insert(symbol, indicators);
1606        }
1607
1608        // Add errors from chart fetch
1609        for (symbol, error) in &charts_response.errors {
1610            response.errors.insert(symbol.to_string(), error.clone());
1611        }
1612
1613        Ok(response)
1614    }
1615
1616    // ========================================================================
1617    // Dynamic Symbol Management
1618    // ========================================================================
1619
1620    /// Add symbols to the watch list
1621    ///
1622    /// Adds new symbols to track without affecting existing cached data.
1623    ///
1624    /// # Example
1625    ///
1626    /// ```no_run
1627    /// use finance_query::Tickers;
1628    ///
1629    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1630    /// let mut tickers = Tickers::new(["AAPL"]).await?;
1631    /// tickers.add_symbols(&["MSFT", "GOOGL"]);
1632    /// assert_eq!(tickers.len(), 3);
1633    /// # Ok(())
1634    /// # }
1635    /// ```
1636    pub fn add_symbols(&mut self, symbols: &[impl AsRef<str>]) {
1637        // Use HashSet for O(n+m) deduplication instead of O(n*m) linear search
1638        use std::collections::HashSet;
1639
1640        let existing: HashSet<&str> = self.symbols.iter().map(|s| &**s).collect();
1641        let to_add: Vec<Arc<str>> = symbols
1642            .iter()
1643            .map(|s| s.as_ref())
1644            .filter(|s| !existing.contains(s))
1645            .map(|s| s.into())
1646            .collect();
1647
1648        self.symbols.extend(to_add);
1649    }
1650
1651    // ========================================================================
1652    // Portfolio Backtesting
1653    // ========================================================================
1654
1655    /// Run a multi-symbol portfolio backtest across all tracked symbols.
1656    ///
1657    /// Fetches charts and dividends for each symbol concurrently, then runs
1658    /// the portfolio engine with the given strategy factory. Capital is shared
1659    /// across all symbols according to the [`PortfolioConfig`] allocation rules.
1660    ///
1661    /// `factory` is called once per symbol to produce an independent strategy
1662    /// instance:
1663    ///
1664    /// ```no_run
1665    /// use finance_query::{Tickers, Interval, TimeRange};
1666    /// use finance_query::backtesting::{SmaCrossover, BacktestConfig};
1667    /// use finance_query::backtesting::portfolio::{PortfolioConfig, RebalanceMode};
1668    ///
1669    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1670    /// let tickers = Tickers::new(["AAPL", "MSFT", "NVDA"]).await?;
1671    ///
1672    /// let config = PortfolioConfig::new(BacktestConfig::default())
1673    ///     .max_total_positions(2)
1674    ///     .rebalance(RebalanceMode::EqualWeight);
1675    ///
1676    /// let result = tickers.backtest(
1677    ///     Interval::OneDay,
1678    ///     TimeRange::TwoYears,
1679    ///     Some(config),
1680    ///     |_sym| SmaCrossover::new(10, 50),
1681    /// ).await?;
1682    ///
1683    /// println!("Portfolio return: {:.2}%", result.portfolio_metrics.total_return_pct);
1684    /// # Ok(())
1685    /// # }
1686    /// ```
1687    ///
1688    /// [`PortfolioConfig`]: backtesting::portfolio::PortfolioConfig
1689    #[cfg(feature = "backtesting")]
1690    pub async fn backtest<S, F>(
1691        &self,
1692        interval: Interval,
1693        range: TimeRange,
1694        config: Option<backtesting::portfolio::PortfolioConfig>,
1695        factory: F,
1696    ) -> backtesting::Result<backtesting::portfolio::PortfolioResult>
1697    where
1698        S: backtesting::Strategy,
1699        F: Fn(&str) -> S,
1700    {
1701        use crate::backtesting::portfolio::{PortfolioEngine, SymbolData};
1702
1703        let config = config.unwrap_or_default();
1704        config.validate(self.symbols.len())?;
1705
1706        // Fetch charts for all symbols (uses the batch chart cache)
1707        let charts = self
1708            .charts(interval, range)
1709            .await
1710            .map_err(|e| backtesting::BacktestError::ChartError(e.to_string()))?;
1711
1712        // Fetch dividends for all symbols (events cache is already warm after charts())
1713        // Treat errors as "no dividends" — dividend processing is best-effort
1714        let dividends_map = self
1715            .dividends(range)
1716            .await
1717            .map(|b| b.dividends)
1718            .unwrap_or_default();
1719
1720        // Assemble SymbolData slices — skip symbols with no chart data
1721        let symbol_data: Vec<SymbolData> = self
1722            .symbols
1723            .iter()
1724            .filter_map(|sym| {
1725                charts.charts.get(sym.as_ref()).map(|chart| {
1726                    let divs = dividends_map.get(sym.as_ref()).cloned().unwrap_or_default();
1727                    SymbolData::new(sym.as_ref(), chart.candles.clone()).with_dividends(divs)
1728                })
1729            })
1730            .collect();
1731
1732        let engine = PortfolioEngine::new(config);
1733        engine.run(&symbol_data, factory)
1734    }
1735
1736    /// Remove symbols from the watch list
1737    ///
1738    /// Removes symbols and clears their cached data to free memory.
1739    ///
1740    /// # Example
1741    ///
1742    /// ```no_run
1743    /// use finance_query::Tickers;
1744    ///
1745    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1746    /// let mut tickers = Tickers::new(["AAPL", "MSFT", "GOOGL"]).await?;
1747    /// tickers.remove_symbols(&["MSFT"]);
1748    /// assert_eq!(tickers.len(), 2);
1749    /// # Ok(())
1750    /// # }
1751    /// ```
1752    pub async fn remove_symbols(&mut self, symbols: &[impl AsRef<str>]) {
1753        use std::collections::HashSet;
1754        let to_remove: HashSet<&str> = symbols.iter().map(|s| s.as_ref()).collect();
1755
1756        // Remove from symbol list — O(1) lookup per element
1757        self.symbols.retain(|s| !to_remove.contains(&**s));
1758
1759        // Acquire all independent write locks in parallel
1760        let (
1761            mut quote_cache,
1762            mut chart_cache,
1763            mut events_cache,
1764            mut financials_cache,
1765            mut news_cache,
1766            mut recommendations_cache,
1767            mut options_cache,
1768            mut spark_cache,
1769        ) = tokio::join!(
1770            self.quote_cache.write(),
1771            self.chart_cache.write(),
1772            self.events_cache.write(),
1773            self.financials_cache.write(),
1774            self.news_cache.write(),
1775            self.recommendations_cache.write(),
1776            self.options_cache.write(),
1777            self.spark_cache.write(),
1778        );
1779
1780        // Simple key caches — O(1) per removal
1781        for symbol in &to_remove {
1782            let key: Arc<str> = (*symbol).into();
1783            quote_cache.remove(&key);
1784            events_cache.remove(&key);
1785            news_cache.remove(&key);
1786        }
1787
1788        // Composite key caches — O(n) retain but O(1) contains check
1789        chart_cache.retain(|(sym, _, _), _| !to_remove.contains(&**sym));
1790        financials_cache.retain(|(sym, _, _), _| !to_remove.contains(&**sym));
1791        recommendations_cache.retain(|(sym, _), _| !to_remove.contains(&**sym));
1792        options_cache.retain(|(sym, _), _| !to_remove.contains(&**sym));
1793        spark_cache.retain(|(sym, _, _), _| !to_remove.contains(&**sym));
1794
1795        // Drop all guards before cfg-gated lock
1796        drop((
1797            quote_cache,
1798            chart_cache,
1799            events_cache,
1800            financials_cache,
1801            news_cache,
1802            recommendations_cache,
1803            options_cache,
1804            spark_cache,
1805        ));
1806
1807        #[cfg(feature = "indicators")]
1808        self.indicators_cache
1809            .write()
1810            .await
1811            .retain(|(sym, _, _), _| !to_remove.contains(&**sym));
1812    }
1813
1814    /// Clear all cached data and fetch guards, forcing fresh fetches on next access.
1815    ///
1816    /// Use this when you need up-to-date data from a long-lived `Tickers` instance.
1817    /// Also clears fetch guard maps to prevent unbounded growth.
1818    pub async fn clear_cache(&self) {
1819        tokio::join!(
1820            // Data caches
1821            async { self.quote_cache.write().await.clear() },
1822            async { self.chart_cache.write().await.clear() },
1823            async { self.events_cache.write().await.clear() },
1824            async { self.financials_cache.write().await.clear() },
1825            async { self.news_cache.write().await.clear() },
1826            async { self.recommendations_cache.write().await.clear() },
1827            async { self.options_cache.write().await.clear() },
1828            async { self.spark_cache.write().await.clear() },
1829            async {
1830                #[cfg(feature = "indicators")]
1831                self.indicators_cache.write().await.clear();
1832            },
1833            // Fetch guard maps (prevent unbounded growth)
1834            async { self.charts_fetch.write().await.clear() },
1835            async { self.financials_fetch.write().await.clear() },
1836            async { self.recommendations_fetch.write().await.clear() },
1837            async { self.options_fetch.write().await.clear() },
1838            async { self.spark_fetch.write().await.clear() },
1839            async {
1840                #[cfg(feature = "indicators")]
1841                self.indicators_fetch.write().await.clear();
1842            },
1843        );
1844    }
1845
1846    /// Clear only the cached quote data.
1847    ///
1848    /// The next call to `quotes()` or `quote()` will re-fetch from the API.
1849    pub async fn clear_quote_cache(&self) {
1850        self.quote_cache.write().await.clear();
1851    }
1852
1853    /// Clear only the cached chart, spark, and events data.
1854    ///
1855    /// The next call to `charts()`, `spark()`, `dividends()`, `splits()`,
1856    /// or `capital_gains()` will re-fetch from the API.
1857    pub async fn clear_chart_cache(&self) {
1858        tokio::join!(
1859            async { self.chart_cache.write().await.clear() },
1860            async { self.events_cache.write().await.clear() },
1861            async { self.spark_cache.write().await.clear() },
1862            async {
1863                #[cfg(feature = "indicators")]
1864                self.indicators_cache.write().await.clear();
1865            },
1866        );
1867    }
1868}
1869
1870#[cfg(test)]
1871mod tests {
1872    use super::*;
1873
1874    #[tokio::test]
1875    #[ignore = "requires network access"]
1876    async fn test_tickers_quotes() {
1877        let tickers = Tickers::new(["AAPL", "MSFT", "GOOGL"]).await.unwrap();
1878        let result = tickers.quotes().await.unwrap();
1879
1880        assert!(result.success_count() > 0);
1881    }
1882
1883    #[tokio::test]
1884    #[ignore = "requires network access"]
1885    async fn test_tickers_charts() {
1886        let tickers = Tickers::new(["AAPL", "MSFT"]).await.unwrap();
1887        let result = tickers
1888            .charts(Interval::OneDay, TimeRange::FiveDays)
1889            .await
1890            .unwrap();
1891
1892        assert!(result.success_count() > 0);
1893    }
1894
1895    #[tokio::test]
1896    #[ignore = "requires network access"]
1897    async fn test_tickers_spark() {
1898        let tickers = Tickers::new(["AAPL", "MSFT", "GOOGL"]).await.unwrap();
1899        let result = tickers
1900            .spark(Interval::FiveMinutes, TimeRange::OneDay)
1901            .await
1902            .unwrap();
1903
1904        assert!(result.success_count() > 0);
1905
1906        // Verify spark data structure
1907        if let Some(spark) = result.sparks.get("AAPL") {
1908            assert!(!spark.closes.is_empty());
1909            assert_eq!(spark.symbol, "AAPL");
1910            // Verify helper methods work
1911            assert!(spark.percent_change().is_some());
1912        }
1913    }
1914
1915    #[tokio::test]
1916    #[ignore = "requires network access"]
1917    async fn test_tickers_dividends() {
1918        let tickers = Tickers::new(["AAPL", "MSFT"]).await.unwrap();
1919        let result = tickers.dividends(TimeRange::OneYear).await.unwrap();
1920
1921        assert!(result.success_count() > 0);
1922
1923        // Verify dividend data structure
1924        if let Some(dividends) = result.dividends.get("AAPL")
1925            && !dividends.is_empty()
1926        {
1927            let div = &dividends[0];
1928            assert!(div.timestamp > 0);
1929            assert!(div.amount > 0.0);
1930        }
1931    }
1932
1933    #[tokio::test]
1934    #[ignore = "requires network access"]
1935    async fn test_tickers_splits() {
1936        let tickers = Tickers::new(["NVDA", "TSLA"]).await.unwrap();
1937        let result = tickers.splits(TimeRange::FiveYears).await.unwrap();
1938
1939        // Note: Not all symbols have splits, so we just check for successful response
1940        assert!(result.success_count() > 0);
1941
1942        // If there are splits, verify structure
1943        for splits in result.splits.values() {
1944            for split in splits {
1945                assert!(split.timestamp > 0);
1946                assert!(split.numerator > 0.0);
1947                assert!(split.denominator > 0.0);
1948                assert!(!split.ratio.is_empty());
1949            }
1950        }
1951    }
1952
1953    #[tokio::test]
1954    #[ignore = "requires network access"]
1955    async fn test_tickers_capital_gains() {
1956        let tickers = Tickers::new(["VFIAX", "VTI"]).await.unwrap();
1957        let result = tickers.capital_gains(TimeRange::TwoYears).await.unwrap();
1958
1959        // Note: Not all symbols have capital gains distributions
1960        assert!(result.success_count() > 0);
1961
1962        // If there are capital gains, verify structure
1963        for gains in result.capital_gains.values() {
1964            for gain in gains {
1965                assert!(gain.timestamp > 0);
1966                assert!(gain.amount >= 0.0);
1967            }
1968        }
1969    }
1970
1971    #[tokio::test]
1972    #[ignore = "requires network access"]
1973    async fn test_tickers_financials() {
1974        let tickers = Tickers::new(["AAPL", "MSFT"]).await.unwrap();
1975        let result = tickers
1976            .financials(StatementType::Income, Frequency::Annual)
1977            .await
1978            .unwrap();
1979
1980        assert!(result.success_count() > 0);
1981
1982        // Verify financial statement structure
1983        for (symbol, stmt) in &result.financials {
1984            assert_eq!(stmt.symbol, *symbol);
1985            assert_eq!(stmt.statement_type, "income");
1986            assert_eq!(stmt.frequency, "annual");
1987            assert!(!stmt.statement.is_empty());
1988
1989            // Common income statement fields
1990            if let Some(revenue) = stmt.statement.get("TotalRevenue") {
1991                assert!(!revenue.is_empty());
1992            }
1993        }
1994    }
1995
1996    #[tokio::test]
1997    #[ignore = "requires network access"]
1998    async fn test_tickers_news() {
1999        let tickers = Tickers::new(["AAPL", "TSLA"]).await.unwrap();
2000        let result = tickers.news().await.unwrap();
2001
2002        assert!(result.success_count() > 0);
2003
2004        // Verify news structure
2005        for articles in result.news.values() {
2006            if !articles.is_empty() {
2007                let article = &articles[0];
2008                assert!(!article.title.is_empty());
2009                assert!(!article.link.is_empty());
2010                assert!(!article.source.is_empty());
2011            }
2012        }
2013    }
2014
2015    #[tokio::test]
2016    #[ignore = "requires network access"]
2017    async fn test_tickers_recommendations() {
2018        let tickers = Tickers::new(["AAPL", "MSFT"]).await.unwrap();
2019        let result = tickers.recommendations(5).await.unwrap();
2020
2021        assert!(result.success_count() > 0);
2022
2023        // Verify recommendations structure
2024        for (symbol, rec) in &result.recommendations {
2025            assert_eq!(rec.symbol, *symbol);
2026            assert!(rec.count() > 0);
2027            for similar in &rec.recommendations {
2028                assert!(!similar.symbol.is_empty());
2029            }
2030        }
2031    }
2032
2033    #[tokio::test]
2034    #[ignore = "requires network access"]
2035    async fn test_tickers_options() {
2036        let tickers = Tickers::new(["AAPL", "MSFT"]).await.unwrap();
2037        let result = tickers.options(None).await.unwrap();
2038
2039        assert!(result.success_count() > 0);
2040
2041        // Verify options structure
2042        for opts in result.options.values() {
2043            assert!(!opts.expiration_dates().is_empty());
2044        }
2045    }
2046
2047    #[tokio::test]
2048    #[ignore = "requires network access"]
2049    #[cfg(feature = "indicators")]
2050    async fn test_tickers_indicators() {
2051        let tickers = Tickers::new(["AAPL", "MSFT"]).await.unwrap();
2052        let result = tickers
2053            .indicators(Interval::OneDay, TimeRange::ThreeMonths)
2054            .await
2055            .unwrap();
2056
2057        assert!(result.success_count() > 0);
2058
2059        // Verify indicators structure
2060        for ind in result.indicators.values() {
2061            // Check that at least some indicators are present
2062            assert!(ind.rsi_14.is_some() || ind.sma_20.is_some());
2063        }
2064    }
2065
2066    #[tokio::test]
2067    async fn test_tickers_add_symbols() {
2068        let mut tickers = Tickers::new(["AAPL"]).await.unwrap();
2069        assert_eq!(tickers.len(), 1);
2070        assert_eq!(tickers.symbols(), &["AAPL"]);
2071
2072        tickers.add_symbols(&["MSFT", "GOOGL"]);
2073        assert_eq!(tickers.len(), 3);
2074        assert!(tickers.symbols().contains(&"AAPL"));
2075        assert!(tickers.symbols().contains(&"MSFT"));
2076        assert!(tickers.symbols().contains(&"GOOGL"));
2077
2078        // Adding duplicate shouldn't increase count
2079        tickers.add_symbols(&["AAPL"]);
2080        assert_eq!(tickers.len(), 3);
2081    }
2082
2083    #[tokio::test]
2084    #[ignore = "requires network access"]
2085    async fn test_tickers_remove_symbols() {
2086        let mut tickers = Tickers::new(["AAPL", "MSFT", "GOOGL"]).await.unwrap();
2087        assert_eq!(tickers.len(), 3);
2088
2089        // Fetch some data to populate caches
2090        let _ = tickers.quotes().await;
2091
2092        // Remove one symbol
2093        tickers.remove_symbols(&["MSFT"]).await;
2094        assert_eq!(tickers.len(), 2);
2095        assert!(tickers.symbols().contains(&"AAPL"));
2096        assert!(!tickers.symbols().contains(&"MSFT"));
2097        assert!(tickers.symbols().contains(&"GOOGL"));
2098
2099        // Verify cache was cleared
2100        let quotes = tickers.quotes().await.unwrap();
2101        assert!(!quotes.quotes.contains_key("MSFT"));
2102        assert_eq!(quotes.quotes.len(), 2);
2103    }
2104}