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 ÷nds,
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, ÷nds)
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 ÷nds,
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}