Skip to main content

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::{CapitalGain, Chart, Dividend, Split};
12use crate::models::financials::FinancialStatement;
13use crate::models::options::Options;
14use crate::models::quote::{
15    AssetProfile, CalendarEvents, DefaultKeyStatistics, Earnings, EarningsHistory, EarningsTrend,
16    EquityPerformance, FinancialData, FundOwnership, FundPerformance, FundProfile, IndexTrend,
17    IndustryTrend, InsiderHolders, InsiderTransactions, InstitutionOwnership,
18    MajorHoldersBreakdown, Module, NetSharePurchaseActivity, Price, Quote, QuoteSummaryResponse,
19    QuoteTypeData, RecommendationTrend, SecFilings, SectorTrend, SummaryDetail, SummaryProfile,
20    TopHoldings, UpgradeDowngradeHistory,
21};
22use crate::models::recommendation::Recommendation;
23use crate::models::recommendation::response::RecommendationResponse;
24use crate::utils::{CacheEntry, EVICTION_THRESHOLD, filter_by_range};
25use std::collections::HashMap;
26use std::sync::{Arc, OnceLock};
27use std::time::Duration;
28use tokio::sync::RwLock;
29
30// Type aliases to keep struct definitions readable.
31type Cache<T> = Arc<RwLock<Option<CacheEntry<T>>>>;
32type MapCache<K, V> = Arc<RwLock<HashMap<K, CacheEntry<V>>>>;
33
34/// Opaque handle to a shared Yahoo Finance client session.
35///
36/// Allows multiple [`Ticker`] and [`Tickers`](crate::Tickers) instances to share
37/// a single authenticated session, avoiding redundant authentication handshakes.
38///
39/// Obtain a handle from an existing `Ticker` via [`Ticker::client_handle()`],
40/// then pass it to other builders via `.client()`.
41///
42/// # Example
43///
44/// ```no_run
45/// use finance_query::Ticker;
46///
47/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
48/// let aapl = Ticker::new("AAPL").await?;
49/// let handle = aapl.client_handle();
50///
51/// // Share the same session — no additional auth
52/// let msft = Ticker::builder("MSFT").client(handle.clone()).build().await?;
53/// let googl = Ticker::builder("GOOGL").client(handle).build().await?;
54/// # Ok(())
55/// # }
56/// ```
57#[derive(Clone)]
58pub struct ClientHandle(pub(crate) Arc<YahooClient>);
59
60/// Builder for Ticker
61///
62/// Provides a fluent API for constructing Ticker instances.
63pub struct TickerBuilder {
64    symbol: Arc<str>,
65    config: ClientConfig,
66    shared_client: Option<ClientHandle>,
67    cache_ttl: Option<Duration>,
68    include_logo: bool,
69}
70
71impl TickerBuilder {
72    fn new(symbol: impl Into<String>) -> Self {
73        Self {
74            symbol: symbol.into().into(),
75            config: ClientConfig::default(),
76            shared_client: None,
77            cache_ttl: None,
78            include_logo: false,
79        }
80    }
81
82    /// Set the region (automatically sets correct lang and region)
83    ///
84    /// This is the recommended way to configure regional settings as it ensures
85    /// lang and region are correctly paired.
86    ///
87    /// # Example
88    ///
89    /// ```no_run
90    /// use finance_query::{Ticker, Region};
91    ///
92    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
93    /// let ticker = Ticker::builder("2330.TW")
94    ///     .region(Region::Taiwan)
95    ///     .build()
96    ///     .await?;
97    /// # Ok(())
98    /// # }
99    /// ```
100    pub fn region(mut self, region: crate::constants::Region) -> Self {
101        self.config.lang = region.lang().to_string();
102        self.config.region = region.region().to_string();
103        self
104    }
105
106    /// Set the language code (e.g., "en-US", "ja-JP", "de-DE")
107    ///
108    /// For standard regions, prefer using `.region()` instead to ensure
109    /// correct lang/region pairing.
110    pub fn lang(mut self, lang: impl Into<String>) -> Self {
111        self.config.lang = lang.into();
112        self
113    }
114
115    /// Set the region code (e.g., "US", "JP", "DE")
116    ///
117    /// For standard regions, prefer using `.region()` instead to ensure
118    /// correct lang/region pairing.
119    pub fn region_code(mut self, region: impl Into<String>) -> Self {
120        self.config.region = region.into();
121        self
122    }
123
124    /// Set the HTTP request timeout
125    pub fn timeout(mut self, timeout: Duration) -> Self {
126        self.config.timeout = timeout;
127        self
128    }
129
130    /// Set the proxy URL
131    pub fn proxy(mut self, proxy: impl Into<String>) -> Self {
132        self.config.proxy = Some(proxy.into());
133        self
134    }
135
136    /// Set a complete ClientConfig (overrides any previously set individual config fields)
137    pub fn config(mut self, config: ClientConfig) -> Self {
138        self.config = config;
139        self
140    }
141
142    /// Share an existing authenticated session instead of creating a new one.
143    ///
144    /// This avoids redundant authentication when you need multiple `Ticker`
145    /// instances or want to share a session between `Ticker` and [`crate::Tickers`].
146    ///
147    /// Obtain a [`ClientHandle`] from any existing `Ticker` via
148    /// [`Ticker::client_handle()`].
149    ///
150    /// When set, the builder's `config`, `timeout`, `proxy`, `lang`, and `region`
151    /// settings are ignored (the shared session's configuration is used instead).
152    ///
153    /// # Example
154    ///
155    /// ```no_run
156    /// use finance_query::Ticker;
157    ///
158    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
159    /// let aapl = Ticker::new("AAPL").await?;
160    /// let handle = aapl.client_handle();
161    ///
162    /// let msft = Ticker::builder("MSFT").client(handle.clone()).build().await?;
163    /// let googl = Ticker::builder("GOOGL").client(handle).build().await?;
164    /// # Ok(())
165    /// # }
166    /// ```
167    pub fn client(mut self, handle: ClientHandle) -> Self {
168        self.shared_client = Some(handle);
169        self
170    }
171
172    /// Enable response caching with a time-to-live.
173    ///
174    /// By default caching is **disabled** — every call fetches fresh data.
175    /// When enabled, responses are reused until the TTL expires, then
176    /// automatically re-fetched. Expired entries in map-based caches
177    /// (chart, options, financials) are evicted on the next write to
178    /// limit memory growth.
179    ///
180    /// # Example
181    ///
182    /// ```no_run
183    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
184    /// use finance_query::Ticker;
185    /// use std::time::Duration;
186    ///
187    /// let ticker = Ticker::builder("AAPL")
188    ///     .cache(Duration::from_secs(30))
189    ///     .build()
190    ///     .await?;
191    /// # Ok(())
192    /// # }
193    /// ```
194    pub fn cache(mut self, ttl: Duration) -> Self {
195        self.cache_ttl = Some(ttl);
196        self
197    }
198
199    /// Include company logo URLs in quote responses.
200    ///
201    /// When enabled, `quote()` will fetch logo URLs in parallel with the
202    /// quote summary, adding a small extra request.
203    pub fn logo(mut self) -> Self {
204        self.include_logo = true;
205        self
206    }
207
208    /// Build the Ticker instance
209    pub async fn build(self) -> Result<Ticker> {
210        let client = match self.shared_client {
211            Some(handle) => handle.0,
212            None => Arc::new(YahooClient::new(self.config).await?),
213        };
214
215        Ok(Ticker {
216            symbol: self.symbol,
217            client,
218            cache_ttl: self.cache_ttl,
219            include_logo: self.include_logo,
220            quote_summary: Default::default(),
221            quote_summary_fetch: Arc::new(tokio::sync::Mutex::new(())),
222            chart_cache: Default::default(),
223            events_cache: Default::default(),
224            recommendations_cache: Default::default(),
225            news_cache: Default::default(),
226            options_cache: Default::default(),
227            financials_cache: Default::default(),
228            #[cfg(feature = "indicators")]
229            indicators_cache: Default::default(),
230            edgar_submissions_cache: Default::default(),
231            edgar_facts_cache: Default::default(),
232        })
233    }
234}
235
236/// Ticker for fetching symbol-specific data.
237///
238/// Provides access to quotes, charts, financials, news, and other data for a specific symbol.
239/// Uses smart lazy loading - quote data is fetched once and cached.
240///
241/// # Example
242///
243/// ```no_run
244/// use finance_query::Ticker;
245///
246/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
247/// let ticker = Ticker::new("AAPL").await?;
248///
249/// // Get quote data
250/// let quote = ticker.quote().await?;
251/// println!("Price: {:?}", quote.regular_market_price);
252///
253/// // Get chart data
254/// use finance_query::{Interval, TimeRange};
255/// let chart = ticker.chart(Interval::OneDay, TimeRange::OneMonth).await?;
256/// println!("Candles: {}", chart.candles.len());
257/// # Ok(())
258/// # }
259/// ```
260#[derive(Clone)]
261pub struct Ticker {
262    symbol: Arc<str>,
263    client: Arc<YahooClient>,
264    cache_ttl: Option<Duration>,
265    include_logo: bool,
266    quote_summary: Cache<QuoteSummaryResponse>,
267    quote_summary_fetch: Arc<tokio::sync::Mutex<()>>,
268    chart_cache: MapCache<(Interval, TimeRange), Chart>,
269    events_cache: Cache<ChartEvents>,
270    recommendations_cache: Cache<RecommendationResponse>,
271    news_cache: Cache<Vec<crate::models::news::News>>,
272    options_cache: MapCache<Option<i64>, Options>,
273    financials_cache: MapCache<
274        (crate::constants::StatementType, crate::constants::Frequency),
275        FinancialStatement,
276    >,
277    #[cfg(feature = "indicators")]
278    indicators_cache: MapCache<(Interval, TimeRange), crate::indicators::IndicatorsSummary>,
279    edgar_submissions_cache: Cache<crate::models::edgar::EdgarSubmissions>,
280    edgar_facts_cache: Cache<crate::models::edgar::CompanyFacts>,
281}
282
283impl Ticker {
284    /// Creates a new ticker with default configuration
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    /// let ticker = Ticker::new("AAPL").await?;
297    /// # Ok(())
298    /// # }
299    /// ```
300    pub async fn new(symbol: impl Into<String>) -> Result<Self> {
301        Self::builder(symbol).build().await
302    }
303
304    /// Creates a new builder for Ticker
305    ///
306    /// Use this for custom configuration (language, region, timeout, proxy).
307    ///
308    /// # Arguments
309    ///
310    /// * `symbol` - Stock symbol (e.g., "AAPL", "MSFT")
311    ///
312    /// # Examples
313    ///
314    /// ```no_run
315    /// use finance_query::Ticker;
316    ///
317    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
318    /// // Simple case with defaults (same as new())
319    /// let ticker = Ticker::builder("AAPL").build().await?;
320    ///
321    /// // With custom configuration
322    /// let ticker = Ticker::builder("AAPL")
323    ///     .lang("ja-JP")
324    ///     .region_code("JP")
325    ///     .build()
326    ///     .await?;
327    /// # Ok(())
328    /// # }
329    /// ```
330    pub fn builder(symbol: impl Into<String>) -> TickerBuilder {
331        TickerBuilder::new(symbol)
332    }
333
334    /// Returns the ticker symbol
335    pub fn symbol(&self) -> &str {
336        &self.symbol
337    }
338
339    /// Returns a shareable handle to this ticker's authenticated session.
340    ///
341    /// Pass the handle to other [`Ticker`] or [`Tickers`](crate::Tickers) builders
342    /// via `.client()` to reuse the same session without re-authenticating.
343    ///
344    /// # Example
345    ///
346    /// ```no_run
347    /// use finance_query::Ticker;
348    ///
349    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
350    /// let aapl = Ticker::new("AAPL").await?;
351    /// let handle = aapl.client_handle();
352    ///
353    /// let msft = Ticker::builder("MSFT").client(handle).build().await?;
354    /// # Ok(())
355    /// # }
356    /// ```
357    pub fn client_handle(&self) -> ClientHandle {
358        ClientHandle(Arc::clone(&self.client))
359    }
360
361    /// Returns `true` if a cache entry exists and has not exceeded the TTL.
362    ///
363    /// Returns `false` when caching is disabled (`cache_ttl` is `None`).
364    #[inline]
365    fn is_cache_fresh<T>(&self, entry: Option<&CacheEntry<T>>) -> bool {
366        CacheEntry::is_fresh_with_ttl(entry, self.cache_ttl)
367    }
368
369    /// Insert into a map cache, amortizing stale-entry eviction.
370    ///
371    /// Only sweeps stale entries when the map exceeds [`EVICTION_THRESHOLD`],
372    /// avoiding O(n) scans on every write.
373    #[inline]
374    fn cache_insert<K: Eq + std::hash::Hash, V>(
375        &self,
376        map: &mut HashMap<K, CacheEntry<V>>,
377        key: K,
378        value: V,
379    ) {
380        if let Some(ttl) = self.cache_ttl {
381            if map.len() >= EVICTION_THRESHOLD {
382                map.retain(|_, entry| entry.is_fresh(ttl));
383            }
384            map.insert(key, CacheEntry::new(value));
385        }
386    }
387
388    /// Helper to construct Recommendation from RecommendationResponse with limit
389    fn build_recommendation_with_limit(
390        &self,
391        response: &RecommendationResponse,
392        limit: u32,
393    ) -> Recommendation {
394        Recommendation {
395            symbol: self.symbol.to_string(),
396            recommendations: response
397                .finance
398                .result
399                .iter()
400                .flat_map(|r| &r.recommended_symbols)
401                .take(limit as usize)
402                .cloned()
403                .collect(),
404        }
405    }
406
407    /// Builds the quote summary URL with all modules.
408    ///
409    /// The module list is computed once and reused across all Ticker instances.
410    fn build_quote_summary_url(&self) -> String {
411        static MODULES_PARAM: OnceLock<String> = OnceLock::new();
412        let modules = MODULES_PARAM.get_or_init(|| {
413            Module::all()
414                .iter()
415                .map(|m| m.as_str())
416                .collect::<Vec<_>>()
417                .join(",")
418        });
419        let url = crate::endpoints::urls::api::quote_summary(&self.symbol);
420        format!("{}?modules={}", url, modules)
421    }
422
423    /// Ensures quote summary is loaded and returns a read guard.
424    ///
425    /// Fast path: read lock only.
426    /// Slow path: serialized fetch (mutex), HTTP I/O with no lock held, brief write lock update.
427    async fn ensure_and_read_quote_summary(
428        &self,
429    ) -> Result<tokio::sync::RwLockReadGuard<'_, Option<CacheEntry<QuoteSummaryResponse>>>> {
430        // Fast path: cache hit
431        {
432            let cache = self.quote_summary.read().await;
433            if self.is_cache_fresh(cache.as_ref()) {
434                return Ok(cache);
435            }
436        }
437
438        // Slow path: serialize fetch operations to prevent duplicate requests
439        let _fetch_guard = self.quote_summary_fetch.lock().await;
440
441        // Double-check: another task may have fetched while we waited on mutex
442        {
443            let cache = self.quote_summary.read().await;
444            if self.is_cache_fresh(cache.as_ref()) {
445                return Ok(cache);
446            }
447        }
448
449        // HTTP I/O with NO lock held — critical for concurrent readers
450        let url = self.build_quote_summary_url();
451        let http_response = self.client.request_with_crumb(&url).await?;
452        let json = http_response.json::<serde_json::Value>().await?;
453        let response = QuoteSummaryResponse::from_json(json, &self.symbol)?;
454
455        // Brief write lock to update cache
456        {
457            let mut cache = self.quote_summary.write().await;
458            *cache = Some(CacheEntry::new(response));
459        }
460
461        // Fetch mutex released automatically, return read guard
462        Ok(self.quote_summary.read().await)
463    }
464}
465
466// Generate quote summary accessor methods using macro to eliminate duplication.
467macros::define_quote_accessors! {
468    /// Get price information
469    price -> Price, price,
470
471    /// Get summary detail
472    summary_detail -> SummaryDetail, summary_detail,
473
474    /// Get financial data
475    financial_data -> FinancialData, financial_data,
476
477    /// Get key statistics
478    key_stats -> DefaultKeyStatistics, default_key_statistics,
479
480    /// Get asset profile
481    asset_profile -> AssetProfile, asset_profile,
482
483    /// Get calendar events
484    calendar_events -> CalendarEvents, calendar_events,
485
486    /// Get earnings
487    earnings -> Earnings, earnings,
488
489    /// Get earnings trend
490    earnings_trend -> EarningsTrend, earnings_trend,
491
492    /// Get earnings history
493    earnings_history -> EarningsHistory, earnings_history,
494
495    /// Get recommendation trend
496    recommendation_trend -> RecommendationTrend, recommendation_trend,
497
498    /// Get insider holders
499    insider_holders -> InsiderHolders, insider_holders,
500
501    /// Get insider transactions
502    insider_transactions -> InsiderTransactions, insider_transactions,
503
504    /// Get institution ownership
505    institution_ownership -> InstitutionOwnership, institution_ownership,
506
507    /// Get fund ownership
508    fund_ownership -> FundOwnership, fund_ownership,
509
510    /// Get major holders breakdown
511    major_holders -> MajorHoldersBreakdown, major_holders_breakdown,
512
513    /// Get net share purchase activity
514    share_purchase_activity -> NetSharePurchaseActivity, net_share_purchase_activity,
515
516    /// Get quote type
517    quote_type -> QuoteTypeData, quote_type,
518
519    /// Get summary profile
520    summary_profile -> SummaryProfile, summary_profile,
521
522    /// Get SEC filings (limited Yahoo Finance data)
523    ///
524    /// **DEPRECATED:** This method returns limited SEC filing metadata from Yahoo Finance.
525    /// For comprehensive filing data directly from SEC EDGAR, use `edgar_submissions()` instead.
526    ///
527    /// To use EDGAR methods:
528    /// ```no_run
529    /// # use finance_query::{Ticker, edgar};
530    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
531    /// edgar::init("user@example.com")?;
532    /// let ticker = Ticker::new("AAPL").await?;
533    /// let submissions = ticker.edgar_submissions().await?;  // Comprehensive EDGAR data
534    /// # Ok(())
535    /// # }
536    /// ```
537    #[deprecated(
538        since = "2.2.0",
539        note = "Use `edgar_submissions()` for comprehensive SEC EDGAR data instead of limited Yahoo Finance metadata"
540    )]
541    sec_filings -> SecFilings, sec_filings,
542
543    /// Get upgrade/downgrade history
544    grading_history -> UpgradeDowngradeHistory, upgrade_downgrade_history,
545
546    /// Get fund performance data (returns, trailing returns, risk statistics)
547    ///
548    /// Primarily relevant for ETFs and mutual funds. Returns `None` for equities.
549    fund_performance -> FundPerformance, fund_performance,
550
551    /// Get fund profile (category, family, fees, legal type)
552    ///
553    /// Primarily relevant for ETFs and mutual funds. Returns `None` for equities.
554    fund_profile -> FundProfile, fund_profile,
555
556    /// Get top holdings for ETFs and mutual funds
557    ///
558    /// Includes top stock/bond holdings with weights and sector weightings.
559    /// Returns `None` for equities.
560    top_holdings -> TopHoldings, top_holdings,
561
562    /// Get index trend data (P/E estimates and growth rates)
563    ///
564    /// Contains trend data for the symbol's associated index.
565    index_trend -> IndexTrend, index_trend,
566
567    /// Get industry trend data
568    ///
569    /// Contains P/E and growth estimates for the symbol's industry.
570    industry_trend -> IndustryTrend, industry_trend,
571
572    /// Get sector trend data
573    ///
574    /// Contains P/E and growth estimates for the symbol's sector.
575    sector_trend -> SectorTrend, sector_trend,
576
577    /// Get equity performance vs benchmark
578    ///
579    /// Performance comparison across multiple time periods.
580    equity_performance -> EquityPerformance, equity_performance,
581}
582
583impl Ticker {
584    /// Get full quote data, optionally including logo URLs.
585    ///
586    /// Use [`TickerBuilder::logo()`](TickerBuilder::logo) to enable logo fetching
587    /// for this ticker instance.
588    ///
589    /// When logos are enabled, fetches both quote summary and logo URL in parallel
590    /// using tokio::join! for minimal latency impact (~0-100ms overhead).
591    pub async fn quote(&self) -> Result<Quote> {
592        let not_found = || crate::error::FinanceError::SymbolNotFound {
593            symbol: Some(self.symbol.to_string()),
594            context: "Quote summary not loaded".to_string(),
595        };
596
597        if self.include_logo {
598            // Ensure quote summary is loaded in background while we fetch logos
599            let (cache_result, logo_result) = tokio::join!(
600                self.ensure_and_read_quote_summary(),
601                self.client.get_logo_url(&self.symbol)
602            );
603            let cache = cache_result?;
604            let entry = cache.as_ref().ok_or_else(not_found)?;
605            let (logo_url, company_logo_url) = logo_result;
606            Ok(Quote::from_response(
607                &entry.value,
608                logo_url,
609                company_logo_url,
610            ))
611        } else {
612            let cache = self.ensure_and_read_quote_summary().await?;
613            let entry = cache.as_ref().ok_or_else(not_found)?;
614            Ok(Quote::from_response(&entry.value, None, None))
615        }
616    }
617
618    /// Get historical chart data
619    pub async fn chart(&self, interval: Interval, range: TimeRange) -> Result<Chart> {
620        // Fast path: return cached Chart directly (no re-parsing)
621        {
622            let cache = self.chart_cache.read().await;
623            if let Some(entry) = cache.get(&(interval, range))
624                && self.is_cache_fresh(Some(entry))
625            {
626                return Ok(entry.value.clone());
627            }
628        }
629
630        // Fetch from Yahoo
631        let json = self.client.get_chart(&self.symbol, interval, range).await?;
632        let chart_result = Self::parse_chart_result(json, &self.symbol)?;
633
634        // Always update events when we have fresh data from Yahoo
635        if let Some(events) = &chart_result.events {
636            let mut events_cache = self.events_cache.write().await;
637            *events_cache = Some(CacheEntry::new(events.clone()));
638        }
639
640        // Materialize Chart from raw result — this is the only place to_candles() runs
641        let chart = Chart {
642            symbol: self.symbol.to_string(),
643            meta: chart_result.meta.clone(),
644            candles: chart_result.to_candles(),
645            interval: Some(interval),
646            range: Some(range),
647        };
648
649        // Only clone when caching is enabled to avoid unnecessary allocations
650        if self.cache_ttl.is_some() {
651            let ret = chart.clone();
652            let mut cache = self.chart_cache.write().await;
653            self.cache_insert(&mut cache, (interval, range), chart);
654            Ok(ret)
655        } else {
656            Ok(chart)
657        }
658    }
659
660    /// Parse a ChartResult from raw JSON, returning a descriptive error on failure.
661    fn parse_chart_result(
662        json: serde_json::Value,
663        symbol: &str,
664    ) -> Result<crate::models::chart::result::ChartResult> {
665        let response = ChartResponse::from_json(json).map_err(|e| {
666            crate::error::FinanceError::ResponseStructureError {
667                field: "chart".to_string(),
668                context: e.to_string(),
669            }
670        })?;
671
672        let results =
673            response
674                .chart
675                .result
676                .ok_or_else(|| crate::error::FinanceError::SymbolNotFound {
677                    symbol: Some(symbol.to_string()),
678                    context: "Chart data not found".to_string(),
679                })?;
680
681        results
682            .into_iter()
683            .next()
684            .ok_or_else(|| crate::error::FinanceError::SymbolNotFound {
685                symbol: Some(symbol.to_string()),
686                context: "Chart data empty".to_string(),
687            })
688    }
689
690    /// Get historical chart data for a custom date range.
691    ///
692    /// Unlike [`chart()`](Self::chart) which uses predefined time ranges,
693    /// this method accepts absolute start/end timestamps for precise date control.
694    ///
695    /// Results are **not cached** since custom ranges have unbounded key space.
696    ///
697    /// # Arguments
698    ///
699    /// * `interval` - Time interval between data points
700    /// * `start` - Start date as Unix timestamp (seconds since epoch)
701    /// * `end` - End date as Unix timestamp (seconds since epoch)
702    ///
703    /// # Example
704    ///
705    /// ```no_run
706    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
707    /// use finance_query::{Ticker, Interval};
708    /// use chrono::NaiveDate;
709    ///
710    /// let ticker = Ticker::new("AAPL").await?;
711    ///
712    /// // Q3 2024
713    /// let start = NaiveDate::from_ymd_opt(2024, 7, 1).unwrap()
714    ///     .and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp();
715    /// let end = NaiveDate::from_ymd_opt(2024, 9, 30).unwrap()
716    ///     .and_hms_opt(23, 59, 59).unwrap().and_utc().timestamp();
717    ///
718    /// let chart = ticker.chart_range(Interval::OneDay, start, end).await?;
719    /// println!("Q3 2024 candles: {}", chart.candles.len());
720    /// # Ok(())
721    /// # }
722    /// ```
723    pub async fn chart_range(&self, interval: Interval, start: i64, end: i64) -> Result<Chart> {
724        let json = self
725            .client
726            .get_chart_range(&self.symbol, interval, start, end)
727            .await?;
728        let chart_result = Self::parse_chart_result(json, &self.symbol)?;
729
730        // Always update events when we have fresh data from Yahoo
731        if let Some(events) = &chart_result.events {
732            let mut events_cache = self.events_cache.write().await;
733            *events_cache = Some(CacheEntry::new(events.clone()));
734        }
735
736        Ok(Chart {
737            symbol: self.symbol.to_string(),
738            meta: chart_result.meta.clone(),
739            candles: chart_result.to_candles(),
740            interval: Some(interval),
741            range: None,
742        })
743    }
744
745    /// Ensures events data is loaded (fetches events only if not cached)
746    async fn ensure_events_loaded(&self) -> Result<()> {
747        // Quick read check
748        {
749            let cache = self.events_cache.read().await;
750            if self.is_cache_fresh(cache.as_ref()) {
751                return Ok(());
752            }
753        }
754
755        // Fetch events using max range with 1d interval to get all historical events
756        // Using 1d interval minimizes candle count compared to shorter intervals
757        let json = crate::endpoints::chart::fetch(
758            &self.client,
759            &self.symbol,
760            Interval::OneDay,
761            TimeRange::Max,
762        )
763        .await?;
764        let chart_result = Self::parse_chart_result(json, &self.symbol)?;
765
766        // Write to events cache unconditionally for temporary storage during this method
767        // Note: when cache_ttl is None, is_cache_fresh() returns false, so this will
768        // be refetched on the next call to dividends()/splits()/capital_gains().
769        // Cache empty ChartEvents when Yahoo returns no events to prevent infinite refetch loops
770        let mut events_cache = self.events_cache.write().await;
771        *events_cache = Some(CacheEntry::new(chart_result.events.unwrap_or_default()));
772
773        Ok(())
774    }
775
776    /// Get dividend history
777    ///
778    /// Returns historical dividend payments sorted by date.
779    /// Events are lazily loaded (fetched once, then filtered by range).
780    ///
781    /// # Arguments
782    ///
783    /// * `range` - Time range to filter dividends
784    ///
785    /// # Example
786    ///
787    /// ```no_run
788    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
789    /// use finance_query::{Ticker, TimeRange};
790    ///
791    /// let ticker = Ticker::new("AAPL").await?;
792    ///
793    /// // Get all dividends
794    /// let all = ticker.dividends(TimeRange::Max).await?;
795    ///
796    /// // Get last year's dividends
797    /// let recent = ticker.dividends(TimeRange::OneYear).await?;
798    /// # Ok(())
799    /// # }
800    /// ```
801    pub async fn dividends(&self, range: TimeRange) -> Result<Vec<Dividend>> {
802        self.ensure_events_loaded().await?;
803
804        let cache = self.events_cache.read().await;
805        let all = cache
806            .as_ref()
807            .map(|e| e.value.to_dividends())
808            .unwrap_or_default();
809
810        Ok(filter_by_range(all, range))
811    }
812
813    /// Get stock split history
814    ///
815    /// Returns historical stock splits sorted by date.
816    /// Events are lazily loaded (fetched once, then filtered by range).
817    ///
818    /// # Arguments
819    ///
820    /// * `range` - Time range to filter splits
821    ///
822    /// # Example
823    ///
824    /// ```no_run
825    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
826    /// use finance_query::{Ticker, TimeRange};
827    ///
828    /// let ticker = Ticker::new("NVDA").await?;
829    ///
830    /// // Get all splits
831    /// let all = ticker.splits(TimeRange::Max).await?;
832    ///
833    /// // Get last 5 years
834    /// let recent = ticker.splits(TimeRange::FiveYears).await?;
835    /// # Ok(())
836    /// # }
837    /// ```
838    pub async fn splits(&self, range: TimeRange) -> Result<Vec<Split>> {
839        self.ensure_events_loaded().await?;
840
841        let cache = self.events_cache.read().await;
842        let all = cache
843            .as_ref()
844            .map(|e| e.value.to_splits())
845            .unwrap_or_default();
846
847        Ok(filter_by_range(all, range))
848    }
849
850    /// Get capital gains distribution history
851    ///
852    /// Returns historical capital gain distributions sorted by date.
853    /// This is primarily relevant for mutual funds and ETFs.
854    /// Events are lazily loaded (fetched once, then filtered by range).
855    ///
856    /// # Arguments
857    ///
858    /// * `range` - Time range to filter capital gains
859    ///
860    /// # Example
861    ///
862    /// ```no_run
863    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
864    /// use finance_query::{Ticker, TimeRange};
865    ///
866    /// let ticker = Ticker::new("VFIAX").await?;
867    ///
868    /// // Get all capital gains
869    /// let all = ticker.capital_gains(TimeRange::Max).await?;
870    ///
871    /// // Get last 2 years
872    /// let recent = ticker.capital_gains(TimeRange::TwoYears).await?;
873    /// # Ok(())
874    /// # }
875    /// ```
876    pub async fn capital_gains(&self, range: TimeRange) -> Result<Vec<CapitalGain>> {
877        self.ensure_events_loaded().await?;
878
879        let cache = self.events_cache.read().await;
880        let all = cache
881            .as_ref()
882            .map(|e| e.value.to_capital_gains())
883            .unwrap_or_default();
884
885        Ok(filter_by_range(all, range))
886    }
887
888    /// Calculate all technical indicators from chart data
889    ///
890    /// # Arguments
891    ///
892    /// * `interval` - The time interval for each candle
893    /// * `range` - The time range to fetch data for
894    ///
895    /// # Returns
896    ///
897    /// Returns `IndicatorsSummary` containing all calculated indicators.
898    ///
899    /// # Example
900    ///
901    /// ```no_run
902    /// use finance_query::{Ticker, Interval, TimeRange};
903    ///
904    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
905    /// let ticker = Ticker::new("AAPL").await?;
906    /// let indicators = ticker.indicators(Interval::OneDay, TimeRange::OneYear).await?;
907    ///
908    /// println!("RSI(14): {:?}", indicators.rsi_14);
909    /// println!("MACD: {:?}", indicators.macd);
910    /// # Ok(())
911    /// # }
912    /// ```
913    #[cfg(feature = "indicators")]
914    pub async fn indicators(
915        &self,
916        interval: Interval,
917        range: TimeRange,
918    ) -> Result<crate::indicators::IndicatorsSummary> {
919        // Check cache first (read lock)
920        {
921            let cache = self.indicators_cache.read().await;
922            if let Some(entry) = cache.get(&(interval, range))
923                && self.is_cache_fresh(Some(entry))
924            {
925                return Ok(entry.value.clone());
926            }
927        }
928
929        // Fetch chart data (this is also cached!)
930        let chart = self.chart(interval, range).await?;
931
932        // Calculate indicators from candles
933        let indicators = crate::indicators::summary::calculate_indicators(&chart.candles);
934
935        // Only clone when caching is enabled
936        if self.cache_ttl.is_some() {
937            let mut cache = self.indicators_cache.write().await;
938            self.cache_insert(&mut cache, (interval, range), indicators.clone());
939            Ok(indicators)
940        } else {
941            Ok(indicators)
942        }
943    }
944
945    /// Calculate a specific technical indicator over a time range.
946    ///
947    /// Returns the full time series for the requested indicator, not just the latest value.
948    /// This is useful when you need historical indicator values for analysis or charting.
949    ///
950    /// # Arguments
951    ///
952    /// * `indicator` - The indicator to calculate (from `crate::indicators::Indicator`)
953    /// * `interval` - Time interval for candles (1d, 1h, etc.)
954    /// * `range` - Time range for historical data
955    ///
956    /// # Returns
957    ///
958    /// An `IndicatorResult` containing the full time series. Access the data using match:
959    /// - `IndicatorResult::Series(values)` - for simple indicators (SMA, EMA, RSI, ATR, OBV, VWAP, WMA)
960    /// - `IndicatorResult::Macd(data)` - for MACD (macd_line, signal_line, histogram)
961    /// - `IndicatorResult::Bollinger(data)` - for Bollinger Bands (upper, middle, lower)
962    ///
963    /// # Example
964    ///
965    /// ```no_run
966    /// use finance_query::{Ticker, Interval, TimeRange};
967    /// use finance_query::indicators::{Indicator, IndicatorResult};
968    ///
969    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
970    /// let ticker = Ticker::new("AAPL").await?;
971    ///
972    /// // Calculate 14-period RSI
973    /// let result = ticker.indicator(
974    ///     Indicator::Rsi(14),
975    ///     Interval::OneDay,
976    ///     TimeRange::ThreeMonths
977    /// ).await?;
978    ///
979    /// match result {
980    ///     IndicatorResult::Series(values) => {
981    ///         println!("Latest RSI: {:?}", values.last());
982    ///     }
983    ///     _ => {}
984    /// }
985    ///
986    /// // Calculate MACD
987    /// let macd_result = ticker.indicator(
988    ///     Indicator::Macd { fast: 12, slow: 26, signal: 9 },
989    ///     Interval::OneDay,
990    ///     TimeRange::SixMonths
991    /// ).await?;
992    ///
993    /// # Ok(())
994    /// # }
995    /// ```
996    #[cfg(feature = "indicators")]
997    pub async fn indicator(
998        &self,
999        indicator: crate::indicators::Indicator,
1000        interval: Interval,
1001        range: TimeRange,
1002    ) -> Result<crate::indicators::IndicatorResult> {
1003        use crate::indicators::{Indicator, IndicatorResult};
1004
1005        // Fetch chart data
1006        let chart = self.chart(interval, range).await?;
1007
1008        // Calculate the requested indicator
1009        // Note: Price vectors are extracted lazily within each arm to avoid waste
1010        let result = match indicator {
1011            Indicator::Sma(period) => IndicatorResult::Series(chart.sma(period)),
1012            Indicator::Ema(period) => IndicatorResult::Series(chart.ema(period)),
1013            Indicator::Rsi(period) => IndicatorResult::Series(chart.rsi(period)?),
1014            Indicator::Macd { fast, slow, signal } => {
1015                IndicatorResult::Macd(chart.macd(fast, slow, signal)?)
1016            }
1017            Indicator::Bollinger { period, std_dev } => {
1018                IndicatorResult::Bollinger(chart.bollinger_bands(period, std_dev)?)
1019            }
1020            Indicator::Atr(period) => IndicatorResult::Series(chart.atr(period)?),
1021            Indicator::Obv => {
1022                let closes = chart.close_prices();
1023                let volumes = chart.volumes();
1024                IndicatorResult::Series(crate::indicators::obv(&closes, &volumes)?)
1025            }
1026            Indicator::Vwap => {
1027                let highs = chart.high_prices();
1028                let lows = chart.low_prices();
1029                let closes = chart.close_prices();
1030                let volumes = chart.volumes();
1031                IndicatorResult::Series(crate::indicators::vwap(&highs, &lows, &closes, &volumes)?)
1032            }
1033            Indicator::Wma(period) => {
1034                let closes = chart.close_prices();
1035                IndicatorResult::Series(crate::indicators::wma(&closes, period)?)
1036            }
1037            Indicator::Dema(period) => {
1038                let closes = chart.close_prices();
1039                IndicatorResult::Series(crate::indicators::dema(&closes, period)?)
1040            }
1041            Indicator::Tema(period) => {
1042                let closes = chart.close_prices();
1043                IndicatorResult::Series(crate::indicators::tema(&closes, period)?)
1044            }
1045            Indicator::Hma(period) => {
1046                let closes = chart.close_prices();
1047                IndicatorResult::Series(crate::indicators::hma(&closes, period)?)
1048            }
1049            Indicator::Vwma(period) => {
1050                let closes = chart.close_prices();
1051                let volumes = chart.volumes();
1052                IndicatorResult::Series(crate::indicators::vwma(&closes, &volumes, period)?)
1053            }
1054            Indicator::Alma {
1055                period,
1056                offset,
1057                sigma,
1058            } => {
1059                let closes = chart.close_prices();
1060                IndicatorResult::Series(crate::indicators::alma(&closes, period, offset, sigma)?)
1061            }
1062            Indicator::McginleyDynamic(period) => {
1063                let closes = chart.close_prices();
1064                IndicatorResult::Series(crate::indicators::mcginley_dynamic(&closes, period)?)
1065            }
1066            Indicator::Stochastic {
1067                k_period,
1068                k_slow: _k_slow,
1069                d_period,
1070            } => {
1071                // TODO: k_slow parameter not yet used (no smoothing applied)
1072                let highs = chart.high_prices();
1073                let lows = chart.low_prices();
1074                let closes = chart.close_prices();
1075                IndicatorResult::Stochastic(crate::indicators::stochastic(
1076                    &highs, &lows, &closes, k_period, d_period,
1077                )?)
1078            }
1079            Indicator::StochasticRsi {
1080                rsi_period,
1081                stoch_period,
1082                k_period: _k_period,
1083                d_period: _d_period,
1084            } => {
1085                // TODO: k_period/d_period smoothing not yet implemented
1086                let closes = chart.close_prices();
1087                IndicatorResult::Series(crate::indicators::stochastic_rsi(
1088                    &closes,
1089                    rsi_period,
1090                    stoch_period,
1091                )?)
1092            }
1093            Indicator::Cci(period) => {
1094                let highs = chart.high_prices();
1095                let lows = chart.low_prices();
1096                let closes = chart.close_prices();
1097                IndicatorResult::Series(crate::indicators::cci(&highs, &lows, &closes, period)?)
1098            }
1099            Indicator::WilliamsR(period) => {
1100                let highs = chart.high_prices();
1101                let lows = chart.low_prices();
1102                let closes = chart.close_prices();
1103                IndicatorResult::Series(crate::indicators::williams_r(
1104                    &highs, &lows, &closes, period,
1105                )?)
1106            }
1107            Indicator::Roc(period) => {
1108                let closes = chart.close_prices();
1109                IndicatorResult::Series(crate::indicators::roc(&closes, period)?)
1110            }
1111            Indicator::Momentum(period) => {
1112                let closes = chart.close_prices();
1113                IndicatorResult::Series(crate::indicators::momentum(&closes, period)?)
1114            }
1115            Indicator::Cmo(period) => {
1116                let closes = chart.close_prices();
1117                IndicatorResult::Series(crate::indicators::cmo(&closes, period)?)
1118            }
1119            Indicator::AwesomeOscillator {
1120                fast: _fast,
1121                slow: _slow,
1122            } => {
1123                // TODO: custom fast/slow periods not yet supported; uses defaults (5, 34)
1124                let highs = chart.high_prices();
1125                let lows = chart.low_prices();
1126                IndicatorResult::Series(crate::indicators::awesome_oscillator(&highs, &lows)?)
1127            }
1128            Indicator::CoppockCurve {
1129                wma_period: _wma_period,
1130                long_roc: _long_roc,
1131                short_roc: _short_roc,
1132            } => {
1133                // TODO: custom wma_period/long_roc/short_roc not yet supported; uses defaults (10, 14, 11)
1134                let closes = chart.close_prices();
1135                IndicatorResult::Series(crate::indicators::coppock_curve(&closes)?)
1136            }
1137            Indicator::Adx(period) => {
1138                let highs = chart.high_prices();
1139                let lows = chart.low_prices();
1140                let closes = chart.close_prices();
1141                IndicatorResult::Series(crate::indicators::adx(&highs, &lows, &closes, period)?)
1142            }
1143            Indicator::Aroon(period) => {
1144                let highs = chart.high_prices();
1145                let lows = chart.low_prices();
1146                IndicatorResult::Aroon(crate::indicators::aroon(&highs, &lows, period)?)
1147            }
1148            Indicator::Supertrend { period, multiplier } => {
1149                let highs = chart.high_prices();
1150                let lows = chart.low_prices();
1151                let closes = chart.close_prices();
1152                IndicatorResult::SuperTrend(crate::indicators::supertrend(
1153                    &highs, &lows, &closes, period, multiplier,
1154                )?)
1155            }
1156            Indicator::Ichimoku {
1157                conversion: _conversion,
1158                base: _base,
1159                lagging: _lagging,
1160                displacement: _displacement,
1161            } => {
1162                // TODO: custom periods not yet supported; uses traditional values (9, 26, 52, 26)
1163                let highs = chart.high_prices();
1164                let lows = chart.low_prices();
1165                let closes = chart.close_prices();
1166                IndicatorResult::Ichimoku(crate::indicators::ichimoku(&highs, &lows, &closes)?)
1167            }
1168            Indicator::ParabolicSar { step, max } => {
1169                let highs = chart.high_prices();
1170                let lows = chart.low_prices();
1171                let closes = chart.close_prices();
1172                IndicatorResult::Series(crate::indicators::parabolic_sar(
1173                    &highs, &lows, &closes, step, max,
1174                )?)
1175            }
1176            Indicator::BullBearPower(_period) => {
1177                // TODO: period parameter not yet used; currently uses EMA(13) internally
1178                let highs = chart.high_prices();
1179                let lows = chart.low_prices();
1180                let closes = chart.close_prices();
1181                IndicatorResult::BullBearPower(crate::indicators::bull_bear_power(
1182                    &highs, &lows, &closes,
1183                )?)
1184            }
1185            Indicator::ElderRay(_period) => {
1186                // TODO: period parameter not yet used; currently uses EMA(13) internally
1187                let highs = chart.high_prices();
1188                let lows = chart.low_prices();
1189                let closes = chart.close_prices();
1190                IndicatorResult::ElderRay(crate::indicators::elder_ray(&highs, &lows, &closes)?)
1191            }
1192            Indicator::KeltnerChannels {
1193                period,
1194                multiplier,
1195                atr_period,
1196            } => {
1197                let highs = chart.high_prices();
1198                let lows = chart.low_prices();
1199                let closes = chart.close_prices();
1200                IndicatorResult::Keltner(crate::indicators::keltner_channels(
1201                    &highs, &lows, &closes, period, atr_period, multiplier,
1202                )?)
1203            }
1204            Indicator::DonchianChannels(period) => {
1205                let highs = chart.high_prices();
1206                let lows = chart.low_prices();
1207                IndicatorResult::Donchian(crate::indicators::donchian_channels(
1208                    &highs, &lows, period,
1209                )?)
1210            }
1211            Indicator::TrueRange => {
1212                let highs = chart.high_prices();
1213                let lows = chart.low_prices();
1214                let closes = chart.close_prices();
1215                IndicatorResult::Series(crate::indicators::true_range(&highs, &lows, &closes)?)
1216            }
1217            Indicator::ChoppinessIndex(period) => {
1218                let highs = chart.high_prices();
1219                let lows = chart.low_prices();
1220                let closes = chart.close_prices();
1221                IndicatorResult::Series(crate::indicators::choppiness_index(
1222                    &highs, &lows, &closes, period,
1223                )?)
1224            }
1225            Indicator::Mfi(period) => {
1226                let highs = chart.high_prices();
1227                let lows = chart.low_prices();
1228                let closes = chart.close_prices();
1229                let volumes = chart.volumes();
1230                IndicatorResult::Series(crate::indicators::mfi(
1231                    &highs, &lows, &closes, &volumes, period,
1232                )?)
1233            }
1234            Indicator::Cmf(period) => {
1235                let highs = chart.high_prices();
1236                let lows = chart.low_prices();
1237                let closes = chart.close_prices();
1238                let volumes = chart.volumes();
1239                IndicatorResult::Series(crate::indicators::cmf(
1240                    &highs, &lows, &closes, &volumes, period,
1241                )?)
1242            }
1243            Indicator::ChaikinOscillator => {
1244                let highs = chart.high_prices();
1245                let lows = chart.low_prices();
1246                let closes = chart.close_prices();
1247                let volumes = chart.volumes();
1248                IndicatorResult::Series(crate::indicators::chaikin_oscillator(
1249                    &highs, &lows, &closes, &volumes,
1250                )?)
1251            }
1252            Indicator::AccumulationDistribution => {
1253                let highs = chart.high_prices();
1254                let lows = chart.low_prices();
1255                let closes = chart.close_prices();
1256                let volumes = chart.volumes();
1257                IndicatorResult::Series(crate::indicators::accumulation_distribution(
1258                    &highs, &lows, &closes, &volumes,
1259                )?)
1260            }
1261            Indicator::BalanceOfPower(period) => {
1262                let opens = chart.open_prices();
1263                let highs = chart.high_prices();
1264                let lows = chart.low_prices();
1265                let closes = chart.close_prices();
1266                IndicatorResult::Series(crate::indicators::balance_of_power(
1267                    &opens, &highs, &lows, &closes, period,
1268                )?)
1269            }
1270        };
1271
1272        Ok(result)
1273    }
1274
1275    /// Get analyst recommendations
1276    pub async fn recommendations(&self, limit: u32) -> Result<Recommendation> {
1277        // Check cache (always fetches max from server, truncated to limit on return)
1278        {
1279            let cache = self.recommendations_cache.read().await;
1280            if let Some(entry) = cache.as_ref()
1281                && self.is_cache_fresh(Some(entry))
1282            {
1283                return Ok(self.build_recommendation_with_limit(&entry.value, limit));
1284            }
1285        }
1286
1287        // Always fetch server maximum (no limit restriction to maximize cache utility)
1288        let json = self.client.get_recommendations(&self.symbol, 15).await?;
1289        let response = RecommendationResponse::from_json(json).map_err(|e| {
1290            crate::error::FinanceError::ResponseStructureError {
1291                field: "finance".to_string(),
1292                context: e.to_string(),
1293            }
1294        })?;
1295
1296        // Cache full response, return truncated result
1297        if self.cache_ttl.is_some() {
1298            let mut cache = self.recommendations_cache.write().await;
1299            *cache = Some(CacheEntry::new(response));
1300            let entry = cache.as_ref().unwrap();
1301            return Ok(self.build_recommendation_with_limit(&entry.value, limit));
1302        }
1303
1304        Ok(self.build_recommendation_with_limit(&response, limit))
1305    }
1306
1307    /// Get financial statements
1308    ///
1309    /// # Arguments
1310    ///
1311    /// * `statement_type` - Type of statement (Income, Balance, CashFlow)
1312    /// * `frequency` - Annual or Quarterly
1313    ///
1314    /// # Example
1315    ///
1316    /// ```no_run
1317    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1318    /// use finance_query::{Ticker, Frequency, StatementType};
1319    ///
1320    /// let ticker = Ticker::new("AAPL").await?;
1321    /// let income = ticker.financials(StatementType::Income, Frequency::Annual).await?;
1322    /// println!("Revenue: {:?}", income.statement.get("TotalRevenue"));
1323    /// # Ok(())
1324    /// # }
1325    /// ```
1326    pub async fn financials(
1327        &self,
1328        statement_type: crate::constants::StatementType,
1329        frequency: crate::constants::Frequency,
1330    ) -> Result<FinancialStatement> {
1331        let cache_key = (statement_type, frequency);
1332
1333        // Check cache
1334        {
1335            let cache = self.financials_cache.read().await;
1336            if let Some(entry) = cache.get(&cache_key)
1337                && self.is_cache_fresh(Some(entry))
1338            {
1339                return Ok(entry.value.clone());
1340            }
1341        }
1342
1343        // Fetch financials
1344        let financials = self
1345            .client
1346            .get_financials(&self.symbol, statement_type, frequency)
1347            .await?;
1348
1349        // Only clone when caching is enabled
1350        if self.cache_ttl.is_some() {
1351            let mut cache = self.financials_cache.write().await;
1352            self.cache_insert(&mut cache, cache_key, financials.clone());
1353            Ok(financials)
1354        } else {
1355            Ok(financials)
1356        }
1357    }
1358
1359    /// Get news articles for this symbol
1360    ///
1361    /// # Example
1362    ///
1363    /// ```no_run
1364    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1365    /// use finance_query::Ticker;
1366    ///
1367    /// let ticker = Ticker::new("AAPL").await?;
1368    /// let news = ticker.news().await?;
1369    /// for article in news {
1370    ///     println!("{}: {}", article.source, article.title);
1371    /// }
1372    /// # Ok(())
1373    /// # }
1374    /// ```
1375    pub async fn news(&self) -> Result<Vec<crate::models::news::News>> {
1376        // Check cache
1377        {
1378            let cache = self.news_cache.read().await;
1379            if self.is_cache_fresh(cache.as_ref()) {
1380                return Ok(cache.as_ref().unwrap().value.clone());
1381            }
1382        }
1383
1384        // Fetch news
1385        let news = crate::scrapers::stockanalysis::scrape_symbol_news(&self.symbol).await?;
1386
1387        // Only clone when caching is enabled
1388        if self.cache_ttl.is_some() {
1389            let mut cache = self.news_cache.write().await;
1390            *cache = Some(CacheEntry::new(news.clone()));
1391            Ok(news)
1392        } else {
1393            Ok(news)
1394        }
1395    }
1396
1397    /// Get options chain
1398    pub async fn options(&self, date: Option<i64>) -> Result<Options> {
1399        // Check cache
1400        {
1401            let cache = self.options_cache.read().await;
1402            if let Some(entry) = cache.get(&date)
1403                && self.is_cache_fresh(Some(entry))
1404            {
1405                return Ok(entry.value.clone());
1406            }
1407        }
1408
1409        // Fetch options
1410        let json = self.client.get_options(&self.symbol, date).await?;
1411        let options: Options = serde_json::from_value(json).map_err(|e| {
1412            crate::error::FinanceError::ResponseStructureError {
1413                field: "options".to_string(),
1414                context: e.to_string(),
1415            }
1416        })?;
1417
1418        // Only clone when caching is enabled
1419        if self.cache_ttl.is_some() {
1420            let mut cache = self.options_cache.write().await;
1421            self.cache_insert(&mut cache, date, options.clone());
1422            Ok(options)
1423        } else {
1424            Ok(options)
1425        }
1426    }
1427
1428    /// Run a backtest with the given strategy and configuration.
1429    ///
1430    /// # Arguments
1431    ///
1432    /// * `strategy` - Trading strategy implementing the Strategy trait
1433    /// * `interval` - Candle interval (1d, 1h, etc.)
1434    /// * `range` - Time range for historical data
1435    /// * `config` - Backtest configuration (optional, uses defaults if None)
1436    ///
1437    /// # Example
1438    ///
1439    /// ```no_run
1440    /// use finance_query::{Ticker, Interval, TimeRange};
1441    /// use finance_query::backtesting::{SmaCrossover, BacktestConfig};
1442    ///
1443    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1444    /// let ticker = Ticker::new("AAPL").await?;
1445    ///
1446    /// // Simple backtest with defaults
1447    /// let strategy = SmaCrossover::new(10, 20);
1448    /// let result = ticker.backtest(
1449    ///     strategy,
1450    ///     Interval::OneDay,
1451    ///     TimeRange::OneYear,
1452    ///     None,
1453    /// ).await?;
1454    ///
1455    /// println!("{}", result.summary());
1456    /// println!("Total trades: {}", result.trades.len());
1457    ///
1458    /// // With custom config
1459    /// let config = BacktestConfig::builder()
1460    ///     .initial_capital(50_000.0)
1461    ///     .commission_pct(0.001)
1462    ///     .stop_loss_pct(0.05)
1463    ///     .allow_short(true)
1464    ///     .build()?;
1465    ///
1466    /// let result = ticker.backtest(
1467    ///     SmaCrossover::new(5, 20).with_short(true),
1468    ///     Interval::OneDay,
1469    ///     TimeRange::TwoYears,
1470    ///     Some(config),
1471    /// ).await?;
1472    /// # Ok(())
1473    /// # }
1474    /// ```
1475    #[cfg(feature = "backtesting")]
1476    pub async fn backtest<S: crate::backtesting::Strategy>(
1477        &self,
1478        strategy: S,
1479        interval: Interval,
1480        range: TimeRange,
1481        config: Option<crate::backtesting::BacktestConfig>,
1482    ) -> crate::backtesting::Result<crate::backtesting::BacktestResult> {
1483        use crate::backtesting::BacktestEngine;
1484
1485        let config = config.unwrap_or_default();
1486        config.validate()?;
1487
1488        // Fetch chart data
1489        let chart = self
1490            .chart(interval, range)
1491            .await
1492            .map_err(|e| crate::backtesting::BacktestError::ChartError(e.to_string()))?;
1493
1494        // Run backtest engine
1495        let engine = BacktestEngine::new(config);
1496        engine.run(&self.symbol, &chart.candles, strategy)
1497    }
1498
1499    // ========================================================================
1500    // SEC EDGAR
1501    // ========================================================================
1502
1503    /// Get SEC EDGAR filing history for this symbol.
1504    ///
1505    /// Returns company metadata and recent filings. Results are cached for
1506    /// the lifetime of this `Ticker` instance.
1507    ///
1508    /// Requires EDGAR to be initialized via `edgar::init(email)`.
1509    ///
1510    /// # Example
1511    ///
1512    /// ```no_run
1513    /// use finance_query::{Ticker, edgar};
1514    ///
1515    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1516    /// edgar::init("user@example.com")?;
1517    /// let ticker = Ticker::new("AAPL").await?;
1518    ///
1519    /// let submissions = ticker.edgar_submissions().await?;
1520    /// println!("Company: {:?}", submissions.name);
1521    /// # Ok(())
1522    /// # }
1523    /// ```
1524    pub async fn edgar_submissions(&self) -> Result<crate::models::edgar::EdgarSubmissions> {
1525        // Check cache
1526        {
1527            let cache = self.edgar_submissions_cache.read().await;
1528            if self.is_cache_fresh(cache.as_ref()) {
1529                return Ok(cache.as_ref().unwrap().value.clone());
1530            }
1531        }
1532
1533        // Fetch using singleton
1534        let cik = crate::edgar::resolve_cik(&self.symbol).await?;
1535        let submissions = crate::edgar::submissions(cik).await?;
1536
1537        // Only clone when caching is enabled
1538        if self.cache_ttl.is_some() {
1539            let mut cache = self.edgar_submissions_cache.write().await;
1540            *cache = Some(CacheEntry::new(submissions.clone()));
1541            Ok(submissions)
1542        } else {
1543            Ok(submissions)
1544        }
1545    }
1546
1547    /// Get SEC EDGAR company facts (structured XBRL financial data) for this symbol.
1548    ///
1549    /// Returns all extracted XBRL facts organized by taxonomy. Results are cached
1550    /// for the lifetime of this `Ticker` instance.
1551    ///
1552    /// Requires EDGAR to be initialized via `edgar::init(email)`.
1553    ///
1554    /// # Example
1555    ///
1556    /// ```no_run
1557    /// use finance_query::{Ticker, edgar};
1558    ///
1559    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1560    /// edgar::init("user@example.com")?;
1561    /// let ticker = Ticker::new("AAPL").await?;
1562    ///
1563    /// let facts = ticker.edgar_company_facts().await?;
1564    /// if let Some(revenue) = facts.get_us_gaap_fact("Revenue") {
1565    ///     println!("Revenue data points: {:?}", revenue.units.keys().collect::<Vec<_>>());
1566    /// }
1567    /// # Ok(())
1568    /// # }
1569    /// ```
1570    pub async fn edgar_company_facts(&self) -> Result<crate::models::edgar::CompanyFacts> {
1571        // Check cache
1572        {
1573            let cache = self.edgar_facts_cache.read().await;
1574            if self.is_cache_fresh(cache.as_ref()) {
1575                return Ok(cache.as_ref().unwrap().value.clone());
1576            }
1577        }
1578
1579        // Fetch using singleton
1580        let cik = crate::edgar::resolve_cik(&self.symbol).await?;
1581        let facts = crate::edgar::company_facts(cik).await?;
1582
1583        // Only clone when caching is enabled
1584        if self.cache_ttl.is_some() {
1585            let mut cache = self.edgar_facts_cache.write().await;
1586            *cache = Some(CacheEntry::new(facts.clone()));
1587            Ok(facts)
1588        } else {
1589            Ok(facts)
1590        }
1591    }
1592
1593    // ========================================================================
1594    // Cache Management
1595    // ========================================================================
1596
1597    /// Clear all cached data, forcing fresh fetches on next access.
1598    ///
1599    /// Use this when you need up-to-date data from a long-lived `Ticker` instance.
1600    ///
1601    /// # Example
1602    ///
1603    /// ```no_run
1604    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1605    /// use finance_query::Ticker;
1606    ///
1607    /// let ticker = Ticker::new("AAPL").await?;
1608    /// let quote = ticker.quote().await?; // fetches from API
1609    ///
1610    /// // ... some time later ...
1611    /// ticker.clear_cache().await;
1612    /// let fresh_quote = ticker.quote().await?; // fetches again
1613    /// # Ok(())
1614    /// # }
1615    /// ```
1616    pub async fn clear_cache(&self) {
1617        // Acquire all independent write locks in parallel
1618        tokio::join!(
1619            async {
1620                *self.quote_summary.write().await = None;
1621            },
1622            async {
1623                self.chart_cache.write().await.clear();
1624            },
1625            async {
1626                *self.events_cache.write().await = None;
1627            },
1628            async {
1629                *self.recommendations_cache.write().await = None;
1630            },
1631            async {
1632                *self.news_cache.write().await = None;
1633            },
1634            async {
1635                self.options_cache.write().await.clear();
1636            },
1637            async {
1638                self.financials_cache.write().await.clear();
1639            },
1640            async {
1641                *self.edgar_submissions_cache.write().await = None;
1642            },
1643            async {
1644                *self.edgar_facts_cache.write().await = None;
1645            },
1646            async {
1647                #[cfg(feature = "indicators")]
1648                self.indicators_cache.write().await.clear();
1649            },
1650        );
1651    }
1652
1653    /// Clear only the cached quote summary data.
1654    ///
1655    /// The next call to any quote accessor (e.g., `price()`, `financial_data()`)
1656    /// will re-fetch all quote modules from the API.
1657    pub async fn clear_quote_cache(&self) {
1658        *self.quote_summary.write().await = None;
1659    }
1660
1661    /// Clear only the cached chart and events data.
1662    ///
1663    /// The next call to `chart()`, `dividends()`, `splits()`, or `capital_gains()`
1664    /// will re-fetch from the API.
1665    pub async fn clear_chart_cache(&self) {
1666        tokio::join!(
1667            async {
1668                self.chart_cache.write().await.clear();
1669            },
1670            async {
1671                *self.events_cache.write().await = None;
1672            },
1673            async {
1674                #[cfg(feature = "indicators")]
1675                self.indicators_cache.write().await.clear();
1676            }
1677        );
1678    }
1679}