1use super::macros::define_batch_response;
6use crate::adapters::yahoo::client::ClientConfig;
7use crate::constants::{Frequency, Interval, Region, StatementType, TimeRange};
8use crate::error::Result;
9#[cfg(any(feature = "backtesting", feature = "indicators"))]
10use crate::indicators;
11use crate::models::chart::events::ChartEvents;
12use crate::models::chart::spark::Spark;
13use crate::models::chart::{CapitalGain, Chart, Dividend, Split};
14use crate::models::corporate::news::News;
15use crate::models::corporate::recommendation::Recommendation;
16use crate::models::fundamentals::FinancialStatement;
17use crate::models::options::Options;
18use crate::models::quote::Quote;
19use crate::providers::yahoo::YahooProvider;
20use crate::providers::{Fetch, Provider, ProviderAdapter, ProviderSet, Routes, build_providers};
21use crate::ticker::ClientHandle;
22use crate::utils::{CacheEntry, CacheMode};
23use std::collections::HashMap;
24use std::sync::Arc;
25use std::time::Duration;
26use tokio::sync::RwLock;
27
28#[cfg(any(feature = "backtesting", feature = "indicators"))]
29mod analysis;
30mod charts;
31mod corporate;
32mod fundamentals;
33mod membership;
34mod quotes;
35
36type MapCache<K, V> = Arc<RwLock<HashMap<K, CacheEntry<V>>>>;
38type ChartCacheKey = (Arc<str>, Interval, TimeRange);
39type QuoteCache = MapCache<Arc<str>, Quote>;
40type ChartCache = MapCache<ChartCacheKey, Chart>;
41type EventsCache = MapCache<Arc<str>, ChartEvents>;
42type FinancialsCache = MapCache<(Arc<str>, StatementType, Frequency), FinancialStatement>;
43type NewsCache = MapCache<Arc<str>, Vec<News>>;
44type RecommendationsCache = MapCache<(Arc<str>, u32), Recommendation>;
45type OptionsCache = MapCache<(Arc<str>, Option<i64>), Options>;
46type SparkCacheKey = (Arc<str>, Interval, TimeRange);
47type SparkCache = MapCache<SparkCacheKey, Spark>;
48#[cfg(feature = "indicators")]
49type IndicatorsCache = MapCache<(Arc<str>, Interval, TimeRange), indicators::IndicatorsSummary>;
50
51type FetchGuard = Arc<tokio::sync::Mutex<()>>;
53type FetchGuardMap<K> = Arc<RwLock<HashMap<K, FetchGuard>>>;
54
55define_batch_response! {
57 BatchQuotesResponse => quotes: Quote
59}
60
61define_batch_response! {
62 BatchChartsResponse => charts: Chart
64}
65
66define_batch_response! {
67 BatchSparksResponse => sparks: Spark
72}
73
74define_batch_response! {
75 BatchDividendsResponse => dividends: Vec<Dividend>
77}
78
79define_batch_response! {
80 BatchSplitsResponse => splits: Vec<Split>
82}
83
84define_batch_response! {
85 BatchCapitalGainsResponse => capital_gains: Vec<CapitalGain>
87}
88
89define_batch_response! {
90 BatchFinancialsResponse => financials: FinancialStatement
92}
93
94define_batch_response! {
95 BatchNewsResponse => news: Vec<News>
97}
98
99define_batch_response! {
100 BatchRecommendationsResponse => recommendations: Recommendation
102}
103
104define_batch_response! {
105 BatchOptionsResponse => options: Options
107}
108
109#[cfg(feature = "indicators")]
110define_batch_response! {
111 BatchIndicatorsResponse => indicators: indicators::IndicatorsSummary
113}
114
115const DEFAULT_MAX_CONCURRENCY: usize = 10;
117
118pub struct TickersBuilder {
120 symbols: Vec<Arc<str>>,
121 config: ClientConfig,
122 shared_client: Option<ClientHandle>,
123 injected_providers: Option<Arc<ProviderSet>>,
124 max_concurrency: usize,
125 cache_mode: CacheMode,
126 include_logo: bool,
127}
128
129impl TickersBuilder {
130 fn new<S, I>(symbols: I) -> Self
131 where
132 S: Into<String>,
133 I: IntoIterator<Item = S>,
134 {
135 Self {
136 symbols: symbols.into_iter().map(|s| s.into().into()).collect(),
137 config: ClientConfig::default(),
138 shared_client: None,
139 injected_providers: None,
140 max_concurrency: DEFAULT_MAX_CONCURRENCY,
141 cache_mode: CacheMode::default(),
142 include_logo: false,
143 }
144 }
145
146 pub fn region(mut self, region: Region) -> Self {
148 self.config.lang = region.lang().to_string();
149 self.config.region = region.region().to_string();
150 self
151 }
152
153 pub fn lang(mut self, lang: impl Into<String>) -> Self {
155 self.config.lang = lang.into();
156 self
157 }
158
159 pub fn region_code(mut self, region: impl Into<String>) -> Self {
161 self.config.region = region.into();
162 self
163 }
164
165 pub fn timeout(mut self, timeout: Duration) -> Self {
167 self.config.timeout = timeout;
168 self
169 }
170
171 pub fn proxy(mut self, proxy: impl Into<String>) -> Self {
173 self.config.proxy = Some(proxy.into());
174 self
175 }
176
177 #[allow(dead_code)]
178 pub(crate) fn config(mut self, config: ClientConfig) -> Self {
179 self.config = config;
180 self
181 }
182
183 pub fn max_concurrency(mut self, n: usize) -> Self {
192 self.max_concurrency = n.max(1);
193 self
194 }
195
196 pub fn cache(mut self, ttl: Duration) -> Self {
216 self.cache_mode = CacheMode::Ttl(ttl);
217 self
218 }
219
220 pub fn cache_forever(mut self) -> Self {
223 self.cache_mode = CacheMode::Lifetime;
224 self
225 }
226
227 pub fn no_cache(mut self) -> Self {
232 self.cache_mode = CacheMode::Off;
233 self
234 }
235
236 pub fn logo(mut self) -> Self {
241 self.include_logo = true;
242 self
243 }
244
245 #[doc(hidden)]
249 pub fn with_provider_set(mut self, set: Arc<ProviderSet>) -> Self {
250 self.injected_providers = Some(set);
251 self
252 }
253
254 pub fn client(mut self, handle: ClientHandle) -> Self {
260 self.shared_client = Some(handle);
261 self
262 }
263
264 pub async fn build(self) -> Result<Tickers> {
266 #[cfg(feature = "translation")]
267 let translate_lang = {
268 let lang = crate::translation::Lang::parse(&self.config.lang)?;
269 (!lang.is_english()).then_some(lang)
270 };
271 let providers = if let Some(set) = self.injected_providers {
272 set
273 } else if let Some(handle) = self.shared_client {
274 let yahoo = YahooProvider::from_client(handle.0);
275 let client = yahoo.client_arc();
276 Arc::new(
277 ProviderSet::new(
278 vec![Arc::new(yahoo) as Arc<dyn ProviderAdapter>],
279 Routes::new(Fetch::Sequential),
280 )
281 .with_yahoo_client(Some(client)),
282 )
283 } else {
284 Arc::new(
285 build_providers(
286 &[Provider::Yahoo],
287 Vec::new(),
288 &self.config,
289 Routes::new(Fetch::Sequential),
290 )
291 .await?,
292 )
293 };
294
295 Ok(Tickers {
296 symbols: self.symbols,
297 providers,
298 max_concurrency: self.max_concurrency,
299 cache_mode: self.cache_mode,
300 include_logo: self.include_logo,
301 #[cfg(feature = "translation")]
302 translate_lang,
303 quote_cache: Default::default(),
304 chart_cache: Default::default(),
305 events_cache: Default::default(),
306 financials_cache: Default::default(),
307 news_cache: Default::default(),
308 recommendations_cache: Default::default(),
309 options_cache: Default::default(),
310 spark_cache: Default::default(),
311 #[cfg(feature = "indicators")]
312 indicators_cache: Default::default(),
313
314 quotes_fetch: Arc::new(tokio::sync::Mutex::new(())),
316 events_fetch: Arc::new(tokio::sync::Mutex::new(())),
317 charts_fetch: Default::default(),
318 financials_fetch: Default::default(),
319 news_fetch: Arc::new(tokio::sync::Mutex::new(())),
320 recommendations_fetch: Default::default(),
321 options_fetch: Default::default(),
322 spark_fetch: Default::default(),
323 #[cfg(feature = "indicators")]
324 indicators_fetch: Default::default(),
325 })
326 }
327}
328
329pub struct Tickers {
360 symbols: Vec<Arc<str>>,
361 providers: Arc<ProviderSet>,
362 max_concurrency: usize,
363 cache_mode: CacheMode,
364 include_logo: bool,
365 #[cfg(feature = "translation")]
366 translate_lang: Option<crate::translation::Lang>,
367 quote_cache: QuoteCache,
368 chart_cache: ChartCache,
369 events_cache: EventsCache,
370 financials_cache: FinancialsCache,
371 news_cache: NewsCache,
372 recommendations_cache: RecommendationsCache,
373 options_cache: OptionsCache,
374 spark_cache: SparkCache,
375 #[cfg(feature = "indicators")]
376 indicators_cache: IndicatorsCache,
377
378 quotes_fetch: FetchGuard,
380 events_fetch: FetchGuard,
381 charts_fetch: FetchGuardMap<(Interval, TimeRange)>,
382 financials_fetch: FetchGuardMap<(StatementType, Frequency)>,
383 news_fetch: FetchGuard,
384 recommendations_fetch: FetchGuardMap<u32>,
385 options_fetch: FetchGuardMap<Option<i64>>,
386 spark_fetch: FetchGuardMap<(Interval, TimeRange)>,
387 #[cfg(feature = "indicators")]
388 indicators_fetch: FetchGuardMap<(Interval, TimeRange)>,
389}
390
391impl std::fmt::Debug for Tickers {
392 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
393 f.debug_struct("Tickers")
394 .field("symbols", &self.symbols)
395 .field("max_concurrency", &self.max_concurrency)
396 .field("cache_mode", &self.cache_mode)
397 .finish_non_exhaustive()
398 }
399}
400
401impl Tickers {
402 pub async fn new<S, I>(symbols: I) -> Result<Self>
419 where
420 S: Into<String>,
421 I: IntoIterator<Item = S>,
422 {
423 Self::builder(symbols).build().await
424 }
425
426 pub fn builder<S, I>(symbols: I) -> TickersBuilder
428 where
429 S: Into<String>,
430 I: IntoIterator<Item = S>,
431 {
432 TickersBuilder::new(symbols)
433 }
434
435 pub fn symbols(&self) -> Vec<&str> {
437 self.symbols.iter().map(|s| &**s).collect()
438 }
439
440 pub fn len(&self) -> usize {
442 self.symbols.len()
443 }
444
445 pub fn is_empty(&self) -> bool {
447 self.symbols.is_empty()
448 }
449
450 pub fn client_handle(&self) -> ClientHandle {
462 ClientHandle(
463 self.providers
464 .first_yahoo()
465 .expect("client_handle requires a Yahoo session; use Providers::tickers() for multi-provider tickers"),
466 )
467 }
468
469 #[inline]
471 fn is_cache_fresh<T>(&self, entry: Option<&CacheEntry<T>>) -> bool {
472 CacheEntry::is_fresh_entry(entry, self.cache_mode)
473 }
474
475 #[cfg(feature = "translation")]
478 pub(crate) async fn translate_response<T: crate::translation::Translatable>(
479 &self,
480 value: &mut T,
481 ) -> Result<()> {
482 if let Some(lang) = &self.translate_lang {
483 crate::translation::translate_with(value, lang).await?;
484 }
485 Ok(())
486 }
487
488 fn all_cached<K: Eq + std::hash::Hash, V>(
490 &self,
491 map: &HashMap<K, CacheEntry<V>>,
492 keys: impl Iterator<Item = K>,
493 ) -> bool {
494 if !self.cache_mode.enabled() {
495 return false;
496 }
497 keys.into_iter()
498 .all(|k| map.get(&k).is_some_and(|e| e.is_fresh(self.cache_mode)))
499 }
500
501 #[inline]
508 fn cache_insert<K: Eq + std::hash::Hash, V>(
509 &self,
510 map: &mut HashMap<K, CacheEntry<V>>,
511 key: K,
512 value: V,
513 ) {
514 crate::utils::cache_insert(
515 map,
516 key,
517 value,
518 self.cache_mode,
519 crate::utils::eviction_threshold_for(self.symbols.len()),
520 );
521 }
522
523 async fn get_fetch_guard<K: Clone + Eq + std::hash::Hash>(
528 guard_map: &FetchGuardMap<K>,
529 key: K,
530 ) -> FetchGuard {
531 {
532 let guards = guard_map.read().await;
533 if let Some(guard) = guards.get(&key) {
534 return Arc::clone(guard);
535 }
536 }
537
538 let mut guards = guard_map.write().await;
539 Arc::clone(
540 guards
541 .entry(key)
542 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))),
543 )
544 }
545}
546
547#[cfg(test)]
548mod tests {
549 use super::*;
550
551 #[tokio::test]
552 async fn default_caches_quotes_across_calls() {
553 use crate::providers::mock::{CountingProvider, provider_set};
554
555 let provider = CountingProvider::new();
556 let tickers = Tickers::builder(["AAPL", "MSFT"])
557 .with_provider_set(provider_set(Arc::clone(&provider)))
558 .build()
559 .await
560 .unwrap();
561
562 let _ = tickers.quotes().await.unwrap();
563 let _ = tickers.quotes().await.unwrap();
564
565 assert_eq!(provider.quotes(), 2, "one per symbol, fetched once");
566 }
567
568 #[tokio::test]
569 async fn no_cache_refetches_quotes() {
570 use crate::providers::mock::{CountingProvider, provider_set};
571
572 let provider = CountingProvider::new();
573 let tickers = Tickers::builder(["AAPL", "MSFT"])
574 .with_provider_set(provider_set(Arc::clone(&provider)))
575 .no_cache()
576 .build()
577 .await
578 .unwrap();
579
580 let _ = tickers.quotes().await.unwrap();
581 let _ = tickers.quotes().await.unwrap();
582
583 assert_eq!(provider.quotes(), 4);
584 }
585
586 #[tokio::test]
587 async fn concurrent_event_accessors_fetch_once() {
588 use crate::providers::mock::{CountingProvider, provider_set};
589
590 let provider = CountingProvider::new();
591 let tickers = Tickers::builder(["AAPL", "MSFT"])
592 .with_provider_set(provider_set(Arc::clone(&provider)))
593 .build()
594 .await
595 .unwrap();
596
597 let (d, s) = tokio::join!(
598 tickers.dividends(TimeRange::OneYear),
599 tickers.splits(TimeRange::OneYear)
600 );
601 assert!(d.is_ok() && s.is_ok());
602 assert_eq!(provider.events(), 2, "one event fetch per symbol, not two");
603 }
604
605 #[tokio::test]
606 async fn calendar_reuses_the_options_cache() {
607 use crate::providers::mock::{CountingProvider, provider_set};
608
609 let provider = CountingProvider::new();
610 let tickers = Tickers::builder(["AAPL"])
611 .with_provider_set(provider_set(Arc::clone(&provider)))
612 .build()
613 .await
614 .unwrap();
615
616 let _ = tickers.options(None).await;
617 let before = provider.options();
618 let _ = tickers.calendar(TimeRange::OneMonth).await;
619 assert_eq!(
620 provider.options(),
621 before,
622 "calendar refetched a cached chain"
623 );
624 }
625}