finance_query/ticker/
core.rs

1//! Ticker implementation for accessing symbol-specific data from Yahoo Finance.
2//!
3//! Provides async interface for fetching quotes, charts, financials, and news.
4
5use 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
28/// Trait for types with a timestamp field
29trait 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
51/// Calculate cutoff timestamp for a given time range
52fn 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            // Approximate: ~days since Jan 1 of current year
72            // Using rough calculation (could use chrono for precision)
73            let days_in_year = (now % (365 * DAY)) / DAY;
74            now - days_in_year * DAY
75        }
76        TimeRange::Max => 0, // No cutoff
77    }
78}
79
80/// Filter a list of timestamped items by time range
81fn 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
94// Core ticker helpers
95struct 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    /// Builds the quote summary URL with all modules
107    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    /// Parses quote summary response from JSON
119    fn parse_quote_summary(&self, json: serde_json::Value) -> Result<QuoteSummaryResponse> {
120        QuoteSummaryResponse::from_json(json, &self.symbol)
121    }
122}
123
124/// Builder for Ticker
125///
126/// Provides a fluent API for constructing Ticker instances.
127pub 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    /// Set the region (automatically sets correct lang and region)
141    ///
142    /// This is the recommended way to configure regional settings as it ensures
143    /// lang and region are correctly paired.
144    ///
145    /// # Example
146    ///
147    /// ```no_run
148    /// use finance_query::{Ticker, Region};
149    ///
150    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
151    /// let ticker = Ticker::builder("2330.TW")
152    ///     .region(Region::Taiwan)
153    ///     .build()
154    ///     .await?;
155    /// # Ok(())
156    /// # }
157    /// ```
158    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    /// Set the language code (e.g., "en-US", "ja-JP", "de-DE")
165    ///
166    /// For standard regions, prefer using `.region()` instead to ensure
167    /// correct lang/region pairing.
168    pub fn lang(mut self, lang: impl Into<String>) -> Self {
169        self.config.lang = lang.into();
170        self
171    }
172
173    /// Set the region code (e.g., "US", "JP", "DE")
174    ///
175    /// For standard regions, prefer using `.region()` instead to ensure
176    /// correct lang/region pairing.
177    pub fn region_code(mut self, region: impl Into<String>) -> Self {
178        self.config.region = region.into();
179        self
180    }
181
182    /// Set the HTTP request timeout
183    pub fn timeout(mut self, timeout: Duration) -> Self {
184        self.config.timeout = timeout;
185        self
186    }
187
188    /// Set the proxy URL
189    pub fn proxy(mut self, proxy: impl Into<String>) -> Self {
190        self.config.proxy = Some(proxy.into());
191        self
192    }
193
194    /// Set a complete ClientConfig (overrides any previously set individual config fields)
195    pub fn config(mut self, config: ClientConfig) -> Self {
196        self.config = config;
197        self
198    }
199
200    /// Build the Ticker instance
201    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
218/// Ticker for fetching symbol-specific data.
219///
220/// Provides access to quotes, charts, financials, news, and other data for a specific symbol.
221/// Uses smart lazy loading - quote data is fetched once and cached.
222///
223/// # Example
224///
225/// ```no_run
226/// use finance_query::Ticker;
227///
228/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
229/// let ticker = Ticker::new("AAPL").await?;
230///
231/// // Get quote data
232/// let quote = ticker.quote(false).await?;
233/// println!("Price: {:?}", quote.regular_market_price);
234///
235/// // Get chart data
236/// use finance_query::{Interval, TimeRange};
237/// let chart = ticker.chart(Interval::OneDay, TimeRange::OneMonth).await?;
238/// println!("Candles: {}", chart.candles.len());
239/// # Ok(())
240/// # }
241/// ```
242pub 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    /// Creates a new ticker with default configuration
263    ///
264    /// # Arguments
265    ///
266    /// * `symbol` - Stock symbol (e.g., "AAPL", "MSFT")
267    ///
268    /// # Examples
269    ///
270    /// ```no_run
271    /// use finance_query::Ticker;
272    ///
273    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
274    /// let ticker = Ticker::new("AAPL").await?;
275    /// # Ok(())
276    /// # }
277    /// ```
278    pub async fn new(symbol: impl Into<String>) -> Result<Self> {
279        Self::builder(symbol).build().await
280    }
281
282    /// Creates a new builder for Ticker
283    ///
284    /// Use this for custom configuration (language, region, timeout, proxy).
285    ///
286    /// # Arguments
287    ///
288    /// * `symbol` - Stock symbol (e.g., "AAPL", "MSFT")
289    ///
290    /// # Examples
291    ///
292    /// ```no_run
293    /// use finance_query::Ticker;
294    ///
295    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
296    /// // Simple case with defaults (same as new())
297    /// let ticker = Ticker::builder("AAPL").build().await?;
298    ///
299    /// // With custom configuration
300    /// let ticker = Ticker::builder("AAPL")
301    ///     .lang("ja-JP")
302    ///     .region_code("JP")
303    ///     .build()
304    ///     .await?;
305    /// # Ok(())
306    /// # }
307    /// ```
308    pub fn builder(symbol: impl Into<String>) -> TickerBuilder {
309        TickerBuilder::new(symbol)
310    }
311
312    /// Returns the ticker symbol
313    pub fn symbol(&self) -> &str {
314        &self.core.symbol
315    }
316
317    /// Ensures quote summary is loaded
318    async fn ensure_quote_summary_loaded(&self) -> Result<()> {
319        // Quick read check
320        {
321            let cache = self.quote_summary.read().await;
322            if cache.is_some() {
323                return Ok(());
324            }
325        }
326
327        // Acquire write lock
328        let mut cache = self.quote_summary.write().await;
329
330        // Double-check (another task may have loaded while we waited)
331        if cache.is_some() {
332            return Ok(());
333        }
334
335        // Fetch using core's URL builder
336        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        // Parse using core's parser
341        let response = self.core.parse_quote_summary(json)?;
342        *cache = Some(response);
343
344        Ok(())
345    }
346}
347
348// Generate quote summary accessor methods using macro to eliminate duplication.
349macros::define_quote_accessors! {
350    /// Get price information
351    price -> Price, "price",
352
353    /// Get summary detail
354    summary_detail -> SummaryDetail, "summaryDetail",
355
356    /// Get financial data
357    financial_data -> FinancialData, "financialData",
358
359    /// Get key statistics
360    key_stats -> DefaultKeyStatistics, "defaultKeyStatistics",
361
362    /// Get asset profile
363    asset_profile -> AssetProfile, "assetProfile",
364
365    /// Get calendar events
366    calendar_events -> CalendarEvents, "calendarEvents",
367
368    /// Get earnings
369    earnings -> Earnings, "earnings",
370
371    /// Get earnings trend
372    earnings_trend -> EarningsTrend, "earningsTrend",
373
374    /// Get earnings history
375    earnings_history -> EarningsHistory, "earningsHistory",
376
377    /// Get recommendation trend
378    recommendation_trend -> RecommendationTrend, "recommendationTrend",
379
380    /// Get insider holders
381    insider_holders -> InsiderHolders, "insiderHolders",
382
383    /// Get insider transactions
384    insider_transactions -> InsiderTransactions, "insiderTransactions",
385
386    /// Get institution ownership
387    institution_ownership -> InstitutionOwnership, "institutionOwnership",
388
389    /// Get fund ownership
390    fund_ownership -> FundOwnership, "fundOwnership",
391
392    /// Get major holders breakdown
393    major_holders -> MajorHoldersBreakdown, "majorHoldersBreakdown",
394
395    /// Get net share purchase activity
396    share_purchase_activity -> NetSharePurchaseActivity, "netSharePurchaseActivity",
397
398    /// Get quote type
399    quote_type -> QuoteTypeData, "quoteType",
400
401    /// Get summary profile
402    summary_profile -> SummaryProfile, "summaryProfile",
403
404    /// Get SEC filings
405    sec_filings -> SecFilings, "secFilings",
406
407    /// Get upgrade/downgrade history
408    grading_history -> UpgradeDowngradeHistory, "upgradeDowngradeHistory",
409}
410
411impl Ticker {
412    /// Get full quote data with optional logo URL
413    ///
414    /// # Arguments
415    ///
416    /// * `include_logo` - Whether to fetch and include the company logo URL
417    ///
418    /// When `include_logo` is true, fetches both quote summary and logo URL in parallel
419    /// using tokio::join! for minimal latency impact (~0-100ms overhead).
420    pub async fn quote(&self, include_logo: bool) -> Result<Quote> {
421        if include_logo {
422            // Parallel fetch: quoteSummary AND logo
423            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            // Handle quoteSummary result (required)
429            quote_result?;
430
431            // Get cached quote summary
432            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            // Create Quote with logos (logo_result is (Option<String>, Option<String>))
442            let (logo_url, company_logo_url) = logo_result;
443            Ok(Quote::from_response(response, logo_url, company_logo_url))
444        } else {
445            // Original behavior - no logo fetch
446            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    /// Get historical chart data
462    pub async fn chart(&self, interval: Interval, range: TimeRange) -> Result<Chart> {
463        // Check cache first
464        {
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        // Fetch using client delegation
478        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        // Cache events on first chart fetch (events are complete history, only cache once)
509        {
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        // Cache the result
517        {
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    /// Ensures events data is loaded (fetches chart with max range if not cached)
532    async fn ensure_events_loaded(&self) -> Result<()> {
533        // Quick read check
534        {
535            let cache = self.events_cache.read().await;
536            if cache.is_some() {
537                return Ok(());
538            }
539        }
540
541        // Fetch chart with max range to get all events
542        // Using daily interval with max range gives us complete dividend/split history
543        self.chart(Interval::OneDay, TimeRange::Max).await?;
544        Ok(())
545    }
546
547    /// Get dividend history
548    ///
549    /// Returns historical dividend payments sorted by date.
550    /// Events are lazily loaded (fetched once, then filtered by range).
551    ///
552    /// # Arguments
553    ///
554    /// * `range` - Time range to filter dividends
555    ///
556    /// # Example
557    ///
558    /// ```no_run
559    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
560    /// use finance_query::{Ticker, TimeRange};
561    ///
562    /// let ticker = Ticker::new("AAPL").await?;
563    ///
564    /// // Get all dividends
565    /// let all = ticker.dividends(TimeRange::Max).await?;
566    ///
567    /// // Get last year's dividends
568    /// let recent = ticker.dividends(TimeRange::OneYear).await?;
569    /// # Ok(())
570    /// # }
571    /// ```
572    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    /// Get stock split history
582    ///
583    /// Returns historical stock splits sorted by date.
584    /// Events are lazily loaded (fetched once, then filtered by range).
585    ///
586    /// # Arguments
587    ///
588    /// * `range` - Time range to filter splits
589    ///
590    /// # Example
591    ///
592    /// ```no_run
593    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
594    /// use finance_query::{Ticker, TimeRange};
595    ///
596    /// let ticker = Ticker::new("NVDA").await?;
597    ///
598    /// // Get all splits
599    /// let all = ticker.splits(TimeRange::Max).await?;
600    ///
601    /// // Get last 5 years
602    /// let recent = ticker.splits(TimeRange::FiveYears).await?;
603    /// # Ok(())
604    /// # }
605    /// ```
606    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    /// Get capital gains distribution history
616    ///
617    /// Returns historical capital gain distributions sorted by date.
618    /// This is primarily relevant for mutual funds and ETFs.
619    /// Events are lazily loaded (fetched once, then filtered by range).
620    ///
621    /// # Arguments
622    ///
623    /// * `range` - Time range to filter capital gains
624    ///
625    /// # Example
626    ///
627    /// ```no_run
628    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
629    /// use finance_query::{Ticker, TimeRange};
630    ///
631    /// let ticker = Ticker::new("VFIAX").await?;
632    ///
633    /// // Get all capital gains
634    /// let all = ticker.capital_gains(TimeRange::Max).await?;
635    ///
636    /// // Get last 2 years
637    /// let recent = ticker.capital_gains(TimeRange::TwoYears).await?;
638    /// # Ok(())
639    /// # }
640    /// ```
641    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    /// Calculate all technical indicators from chart data
654    ///
655    /// # Arguments
656    ///
657    /// * `interval` - The time interval for each candle
658    /// * `range` - The time range to fetch data for
659    ///
660    /// # Returns
661    ///
662    /// Returns `IndicatorsSummary` containing all calculated indicators.
663    ///
664    /// # Example
665    ///
666    /// ```no_run
667    /// use finance_query::{Ticker, Interval, TimeRange};
668    ///
669    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
670    /// let ticker = Ticker::new("AAPL").await?;
671    /// let indicators = ticker.indicators(Interval::OneDay, TimeRange::OneYear).await?;
672    ///
673    /// println!("RSI(14): {:?}", indicators.rsi_14);
674    /// println!("MACD: {:?}", indicators.macd);
675    /// # Ok(())
676    /// # }
677    /// ```
678    pub async fn indicators(
679        &self,
680        interval: Interval,
681        range: TimeRange,
682    ) -> Result<crate::models::indicators::IndicatorsSummary> {
683        // Fetch chart data
684        let chart = self.chart(interval, range).await?;
685
686        // Calculate indicators from candles
687        Ok(crate::models::indicators::calculate_indicators(
688            &chart.candles,
689        ))
690    }
691
692    /// Get analyst recommendations
693    pub async fn recommendations(&self, limit: u32) -> Result<Recommendation> {
694        // Check cache
695        {
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        // Fetch using client delegation
712        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        // Cache
724        {
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    /// Get financial statements
742    ///
743    /// # Arguments
744    ///
745    /// * `statement_type` - Type of statement (Income, Balance, CashFlow)
746    /// * `frequency` - Annual or Quarterly
747    ///
748    /// # Example
749    ///
750    /// ```no_run
751    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
752    /// use finance_query::{Ticker, Frequency, StatementType};
753    ///
754    /// let ticker = Ticker::new("AAPL").await?;
755    /// let income = ticker.financials(StatementType::Income, Frequency::Annual).await?;
756    /// println!("Revenue: {:?}", income.statement.get("TotalRevenue"));
757    /// # Ok(())
758    /// # }
759    /// ```
760    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        // Check cache
768        {
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        // Fetch financials
776        let financials = self
777            .client
778            .get_financials(&self.core.symbol, statement_type, frequency)
779            .await?;
780
781        // Update cache
782        {
783            let mut cache = self.financials_cache.write().await;
784            cache.insert(cache_key, financials.clone());
785        }
786
787        Ok(financials)
788    }
789
790    /// Get news articles for this symbol
791    ///
792    /// # Example
793    ///
794    /// ```no_run
795    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
796    /// use finance_query::Ticker;
797    ///
798    /// let ticker = Ticker::new("AAPL").await?;
799    /// let news = ticker.news().await?;
800    /// for article in news {
801    ///     println!("{}: {}", article.source, article.title);
802    /// }
803    /// # Ok(())
804    /// # }
805    /// ```
806    pub async fn news(&self) -> Result<Vec<crate::models::news::News>> {
807        // Check cache
808        {
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        // Fetch news
816        let news = crate::scrapers::stockanalysis::scrape_symbol_news(&self.core.symbol).await?;
817
818        // Update cache
819        {
820            let mut cache = self.news_cache.write().await;
821            *cache = Some(news.clone());
822        }
823
824        Ok(news)
825    }
826
827    /// Get options chain
828    pub async fn options(&self, date: Option<i64>) -> Result<Options> {
829        // Check cache
830        {
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        // Fetch options
838        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        // Update cache
847        {
848            let mut cache = self.options_cache.write().await;
849            cache.insert(date, options.clone());
850        }
851
852        Ok(options)
853    }
854}