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> {
989 ticker_fetch!(
990 self,
991 FUNDAMENTALS,
992 as_fundamentals,
993 CompanyProfile,
994 fetch_company_profile
995 )
996 }
997
998 pub async fn earnings_transcript(
1005 &self,
1006 quarter: Option<&str>,
1007 year: Option<i32>,
1008 ) -> Result<crate::models::corporate::earnings_transcript::EarningsTranscript> {
1009 ticker_fetch!(
1010 self,
1011 CORPORATE,
1012 as_corporate,
1013 EarningsTranscript,
1014 fetch_earnings_transcript,
1015 quarter,
1016 year
1017 )
1018 }
1019
1020 #[cfg(feature = "indicators")]
1021 pub async fn indicator(
1023 &self,
1024 indicator: indicators::Indicator,
1025 interval: Interval,
1026 range: TimeRange,
1027 ) -> Result<indicators::IndicatorResult> {
1028 let chart = self.chart(interval, range).await?;
1029 Ok(indicators::compute_indicator(indicator, &chart)?)
1030 }
1031
1032 #[cfg(feature = "backtesting")]
1033 pub async fn backtest<S: backtesting::Strategy>(
1035 &self,
1036 strategy: S,
1037 interval: Interval,
1038 range: TimeRange,
1039 config: Option<backtesting::BacktestConfig>,
1040 ) -> backtesting::Result<backtesting::BacktestResult> {
1041 let config = config.unwrap_or_default();
1042 config.validate()?;
1043 let (chart, dividends) = tokio::join!(self.chart(interval, range), self.dividends(range));
1046 let chart = chart.map_err(|e| backtesting::BacktestError::ChartError(e.to_string()))?;
1047 let dividends = dividends.unwrap_or_default();
1048 backtesting::BacktestEngine::new(config).run_with_dividends(
1049 &self.symbol,
1050 &chart.candles,
1051 strategy,
1052 ÷nds,
1053 )
1054 }
1055
1056 #[cfg(feature = "backtesting")]
1057 pub async fn backtest_with_benchmark<S: backtesting::Strategy>(
1059 &self,
1060 strategy: S,
1061 interval: Interval,
1062 range: TimeRange,
1063 config: Option<backtesting::BacktestConfig>,
1064 benchmark: &str,
1065 ) -> backtesting::Result<backtesting::BacktestResult> {
1066 let config = config.unwrap_or_default();
1067 config.validate()?;
1068 let bench_fut = async {
1069 let bench_ticker = Ticker::new(benchmark).await?;
1070 bench_ticker.chart(interval, range).await
1071 };
1072 let (chart, bench_chart, dividends) = tokio::join!(
1076 self.chart(interval, range),
1077 bench_fut,
1078 self.dividends(range)
1079 );
1080 let chart = chart.map_err(|e| backtesting::BacktestError::ChartError(e.to_string()))?;
1081 let bench_chart =
1082 bench_chart.map_err(|e| backtesting::BacktestError::ChartError(e.to_string()))?;
1083 let dividends = dividends.unwrap_or_default();
1084 backtesting::BacktestEngine::new(config).run_with_benchmark(
1085 &self.symbol,
1086 &chart.candles,
1087 strategy,
1088 ÷nds,
1089 benchmark,
1090 &bench_chart.candles,
1091 )
1092 }
1093
1094 #[cfg(feature = "risk")]
1095 pub async fn risk(
1097 &self,
1098 interval: Interval,
1099 range: TimeRange,
1100 benchmark: Option<&str>,
1101 ) -> Result<risk::RiskSummary> {
1102 let bench_fut = async {
1103 let Some(sym) = benchmark else {
1104 return Result::Ok(None);
1105 };
1106 let bt = Ticker::new(sym).await?;
1107 let bench_chart = bt.chart(interval, range).await?;
1108 Result::Ok(Some(risk::candles_to_returns(&bench_chart.candles)))
1109 };
1110 let (chart, bench_returns) = tokio::join!(self.chart(interval, range), bench_fut);
1114 let chart = chart?;
1115 let bench_returns = bench_returns?;
1116 Ok(risk::compute_risk_summary(
1117 &chart.candles,
1118 bench_returns.as_deref(),
1119 ))
1120 }
1121
1122 pub async fn calendar(
1134 &self,
1135 range: TimeRange,
1136 ) -> Result<Vec<crate::models::calendar::CalendarEvent>> {
1137 let now = chrono::Utc::now().timestamp();
1138 let window = (now, now + range.approx_duration_secs());
1139
1140 #[cfg(feature = "fred")]
1143 let (calendar_events, options, releases) = tokio::join!(
1144 self.calendar_events(),
1145 self.options(None),
1146 crate::adapters::fred::release_dates(),
1147 );
1148 #[cfg(not(feature = "fred"))]
1149 let (calendar_events, options) = tokio::join!(self.calendar_events(), self.options(None));
1150
1151 let calendar_events = calendar_events?;
1152 let options = options.ok();
1153
1154 let mut events = crate::models::calendar::build_symbol_events(
1155 &self.symbol,
1156 calendar_events.as_ref(),
1157 options.as_ref(),
1158 window,
1159 );
1160
1161 #[cfg(feature = "fred")]
1162 if let Ok(releases) = releases {
1163 events.extend(crate::models::calendar::build_economic_events(
1164 releases, window,
1165 ));
1166 }
1167
1168 crate::models::calendar::sort_events(&mut events);
1169 Ok(events)
1170 }
1171
1172 async fn ensure_quote(
1173 &self,
1174 ) -> Result<tokio::sync::RwLockReadGuard<'_, Option<CacheEntry<QuoteSummaryResponse>>>> {
1175 {
1176 let cache = self.quote_cache.read().await;
1177 if self.is_cache_fresh(cache.as_ref()) {
1178 return Ok(cache);
1179 }
1180 }
1181 let _guard = self.quote_fetch.lock().await;
1182 {
1183 let cache = self.quote_cache.read().await;
1184 if self.is_cache_fresh(cache.as_ref()) {
1185 return Ok(cache);
1186 }
1187 }
1188 let summary = ticker_fetch!(self, QUOTE, as_quote, Quote, fetch_quote)?;
1189 {
1190 let mut cache = self.quote_cache.write().await;
1191 *cache = Some(CacheEntry::new(summary));
1192 }
1193 Ok(self.quote_cache.read().await)
1194 }
1195}
1196
1197super::macros::define_quote_accessors! {
1198 price -> Price, price,
1200 summary_detail -> SummaryDetail, summary_detail,
1201 financial_data -> FinancialData, financial_data,
1202 key_stats -> DefaultKeyStatistics, default_key_statistics,
1203 asset_profile -> AssetProfile, asset_profile,
1204 calendar_events -> CalendarEvents, calendar_events,
1205 earnings -> Earnings, earnings,
1206 earnings_trend -> EarningsTrend, earnings_trend,
1207 earnings_history -> EarningsHistory, earnings_history,
1208 recommendation_trend -> RecommendationTrend, recommendation_trend,
1209 insider_holders -> InsiderHolders, insider_holders,
1210 insider_transactions -> InsiderTransactions, insider_transactions,
1211 institution_ownership -> InstitutionOwnership, institution_ownership,
1212 fund_ownership -> FundOwnership, fund_ownership,
1213 major_holders -> MajorHoldersBreakdown, major_holders_breakdown,
1214 share_purchase_activity -> NetSharePurchaseActivity, net_share_purchase_activity,
1215 quote_type -> QuoteTypeData, quote_type,
1216 summary_profile -> SummaryProfile, summary_profile,
1217 sec_filings -> SecFilings, sec_filings,
1218 grading_history -> UpgradeDowngradeHistory, upgrade_downgrade_history,
1219 fund_performance -> FundPerformance, fund_performance,
1220 fund_profile -> FundProfile, fund_profile,
1221 top_holdings -> TopHoldings, top_holdings,
1222 index_trend -> IndexTrend, index_trend,
1223 industry_trend -> IndustryTrend, industry_trend,
1224 sector_trend -> SectorTrend, sector_trend,
1225 equity_performance -> EquityPerformance, equity_performance,
1226}
1227
1228#[cfg(test)]
1229mod tests {
1230 use super::*;
1231 use crate::providers::mock::{CountingProvider, provider_set};
1232
1233 #[tokio::test]
1234 async fn default_caches_quote_across_accessors() {
1235 let provider = CountingProvider::new();
1236 let ticker = Ticker::builder("AAPL")
1237 .with_provider_set(provider_set(Arc::clone(&provider)))
1238 .build()
1239 .await
1240 .unwrap();
1241
1242 let _ = ticker.price().await.unwrap();
1243 let _ = ticker.summary_detail().await.unwrap();
1244 let _ = ticker.asset_profile().await.unwrap();
1245
1246 assert_eq!(provider.quotes(), 1);
1247 }
1248
1249 #[tokio::test]
1250 async fn no_cache_refetches_every_accessor() {
1251 let provider = CountingProvider::new();
1252 let ticker = Ticker::builder("AAPL")
1253 .with_provider_set(provider_set(Arc::clone(&provider)))
1254 .no_cache()
1255 .build()
1256 .await
1257 .unwrap();
1258
1259 let _ = ticker.price().await.unwrap();
1260 let _ = ticker.summary_detail().await.unwrap();
1261 let _ = ticker.asset_profile().await.unwrap();
1262
1263 assert_eq!(provider.quotes(), 3);
1264 }
1265
1266 #[tokio::test]
1267 async fn charts_cache_per_interval_and_range() {
1268 let provider = CountingProvider::new();
1269 let ticker = Ticker::builder("AAPL")
1270 .with_provider_set(provider_set(Arc::clone(&provider)))
1271 .build()
1272 .await
1273 .unwrap();
1274
1275 let _ = ticker
1276 .chart(Interval::OneDay, TimeRange::OneMonth)
1277 .await
1278 .unwrap();
1279 let _ = ticker
1280 .chart(Interval::OneDay, TimeRange::OneMonth)
1281 .await
1282 .unwrap();
1283 assert_eq!(provider.charts(), 1);
1284
1285 let _ = ticker
1286 .chart(Interval::OneDay, TimeRange::OneYear)
1287 .await
1288 .unwrap();
1289 assert_eq!(provider.charts(), 2);
1290 }
1291
1292 #[tokio::test]
1293 async fn unresolved_logo_is_not_cached() {
1294 let provider = CountingProvider::new();
1295 let ticker = Ticker::builder("AAPL")
1296 .with_provider_set(provider_set(Arc::clone(&provider)))
1297 .logo()
1298 .build()
1299 .await
1300 .unwrap();
1301
1302 let _: Quote<crate::format::Raw> = ticker.quote().await.unwrap();
1303 assert!(
1304 ticker.logo_cache.read().await.is_none(),
1305 "an unresolved logo must not be cached, or one blip is permanent"
1306 );
1307 }
1308
1309 #[tokio::test]
1310 async fn concurrent_chart_misses_dedup_to_one_fetch() {
1311 let provider = CountingProvider::new();
1312 let ticker = Arc::new(
1313 Ticker::builder("AAPL")
1314 .with_provider_set(provider_set(Arc::clone(&provider)))
1315 .build()
1316 .await
1317 .unwrap(),
1318 );
1319
1320 let mut handles = Vec::new();
1321 for _ in 0..8 {
1322 let ticker = Arc::clone(&ticker);
1323 handles.push(tokio::spawn(async move {
1324 ticker
1325 .chart(Interval::OneDay, TimeRange::OneMonth)
1326 .await
1327 .unwrap()
1328 }));
1329 }
1330 for h in handles {
1331 h.await.unwrap();
1332 }
1333
1334 assert_eq!(provider.charts(), 1);
1335 }
1336
1337 #[tokio::test]
1338 async fn concurrent_news_misses_dedup_to_one_fetch() {
1339 let provider = CountingProvider::new();
1340 let ticker = Arc::new(
1341 Ticker::builder("AAPL")
1342 .with_provider_set(provider_set(Arc::clone(&provider)))
1343 .build()
1344 .await
1345 .unwrap(),
1346 );
1347
1348 let mut handles = Vec::new();
1349 for _ in 0..8 {
1350 let ticker = Arc::clone(&ticker);
1351 handles.push(tokio::spawn(async move { ticker.news().await.unwrap() }));
1352 }
1353 for h in handles {
1354 h.await.unwrap();
1355 }
1356
1357 assert_eq!(provider.news(), 1);
1358 }
1359
1360 #[tokio::test(start_paused = true)]
1361 async fn ttl_expires() {
1362 let provider = CountingProvider::new();
1363 let ticker = Ticker::builder("AAPL")
1364 .with_provider_set(provider_set(Arc::clone(&provider)))
1365 .cache(Duration::from_secs(60))
1366 .build()
1367 .await
1368 .unwrap();
1369
1370 let _ = ticker
1371 .chart(Interval::OneDay, TimeRange::OneMonth)
1372 .await
1373 .unwrap();
1374 tokio::time::advance(Duration::from_secs(120)).await;
1375 let _ = ticker
1376 .chart(Interval::OneDay, TimeRange::OneMonth)
1377 .await
1378 .unwrap();
1379
1380 assert_eq!(provider.charts(), 2);
1381 }
1382}