1use 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
38type 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
53type FetchGuard = Arc<tokio::sync::Mutex<()>>;
55type FetchGuardMap<K> = Arc<RwLock<HashMap<K, FetchGuard>>>;
56
57define_batch_response! {
59 BatchQuotesResponse => quotes: Quote
61}
62
63define_batch_response! {
64 BatchChartsResponse => charts: Chart
66}
67
68define_batch_response! {
69 BatchSparksResponse => sparks: Spark
74}
75
76define_batch_response! {
77 BatchDividendsResponse => dividends: Vec<Dividend>
79}
80
81define_batch_response! {
82 BatchSplitsResponse => splits: Vec<Split>
84}
85
86define_batch_response! {
87 BatchCapitalGainsResponse => capital_gains: Vec<CapitalGain>
89}
90
91define_batch_response! {
92 BatchFinancialsResponse => financials: FinancialStatement
94}
95
96define_batch_response! {
97 BatchNewsResponse => news: Vec<News>
99}
100
101define_batch_response! {
102 BatchRecommendationsResponse => recommendations: Recommendation
104}
105
106define_batch_response! {
107 BatchOptionsResponse => options: Options
109}
110
111#[cfg(feature = "indicators")]
112define_batch_response! {
113 BatchIndicatorsResponse => indicators: indicators::IndicatorsSummary
115}
116
117const DEFAULT_MAX_CONCURRENCY: usize = 10;
119
120pub 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 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 pub fn lang(mut self, lang: impl Into<String>) -> Self {
157 self.config.lang = lang.into();
158 self
159 }
160
161 pub fn region_code(mut self, region: impl Into<String>) -> Self {
163 self.config.region = region.into();
164 self
165 }
166
167 pub fn timeout(mut self, timeout: Duration) -> Self {
169 self.config.timeout = timeout;
170 self
171 }
172
173 pub fn proxy(mut self, proxy: impl Into<String>) -> Self {
175 self.config.proxy = Some(proxy.into());
176 self
177 }
178
179 pub fn config(mut self, config: ClientConfig) -> Self {
181 self.config = config;
182 self
183 }
184
185 pub fn max_concurrency(mut self, n: usize) -> Self {
194 self.max_concurrency = n.max(1);
195 self
196 }
197
198 pub fn cache(mut self, ttl: Duration) -> Self {
219 self.cache_ttl = Some(ttl);
220 self
221 }
222
223 pub fn logo(mut self) -> Self {
228 self.include_logo = true;
229 self
230 }
231
232 pub(crate) fn with_provider_set(mut self, set: Arc<ProviderSet>) -> Self {
234 self.injected_providers = Some(set);
235 self
236 }
237
238 pub fn client(mut self, handle: ClientHandle) -> Self {
244 self.shared_client = Some(handle);
245 self
246 }
247
248 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 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
309pub 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 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 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 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 pub fn symbols(&self) -> Vec<&str> {
416 self.symbols.iter().map(|s| &**s).collect()
417 }
418
419 pub fn len(&self) -> usize {
421 self.symbols.len()
422 }
423
424 pub fn is_empty(&self) -> bool {
426 self.symbols.is_empty()
427 }
428
429 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 #[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 #[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 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 #[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 pub async fn quotes(&self) -> Result<BatchQuotesResponse> {
501 {
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 {
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 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 #[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 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 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 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 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 pub async fn charts(
765 &self,
766 interval: Interval,
767 range: TimeRange,
768 ) -> Result<BatchChartsResponse> {
769 {
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 let fetch_guard = Self::get_fetch_guard(&self.charts_fetch, (interval, range)).await;
790 let _guard = fetch_guard.lock().await;
791
792 {
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 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 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 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 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 async fn ensure_events_loaded(&self) -> Result<()> {
975 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 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 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 pub async fn spark(&self, interval: Interval, range: TimeRange) -> Result<BatchSparksResponse> {
1062 {
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 let fetch_guard = Self::get_fetch_guard(&self.spark_fetch, (interval, range)).await;
1083 let _guard = fetch_guard.lock().await;
1084
1085 {
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 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 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 for (symbol, spark) in parsed_sparks {
1149 response.sparks.insert(symbol.to_string(), spark);
1150 }
1151
1152 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 pub async fn dividends(&self, range: TimeRange) -> Result<BatchDividendsResponse> {
1200 let mut response = BatchDividendsResponse::with_capacity(self.symbols.len());
1201
1202 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 pub async fn splits(&self, range: TimeRange) -> Result<BatchSplitsResponse> {
1249 let mut response = BatchSplitsResponse::with_capacity(self.symbols.len());
1250
1251 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 pub async fn capital_gains(&self, range: TimeRange) -> Result<BatchCapitalGainsResponse> {
1297 let mut response = BatchCapitalGainsResponse::with_capacity(self.symbols.len());
1298
1299 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 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 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 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 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 #[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 {
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 let fetch_guard = Self::get_fetch_guard(&self.indicators_fetch, (interval, range)).await;
1563 let _guard = fetch_guard.lock().await;
1564
1565 {
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 let charts_response = self.charts(interval, range).await?;
1583
1584 let mut response = BatchIndicatorsResponse::with_capacity(self.symbols.len());
1585
1586 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 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 for (symbol, indicators) in calculated_indicators {
1605 response.indicators.insert(symbol, indicators);
1606 }
1607
1608 for (symbol, error) in &charts_response.errors {
1610 response.errors.insert(symbol.to_string(), error.clone());
1611 }
1612
1613 Ok(response)
1614 }
1615
1616 pub fn add_symbols(&mut self, symbols: &[impl AsRef<str>]) {
1637 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 #[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 let charts = self
1708 .charts(interval, range)
1709 .await
1710 .map_err(|e| backtesting::BacktestError::ChartError(e.to_string()))?;
1711
1712 let dividends_map = self
1715 .dividends(range)
1716 .await
1717 .map(|b| b.dividends)
1718 .unwrap_or_default();
1719
1720 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 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 self.symbols.retain(|s| !to_remove.contains(&**s));
1758
1759 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 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 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((
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 pub async fn clear_cache(&self) {
1819 tokio::join!(
1820 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 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 pub async fn clear_quote_cache(&self) {
1850 self.quote_cache.write().await.clear();
1851 }
1852
1853 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 if let Some(spark) = result.sparks.get("AAPL") {
1908 assert!(!spark.closes.is_empty());
1909 assert_eq!(spark.symbol, "AAPL");
1910 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 if let Some(dividends) = result.dividends.get("AAPL")
1925 && !dividends.is_empty()
1926 {
1927 let div = ÷nds[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 assert!(result.success_count() > 0);
1941
1942 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 assert!(result.success_count() > 0);
1961
1962 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 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 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 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 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 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 for ind in result.indicators.values() {
2061 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 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 let _ = tickers.quotes().await;
2091
2092 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 let quotes = tickers.quotes().await.unwrap();
2101 assert!(!quotes.quotes.contains_key("MSFT"));
2102 assert_eq!(quotes.quotes.len(), 2);
2103 }
2104}