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