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        // For intraday intervals with ranges that exceed Yahoo's native support, bypass
631        // get_chart entirely and use chart_range so chunking is applied automatically.
632        if let Some((max_secs, native_ranges)) = crate::endpoints::chart::intraday_limit(interval)
633            && !native_ranges.contains(&range)
634        {
635            // Small buffer so the request window is strictly inside the hard limit.
636            const RANGE_BOUNDARY_BUFFER_SECS: i64 = 5;
637            let end = crate::utils::now_unix_secs();
638            let start = end - max_secs + RANGE_BOUNDARY_BUFFER_SECS;
639            return self.chart_range(interval, start, end).await;
640        }
641
642        // Fetch from Yahoo
643        let json = self.client.get_chart(&self.symbol, interval, range).await?;
644        let chart_result = Self::parse_chart_result(json, &self.symbol)?;
645
646        // Always update events when we have fresh data from Yahoo
647        if let Some(events) = &chart_result.events {
648            let mut events_cache = self.events_cache.write().await;
649            *events_cache = Some(CacheEntry::new(events.clone()));
650        }
651
652        // Materialize Chart from raw result — this is the only place to_candles() runs
653        let chart = Chart {
654            symbol: self.symbol.to_string(),
655            meta: chart_result.meta.clone(),
656            candles: chart_result.to_candles(),
657            interval: Some(interval),
658            range: Some(range),
659        };
660
661        // Only clone when caching is enabled to avoid unnecessary allocations
662        if self.cache_ttl.is_some() {
663            let ret = chart.clone();
664            let mut cache = self.chart_cache.write().await;
665            self.cache_insert(&mut cache, (interval, range), chart);
666            Ok(ret)
667        } else {
668            Ok(chart)
669        }
670    }
671
672    /// Parse a ChartResult from raw JSON, returning a descriptive error on failure.
673    fn parse_chart_result(
674        json: serde_json::Value,
675        symbol: &str,
676    ) -> Result<crate::models::chart::result::ChartResult> {
677        let response = ChartResponse::from_json(json).map_err(|e| {
678            crate::error::FinanceError::ResponseStructureError {
679                field: "chart".to_string(),
680                context: e.to_string(),
681            }
682        })?;
683
684        let results =
685            response
686                .chart
687                .result
688                .ok_or_else(|| crate::error::FinanceError::SymbolNotFound {
689                    symbol: Some(symbol.to_string()),
690                    context: "Chart data not found".to_string(),
691                })?;
692
693        results
694            .into_iter()
695            .next()
696            .ok_or_else(|| crate::error::FinanceError::SymbolNotFound {
697                symbol: Some(symbol.to_string()),
698                context: "Chart data empty".to_string(),
699            })
700    }
701
702    /// Get historical chart data for a custom date range.
703    ///
704    /// Unlike [`chart()`](Self::chart) which uses predefined time ranges,
705    /// this method accepts absolute start/end timestamps for precise date control.
706    ///
707    /// Results are **not cached** since custom ranges have unbounded key space.
708    /// For intraday intervals whose span exceeds Yahoo's per-request limit, the
709    /// range is automatically split into parallel chunks and merged transparently.
710    ///
711    /// # Arguments
712    ///
713    /// * `interval` - Time interval between data points
714    /// * `start` - Start date as Unix timestamp (seconds since epoch)
715    /// * `end` - End date as Unix timestamp (seconds since epoch)
716    ///
717    /// # Example
718    ///
719    /// ```no_run
720    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
721    /// use finance_query::{Ticker, Interval};
722    /// use chrono::NaiveDate;
723    ///
724    /// let ticker = Ticker::new("AAPL").await?;
725    ///
726    /// // Q3 2024
727    /// let start = NaiveDate::from_ymd_opt(2024, 7, 1).unwrap()
728    ///     .and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp();
729    /// let end = NaiveDate::from_ymd_opt(2024, 9, 30).unwrap()
730    ///     .and_hms_opt(23, 59, 59).unwrap().and_utc().timestamp();
731    ///
732    /// let chart = ticker.chart_range(Interval::OneDay, start, end).await?;
733    /// println!("Q3 2024 candles: {}", chart.candles.len());
734    /// # Ok(())
735    /// # }
736    /// ```
737    pub async fn chart_range(&self, interval: Interval, start: i64, end: i64) -> Result<Chart> {
738        if start >= end {
739            return Err(crate::error::FinanceError::InvalidParameter {
740                param: "end".to_string(),
741                reason: format!("`end` ({end}) must be greater than `start` ({start})"),
742            });
743        }
744
745        // Auto-chunk when the span exceeds Yahoo's per-request limit for this interval
746        if let Some(chunk_secs) = crate::endpoints::chart::intraday_chunk_secs(interval)
747            && end - start > chunk_secs
748        {
749            return self
750                .chart_range_chunked(interval, start, end, chunk_secs)
751                .await;
752        }
753
754        let json = self
755            .client
756            .get_chart_range(&self.symbol, interval, start, end)
757            .await?;
758        let chart_result = Self::parse_chart_result(json, &self.symbol)?;
759
760        // Always update events when we have fresh data from Yahoo
761        if let Some(events) = &chart_result.events {
762            let mut events_cache = self.events_cache.write().await;
763            *events_cache = Some(CacheEntry::new(events.clone()));
764        }
765
766        Ok(Chart {
767            symbol: self.symbol.to_string(),
768            meta: chart_result.meta.clone(),
769            candles: chart_result.to_candles(),
770            interval: Some(interval),
771            range: None,
772        })
773    }
774
775    /// Fetch a large intraday date range by splitting it into parallel chunks.
776    ///
777    /// Yahoo Finance rejects requests with a span > ~8 days for sub-hour intervals.
778    /// This splits the window into `chunk_secs`-sized slices, fetches them concurrently,
779    /// and merges the candles (sorted, deduplicated by timestamp).
780    async fn chart_range_chunked(
781        &self,
782        interval: Interval,
783        start: i64,
784        end: i64,
785        chunk_secs: i64,
786    ) -> Result<Chart> {
787        // Build chunk boundaries
788        let mut chunks: Vec<(i64, i64)> = Vec::new();
789        let mut s = start;
790        while s < end {
791            chunks.push((s, (s + chunk_secs).min(end)));
792            s += chunk_secs;
793        }
794
795        // Fetch all chunks in parallel
796        let fetches: Vec<_> = chunks
797            .iter()
798            .map(|&(s, e)| self.client.get_chart_range(&self.symbol, interval, s, e))
799            .collect();
800
801        let results = futures::future::join_all(fetches).await;
802
803        // Parse and merge
804        let mut all_candles: Vec<crate::models::chart::Candle> = Vec::new();
805        let mut base_meta: Option<crate::models::chart::ChartMeta> = None;
806        let mut accumulated_events: Option<crate::models::chart::events::ChartEvents> = None;
807
808        for result in results {
809            // Skip empty chunks (e.g. weekend/holiday windows) rather than failing the whole call
810            let json = match result {
811                Ok(j) => j,
812                Err(e) => {
813                    tracing::warn!("Skipping failed chunk for {}: {}", self.symbol, e);
814                    continue;
815                }
816            };
817            let chart_result = match Self::parse_chart_result(json, &self.symbol) {
818                Ok(r) => r,
819                Err(e) => {
820                    tracing::warn!("Skipping unparseable chunk for {}: {}", self.symbol, e);
821                    continue;
822                }
823            };
824
825            if base_meta.is_none() {
826                base_meta = Some(chart_result.meta.clone());
827            }
828
829            // Accumulate events across all chunks — each chunk covers only its own window,
830            // so events (dividends, splits) may appear in any chunk.
831            let candles = chart_result.to_candles();
832            if let Some(events) = chart_result.events {
833                match &mut accumulated_events {
834                    None => accumulated_events = Some(events),
835                    Some(acc) => {
836                        acc.dividends.extend(events.dividends);
837                        acc.splits.extend(events.splits);
838                        acc.capital_gains.extend(events.capital_gains);
839                    }
840                }
841            }
842            all_candles.extend(candles);
843        }
844
845        // Write merged events cache once after all chunks are processed
846        if let Some(events) = accumulated_events {
847            let mut events_cache = self.events_cache.write().await;
848            *events_cache = Some(CacheEntry::new(events));
849        }
850
851        // Sort and deduplicate (chunk boundaries may overlap by one candle)
852        all_candles.sort_unstable_by_key(|c| c.timestamp);
853        all_candles.dedup_by_key(|c| c.timestamp);
854
855        let meta = base_meta.ok_or_else(|| crate::error::FinanceError::SymbolNotFound {
856            symbol: Some(self.symbol.to_string()),
857            context: "No chart data returned across all chunks".to_string(),
858        })?;
859
860        Ok(Chart {
861            symbol: self.symbol.to_string(),
862            meta,
863            candles: all_candles,
864            interval: Some(interval),
865            range: None,
866        })
867    }
868
869    /// Ensures events data is loaded (fetches events only if not cached)
870    async fn ensure_events_loaded(&self) -> Result<()> {
871        // Quick read check
872        {
873            let cache = self.events_cache.read().await;
874            if self.is_cache_fresh(cache.as_ref()) {
875                return Ok(());
876            }
877        }
878
879        // Fetch events using max range with 1d interval to get all historical events
880        // Using 1d interval minimizes candle count compared to shorter intervals
881        let json = crate::endpoints::chart::fetch(
882            &self.client,
883            &self.symbol,
884            Interval::OneDay,
885            TimeRange::Max,
886        )
887        .await?;
888        let chart_result = Self::parse_chart_result(json, &self.symbol)?;
889
890        // Write to events cache unconditionally for temporary storage during this method
891        // Note: when cache_ttl is None, is_cache_fresh() returns false, so this will
892        // be refetched on the next call to dividends()/splits()/capital_gains().
893        // Cache empty ChartEvents when Yahoo returns no events to prevent infinite refetch loops
894        let mut events_cache = self.events_cache.write().await;
895        *events_cache = Some(CacheEntry::new(chart_result.events.unwrap_or_default()));
896
897        Ok(())
898    }
899
900    /// Get dividend history
901    ///
902    /// Returns historical dividend payments sorted by date.
903    /// Events are lazily loaded (fetched once, then filtered by range).
904    ///
905    /// # Arguments
906    ///
907    /// * `range` - Time range to filter dividends
908    ///
909    /// # Example
910    ///
911    /// ```no_run
912    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
913    /// use finance_query::{Ticker, TimeRange};
914    ///
915    /// let ticker = Ticker::new("AAPL").await?;
916    ///
917    /// // Get all dividends
918    /// let all = ticker.dividends(TimeRange::Max).await?;
919    ///
920    /// // Get last year's dividends
921    /// let recent = ticker.dividends(TimeRange::OneYear).await?;
922    /// # Ok(())
923    /// # }
924    /// ```
925    pub async fn dividends(&self, range: TimeRange) -> Result<Vec<Dividend>> {
926        self.ensure_events_loaded().await?;
927
928        let cache = self.events_cache.read().await;
929        let all = cache
930            .as_ref()
931            .map(|e| e.value.to_dividends())
932            .unwrap_or_default();
933
934        Ok(filter_by_range(all, range))
935    }
936
937    /// Compute dividend analytics for the requested time range.
938    ///
939    /// Calculates statistics on the dividend history: total paid, payment count,
940    /// average payment, and Compound Annual Growth Rate (CAGR).
941    ///
942    /// **CAGR note:** requires at least two payments spanning at least one calendar year.
943    ///
944    /// # Arguments
945    ///
946    /// * `range` - Time range to analyse
947    ///
948    /// # Example
949    ///
950    /// ```no_run
951    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
952    /// use finance_query::{Ticker, TimeRange};
953    ///
954    /// let ticker = Ticker::new("AAPL").await?;
955    /// let analytics = ticker.dividend_analytics(TimeRange::FiveYears).await?;
956    ///
957    /// println!("Total paid: ${:.2}", analytics.total_paid);
958    /// println!("Payments:   {}", analytics.payment_count);
959    /// if let Some(cagr) = analytics.cagr {
960    ///     println!("CAGR:       {:.1}%", cagr * 100.0);
961    /// }
962    /// # Ok(())
963    /// # }
964    /// ```
965    pub async fn dividend_analytics(
966        &self,
967        range: TimeRange,
968    ) -> Result<crate::models::chart::DividendAnalytics> {
969        let dividends = self.dividends(range).await?;
970        Ok(crate::models::chart::DividendAnalytics::from_dividends(
971            &dividends,
972        ))
973    }
974
975    /// Get stock split history
976    ///
977    /// Returns historical stock splits sorted by date.
978    /// Events are lazily loaded (fetched once, then filtered by range).
979    ///
980    /// # Arguments
981    ///
982    /// * `range` - Time range to filter splits
983    ///
984    /// # Example
985    ///
986    /// ```no_run
987    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
988    /// use finance_query::{Ticker, TimeRange};
989    ///
990    /// let ticker = Ticker::new("NVDA").await?;
991    ///
992    /// // Get all splits
993    /// let all = ticker.splits(TimeRange::Max).await?;
994    ///
995    /// // Get last 5 years
996    /// let recent = ticker.splits(TimeRange::FiveYears).await?;
997    /// # Ok(())
998    /// # }
999    /// ```
1000    pub async fn splits(&self, range: TimeRange) -> Result<Vec<Split>> {
1001        self.ensure_events_loaded().await?;
1002
1003        let cache = self.events_cache.read().await;
1004        let all = cache
1005            .as_ref()
1006            .map(|e| e.value.to_splits())
1007            .unwrap_or_default();
1008
1009        Ok(filter_by_range(all, range))
1010    }
1011
1012    /// Get capital gains distribution history
1013    ///
1014    /// Returns historical capital gain distributions sorted by date.
1015    /// This is primarily relevant for mutual funds and ETFs.
1016    /// Events are lazily loaded (fetched once, then filtered by range).
1017    ///
1018    /// # Arguments
1019    ///
1020    /// * `range` - Time range to filter capital gains
1021    ///
1022    /// # Example
1023    ///
1024    /// ```no_run
1025    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1026    /// use finance_query::{Ticker, TimeRange};
1027    ///
1028    /// let ticker = Ticker::new("VFIAX").await?;
1029    ///
1030    /// // Get all capital gains
1031    /// let all = ticker.capital_gains(TimeRange::Max).await?;
1032    ///
1033    /// // Get last 2 years
1034    /// let recent = ticker.capital_gains(TimeRange::TwoYears).await?;
1035    /// # Ok(())
1036    /// # }
1037    /// ```
1038    pub async fn capital_gains(&self, range: TimeRange) -> Result<Vec<CapitalGain>> {
1039        self.ensure_events_loaded().await?;
1040
1041        let cache = self.events_cache.read().await;
1042        let all = cache
1043            .as_ref()
1044            .map(|e| e.value.to_capital_gains())
1045            .unwrap_or_default();
1046
1047        Ok(filter_by_range(all, range))
1048    }
1049
1050    /// Calculate all technical indicators from chart data
1051    ///
1052    /// # Arguments
1053    ///
1054    /// * `interval` - The time interval for each candle
1055    /// * `range` - The time range to fetch data for
1056    ///
1057    /// # Returns
1058    ///
1059    /// Returns `IndicatorsSummary` containing all calculated indicators.
1060    ///
1061    /// # Example
1062    ///
1063    /// ```no_run
1064    /// use finance_query::{Ticker, Interval, TimeRange};
1065    ///
1066    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1067    /// let ticker = Ticker::new("AAPL").await?;
1068    /// let indicators = ticker.indicators(Interval::OneDay, TimeRange::OneYear).await?;
1069    ///
1070    /// println!("RSI(14): {:?}", indicators.rsi_14);
1071    /// println!("MACD: {:?}", indicators.macd);
1072    /// # Ok(())
1073    /// # }
1074    /// ```
1075    #[cfg(feature = "indicators")]
1076    pub async fn indicators(
1077        &self,
1078        interval: Interval,
1079        range: TimeRange,
1080    ) -> Result<crate::indicators::IndicatorsSummary> {
1081        // Check cache first (read lock)
1082        {
1083            let cache = self.indicators_cache.read().await;
1084            if let Some(entry) = cache.get(&(interval, range))
1085                && self.is_cache_fresh(Some(entry))
1086            {
1087                return Ok(entry.value.clone());
1088            }
1089        }
1090
1091        // Fetch chart data (this is also cached!)
1092        let chart = self.chart(interval, range).await?;
1093
1094        // Calculate indicators from candles
1095        let indicators = crate::indicators::summary::calculate_indicators(&chart.candles);
1096
1097        // Only clone when caching is enabled
1098        if self.cache_ttl.is_some() {
1099            let mut cache = self.indicators_cache.write().await;
1100            self.cache_insert(&mut cache, (interval, range), indicators.clone());
1101            Ok(indicators)
1102        } else {
1103            Ok(indicators)
1104        }
1105    }
1106
1107    /// Calculate a specific technical indicator over a time range.
1108    ///
1109    /// Returns the full time series for the requested indicator, not just the latest value.
1110    /// This is useful when you need historical indicator values for analysis or charting.
1111    ///
1112    /// # Arguments
1113    ///
1114    /// * `indicator` - The indicator to calculate (from `crate::indicators::Indicator`)
1115    /// * `interval` - Time interval for candles (1d, 1h, etc.)
1116    /// * `range` - Time range for historical data
1117    ///
1118    /// # Returns
1119    ///
1120    /// An `IndicatorResult` containing the full time series. Access the data using match:
1121    /// - `IndicatorResult::Series(values)` - for simple indicators (SMA, EMA, RSI, ATR, OBV, VWAP, WMA)
1122    /// - `IndicatorResult::Macd(data)` - for MACD (macd_line, signal_line, histogram)
1123    /// - `IndicatorResult::Bollinger(data)` - for Bollinger Bands (upper, middle, lower)
1124    ///
1125    /// # Example
1126    ///
1127    /// ```no_run
1128    /// use finance_query::{Ticker, Interval, TimeRange};
1129    /// use finance_query::indicators::{Indicator, IndicatorResult};
1130    ///
1131    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1132    /// let ticker = Ticker::new("AAPL").await?;
1133    ///
1134    /// // Calculate 14-period RSI
1135    /// let result = ticker.indicator(
1136    ///     Indicator::Rsi(14),
1137    ///     Interval::OneDay,
1138    ///     TimeRange::ThreeMonths
1139    /// ).await?;
1140    ///
1141    /// match result {
1142    ///     IndicatorResult::Series(values) => {
1143    ///         println!("Latest RSI: {:?}", values.last());
1144    ///     }
1145    ///     _ => {}
1146    /// }
1147    ///
1148    /// // Calculate MACD
1149    /// let macd_result = ticker.indicator(
1150    ///     Indicator::Macd { fast: 12, slow: 26, signal: 9 },
1151    ///     Interval::OneDay,
1152    ///     TimeRange::SixMonths
1153    /// ).await?;
1154    ///
1155    /// # Ok(())
1156    /// # }
1157    /// ```
1158    #[cfg(feature = "indicators")]
1159    pub async fn indicator(
1160        &self,
1161        indicator: crate::indicators::Indicator,
1162        interval: Interval,
1163        range: TimeRange,
1164    ) -> Result<crate::indicators::IndicatorResult> {
1165        use crate::indicators::{Indicator, IndicatorResult};
1166
1167        // Fetch chart data
1168        let chart = self.chart(interval, range).await?;
1169
1170        // Calculate the requested indicator
1171        // Note: Price vectors are extracted lazily within each arm to avoid waste
1172        let result = match indicator {
1173            Indicator::Sma(period) => IndicatorResult::Series(chart.sma(period)),
1174            Indicator::Ema(period) => IndicatorResult::Series(chart.ema(period)),
1175            Indicator::Rsi(period) => IndicatorResult::Series(chart.rsi(period)?),
1176            Indicator::Macd { fast, slow, signal } => {
1177                IndicatorResult::Macd(chart.macd(fast, slow, signal)?)
1178            }
1179            Indicator::Bollinger { period, std_dev } => {
1180                IndicatorResult::Bollinger(chart.bollinger_bands(period, std_dev)?)
1181            }
1182            Indicator::Atr(period) => IndicatorResult::Series(chart.atr(period)?),
1183            Indicator::Obv => {
1184                let closes = chart.close_prices();
1185                let volumes = chart.volumes();
1186                IndicatorResult::Series(crate::indicators::obv(&closes, &volumes)?)
1187            }
1188            Indicator::Vwap => {
1189                let highs = chart.high_prices();
1190                let lows = chart.low_prices();
1191                let closes = chart.close_prices();
1192                let volumes = chart.volumes();
1193                IndicatorResult::Series(crate::indicators::vwap(&highs, &lows, &closes, &volumes)?)
1194            }
1195            Indicator::Wma(period) => {
1196                let closes = chart.close_prices();
1197                IndicatorResult::Series(crate::indicators::wma(&closes, period)?)
1198            }
1199            Indicator::Dema(period) => {
1200                let closes = chart.close_prices();
1201                IndicatorResult::Series(crate::indicators::dema(&closes, period)?)
1202            }
1203            Indicator::Tema(period) => {
1204                let closes = chart.close_prices();
1205                IndicatorResult::Series(crate::indicators::tema(&closes, period)?)
1206            }
1207            Indicator::Hma(period) => {
1208                let closes = chart.close_prices();
1209                IndicatorResult::Series(crate::indicators::hma(&closes, period)?)
1210            }
1211            Indicator::Vwma(period) => {
1212                let closes = chart.close_prices();
1213                let volumes = chart.volumes();
1214                IndicatorResult::Series(crate::indicators::vwma(&closes, &volumes, period)?)
1215            }
1216            Indicator::Alma {
1217                period,
1218                offset,
1219                sigma,
1220            } => {
1221                let closes = chart.close_prices();
1222                IndicatorResult::Series(crate::indicators::alma(&closes, period, offset, sigma)?)
1223            }
1224            Indicator::McginleyDynamic(period) => {
1225                let closes = chart.close_prices();
1226                IndicatorResult::Series(crate::indicators::mcginley_dynamic(&closes, period)?)
1227            }
1228            Indicator::Stochastic {
1229                k_period,
1230                k_slow,
1231                d_period,
1232            } => {
1233                let highs = chart.high_prices();
1234                let lows = chart.low_prices();
1235                let closes = chart.close_prices();
1236                IndicatorResult::Stochastic(crate::indicators::stochastic(
1237                    &highs, &lows, &closes, k_period, k_slow, d_period,
1238                )?)
1239            }
1240            Indicator::StochasticRsi {
1241                rsi_period,
1242                stoch_period,
1243                k_period,
1244                d_period,
1245            } => {
1246                let closes = chart.close_prices();
1247                IndicatorResult::Stochastic(crate::indicators::stochastic_rsi(
1248                    &closes,
1249                    rsi_period,
1250                    stoch_period,
1251                    k_period,
1252                    d_period,
1253                )?)
1254            }
1255            Indicator::Cci(period) => {
1256                let highs = chart.high_prices();
1257                let lows = chart.low_prices();
1258                let closes = chart.close_prices();
1259                IndicatorResult::Series(crate::indicators::cci(&highs, &lows, &closes, period)?)
1260            }
1261            Indicator::WilliamsR(period) => {
1262                let highs = chart.high_prices();
1263                let lows = chart.low_prices();
1264                let closes = chart.close_prices();
1265                IndicatorResult::Series(crate::indicators::williams_r(
1266                    &highs, &lows, &closes, period,
1267                )?)
1268            }
1269            Indicator::Roc(period) => {
1270                let closes = chart.close_prices();
1271                IndicatorResult::Series(crate::indicators::roc(&closes, period)?)
1272            }
1273            Indicator::Momentum(period) => {
1274                let closes = chart.close_prices();
1275                IndicatorResult::Series(crate::indicators::momentum(&closes, period)?)
1276            }
1277            Indicator::Cmo(period) => {
1278                let closes = chart.close_prices();
1279                IndicatorResult::Series(crate::indicators::cmo(&closes, period)?)
1280            }
1281            Indicator::AwesomeOscillator { fast, slow } => {
1282                let highs = chart.high_prices();
1283                let lows = chart.low_prices();
1284                IndicatorResult::Series(crate::indicators::awesome_oscillator(
1285                    &highs, &lows, fast, slow,
1286                )?)
1287            }
1288            Indicator::CoppockCurve {
1289                wma_period,
1290                long_roc,
1291                short_roc,
1292            } => {
1293                let closes = chart.close_prices();
1294                IndicatorResult::Series(crate::indicators::coppock_curve(
1295                    &closes, long_roc, short_roc, wma_period,
1296                )?)
1297            }
1298            Indicator::Adx(period) => {
1299                let highs = chart.high_prices();
1300                let lows = chart.low_prices();
1301                let closes = chart.close_prices();
1302                IndicatorResult::Series(crate::indicators::adx(&highs, &lows, &closes, period)?)
1303            }
1304            Indicator::Aroon(period) => {
1305                let highs = chart.high_prices();
1306                let lows = chart.low_prices();
1307                IndicatorResult::Aroon(crate::indicators::aroon(&highs, &lows, period)?)
1308            }
1309            Indicator::Supertrend { period, multiplier } => {
1310                let highs = chart.high_prices();
1311                let lows = chart.low_prices();
1312                let closes = chart.close_prices();
1313                IndicatorResult::SuperTrend(crate::indicators::supertrend(
1314                    &highs, &lows, &closes, period, multiplier,
1315                )?)
1316            }
1317            Indicator::Ichimoku {
1318                conversion,
1319                base,
1320                lagging,
1321                displacement,
1322            } => {
1323                let highs = chart.high_prices();
1324                let lows = chart.low_prices();
1325                let closes = chart.close_prices();
1326                IndicatorResult::Ichimoku(crate::indicators::ichimoku(
1327                    &highs,
1328                    &lows,
1329                    &closes,
1330                    conversion,
1331                    base,
1332                    lagging,
1333                    displacement,
1334                )?)
1335            }
1336            Indicator::ParabolicSar { step, max } => {
1337                let highs = chart.high_prices();
1338                let lows = chart.low_prices();
1339                let closes = chart.close_prices();
1340                IndicatorResult::Series(crate::indicators::parabolic_sar(
1341                    &highs, &lows, &closes, step, max,
1342                )?)
1343            }
1344            Indicator::BullBearPower(period) => {
1345                let highs = chart.high_prices();
1346                let lows = chart.low_prices();
1347                let closes = chart.close_prices();
1348                IndicatorResult::BullBearPower(crate::indicators::bull_bear_power(
1349                    &highs, &lows, &closes, period,
1350                )?)
1351            }
1352            Indicator::ElderRay(period) => {
1353                let highs = chart.high_prices();
1354                let lows = chart.low_prices();
1355                let closes = chart.close_prices();
1356                IndicatorResult::ElderRay(crate::indicators::elder_ray(
1357                    &highs, &lows, &closes, period,
1358                )?)
1359            }
1360            Indicator::KeltnerChannels {
1361                period,
1362                multiplier,
1363                atr_period,
1364            } => {
1365                let highs = chart.high_prices();
1366                let lows = chart.low_prices();
1367                let closes = chart.close_prices();
1368                IndicatorResult::Keltner(crate::indicators::keltner_channels(
1369                    &highs, &lows, &closes, period, atr_period, multiplier,
1370                )?)
1371            }
1372            Indicator::DonchianChannels(period) => {
1373                let highs = chart.high_prices();
1374                let lows = chart.low_prices();
1375                IndicatorResult::Donchian(crate::indicators::donchian_channels(
1376                    &highs, &lows, period,
1377                )?)
1378            }
1379            Indicator::TrueRange => {
1380                let highs = chart.high_prices();
1381                let lows = chart.low_prices();
1382                let closes = chart.close_prices();
1383                IndicatorResult::Series(crate::indicators::true_range(&highs, &lows, &closes)?)
1384            }
1385            Indicator::ChoppinessIndex(period) => {
1386                let highs = chart.high_prices();
1387                let lows = chart.low_prices();
1388                let closes = chart.close_prices();
1389                IndicatorResult::Series(crate::indicators::choppiness_index(
1390                    &highs, &lows, &closes, period,
1391                )?)
1392            }
1393            Indicator::Mfi(period) => {
1394                let highs = chart.high_prices();
1395                let lows = chart.low_prices();
1396                let closes = chart.close_prices();
1397                let volumes = chart.volumes();
1398                IndicatorResult::Series(crate::indicators::mfi(
1399                    &highs, &lows, &closes, &volumes, period,
1400                )?)
1401            }
1402            Indicator::Cmf(period) => {
1403                let highs = chart.high_prices();
1404                let lows = chart.low_prices();
1405                let closes = chart.close_prices();
1406                let volumes = chart.volumes();
1407                IndicatorResult::Series(crate::indicators::cmf(
1408                    &highs, &lows, &closes, &volumes, period,
1409                )?)
1410            }
1411            Indicator::ChaikinOscillator => {
1412                let highs = chart.high_prices();
1413                let lows = chart.low_prices();
1414                let closes = chart.close_prices();
1415                let volumes = chart.volumes();
1416                IndicatorResult::Series(crate::indicators::chaikin_oscillator(
1417                    &highs, &lows, &closes, &volumes,
1418                )?)
1419            }
1420            Indicator::AccumulationDistribution => {
1421                let highs = chart.high_prices();
1422                let lows = chart.low_prices();
1423                let closes = chart.close_prices();
1424                let volumes = chart.volumes();
1425                IndicatorResult::Series(crate::indicators::accumulation_distribution(
1426                    &highs, &lows, &closes, &volumes,
1427                )?)
1428            }
1429            Indicator::BalanceOfPower(period) => {
1430                let opens = chart.open_prices();
1431                let highs = chart.high_prices();
1432                let lows = chart.low_prices();
1433                let closes = chart.close_prices();
1434                IndicatorResult::Series(crate::indicators::balance_of_power(
1435                    &opens, &highs, &lows, &closes, period,
1436                )?)
1437            }
1438        };
1439
1440        Ok(result)
1441    }
1442
1443    /// Get analyst recommendations
1444    pub async fn recommendations(&self, limit: u32) -> Result<Recommendation> {
1445        // Check cache (always fetches max from server, truncated to limit on return)
1446        {
1447            let cache = self.recommendations_cache.read().await;
1448            if let Some(entry) = cache.as_ref()
1449                && self.is_cache_fresh(Some(entry))
1450            {
1451                return Ok(self.build_recommendation_with_limit(&entry.value, limit));
1452            }
1453        }
1454
1455        // Always fetch server maximum (no limit restriction to maximize cache utility)
1456        let json = self.client.get_recommendations(&self.symbol, 15).await?;
1457        let response = RecommendationResponse::from_json(json).map_err(|e| {
1458            crate::error::FinanceError::ResponseStructureError {
1459                field: "finance".to_string(),
1460                context: e.to_string(),
1461            }
1462        })?;
1463
1464        // Cache full response, return truncated result
1465        if self.cache_ttl.is_some() {
1466            let mut cache = self.recommendations_cache.write().await;
1467            *cache = Some(CacheEntry::new(response));
1468            let entry = cache.as_ref().unwrap();
1469            return Ok(self.build_recommendation_with_limit(&entry.value, limit));
1470        }
1471
1472        Ok(self.build_recommendation_with_limit(&response, limit))
1473    }
1474
1475    /// Get financial statements
1476    ///
1477    /// # Arguments
1478    ///
1479    /// * `statement_type` - Type of statement (Income, Balance, CashFlow)
1480    /// * `frequency` - Annual or Quarterly
1481    ///
1482    /// # Example
1483    ///
1484    /// ```no_run
1485    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1486    /// use finance_query::{Ticker, Frequency, StatementType};
1487    ///
1488    /// let ticker = Ticker::new("AAPL").await?;
1489    /// let income = ticker.financials(StatementType::Income, Frequency::Annual).await?;
1490    /// println!("Revenue: {:?}", income.statement.get("TotalRevenue"));
1491    /// # Ok(())
1492    /// # }
1493    /// ```
1494    pub async fn financials(
1495        &self,
1496        statement_type: crate::constants::StatementType,
1497        frequency: crate::constants::Frequency,
1498    ) -> Result<FinancialStatement> {
1499        let cache_key = (statement_type, frequency);
1500
1501        // Check cache
1502        {
1503            let cache = self.financials_cache.read().await;
1504            if let Some(entry) = cache.get(&cache_key)
1505                && self.is_cache_fresh(Some(entry))
1506            {
1507                return Ok(entry.value.clone());
1508            }
1509        }
1510
1511        // Fetch financials
1512        let financials = self
1513            .client
1514            .get_financials(&self.symbol, statement_type, frequency)
1515            .await?;
1516
1517        // Only clone when caching is enabled
1518        if self.cache_ttl.is_some() {
1519            let mut cache = self.financials_cache.write().await;
1520            self.cache_insert(&mut cache, cache_key, financials.clone());
1521            Ok(financials)
1522        } else {
1523            Ok(financials)
1524        }
1525    }
1526
1527    /// Get news articles for this symbol
1528    ///
1529    /// # Example
1530    ///
1531    /// ```no_run
1532    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1533    /// use finance_query::Ticker;
1534    ///
1535    /// let ticker = Ticker::new("AAPL").await?;
1536    /// let news = ticker.news().await?;
1537    /// for article in news {
1538    ///     println!("{}: {}", article.source, article.title);
1539    /// }
1540    /// # Ok(())
1541    /// # }
1542    /// ```
1543    pub async fn news(&self) -> Result<Vec<crate::models::news::News>> {
1544        // Check cache
1545        {
1546            let cache = self.news_cache.read().await;
1547            if self.is_cache_fresh(cache.as_ref()) {
1548                return Ok(cache.as_ref().unwrap().value.clone());
1549            }
1550        }
1551
1552        // Fetch news
1553        let news = crate::scrapers::stockanalysis::scrape_symbol_news(&self.symbol).await?;
1554
1555        // Only clone when caching is enabled
1556        if self.cache_ttl.is_some() {
1557            let mut cache = self.news_cache.write().await;
1558            *cache = Some(CacheEntry::new(news.clone()));
1559            Ok(news)
1560        } else {
1561            Ok(news)
1562        }
1563    }
1564
1565    /// Get options chain
1566    pub async fn options(&self, date: Option<i64>) -> Result<Options> {
1567        // Check cache
1568        {
1569            let cache = self.options_cache.read().await;
1570            if let Some(entry) = cache.get(&date)
1571                && self.is_cache_fresh(Some(entry))
1572            {
1573                return Ok(entry.value.clone());
1574            }
1575        }
1576
1577        // Fetch options
1578        let json = self.client.get_options(&self.symbol, date).await?;
1579        let options: Options = serde_json::from_value(json).map_err(|e| {
1580            crate::error::FinanceError::ResponseStructureError {
1581                field: "options".to_string(),
1582                context: e.to_string(),
1583            }
1584        })?;
1585
1586        // Only clone when caching is enabled
1587        if self.cache_ttl.is_some() {
1588            let mut cache = self.options_cache.write().await;
1589            self.cache_insert(&mut cache, date, options.clone());
1590            Ok(options)
1591        } else {
1592            Ok(options)
1593        }
1594    }
1595
1596    /// Run a backtest with the given strategy and configuration.
1597    ///
1598    /// # Arguments
1599    ///
1600    /// * `strategy` - Trading strategy implementing the Strategy trait
1601    /// * `interval` - Candle interval (1d, 1h, etc.)
1602    /// * `range` - Time range for historical data
1603    /// * `config` - Backtest configuration (optional, uses defaults if None)
1604    ///
1605    /// # Example
1606    ///
1607    /// ```no_run
1608    /// use finance_query::{Ticker, Interval, TimeRange};
1609    /// use finance_query::backtesting::{SmaCrossover, BacktestConfig};
1610    ///
1611    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1612    /// let ticker = Ticker::new("AAPL").await?;
1613    ///
1614    /// // Simple backtest with defaults
1615    /// let strategy = SmaCrossover::new(10, 20);
1616    /// let result = ticker.backtest(
1617    ///     strategy,
1618    ///     Interval::OneDay,
1619    ///     TimeRange::OneYear,
1620    ///     None,
1621    /// ).await?;
1622    ///
1623    /// println!("{}", result.summary());
1624    /// println!("Total trades: {}", result.trades.len());
1625    ///
1626    /// // With custom config
1627    /// let config = BacktestConfig::builder()
1628    ///     .initial_capital(50_000.0)
1629    ///     .commission_pct(0.001)
1630    ///     .stop_loss_pct(0.05)
1631    ///     .allow_short(true)
1632    ///     .build()?;
1633    ///
1634    /// let result = ticker.backtest(
1635    ///     SmaCrossover::new(5, 20),
1636    ///     Interval::OneDay,
1637    ///     TimeRange::TwoYears,
1638    ///     Some(config),
1639    /// ).await?;
1640    /// # Ok(())
1641    /// # }
1642    /// ```
1643    #[cfg(feature = "backtesting")]
1644    pub async fn backtest<S: crate::backtesting::Strategy>(
1645        &self,
1646        strategy: S,
1647        interval: Interval,
1648        range: TimeRange,
1649        config: Option<crate::backtesting::BacktestConfig>,
1650    ) -> crate::backtesting::Result<crate::backtesting::BacktestResult> {
1651        use crate::backtesting::BacktestEngine;
1652
1653        let config = config.unwrap_or_default();
1654        config.validate()?;
1655
1656        // Fetch chart data — also populates the events cache used by dividends()
1657        let chart = self
1658            .chart(interval, range)
1659            .await
1660            .map_err(|e| crate::backtesting::BacktestError::ChartError(e.to_string()))?;
1661
1662        // Fetch dividends from the events cache (no extra network request after chart())
1663        let dividends = self.dividends(range).await.unwrap_or_default();
1664
1665        // Run backtest engine with dividend data
1666        let engine = BacktestEngine::new(config);
1667        engine.run_with_dividends(&self.symbol, &chart.candles, strategy, &dividends)
1668    }
1669
1670    /// Run a backtest and compare performance against a benchmark symbol.
1671    ///
1672    /// Fetches both the symbol chart and the benchmark chart concurrently, then
1673    /// runs the backtest and populates [`BacktestResult::benchmark`] with
1674    /// comparison metrics (alpha, beta, information ratio, buy-and-hold return).
1675    ///
1676    /// Requires the **`backtesting`** feature flag.
1677    ///
1678    /// # Arguments
1679    ///
1680    /// * `strategy` - The strategy to backtest
1681    /// * `interval` - Candle interval
1682    /// * `range` - Historical range
1683    /// * `config` - Optional backtest configuration (uses defaults if `None`)
1684    /// * `benchmark` - Symbol to use as benchmark (e.g. `"SPY"`)
1685    #[cfg(feature = "backtesting")]
1686    pub async fn backtest_with_benchmark<S: crate::backtesting::Strategy>(
1687        &self,
1688        strategy: S,
1689        interval: Interval,
1690        range: TimeRange,
1691        config: Option<crate::backtesting::BacktestConfig>,
1692        benchmark: &str,
1693    ) -> crate::backtesting::Result<crate::backtesting::BacktestResult> {
1694        use crate::backtesting::BacktestEngine;
1695
1696        let config = config.unwrap_or_default();
1697        config.validate()?;
1698
1699        // Fetch the symbol chart and benchmark chart concurrently
1700        let benchmark_ticker = crate::Ticker::new(benchmark)
1701            .await
1702            .map_err(|e| crate::backtesting::BacktestError::ChartError(e.to_string()))?;
1703
1704        let (chart, bench_chart) = tokio::try_join!(
1705            self.chart(interval, range),
1706            benchmark_ticker.chart(interval, range),
1707        )
1708        .map_err(|e| crate::backtesting::BacktestError::ChartError(e.to_string()))?;
1709
1710        // Fetch dividends from events cache (no extra network request after chart())
1711        let dividends = self.dividends(range).await.unwrap_or_default();
1712
1713        let engine = BacktestEngine::new(config);
1714        engine.run_with_benchmark(
1715            &self.symbol,
1716            &chart.candles,
1717            strategy,
1718            &dividends,
1719            benchmark,
1720            &bench_chart.candles,
1721        )
1722    }
1723
1724    // ========================================================================
1725    // Risk Analytics
1726    // ========================================================================
1727
1728    /// Compute a risk summary for this symbol.
1729    ///
1730    /// Requires the **`risk`** feature flag.
1731    ///
1732    /// Calculates Value at Risk, Sharpe/Sortino/Calmar ratios, and maximum drawdown
1733    /// from close-to-close returns derived from the requested chart data.
1734    ///
1735    /// # Arguments
1736    ///
1737    /// * `interval` - Candle interval (use `Interval::OneDay` for daily risk metrics)
1738    /// * `range` - Historical range to analyse
1739    /// * `benchmark` - Optional symbol to use as the benchmark for beta calculation
1740    ///
1741    /// # Example
1742    ///
1743    /// ```no_run
1744    /// use finance_query::{Ticker, Interval, TimeRange};
1745    ///
1746    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1747    /// let ticker = Ticker::new("AAPL").await?;
1748    ///
1749    /// // Risk vs no benchmark
1750    /// let summary = ticker.risk(Interval::OneDay, TimeRange::OneYear, None).await?;
1751    /// println!("VaR 95%:      {:.2}%", summary.var_95 * 100.0);
1752    /// println!("Max drawdown: {:.2}%", summary.max_drawdown * 100.0);
1753    ///
1754    /// // Risk with S&P 500 as benchmark
1755    /// let summary = ticker.risk(Interval::OneDay, TimeRange::OneYear, Some("^GSPC")).await?;
1756    /// println!("Beta: {:?}", summary.beta);
1757    /// # Ok(())
1758    /// # }
1759    /// ```
1760    #[cfg(feature = "risk")]
1761    pub async fn risk(
1762        &self,
1763        interval: Interval,
1764        range: TimeRange,
1765        benchmark: Option<&str>,
1766    ) -> Result<crate::risk::RiskSummary> {
1767        let chart = self.chart(interval, range).await?;
1768
1769        let benchmark_returns = if let Some(sym) = benchmark {
1770            let bench_ticker = Ticker::new(sym).await?;
1771            let bench_chart = bench_ticker.chart(interval, range).await?;
1772            Some(crate::risk::candles_to_returns(&bench_chart.candles))
1773        } else {
1774            None
1775        };
1776
1777        Ok(crate::risk::compute_risk_summary(
1778            &chart.candles,
1779            benchmark_returns.as_deref(),
1780        ))
1781    }
1782
1783    // ========================================================================
1784    // SEC EDGAR
1785    // ========================================================================
1786
1787    /// Get SEC EDGAR filing history for this symbol.
1788    ///
1789    /// Returns company metadata and recent filings. Results are cached for
1790    /// the lifetime of this `Ticker` instance.
1791    ///
1792    /// Requires EDGAR to be initialized via `edgar::init(email)`.
1793    ///
1794    /// # Example
1795    ///
1796    /// ```no_run
1797    /// use finance_query::{Ticker, edgar};
1798    ///
1799    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1800    /// edgar::init("user@example.com")?;
1801    /// let ticker = Ticker::new("AAPL").await?;
1802    ///
1803    /// let submissions = ticker.edgar_submissions().await?;
1804    /// println!("Company: {:?}", submissions.name);
1805    /// # Ok(())
1806    /// # }
1807    /// ```
1808    pub async fn edgar_submissions(&self) -> Result<crate::models::edgar::EdgarSubmissions> {
1809        // Check cache
1810        {
1811            let cache = self.edgar_submissions_cache.read().await;
1812            if self.is_cache_fresh(cache.as_ref()) {
1813                return Ok(cache.as_ref().unwrap().value.clone());
1814            }
1815        }
1816
1817        // Fetch using singleton
1818        let cik = crate::edgar::resolve_cik(&self.symbol).await?;
1819        let submissions = crate::edgar::submissions(cik).await?;
1820
1821        // Only clone when caching is enabled
1822        if self.cache_ttl.is_some() {
1823            let mut cache = self.edgar_submissions_cache.write().await;
1824            *cache = Some(CacheEntry::new(submissions.clone()));
1825            Ok(submissions)
1826        } else {
1827            Ok(submissions)
1828        }
1829    }
1830
1831    /// Get SEC EDGAR company facts (structured XBRL financial data) for this symbol.
1832    ///
1833    /// Returns all extracted XBRL facts organized by taxonomy. Results are cached
1834    /// for the lifetime of this `Ticker` instance.
1835    ///
1836    /// Requires EDGAR to be initialized via `edgar::init(email)`.
1837    ///
1838    /// # Example
1839    ///
1840    /// ```no_run
1841    /// use finance_query::{Ticker, edgar};
1842    ///
1843    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1844    /// edgar::init("user@example.com")?;
1845    /// let ticker = Ticker::new("AAPL").await?;
1846    ///
1847    /// let facts = ticker.edgar_company_facts().await?;
1848    /// if let Some(revenue) = facts.get_us_gaap_fact("Revenue") {
1849    ///     println!("Revenue data points: {:?}", revenue.units.keys().collect::<Vec<_>>());
1850    /// }
1851    /// # Ok(())
1852    /// # }
1853    /// ```
1854    pub async fn edgar_company_facts(&self) -> Result<crate::models::edgar::CompanyFacts> {
1855        // Check cache
1856        {
1857            let cache = self.edgar_facts_cache.read().await;
1858            if self.is_cache_fresh(cache.as_ref()) {
1859                return Ok(cache.as_ref().unwrap().value.clone());
1860            }
1861        }
1862
1863        // Fetch using singleton
1864        let cik = crate::edgar::resolve_cik(&self.symbol).await?;
1865        let facts = crate::edgar::company_facts(cik).await?;
1866
1867        // Only clone when caching is enabled
1868        if self.cache_ttl.is_some() {
1869            let mut cache = self.edgar_facts_cache.write().await;
1870            *cache = Some(CacheEntry::new(facts.clone()));
1871            Ok(facts)
1872        } else {
1873            Ok(facts)
1874        }
1875    }
1876
1877    // ========================================================================
1878    // Cache Management
1879    // ========================================================================
1880
1881    /// Clear all cached data, forcing fresh fetches on next access.
1882    ///
1883    /// Use this when you need up-to-date data from a long-lived `Ticker` instance.
1884    ///
1885    /// # Example
1886    ///
1887    /// ```no_run
1888    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1889    /// use finance_query::Ticker;
1890    ///
1891    /// let ticker = Ticker::new("AAPL").await?;
1892    /// let quote = ticker.quote().await?; // fetches from API
1893    ///
1894    /// // ... some time later ...
1895    /// ticker.clear_cache().await;
1896    /// let fresh_quote = ticker.quote().await?; // fetches again
1897    /// # Ok(())
1898    /// # }
1899    /// ```
1900    pub async fn clear_cache(&self) {
1901        // Acquire all independent write locks in parallel
1902        tokio::join!(
1903            async {
1904                *self.quote_summary.write().await = None;
1905            },
1906            async {
1907                self.chart_cache.write().await.clear();
1908            },
1909            async {
1910                *self.events_cache.write().await = None;
1911            },
1912            async {
1913                *self.recommendations_cache.write().await = None;
1914            },
1915            async {
1916                *self.news_cache.write().await = None;
1917            },
1918            async {
1919                self.options_cache.write().await.clear();
1920            },
1921            async {
1922                self.financials_cache.write().await.clear();
1923            },
1924            async {
1925                *self.edgar_submissions_cache.write().await = None;
1926            },
1927            async {
1928                *self.edgar_facts_cache.write().await = None;
1929            },
1930            async {
1931                #[cfg(feature = "indicators")]
1932                self.indicators_cache.write().await.clear();
1933            },
1934        );
1935    }
1936
1937    /// Clear only the cached quote summary data.
1938    ///
1939    /// The next call to any quote accessor (e.g., `price()`, `financial_data()`)
1940    /// will re-fetch all quote modules from the API.
1941    pub async fn clear_quote_cache(&self) {
1942        *self.quote_summary.write().await = None;
1943    }
1944
1945    /// Clear only the cached chart and events data.
1946    ///
1947    /// The next call to `chart()`, `dividends()`, `splits()`, or `capital_gains()`
1948    /// will re-fetch from the API.
1949    pub async fn clear_chart_cache(&self) {
1950        tokio::join!(
1951            async {
1952                self.chart_cache.write().await.clear();
1953            },
1954            async {
1955                *self.events_cache.write().await = None;
1956            },
1957            async {
1958                #[cfg(feature = "indicators")]
1959                self.indicators_cache.write().await.clear();
1960            }
1961        );
1962    }
1963}