1use crate::adapters::edgar;
4use crate::adapters::yahoo::client::{ClientConfig, YahooClient};
5#[cfg(feature = "backtesting")]
6use crate::backtesting;
7use crate::constants::{Frequency, Interval, Region, StatementType, TimeRange};
8use crate::error::{FinanceError, Result};
9use crate::format::Both;
10#[cfg(any(feature = "backtesting", feature = "indicators"))]
11use crate::indicators;
12use crate::models::chart::events::ChartEvents;
13use crate::models::chart::{CapitalGain, Chart, Dividend, DividendAnalytics, Split};
14use crate::models::corporate::news::News;
15use crate::models::corporate::recommendation::Recommendation;
16use crate::models::filings::{CompanyFacts, EdgarSubmissions, ProviderFilings};
17use crate::models::format::Format;
18use crate::models::fundamentals::FinancialStatement;
19use crate::models::options::Options;
20use crate::models::quote::{
21 AssetProfile, CalendarEvents, DefaultKeyStatistics, Earnings, EarningsHistory, EarningsTrend,
22 EquityPerformance, FinancialData, FundOwnership, FundPerformance, FundProfile, IndexTrend,
23 IndustryTrend, InsiderHolders, InsiderTransactions, InstitutionOwnership,
24 MajorHoldersBreakdown, NetSharePurchaseActivity, Price, Quote, QuoteSummaryResponse,
25 QuoteTypeData, RecommendationTrend, SecFilings, SectorTrend, SummaryDetail, SummaryProfile,
26 TopHoldings, UpgradeDowngradeHistory,
27};
28
29use super::macros::ticker_fetch;
30use crate::providers::types::recommendation_from_similar;
31use crate::providers::yahoo::YahooProvider;
32use crate::providers::{
33 Capability, Fetch, Provider, ProviderAdapter, ProviderSet, Routes, build_providers,
34};
35#[cfg(feature = "risk")]
36use crate::risk;
37use crate::utils::{CacheEntry, CacheMode, FetchGuards, filter_by_range};
38use std::collections::HashMap;
39use std::sync::Arc;
40use std::time::Duration;
41use tokio::sync::RwLock;
42
43type Cache<T> = Arc<RwLock<Option<CacheEntry<T>>>>;
44type MapCache<K, V> = Arc<RwLock<HashMap<K, CacheEntry<V>>>>;
45
46#[derive(Clone)]
69pub struct ClientHandle(pub(crate) Arc<YahooClient>);
70pub struct TickerBuilder {
75 symbol: Arc<str>,
76 config: ClientConfig,
77 shared_client: Option<ClientHandle>,
78 injected_providers: Option<Arc<ProviderSet>>,
79 cache_mode: CacheMode,
80 include_logo: bool,
81}
82
83impl TickerBuilder {
84 fn new(symbol: impl Into<String>) -> Self {
85 Self {
86 symbol: symbol.into().into(),
87 config: ClientConfig::default(),
88 shared_client: None,
89 injected_providers: None,
90 cache_mode: CacheMode::default(),
91 include_logo: false,
92 }
93 }
94 pub fn region(mut self, region: Region) -> Self {
96 self.config.lang = region.lang().to_string();
97 self.config.region = region.region().to_string();
98 self
99 }
100 pub fn lang(mut self, lang: impl Into<String>) -> Self {
102 self.config.lang = lang.into();
103 self
104 }
105 pub fn region_code(mut self, r: impl Into<String>) -> Self {
107 self.config.region = r.into();
108 self
109 }
110 pub fn timeout(mut self, t: Duration) -> Self {
112 self.config.timeout = t;
113 self
114 }
115 pub fn proxy(mut self, p: impl Into<String>) -> Self {
117 self.config.proxy = Some(p.into());
118 self
119 }
120 #[allow(dead_code)]
121 pub(crate) fn config(mut self, c: ClientConfig) -> Self {
122 self.config = c;
123 self
124 }
125 #[doc(hidden)]
129 pub fn with_provider_set(mut self, set: Arc<ProviderSet>) -> Self {
130 self.injected_providers = Some(set);
131 self
132 }
133 pub fn client(mut self, handle: ClientHandle) -> Self {
141 self.shared_client = Some(handle);
142 self
143 }
144 pub fn cache(mut self, ttl: Duration) -> Self {
146 self.cache_mode = CacheMode::Ttl(ttl);
147 self
148 }
149 pub fn cache_forever(mut self) -> Self {
152 self.cache_mode = CacheMode::Lifetime;
153 self
154 }
155 pub fn no_cache(mut self) -> Self {
160 self.cache_mode = CacheMode::Off;
161 self
162 }
163 pub fn logo(mut self) -> Self {
165 self.include_logo = true;
166 self
167 }
168
169 pub async fn build(self) -> Result<Ticker> {
171 #[cfg(feature = "translation")]
172 let translate_lang = {
173 let lang = crate::translation::Lang::parse(&self.config.lang)?;
174 (!lang.is_english()).then_some(lang)
175 };
176 let providers = if let Some(set) = self.injected_providers {
177 set
178 } else if let Some(handle) = self.shared_client {
179 let yahoo = YahooProvider::from_client(handle.0);
180 let client = yahoo.client_arc();
181 Arc::new(
182 ProviderSet::new(
183 vec![Arc::new(yahoo) as Arc<dyn ProviderAdapter>],
184 Routes::new(Fetch::Sequential),
185 )
186 .with_yahoo_client(Some(client)),
187 )
188 } else {
189 Arc::new(
190 build_providers(
191 &[Provider::Yahoo],
192 Vec::new(),
193 &self.config,
194 Routes::new(Fetch::Sequential),
195 )
196 .await?,
197 )
198 };
199 Ok(Ticker {
200 symbol: self.symbol,
201 providers,
202 cache_mode: self.cache_mode,
203 include_logo: self.include_logo,
204 #[cfg(feature = "translation")]
205 translate_lang,
206 quote_cache: Default::default(),
207 quote_fetch: Arc::new(tokio::sync::Mutex::new(())),
208 chart_cache: Default::default(),
209 chart_guards: Default::default(),
210 events_cache: Default::default(),
211 events_fetch: Arc::new(tokio::sync::Mutex::new(())),
212 news_cache: Default::default(),
213 news_fetch: Arc::new(tokio::sync::Mutex::new(())),
214 logo_cache: Default::default(),
215 options_cache: Default::default(),
216 options_guards: Default::default(),
217 financials_cache: Default::default(),
218 financials_guards: Default::default(),
219 #[cfg(feature = "indicators")]
220 indicators_cache: Default::default(),
221 #[cfg(feature = "indicators")]
222 indicators_guards: Default::default(),
223 edgar_submissions_cache: Default::default(),
224 edgar_submissions_fetch: Arc::new(tokio::sync::Mutex::new(())),
225 edgar_facts_cache: Default::default(),
226 edgar_facts_fetch: Arc::new(tokio::sync::Mutex::new(())),
227 })
228 }
229}
230
231pub struct Ticker {
237 symbol: Arc<str>,
238 providers: Arc<ProviderSet>,
239 cache_mode: CacheMode,
240 include_logo: bool,
241 #[cfg(feature = "translation")]
242 translate_lang: Option<crate::translation::Lang>,
243 quote_cache: Cache<QuoteSummaryResponse>,
244 quote_fetch: Arc<tokio::sync::Mutex<()>>,
245 chart_cache: MapCache<(Interval, TimeRange), Chart>,
246 chart_guards: FetchGuards<(Interval, TimeRange)>,
247 events_cache: Cache<ChartEvents>,
248 events_fetch: Arc<tokio::sync::Mutex<()>>,
249 news_cache: Cache<Vec<News>>,
250 news_fetch: Arc<tokio::sync::Mutex<()>>,
251 logo_cache: Cache<(Option<String>, Option<String>)>,
252 options_cache: MapCache<Option<i64>, Options>,
253 options_guards: FetchGuards<Option<i64>>,
254 financials_cache: MapCache<(StatementType, Frequency), FinancialStatement>,
255 financials_guards: FetchGuards<(StatementType, Frequency)>,
256 #[cfg(feature = "indicators")]
257 indicators_cache: MapCache<(Interval, TimeRange), indicators::IndicatorsSummary>,
258 #[cfg(feature = "indicators")]
259 indicators_guards: FetchGuards<(Interval, TimeRange)>,
260 edgar_submissions_cache: Cache<EdgarSubmissions>,
261 edgar_submissions_fetch: Arc<tokio::sync::Mutex<()>>,
262 edgar_facts_cache: Cache<CompanyFacts>,
263 edgar_facts_fetch: Arc<tokio::sync::Mutex<()>>,
264}
265
266impl Ticker {
267 pub async fn new(symbol: impl Into<String>) -> Result<Self> {
269 Self::builder(symbol).build().await
270 }
271 pub fn builder(symbol: impl Into<String>) -> TickerBuilder {
273 TickerBuilder::new(symbol)
274 }
275 pub fn symbol(&self) -> &str {
277 &self.symbol
278 }
279
280 pub fn client_handle(&self) -> ClientHandle {
291 ClientHandle(
292 self.providers
293 .first_yahoo()
294 .expect("client_handle requires a Yahoo session; use Providers::ticker() for multi-provider tickers"),
295 )
296 }
297
298 #[allow(dead_code)]
299 pub(crate) fn provider_set(&self) -> &Arc<ProviderSet> {
300 &self.providers
301 }
302
303 #[cfg(feature = "translation")]
306 pub(crate) async fn translate_response<T: crate::translation::Translatable>(
307 &self,
308 value: &mut T,
309 ) -> Result<()> {
310 if let Some(lang) = &self.translate_lang {
311 crate::translation::translate_with(value, lang).await?;
312 }
313 Ok(())
314 }
315
316 fn is_cache_fresh<T>(&self, entry: Option<&CacheEntry<T>>) -> bool {
317 CacheEntry::is_fresh_entry(entry, self.cache_mode)
318 }
319
320 fn cache_insert<K: Eq + std::hash::Hash, V>(
321 &self,
322 map: &mut HashMap<K, CacheEntry<V>>,
323 key: K,
324 value: V,
325 ) {
326 crate::utils::cache_insert(
327 map,
328 key,
329 value,
330 self.cache_mode,
331 crate::utils::EVICTION_THRESHOLD,
332 );
333 }
334
335 pub async fn quote<F>(&self) -> Result<Quote<F>>
337 where
338 F: Format,
339 Quote<Both>: Into<Quote<F>>,
340 {
341 let logo_fut = async {
342 if !self.include_logo {
343 return (None, None);
344 }
345 if let Some(e) = self.logo_cache.read().await.as_ref()
346 && self.is_cache_fresh(Some(e))
347 {
348 return e.value.clone();
349 }
350 let fetched = match self.providers.first_yahoo() {
351 Ok(y) => y.get_logo_url(&self.symbol).await,
352 Err(e) => Err(e),
353 };
354 match fetched {
359 Ok(logos) => {
360 if self.cache_mode.enabled() {
361 *self.logo_cache.write().await = Some(CacheEntry::new(logos.clone()));
362 }
363 logos
364 }
365 Err(_) => (None, None),
366 }
367 };
368
369 let (cache, (logo_url, company_logo_url)) = tokio::join!(self.ensure_quote(), logo_fut);
370 let cache = cache?;
371 let summary = cache.as_ref().ok_or_else(|| {
372 FinanceError::ApiError("Quote summary cache was empty after fetch".to_string())
373 })?;
374 let quote = Quote::from_response(&summary.value, logo_url, company_logo_url);
375 #[cfg(feature = "translation")]
376 let quote = {
377 drop(cache);
378 let mut quote = quote;
379 self.translate_response(&mut quote).await?;
380 quote
381 };
382 Ok(quote.into())
383 }
384
385 fn chart_from_provider_data(
386 mut data: Chart,
387 interval: Option<Interval>,
388 range: Option<TimeRange>,
389 ) -> Chart {
390 data.interval = interval;
391 data.range = range;
392 data
393 }
394
395 pub async fn chart(&self, interval: Interval, range: TimeRange) -> Result<Chart> {
397 let key = (interval, range);
398 {
399 let cache = self.chart_cache.read().await;
400 if let Some(entry) = cache.get(&key)
401 && self.is_cache_fresh(Some(entry))
402 {
403 return Ok(entry.value.clone());
404 }
405 }
406 self.chart_guards
407 .dedup(key, || async {
408 {
409 let cache = self.chart_cache.read().await;
410 if let Some(entry) = cache.get(&key)
411 && self.is_cache_fresh(Some(entry))
412 {
413 return Ok(entry.value.clone());
414 }
415 }
416 let data =
417 ticker_fetch!(self, CHART, as_chart, Chart, fetch_chart, interval, range)?;
418 let chart = Self::chart_from_provider_data(data, Some(interval), Some(range));
419 if self.cache_mode.enabled() {
420 let mut cache = self.chart_cache.write().await;
421 self.cache_insert(&mut cache, key, chart.clone());
422 }
423 Ok(chart)
424 })
425 .await
426 }
427
428 pub async fn chart_range(&self, interval: Interval, start: i64, end: i64) -> Result<Chart> {
430 if start >= end {
431 return Err(FinanceError::InvalidParameter {
432 param: "end".into(),
433 reason: format!("end ({end}) must be > start ({start})"),
434 });
435 }
436 let data = ticker_fetch!(
437 self,
438 CHART,
439 as_chart,
440 ChartRange,
441 fetch_chart_range,
442 interval,
443 start,
444 end
445 )?;
446 Ok(Self::chart_from_provider_data(data, Some(interval), None))
447 }
448
449 async fn ensure_events(&self) -> Result<()> {
450 {
451 let cache = self.events_cache.read().await;
452 if self.is_cache_fresh(cache.as_ref()) {
453 return Ok(());
454 }
455 }
456 let _guard = self.events_fetch.lock().await;
457 {
458 let cache = self.events_cache.read().await;
459 if self.is_cache_fresh(cache.as_ref()) {
460 return Ok(());
461 }
462 }
463 let events = ticker_fetch!(self, CORPORATE, as_corporate, Events, fetch_events)?;
464 let mut cache = self.events_cache.write().await;
465 *cache = Some(CacheEntry::new(events));
466 Ok(())
467 }
468
469 pub async fn dividends(&self, range: TimeRange) -> Result<Vec<Dividend>> {
471 self.ensure_events().await?;
472 let cache = self.events_cache.read().await;
473 let all = cache
474 .as_ref()
475 .map(|e| e.value.to_dividends())
476 .unwrap_or_default();
477 Ok(filter_by_range(all, range))
478 }
479 pub async fn dividend_analytics(&self, range: TimeRange) -> Result<DividendAnalytics> {
481 let divs = self.dividends(range).await?;
482 Ok(DividendAnalytics::from_dividends(&divs))
483 }
484 pub async fn splits(&self, range: TimeRange) -> Result<Vec<Split>> {
486 self.ensure_events().await?;
487 let cache = self.events_cache.read().await;
488 let all = cache
489 .as_ref()
490 .map(|e| e.value.to_splits())
491 .unwrap_or_default();
492 Ok(filter_by_range(all, range))
493 }
494 pub async fn capital_gains(&self, range: TimeRange) -> Result<Vec<CapitalGain>> {
496 self.ensure_events().await?;
497 let cache = self.events_cache.read().await;
498 let all = cache
499 .as_ref()
500 .map(|e| e.value.to_capital_gains())
501 .unwrap_or_default();
502 Ok(filter_by_range(all, range))
503 }
504
505 pub async fn recommendations(&self, limit: u32) -> Result<Recommendation> {
507 if limit == 0 {
508 return Err(FinanceError::InvalidParameter {
509 param: "limit".into(),
510 reason: "limit must be > 0".into(),
511 });
512 }
513 let sym = self.symbol.clone();
514 let (provider_id, items) = self
515 .providers
516 .fetch(Capability::CORPORATE, move |p| {
517 let sym = sym.clone();
518 let p = p.clone();
519 async move {
520 let r = p
521 .as_corporate()
522 .ok_or_else(|| {
523 p.not_supported(crate::providers::Operation::Recommendations)
524 })?
525 .fetch_similar_symbols(&sym, limit)
526 .await?;
527 Ok((p.id(), r))
528 }
529 })
530 .await?;
531 Ok(recommendation_from_similar(
532 self.symbol.to_string(),
533 Some(provider_id),
534 items,
535 Some(limit),
536 ))
537 }
538
539 pub async fn news(&self) -> Result<Vec<News>> {
541 {
542 let cache = self.news_cache.read().await;
543 if let Some(e) = cache.as_ref()
544 && self.is_cache_fresh(Some(e))
545 {
546 return Ok(e.value.clone());
547 }
548 }
549 let _guard = self.news_fetch.lock().await;
550 {
551 let cache = self.news_cache.read().await;
552 if let Some(e) = cache.as_ref()
553 && self.is_cache_fresh(Some(e))
554 {
555 return Ok(e.value.clone());
556 }
557 }
558 let data = ticker_fetch!(self, CORPORATE, as_corporate, News, fetch_news)?;
559 let news = data;
560 #[cfg(feature = "sentiment")]
562 let news = {
563 let mut news = news;
564 for article in news.iter_mut() {
565 article.sentiment = Some(crate::models::sentiment::analyze(&article.title));
566 }
567 news
568 };
569 #[cfg(feature = "translation")]
570 let news = {
571 let mut news = news;
572 self.translate_response(&mut news).await?;
573 news
574 };
575 if self.cache_mode.enabled() {
576 let mut c = self.news_cache.write().await;
577 *c = Some(CacheEntry::new(news.clone()));
578 }
579 Ok(news)
580 }
581
582 #[cfg(feature = "sentiment")]
589 pub async fn news_sentiment(&self) -> Result<crate::models::sentiment::Sentiment> {
590 let news = self.news().await?;
591 let scores: Vec<f64> = news
592 .iter()
593 .filter_map(|n| n.sentiment.as_ref().map(|s| s.score))
594 .collect();
595 Ok(crate::models::sentiment::aggregate(&scores)
596 .unwrap_or_else(crate::models::sentiment::Sentiment::neutral))
597 }
598
599 pub async fn options(&self, date: Option<i64>) -> Result<Options> {
601 {
602 let cache = self.options_cache.read().await;
603 if let Some(e) = cache.get(&date)
604 && self.is_cache_fresh(Some(e))
605 {
606 return Ok(e.value.clone());
607 }
608 }
609 self.options_guards
610 .dedup(date, || async {
611 {
612 let cache = self.options_cache.read().await;
613 if let Some(e) = cache.get(&date)
614 && self.is_cache_fresh(Some(e))
615 {
616 return Ok(e.value.clone());
617 }
618 }
619 let opts = ticker_fetch!(self, OPTIONS, as_options, Options, fetch_options, date)?;
620 if self.cache_mode.enabled() {
621 let mut c = self.options_cache.write().await;
622 self.cache_insert(&mut c, date, opts.clone());
623 }
624 Ok(opts)
625 })
626 .await
627 }
628
629 pub async fn financials(
631 &self,
632 stmt_type: StatementType,
633 frequency: Frequency,
634 ) -> Result<FinancialStatement> {
635 let key = (stmt_type, frequency);
636 {
637 let cache = self.financials_cache.read().await;
638 if let Some(e) = cache.get(&key)
639 && self.is_cache_fresh(Some(e))
640 {
641 return Ok(e.value.clone());
642 }
643 }
644 self.financials_guards
645 .dedup(key, || async {
646 {
647 let cache = self.financials_cache.read().await;
648 if let Some(e) = cache.get(&key)
649 && self.is_cache_fresh(Some(e))
650 {
651 return Ok(e.value.clone());
652 }
653 }
654 let stmt = ticker_fetch!(
655 self,
656 FUNDAMENTALS,
657 as_fundamentals,
658 Financials,
659 fetch_financials,
660 stmt_type,
661 frequency
662 )?;
663 if self.cache_mode.enabled() {
664 let mut c = self.financials_cache.write().await;
665 self.cache_insert(&mut c, key, stmt.clone());
666 }
667 Ok(stmt)
668 })
669 .await
670 }
671
672 #[cfg(feature = "indicators")]
673 pub async fn indicators(
675 &self,
676 interval: Interval,
677 range: TimeRange,
678 ) -> Result<indicators::IndicatorsSummary> {
679 let key = (interval, range);
680 {
681 let cache = self.indicators_cache.read().await;
682 if let Some(e) = cache.get(&key)
683 && self.is_cache_fresh(Some(e))
684 {
685 return Ok(e.value.clone());
686 }
687 }
688 self.indicators_guards
689 .dedup(key, || async {
690 {
691 let cache = self.indicators_cache.read().await;
692 if let Some(e) = cache.get(&key)
693 && self.is_cache_fresh(Some(e))
694 {
695 return Ok(e.value.clone());
696 }
697 }
698 let chart = self.chart(interval, range).await?;
699 let ind = indicators::summary::calculate_indicators(&chart.candles);
700 if self.cache_mode.enabled() {
701 let mut c = self.indicators_cache.write().await;
702 self.cache_insert(&mut c, key, ind.clone());
703 }
704 Ok(ind)
705 })
706 .await
707 }
708
709 pub async fn edgar_submissions(&self) -> Result<EdgarSubmissions> {
715 {
716 let cache = self.edgar_submissions_cache.read().await;
717 if let Some(e) = cache.as_ref()
718 && self.is_cache_fresh(Some(e))
719 {
720 return Ok(e.value.clone());
721 }
722 }
723 let _guard = self.edgar_submissions_fetch.lock().await;
724 {
725 let cache = self.edgar_submissions_cache.read().await;
726 if let Some(e) = cache.as_ref()
727 && self.is_cache_fresh(Some(e))
728 {
729 return Ok(e.value.clone());
730 }
731 }
732 let subs = edgar::submissions_for_symbol(&self.symbol).await?;
733 if self.cache_mode.enabled() {
734 let mut c = self.edgar_submissions_cache.write().await;
735 *c = Some(CacheEntry::new(subs.clone()));
736 }
737 Ok(subs)
738 }
739
740 pub async fn edgar_company_facts(&self) -> Result<CompanyFacts> {
745 {
746 let cache = self.edgar_facts_cache.read().await;
747 if let Some(e) = cache.as_ref()
748 && self.is_cache_fresh(Some(e))
749 {
750 return Ok(e.value.clone());
751 }
752 }
753 let _guard = self.edgar_facts_fetch.lock().await;
754 {
755 let cache = self.edgar_facts_cache.read().await;
756 if let Some(e) = cache.as_ref()
757 && self.is_cache_fresh(Some(e))
758 {
759 return Ok(e.value.clone());
760 }
761 }
762 let facts = edgar::company_facts_for_symbol(&self.symbol).await?;
763 if self.cache_mode.enabled() {
764 let mut c = self.edgar_facts_cache.write().await;
765 *c = Some(CacheEntry::new(facts.clone()));
766 }
767 Ok(facts)
768 }
769
770 pub async fn filings(&self) -> Result<ProviderFilings> {
779 ticker_fetch!(self, FILINGS, as_filings, Filings, fetch_filings)
780 }
781
782 pub async fn short_interest(&self) -> Result<Vec<crate::models::fundamentals::ShortInterest>> {
788 ticker_fetch!(
789 self,
790 FUNDAMENTALS,
791 as_fundamentals,
792 ShortInterest,
793 fetch_short_interest
794 )
795 }
796
797 pub async fn short_volume(&self) -> Result<Vec<crate::models::fundamentals::ShortVolume>> {
800 ticker_fetch!(
801 self,
802 FUNDAMENTALS,
803 as_fundamentals,
804 ShortVolume,
805 fetch_short_volume
806 )
807 }
808
809 pub async fn share_float(&self) -> Result<crate::models::fundamentals::ShareFloat> {
813 ticker_fetch!(
814 self,
815 FUNDAMENTALS,
816 as_fundamentals,
817 ShareFloat,
818 fetch_share_float
819 )
820 }
821
822 pub async fn press_releases(
826 &self,
827 limit: u32,
828 ) -> Result<Vec<crate::models::corporate::press_release::PressRelease>> {
829 ticker_fetch!(
830 self,
831 CORPORATE,
832 as_corporate,
833 PressReleases,
834 fetch_press_releases,
835 limit
836 )
837 }
838
839 pub async fn price_target_consensus(
844 &self,
845 ) -> Result<crate::models::fundamentals::PriceTargetConsensus> {
846 ticker_fetch!(
847 self,
848 FUNDAMENTALS,
849 as_fundamentals,
850 PriceTargetConsensus,
851 fetch_price_target_consensus
852 )
853 }
854
855 pub async fn price_target_summary(
859 &self,
860 ) -> Result<crate::models::fundamentals::PriceTargetSummary> {
861 ticker_fetch!(
862 self,
863 FUNDAMENTALS,
864 as_fundamentals,
865 PriceTargetSummary,
866 fetch_price_target_summary
867 )
868 }
869
870 pub async fn rating_consensus(&self) -> Result<crate::models::fundamentals::RatingConsensus> {
875 ticker_fetch!(
876 self,
877 FUNDAMENTALS,
878 as_fundamentals,
879 RatingConsensus,
880 fetch_rating_consensus
881 )
882 }
883
884 pub async fn key_metrics_ttm(&self) -> Result<crate::models::fundamentals::KeyMetricsTtm> {
891 ticker_fetch!(
892 self,
893 FUNDAMENTALS,
894 as_fundamentals,
895 KeyMetricsTtm,
896 fetch_key_metrics_ttm
897 )
898 }
899
900 pub async fn ratios_ttm(&self) -> Result<crate::models::fundamentals::FinancialRatiosTtm> {
903 ticker_fetch!(
904 self,
905 FUNDAMENTALS,
906 as_fundamentals,
907 RatiosTtm,
908 fetch_ratios_ttm
909 )
910 }
911
912 pub async fn executive_compensation(
916 &self,
917 ) -> Result<Vec<crate::models::corporate::governance::ExecutiveCompensation>> {
918 ticker_fetch!(
919 self,
920 CORPORATE,
921 as_corporate,
922 ExecutiveCompensation,
923 fetch_executive_compensation
924 )
925 }
926
927 pub async fn employee_count(
931 &self,
932 ) -> Result<Vec<crate::models::corporate::governance::EmployeeCount>> {
933 ticker_fetch!(
934 self,
935 CORPORATE,
936 as_corporate,
937 EmployeeCount,
938 fetch_employee_count
939 )
940 }
941
942 pub async fn etf_profile(&self) -> Result<crate::models::fundamentals::EtfProfile> {
949 ticker_fetch!(
950 self,
951 FUNDAMENTALS,
952 as_fundamentals,
953 EtfProfile,
954 fetch_etf_profile
955 )
956 }
957
958 pub async fn earnings_surprises(
961 &self,
962 ) -> Result<Vec<crate::models::fundamentals::EarningsSurprise>> {
963 ticker_fetch!(
964 self,
965 FUNDAMENTALS,
966 as_fundamentals,
967 EarningsSurprises,
968 fetch_earnings_surprises
969 )
970 }
971
972 pub async fn grading_actions(&self) -> Result<Vec<crate::models::fundamentals::GradingAction>> {
977 ticker_fetch!(
978 self,
979 FUNDAMENTALS,
980 as_fundamentals,
981 GradingHistory,
982 fetch_grading_history
983 )
984 }
985
986 pub async fn company_profile(&self) -> Result<crate::models::fundamentals::CompanyProfile> {
990 ticker_fetch!(
991 self,
992 FUNDAMENTALS,
993 as_fundamentals,
994 CompanyProfile,
995 fetch_company_profile
996 )
997 }
998
999 pub async fn earnings_transcript(
1006 &self,
1007 quarter: Option<&str>,
1008 year: Option<i32>,
1009 ) -> Result<crate::models::corporate::earnings_transcript::EarningsTranscript> {
1010 ticker_fetch!(
1011 self,
1012 CORPORATE,
1013 as_corporate,
1014 EarningsTranscript,
1015 fetch_earnings_transcript,
1016 quarter,
1017 year
1018 )
1019 }
1020
1021 #[cfg(feature = "indicators")]
1022 pub async fn indicator(
1024 &self,
1025 indicator: indicators::Indicator,
1026 interval: Interval,
1027 range: TimeRange,
1028 ) -> Result<indicators::IndicatorResult> {
1029 let chart = self.chart(interval, range).await?;
1030 Ok(indicators::compute_indicator(indicator, &chart)?)
1031 }
1032
1033 #[cfg(feature = "backtesting")]
1034 pub async fn backtest<S: backtesting::Strategy>(
1036 &self,
1037 strategy: S,
1038 interval: Interval,
1039 range: TimeRange,
1040 config: Option<backtesting::BacktestConfig>,
1041 ) -> backtesting::Result<backtesting::BacktestResult> {
1042 let config = config.unwrap_or_default();
1043 config.validate()?;
1044 let (chart, dividends) = tokio::join!(self.chart(interval, range), self.dividends(range));
1047 let chart = chart.map_err(|e| backtesting::BacktestError::ChartError(e.to_string()))?;
1048 let dividends = dividends.unwrap_or_default();
1049 backtesting::BacktestEngine::new(config).run_with_dividends(
1050 &self.symbol,
1051 &chart.candles,
1052 strategy,
1053 ÷nds,
1054 )
1055 }
1056
1057 #[cfg(feature = "backtesting")]
1058 pub async fn backtest_with_benchmark<S: backtesting::Strategy>(
1060 &self,
1061 strategy: S,
1062 interval: Interval,
1063 range: TimeRange,
1064 config: Option<backtesting::BacktestConfig>,
1065 benchmark: &str,
1066 ) -> backtesting::Result<backtesting::BacktestResult> {
1067 let config = config.unwrap_or_default();
1068 config.validate()?;
1069 let bench_fut = async {
1070 let bench_ticker = Ticker::new(benchmark).await?;
1071 bench_ticker.chart(interval, range).await
1072 };
1073 let (chart, bench_chart, dividends) = tokio::join!(
1077 self.chart(interval, range),
1078 bench_fut,
1079 self.dividends(range)
1080 );
1081 let chart = chart.map_err(|e| backtesting::BacktestError::ChartError(e.to_string()))?;
1082 let bench_chart =
1083 bench_chart.map_err(|e| backtesting::BacktestError::ChartError(e.to_string()))?;
1084 let dividends = dividends.unwrap_or_default();
1085 backtesting::BacktestEngine::new(config).run_with_benchmark(
1086 &self.symbol,
1087 &chart.candles,
1088 strategy,
1089 ÷nds,
1090 benchmark,
1091 &bench_chart.candles,
1092 )
1093 }
1094
1095 #[cfg(feature = "risk")]
1096 pub async fn risk(
1098 &self,
1099 interval: Interval,
1100 range: TimeRange,
1101 benchmark: Option<&str>,
1102 ) -> Result<risk::RiskSummary> {
1103 let bench_fut = async {
1104 let Some(sym) = benchmark else {
1105 return Result::Ok(None);
1106 };
1107 let bt = Ticker::new(sym).await?;
1108 let bench_chart = bt.chart(interval, range).await?;
1109 Result::Ok(Some(risk::candles_to_returns(&bench_chart.candles)))
1110 };
1111 let (chart, bench_returns) = tokio::join!(self.chart(interval, range), bench_fut);
1115 let chart = chart?;
1116 let bench_returns = bench_returns?;
1117 Ok(risk::compute_risk_summary(
1118 &chart.candles,
1119 bench_returns.as_deref(),
1120 ))
1121 }
1122
1123 pub async fn calendar(
1135 &self,
1136 range: TimeRange,
1137 ) -> Result<Vec<crate::models::calendar::CalendarEvent>> {
1138 let now = chrono::Utc::now().timestamp();
1139 let window = (now, now + range.approx_duration_secs());
1140
1141 #[cfg(feature = "fred")]
1144 let (calendar_events, options, releases) = tokio::join!(
1145 self.calendar_events(),
1146 self.options(None),
1147 crate::adapters::fred::release_dates(),
1148 );
1149 #[cfg(not(feature = "fred"))]
1150 let (calendar_events, options) = tokio::join!(self.calendar_events(), self.options(None));
1151
1152 let calendar_events = calendar_events?;
1153 let options = options.ok();
1154
1155 let mut events = crate::models::calendar::build_symbol_events(
1156 &self.symbol,
1157 calendar_events.as_ref(),
1158 options.as_ref(),
1159 window,
1160 );
1161
1162 #[cfg(feature = "fred")]
1163 if let Ok(releases) = releases {
1164 events.extend(crate::models::calendar::build_economic_events(
1165 releases, window,
1166 ));
1167 }
1168
1169 crate::models::calendar::sort_events(&mut events);
1170 Ok(events)
1171 }
1172
1173 async fn ensure_quote(
1174 &self,
1175 ) -> Result<tokio::sync::RwLockReadGuard<'_, Option<CacheEntry<QuoteSummaryResponse>>>> {
1176 {
1177 let cache = self.quote_cache.read().await;
1178 if self.is_cache_fresh(cache.as_ref()) {
1179 return Ok(cache);
1180 }
1181 }
1182 let _guard = self.quote_fetch.lock().await;
1183 {
1184 let cache = self.quote_cache.read().await;
1185 if self.is_cache_fresh(cache.as_ref()) {
1186 return Ok(cache);
1187 }
1188 }
1189 let summary = ticker_fetch!(self, QUOTE, as_quote, Quote, fetch_quote)?;
1190 {
1191 let mut cache = self.quote_cache.write().await;
1192 *cache = Some(CacheEntry::new(summary));
1193 }
1194 Ok(self.quote_cache.read().await)
1195 }
1196}
1197
1198super::macros::define_quote_accessors! {
1199 price -> Price, price,
1201 summary_detail -> SummaryDetail, summary_detail,
1202 financial_data -> FinancialData, financial_data,
1203 key_stats -> DefaultKeyStatistics, default_key_statistics,
1204 asset_profile -> AssetProfile, asset_profile,
1205 calendar_events -> CalendarEvents, calendar_events,
1206 earnings -> Earnings, earnings,
1207 earnings_trend -> EarningsTrend, earnings_trend,
1208 earnings_history -> EarningsHistory, earnings_history,
1209 recommendation_trend -> RecommendationTrend, recommendation_trend,
1210 insider_holders -> InsiderHolders, insider_holders,
1211 insider_transactions -> InsiderTransactions, insider_transactions,
1212 institution_ownership -> InstitutionOwnership, institution_ownership,
1213 fund_ownership -> FundOwnership, fund_ownership,
1214 major_holders -> MajorHoldersBreakdown, major_holders_breakdown,
1215 share_purchase_activity -> NetSharePurchaseActivity, net_share_purchase_activity,
1216 quote_type -> QuoteTypeData, quote_type,
1217 summary_profile -> SummaryProfile, summary_profile,
1218 sec_filings -> SecFilings, sec_filings,
1219 grading_history -> UpgradeDowngradeHistory, upgrade_downgrade_history,
1220 fund_performance -> FundPerformance, fund_performance,
1221 fund_profile -> FundProfile, fund_profile,
1222 top_holdings -> TopHoldings, top_holdings,
1223 index_trend -> IndexTrend, index_trend,
1224 industry_trend -> IndustryTrend, industry_trend,
1225 sector_trend -> SectorTrend, sector_trend,
1226 equity_performance -> EquityPerformance, equity_performance,
1227}
1228
1229#[cfg(test)]
1230mod tests {
1231 use super::*;
1232 use crate::providers::mock::{CountingProvider, provider_set};
1233
1234 #[tokio::test]
1235 async fn default_caches_quote_across_accessors() {
1236 let provider = CountingProvider::new();
1237 let ticker = Ticker::builder("AAPL")
1238 .with_provider_set(provider_set(Arc::clone(&provider)))
1239 .build()
1240 .await
1241 .unwrap();
1242
1243 let _ = ticker.price().await.unwrap();
1244 let _ = ticker.summary_detail().await.unwrap();
1245 let _ = ticker.asset_profile().await.unwrap();
1246
1247 assert_eq!(provider.quotes(), 1);
1248 }
1249
1250 #[tokio::test]
1251 async fn no_cache_refetches_every_accessor() {
1252 let provider = CountingProvider::new();
1253 let ticker = Ticker::builder("AAPL")
1254 .with_provider_set(provider_set(Arc::clone(&provider)))
1255 .no_cache()
1256 .build()
1257 .await
1258 .unwrap();
1259
1260 let _ = ticker.price().await.unwrap();
1261 let _ = ticker.summary_detail().await.unwrap();
1262 let _ = ticker.asset_profile().await.unwrap();
1263
1264 assert_eq!(provider.quotes(), 3);
1265 }
1266
1267 #[tokio::test]
1268 async fn charts_cache_per_interval_and_range() {
1269 let provider = CountingProvider::new();
1270 let ticker = Ticker::builder("AAPL")
1271 .with_provider_set(provider_set(Arc::clone(&provider)))
1272 .build()
1273 .await
1274 .unwrap();
1275
1276 let _ = ticker
1277 .chart(Interval::OneDay, TimeRange::OneMonth)
1278 .await
1279 .unwrap();
1280 let _ = ticker
1281 .chart(Interval::OneDay, TimeRange::OneMonth)
1282 .await
1283 .unwrap();
1284 assert_eq!(provider.charts(), 1);
1285
1286 let _ = ticker
1287 .chart(Interval::OneDay, TimeRange::OneYear)
1288 .await
1289 .unwrap();
1290 assert_eq!(provider.charts(), 2);
1291 }
1292
1293 #[tokio::test]
1294 async fn unresolved_logo_is_not_cached() {
1295 let provider = CountingProvider::new();
1296 let ticker = Ticker::builder("AAPL")
1297 .with_provider_set(provider_set(Arc::clone(&provider)))
1298 .logo()
1299 .build()
1300 .await
1301 .unwrap();
1302
1303 let _: Quote<crate::format::Raw> = ticker.quote().await.unwrap();
1304 assert!(
1305 ticker.logo_cache.read().await.is_none(),
1306 "an unresolved logo must not be cached, or one blip is permanent"
1307 );
1308 }
1309
1310 #[tokio::test]
1311 async fn concurrent_chart_misses_dedup_to_one_fetch() {
1312 let provider = CountingProvider::new();
1313 let ticker = Arc::new(
1314 Ticker::builder("AAPL")
1315 .with_provider_set(provider_set(Arc::clone(&provider)))
1316 .build()
1317 .await
1318 .unwrap(),
1319 );
1320
1321 let mut handles = Vec::new();
1322 for _ in 0..8 {
1323 let ticker = Arc::clone(&ticker);
1324 handles.push(tokio::spawn(async move {
1325 ticker
1326 .chart(Interval::OneDay, TimeRange::OneMonth)
1327 .await
1328 .unwrap()
1329 }));
1330 }
1331 for h in handles {
1332 h.await.unwrap();
1333 }
1334
1335 assert_eq!(provider.charts(), 1);
1336 }
1337
1338 #[tokio::test]
1339 async fn concurrent_news_misses_dedup_to_one_fetch() {
1340 let provider = CountingProvider::new();
1341 let ticker = Arc::new(
1342 Ticker::builder("AAPL")
1343 .with_provider_set(provider_set(Arc::clone(&provider)))
1344 .build()
1345 .await
1346 .unwrap(),
1347 );
1348
1349 let mut handles = Vec::new();
1350 for _ in 0..8 {
1351 let ticker = Arc::clone(&ticker);
1352 handles.push(tokio::spawn(async move { ticker.news().await.unwrap() }));
1353 }
1354 for h in handles {
1355 h.await.unwrap();
1356 }
1357
1358 assert_eq!(provider.news(), 1);
1359 }
1360
1361 #[tokio::test(start_paused = true)]
1362 async fn ttl_expires() {
1363 let provider = CountingProvider::new();
1364 let ticker = Ticker::builder("AAPL")
1365 .with_provider_set(provider_set(Arc::clone(&provider)))
1366 .cache(Duration::from_secs(60))
1367 .build()
1368 .await
1369 .unwrap();
1370
1371 let _ = ticker
1372 .chart(Interval::OneDay, TimeRange::OneMonth)
1373 .await
1374 .unwrap();
1375 tokio::time::advance(Duration::from_secs(120)).await;
1376 let _ = ticker
1377 .chart(Interval::OneDay, TimeRange::OneMonth)
1378 .await
1379 .unwrap();
1380
1381 assert_eq!(provider.charts(), 2);
1382 }
1383}