1use super::macros;
6use crate::client::{ClientConfig, YahooClient};
7use crate::constants::{Interval, TimeRange};
8use crate::error::Result;
9use crate::models::chart::events::ChartEvents;
10use crate::models::chart::response::ChartResponse;
11use crate::models::chart::result::ChartResult;
12use crate::models::chart::{CapitalGain, Chart, Dividend, Split};
13use crate::models::financials::FinancialStatement;
14use crate::models::options::Options;
15use crate::models::quote::{
16 AssetProfile, CalendarEvents, DefaultKeyStatistics, Earnings, EarningsHistory, EarningsTrend,
17 FinancialData, FundOwnership, InsiderHolders, InsiderTransactions, InstitutionOwnership,
18 MajorHoldersBreakdown, Module, NetSharePurchaseActivity, Price, Quote, QuoteSummaryResponse,
19 QuoteTypeData, RecommendationTrend, SecFilings, SummaryDetail, SummaryProfile,
20 UpgradeDowngradeHistory,
21};
22use crate::models::recommendation::Recommendation;
23use crate::models::recommendation::response::RecommendationResponse;
24use std::collections::HashMap;
25use std::sync::Arc;
26use std::time::{Duration, SystemTime, UNIX_EPOCH};
27
28trait HasTimestamp {
30 fn timestamp(&self) -> i64;
31}
32
33impl HasTimestamp for Dividend {
34 fn timestamp(&self) -> i64 {
35 self.timestamp
36 }
37}
38
39impl HasTimestamp for Split {
40 fn timestamp(&self) -> i64 {
41 self.timestamp
42 }
43}
44
45impl HasTimestamp for CapitalGain {
46 fn timestamp(&self) -> i64 {
47 self.timestamp
48 }
49}
50
51fn range_to_cutoff(range: TimeRange) -> i64 {
53 let now = SystemTime::now()
54 .duration_since(UNIX_EPOCH)
55 .unwrap()
56 .as_secs() as i64;
57
58 const DAY: i64 = 86400;
59
60 match range {
61 TimeRange::OneDay => now - DAY,
62 TimeRange::FiveDays => now - 5 * DAY,
63 TimeRange::OneMonth => now - 30 * DAY,
64 TimeRange::ThreeMonths => now - 90 * DAY,
65 TimeRange::SixMonths => now - 180 * DAY,
66 TimeRange::OneYear => now - 365 * DAY,
67 TimeRange::TwoYears => now - 2 * 365 * DAY,
68 TimeRange::FiveYears => now - 5 * 365 * DAY,
69 TimeRange::TenYears => now - 10 * 365 * DAY,
70 TimeRange::YearToDate => {
71 let days_in_year = (now % (365 * DAY)) / DAY;
74 now - days_in_year * DAY
75 }
76 TimeRange::Max => 0, }
78}
79
80fn filter_by_range<T: HasTimestamp>(items: Vec<T>, range: TimeRange) -> Vec<T> {
82 match range {
83 TimeRange::Max => items,
84 range => {
85 let cutoff = range_to_cutoff(range);
86 items
87 .into_iter()
88 .filter(|item| item.timestamp() >= cutoff)
89 .collect()
90 }
91 }
92}
93
94struct TickerCoreData {
96 symbol: String,
97}
98
99impl TickerCoreData {
100 fn new(symbol: impl Into<String>) -> Self {
101 Self {
102 symbol: symbol.into(),
103 }
104 }
105
106 fn build_quote_summary_url(&self) -> String {
108 let url = crate::endpoints::urls::api::quote_summary(&self.symbol);
109 let quote_modules = Module::all();
110 let module_str = quote_modules
111 .iter()
112 .map(|m| m.as_str())
113 .collect::<Vec<_>>()
114 .join(",");
115 format!("{}?modules={}", url, module_str)
116 }
117
118 fn parse_quote_summary(&self, json: serde_json::Value) -> Result<QuoteSummaryResponse> {
120 QuoteSummaryResponse::from_json(json, &self.symbol)
121 }
122}
123
124pub struct TickerBuilder {
128 symbol: String,
129 config: ClientConfig,
130}
131
132impl TickerBuilder {
133 fn new(symbol: impl Into<String>) -> Self {
134 Self {
135 symbol: symbol.into(),
136 config: ClientConfig::default(),
137 }
138 }
139
140 pub fn region(mut self, region: crate::constants::Region) -> Self {
159 self.config.lang = region.lang().to_string();
160 self.config.region = region.region().to_string();
161 self
162 }
163
164 pub fn lang(mut self, lang: impl Into<String>) -> Self {
169 self.config.lang = lang.into();
170 self
171 }
172
173 pub fn region_code(mut self, region: impl Into<String>) -> Self {
178 self.config.region = region.into();
179 self
180 }
181
182 pub fn timeout(mut self, timeout: Duration) -> Self {
184 self.config.timeout = timeout;
185 self
186 }
187
188 pub fn proxy(mut self, proxy: impl Into<String>) -> Self {
190 self.config.proxy = Some(proxy.into());
191 self
192 }
193
194 pub fn config(mut self, config: ClientConfig) -> Self {
196 self.config = config;
197 self
198 }
199
200 pub async fn build(self) -> Result<Ticker> {
202 let client = Arc::new(YahooClient::new(self.config).await?);
203
204 Ok(Ticker {
205 core: TickerCoreData::new(self.symbol),
206 client,
207 quote_summary: Arc::new(tokio::sync::RwLock::new(None)),
208 chart_cache: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
209 events_cache: Arc::new(tokio::sync::RwLock::new(None)),
210 recommendations_cache: Arc::new(tokio::sync::RwLock::new(None)),
211 news_cache: Arc::new(tokio::sync::RwLock::new(None)),
212 options_cache: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
213 financials_cache: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
214 })
215 }
216}
217
218pub struct Ticker {
243 core: TickerCoreData,
244 client: Arc<YahooClient>,
245 quote_summary: Arc<tokio::sync::RwLock<Option<QuoteSummaryResponse>>>,
246 chart_cache: Arc<tokio::sync::RwLock<HashMap<(Interval, TimeRange), ChartResult>>>,
247 events_cache: Arc<tokio::sync::RwLock<Option<ChartEvents>>>,
248 recommendations_cache: Arc<tokio::sync::RwLock<Option<RecommendationResponse>>>,
249 news_cache: Arc<tokio::sync::RwLock<Option<Vec<crate::models::news::News>>>>,
250 options_cache: Arc<tokio::sync::RwLock<HashMap<Option<i64>, Options>>>,
251 financials_cache: Arc<
252 tokio::sync::RwLock<
253 HashMap<
254 (crate::constants::StatementType, crate::constants::Frequency),
255 FinancialStatement,
256 >,
257 >,
258 >,
259}
260
261impl Ticker {
262 pub async fn new(symbol: impl Into<String>) -> Result<Self> {
279 Self::builder(symbol).build().await
280 }
281
282 pub fn builder(symbol: impl Into<String>) -> TickerBuilder {
309 TickerBuilder::new(symbol)
310 }
311
312 pub fn symbol(&self) -> &str {
314 &self.core.symbol
315 }
316
317 async fn ensure_quote_summary_loaded(&self) -> Result<()> {
319 {
321 let cache = self.quote_summary.read().await;
322 if cache.is_some() {
323 return Ok(());
324 }
325 }
326
327 let mut cache = self.quote_summary.write().await;
329
330 if cache.is_some() {
332 return Ok(());
333 }
334
335 let url = self.core.build_quote_summary_url();
337 let http_response = self.client.request_with_crumb(&url).await?;
338 let json = http_response.json::<serde_json::Value>().await?;
339
340 let response = self.core.parse_quote_summary(json)?;
342 *cache = Some(response);
343
344 Ok(())
345 }
346}
347
348macros::define_quote_accessors! {
350 price -> Price, "price",
352
353 summary_detail -> SummaryDetail, "summaryDetail",
355
356 financial_data -> FinancialData, "financialData",
358
359 key_stats -> DefaultKeyStatistics, "defaultKeyStatistics",
361
362 asset_profile -> AssetProfile, "assetProfile",
364
365 calendar_events -> CalendarEvents, "calendarEvents",
367
368 earnings -> Earnings, "earnings",
370
371 earnings_trend -> EarningsTrend, "earningsTrend",
373
374 earnings_history -> EarningsHistory, "earningsHistory",
376
377 recommendation_trend -> RecommendationTrend, "recommendationTrend",
379
380 insider_holders -> InsiderHolders, "insiderHolders",
382
383 insider_transactions -> InsiderTransactions, "insiderTransactions",
385
386 institution_ownership -> InstitutionOwnership, "institutionOwnership",
388
389 fund_ownership -> FundOwnership, "fundOwnership",
391
392 major_holders -> MajorHoldersBreakdown, "majorHoldersBreakdown",
394
395 share_purchase_activity -> NetSharePurchaseActivity, "netSharePurchaseActivity",
397
398 quote_type -> QuoteTypeData, "quoteType",
400
401 summary_profile -> SummaryProfile, "summaryProfile",
403
404 sec_filings -> SecFilings, "secFilings",
406
407 grading_history -> UpgradeDowngradeHistory, "upgradeDowngradeHistory",
409}
410
411impl Ticker {
412 pub async fn quote(&self, include_logo: bool) -> Result<Quote> {
421 if include_logo {
422 let (quote_result, logo_result) = tokio::join!(
424 self.ensure_quote_summary_loaded(),
425 self.client.get_logo_url(&self.core.symbol)
426 );
427
428 quote_result?;
430
431 let cache = self.quote_summary.read().await;
433 let response =
434 cache
435 .as_ref()
436 .ok_or_else(|| crate::error::YahooError::SymbolNotFound {
437 symbol: Some(self.core.symbol.clone()),
438 context: "Quote summary not loaded".to_string(),
439 })?;
440
441 let (logo_url, company_logo_url) = logo_result;
443 Ok(Quote::from_response(response, logo_url, company_logo_url))
444 } else {
445 self.ensure_quote_summary_loaded().await?;
447
448 let cache = self.quote_summary.read().await;
449 let response =
450 cache
451 .as_ref()
452 .ok_or_else(|| crate::error::YahooError::SymbolNotFound {
453 symbol: Some(self.core.symbol.clone()),
454 context: "Quote summary not loaded".to_string(),
455 })?;
456
457 Ok(Quote::from_response(response, None, None))
458 }
459 }
460
461 pub async fn chart(&self, interval: Interval, range: TimeRange) -> Result<Chart> {
463 {
465 let cache = self.chart_cache.read().await;
466 if let Some(cached) = cache.get(&(interval, range)) {
467 return Ok(Chart {
468 symbol: self.core.symbol.clone(),
469 meta: cached.meta.clone(),
470 candles: cached.to_candles(),
471 interval: Some(interval.as_str().to_string()),
472 range: Some(range.as_str().to_string()),
473 });
474 }
475 }
476
477 let json = self
479 .client
480 .get_chart(&self.core.symbol, interval, range)
481 .await?;
482 let response = ChartResponse::from_json(json).map_err(|e| {
483 crate::error::YahooError::ResponseStructureError {
484 field: "chart".to_string(),
485 context: e.to_string(),
486 }
487 })?;
488
489 let mut results =
490 response
491 .chart
492 .result
493 .ok_or_else(|| crate::error::YahooError::SymbolNotFound {
494 symbol: Some(self.core.symbol.clone()),
495 context: "Chart data not found".to_string(),
496 })?;
497
498 let result = results
499 .pop()
500 .ok_or_else(|| crate::error::YahooError::SymbolNotFound {
501 symbol: Some(self.core.symbol.clone()),
502 context: "Chart data empty".to_string(),
503 })?;
504
505 let candles = result.to_candles();
506 let meta = result.meta.clone();
507
508 {
510 let mut events_cache = self.events_cache.write().await;
511 if events_cache.is_none() {
512 *events_cache = result.events.clone();
513 }
514 }
515
516 {
518 let mut cache = self.chart_cache.write().await;
519 cache.insert((interval, range), result);
520 }
521
522 Ok(Chart {
523 symbol: self.core.symbol.clone(),
524 meta,
525 candles,
526 interval: Some(interval.as_str().to_string()),
527 range: Some(range.as_str().to_string()),
528 })
529 }
530
531 async fn ensure_events_loaded(&self) -> Result<()> {
533 {
535 let cache = self.events_cache.read().await;
536 if cache.is_some() {
537 return Ok(());
538 }
539 }
540
541 self.chart(Interval::OneDay, TimeRange::Max).await?;
544 Ok(())
545 }
546
547 pub async fn dividends(&self, range: TimeRange) -> Result<Vec<Dividend>> {
573 self.ensure_events_loaded().await?;
574
575 let cache = self.events_cache.read().await;
576 let all = cache.as_ref().map(|e| e.to_dividends()).unwrap_or_default();
577
578 Ok(filter_by_range(all, range))
579 }
580
581 pub async fn splits(&self, range: TimeRange) -> Result<Vec<Split>> {
607 self.ensure_events_loaded().await?;
608
609 let cache = self.events_cache.read().await;
610 let all = cache.as_ref().map(|e| e.to_splits()).unwrap_or_default();
611
612 Ok(filter_by_range(all, range))
613 }
614
615 pub async fn capital_gains(&self, range: TimeRange) -> Result<Vec<CapitalGain>> {
642 self.ensure_events_loaded().await?;
643
644 let cache = self.events_cache.read().await;
645 let all = cache
646 .as_ref()
647 .map(|e| e.to_capital_gains())
648 .unwrap_or_default();
649
650 Ok(filter_by_range(all, range))
651 }
652
653 pub async fn indicators(
679 &self,
680 interval: Interval,
681 range: TimeRange,
682 ) -> Result<crate::models::indicators::IndicatorsSummary> {
683 let chart = self.chart(interval, range).await?;
685
686 Ok(crate::models::indicators::calculate_indicators(
688 &chart.candles,
689 ))
690 }
691
692 pub async fn recommendations(&self, limit: u32) -> Result<Recommendation> {
694 {
696 let cache = self.recommendations_cache.read().await;
697 if let Some(cached) = cache.as_ref() {
698 return Ok(Recommendation {
699 symbol: self.core.symbol.clone(),
700 recommendations: cached
701 .finance
702 .result
703 .iter()
704 .flat_map(|r| &r.recommended_symbols)
705 .cloned()
706 .collect(),
707 });
708 }
709 }
710
711 let json = self
713 .client
714 .get_recommendations(&self.core.symbol, limit)
715 .await?;
716 let response = RecommendationResponse::from_json(json).map_err(|e| {
717 crate::error::YahooError::ResponseStructureError {
718 field: "finance".to_string(),
719 context: e.to_string(),
720 }
721 })?;
722
723 {
725 let mut cache = self.recommendations_cache.write().await;
726 *cache = Some(response.clone());
727 }
728
729 Ok(Recommendation {
730 symbol: self.core.symbol.clone(),
731 recommendations: response
732 .finance
733 .result
734 .iter()
735 .flat_map(|r| &r.recommended_symbols)
736 .cloned()
737 .collect(),
738 })
739 }
740
741 pub async fn financials(
761 &self,
762 statement_type: crate::constants::StatementType,
763 frequency: crate::constants::Frequency,
764 ) -> Result<FinancialStatement> {
765 let cache_key = (statement_type, frequency);
766
767 {
769 let cache = self.financials_cache.read().await;
770 if let Some(cached) = cache.get(&cache_key) {
771 return Ok(cached.clone());
772 }
773 }
774
775 let financials = self
777 .client
778 .get_financials(&self.core.symbol, statement_type, frequency)
779 .await?;
780
781 {
783 let mut cache = self.financials_cache.write().await;
784 cache.insert(cache_key, financials.clone());
785 }
786
787 Ok(financials)
788 }
789
790 pub async fn news(&self) -> Result<Vec<crate::models::news::News>> {
807 {
809 let cache = self.news_cache.read().await;
810 if let Some(cached) = cache.as_ref() {
811 return Ok(cached.clone());
812 }
813 }
814
815 let news = crate::scrapers::stockanalysis::scrape_symbol_news(&self.core.symbol).await?;
817
818 {
820 let mut cache = self.news_cache.write().await;
821 *cache = Some(news.clone());
822 }
823
824 Ok(news)
825 }
826
827 pub async fn options(&self, date: Option<i64>) -> Result<Options> {
829 {
831 let cache = self.options_cache.read().await;
832 if let Some(cached) = cache.get(&date) {
833 return Ok(cached.clone());
834 }
835 }
836
837 let json = self.client.get_options(&self.core.symbol, date).await?;
839 let options: Options = serde_json::from_value(json).map_err(|e| {
840 crate::error::YahooError::ResponseStructureError {
841 field: "options".to_string(),
842 context: e.to_string(),
843 }
844 })?;
845
846 {
848 let mut cache = self.options_cache.write().await;
849 cache.insert(date, options.clone());
850 }
851
852 Ok(options)
853 }
854}