Skip to main content

finance_query/tickers/core/
charts.rs

1use super::{BatchChartsResponse, BatchSparksResponse, Tickers};
2use crate::constants::{Interval, TimeRange};
3use crate::error::{FinanceError, Result};
4use crate::models::chart::Chart;
5use crate::providers::Capability;
6use futures::stream::{self, StreamExt};
7use std::sync::Arc;
8
9impl Tickers {
10    /// Batch fetch charts for all symbols concurrently
11    ///
12    /// Chart data cannot be batched in a single request, so this fetches
13    /// all charts concurrently using tokio for maximum performance.
14    pub async fn charts(
15        &self,
16        interval: Interval,
17        range: TimeRange,
18    ) -> Result<BatchChartsResponse> {
19        // Fast path: check if all symbols are cached
20        {
21            let cache = self.chart_cache.read().await;
22            if self.all_cached(
23                &cache,
24                self.symbols.iter().map(|s| (s.clone(), interval, range)),
25            ) {
26                let mut response = BatchChartsResponse::with_capacity(self.symbols.len());
27                for symbol in &self.symbols {
28                    if let Some(entry) = cache.get(&(symbol.clone(), interval, range)) {
29                        response
30                            .charts
31                            .insert(symbol.to_string(), entry.value.clone());
32                    }
33                }
34                return Ok(response);
35            }
36        }
37
38        // Slow path: acquire fetch guard to prevent duplicate concurrent requests
39        let fetch_guard = Self::get_fetch_guard(&self.charts_fetch, (interval, range)).await;
40        let _guard = fetch_guard.lock().await;
41
42        // Double-check: another task may have fetched while we waited
43        {
44            let cache = self.chart_cache.read().await;
45            if self.all_cached(
46                &cache,
47                self.symbols.iter().map(|s| (s.clone(), interval, range)),
48            ) {
49                let mut response = BatchChartsResponse::with_capacity(self.symbols.len());
50                for symbol in &self.symbols {
51                    if let Some(entry) = cache.get(&(symbol.clone(), interval, range)) {
52                        response
53                            .charts
54                            .insert(symbol.to_string(), entry.value.clone());
55                    }
56                }
57                return Ok(response);
58            }
59        }
60
61        // Fetch all charts concurrently via provider dispatch (no lock held during I/O)
62        let futures: Vec<_> = self
63            .symbols
64            .iter()
65            .map(|symbol| {
66                let providers = Arc::clone(&self.providers);
67                let symbol = Arc::clone(symbol);
68                async move {
69                    let sym = symbol.to_string();
70                    let result = providers
71                        .fetch(Capability::CHART, |p| {
72                            let sym = sym.clone();
73                            let p = p.clone();
74                            async move {
75                                p.as_chart()
76                                    .ok_or_else(|| {
77                                        p.not_supported(crate::providers::Operation::Chart)
78                                    })?
79                                    .fetch_chart(&sym, interval, range)
80                                    .await
81                            }
82                        })
83                        .await;
84                    (symbol, result)
85                }
86            })
87            .collect();
88
89        let results: Vec<_> = stream::iter(futures)
90            .buffer_unordered(self.max_concurrency)
91            .collect()
92            .await;
93
94        let mut response = BatchChartsResponse::with_capacity(self.symbols.len());
95        let mut parsed_charts: Vec<(Arc<str>, Chart)> = Vec::new();
96
97        for (symbol, result) in results {
98            match result {
99                Ok(data) => {
100                    let chart = data;
101                    parsed_charts.push((symbol, chart));
102                }
103                Err(e) => {
104                    response.errors.insert(symbol.to_string(), e.to_string());
105                }
106            }
107        }
108
109        // Move into cache, then clone for response — avoids double-clone
110        if self.cache_mode.enabled() {
111            let mut cache = self.chart_cache.write().await;
112            let cache_keys: Vec<_> = parsed_charts
113                .into_iter()
114                .map(|(symbol, chart)| {
115                    self.cache_insert(&mut cache, (symbol.clone(), interval, range), chart);
116                    symbol
117                })
118                .collect();
119            for symbol in cache_keys {
120                if let Some(cached) = cache.get(&(symbol.clone(), interval, range)) {
121                    response
122                        .charts
123                        .insert(symbol.to_string(), cached.value.clone());
124                }
125            }
126        } else {
127            for (symbol, chart) in parsed_charts {
128                response.charts.insert(symbol.to_string(), chart);
129            }
130        }
131
132        Ok(response)
133    }
134
135    /// Get a specific chart by symbol
136    pub async fn chart(&self, symbol: &str, interval: Interval, range: TimeRange) -> Result<Chart> {
137        {
138            let cache = self.chart_cache.read().await;
139            let key: Arc<str> = symbol.into();
140            if let Some(entry) = cache.get(&(key, interval, range))
141                && self.is_cache_fresh(Some(entry))
142            {
143                return Ok(entry.value.clone());
144            }
145        }
146
147        let response = self.charts(interval, range).await?;
148
149        response
150            .charts
151            .get(symbol)
152            .cloned()
153            .ok_or_else(|| FinanceError::SymbolNotFound {
154                symbol: Some(symbol.to_string()),
155                context: response
156                    .errors
157                    .get(symbol)
158                    .cloned()
159                    .unwrap_or_else(|| "Symbol not found".to_string()),
160            })
161    }
162
163    /// Batch fetch chart data for a custom date range for all symbols concurrently.
164    ///
165    /// Unlike [`charts()`](Self::charts) which uses predefined time ranges,
166    /// this method accepts absolute start/end timestamps. Results are **not cached**
167    /// since custom ranges have unbounded key space.
168    ///
169    /// # Arguments
170    ///
171    /// * `interval` - Time interval between data points
172    /// * `start` - Start date as Unix timestamp (seconds since epoch)
173    /// * `end` - End date as Unix timestamp (seconds since epoch)
174    pub async fn charts_range(
175        &self,
176        interval: Interval,
177        start: i64,
178        end: i64,
179    ) -> Result<BatchChartsResponse> {
180        let futures: Vec<_> = self
181            .symbols
182            .iter()
183            .map(|symbol| {
184                let providers = Arc::clone(&self.providers);
185                let symbol = Arc::clone(symbol);
186                async move {
187                    let sym = symbol.to_string();
188                    let result = providers
189                        .fetch(Capability::CHART, |p| {
190                            let sym = sym.clone();
191                            let p = p.clone();
192                            async move {
193                                p.as_chart()
194                                    .ok_or_else(|| {
195                                        p.not_supported(crate::providers::Operation::ChartRange)
196                                    })?
197                                    .fetch_chart_range(&sym, interval, start, end)
198                                    .await
199                            }
200                        })
201                        .await;
202                    (symbol, result)
203                }
204            })
205            .collect();
206
207        let results: Vec<_> = stream::iter(futures)
208            .buffer_unordered(self.max_concurrency)
209            .collect()
210            .await;
211
212        let mut response = BatchChartsResponse::with_capacity(self.symbols.len());
213
214        for (symbol, result) in results {
215            match result {
216                Ok(data) => {
217                    let chart = data;
218                    response.charts.insert(symbol.to_string(), chart);
219                }
220                Err(e) => {
221                    response.errors.insert(symbol.to_string(), e.to_string());
222                }
223            }
224        }
225
226        Ok(response)
227    }
228
229    /// Batch fetch spark data for all symbols in a single request.
230    ///
231    /// Spark data is optimized for sparkline rendering, returning only close prices.
232    /// Unlike `charts()`, this fetches all symbols in ONE API call, making it
233    /// much more efficient for displaying price trends on dashboards or watchlists.
234    ///
235    /// # Arguments
236    ///
237    /// * `interval` - Time interval between data points (e.g., `Interval::FiveMinutes`)
238    /// * `range` - Time range to fetch (e.g., `TimeRange::OneDay`)
239    ///
240    /// # Example
241    ///
242    /// ```no_run
243    /// use finance_query::{Tickers, Interval, TimeRange};
244    ///
245    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
246    /// let tickers = Tickers::new(["AAPL", "MSFT", "GOOGL"]).await?;
247    /// let sparks = tickers.spark(Interval::FiveMinutes, TimeRange::OneDay).await?;
248    ///
249    /// for (symbol, spark) in &sparks.sparks {
250    ///     if let Some(change) = spark.percent_change() {
251    ///         println!("{}: {:.2}%", symbol, change);
252    ///     }
253    /// }
254    /// # Ok(())
255    /// # }
256    /// ```
257    pub async fn spark(&self, interval: Interval, range: TimeRange) -> Result<BatchSparksResponse> {
258        // Fast path: check if all symbols are cached
259        {
260            let cache = self.spark_cache.read().await;
261            if self.all_cached(
262                &cache,
263                self.symbols.iter().map(|s| (s.clone(), interval, range)),
264            ) {
265                let mut response = BatchSparksResponse::with_capacity(self.symbols.len());
266                for symbol in &self.symbols {
267                    if let Some(entry) = cache.get(&(symbol.clone(), interval, range)) {
268                        response
269                            .sparks
270                            .insert(symbol.to_string(), entry.value.clone());
271                    }
272                }
273                return Ok(response);
274            }
275        }
276
277        // Slow path: acquire fetch guard
278        let fetch_guard = Self::get_fetch_guard(&self.spark_fetch, (interval, range)).await;
279        let _guard = fetch_guard.lock().await;
280
281        // Double-check after guard
282        {
283            let cache = self.spark_cache.read().await;
284            if self.all_cached(
285                &cache,
286                self.symbols.iter().map(|s| (s.clone(), interval, range)),
287            ) {
288                let mut response = BatchSparksResponse::with_capacity(self.symbols.len());
289                for symbol in &self.symbols {
290                    if let Some(entry) = cache.get(&(symbol.clone(), interval, range)) {
291                        response
292                            .sparks
293                            .insert(symbol.to_string(), entry.value.clone());
294                    }
295                }
296                return Ok(response);
297            }
298        }
299
300        // Dispatch through the provider set under the CHART capability so spark
301        // honors routing like every other chart path (Yahoo is the default).
302        let providers = Arc::clone(&self.providers);
303        let syms: Vec<String> = self.symbols.iter().map(|s| s.to_string()).collect();
304        let spark_result = providers
305            .fetch(Capability::CHART, |p| {
306                let syms = syms.clone();
307                let p = p.clone();
308                async move {
309                    let syms_ref: Vec<&str> = syms.iter().map(String::as_str).collect();
310                    p.as_chart()
311                        .ok_or_else(|| p.not_supported(crate::providers::Operation::Spark))?
312                        .fetch_spark(&syms_ref, interval, range)
313                        .await
314                }
315            })
316            .await;
317
318        let mut response = BatchSparksResponse::with_capacity(self.symbols.len());
319
320        match spark_result {
321            Ok(parsed_sparks) => {
322                // Cache all parsed sparks
323                if self.cache_mode.enabled() {
324                    let mut cache = self.spark_cache.write().await;
325                    for (symbol, spark) in &parsed_sparks {
326                        let key: Arc<str> = symbol.as_str().into();
327                        self.cache_insert(&mut cache, (key, interval, range), spark.clone());
328                    }
329                }
330
331                // Build response
332                for (symbol, spark) in parsed_sparks {
333                    response.sparks.insert(symbol, spark);
334                }
335
336                // Track missing symbols
337                for symbol in &self.symbols {
338                    let symbol_str = &**symbol;
339                    if !response.sparks.contains_key(symbol_str)
340                        && !response.errors.contains_key(symbol_str)
341                    {
342                        response.errors.insert(
343                            symbol.to_string(),
344                            "Symbol not found in response".to_string(),
345                        );
346                    }
347                }
348            }
349            Err(e) => {
350                for symbol in &self.symbols {
351                    response.errors.insert(symbol.to_string(), e.to_string());
352                }
353            }
354        }
355
356        Ok(response)
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    #[tokio::test]
365    #[ignore = "requires network access"]
366    async fn test_tickers_charts() {
367        let tickers = Tickers::new(["AAPL", "MSFT"]).await.unwrap();
368        let result = tickers
369            .charts(Interval::OneDay, TimeRange::FiveDays)
370            .await
371            .unwrap();
372
373        assert!(result.success_count() > 0);
374    }
375
376    #[tokio::test]
377    #[ignore = "requires network access"]
378    async fn test_tickers_spark() {
379        let tickers = Tickers::new(["AAPL", "MSFT", "GOOGL"]).await.unwrap();
380        let result = tickers
381            .spark(Interval::FiveMinutes, TimeRange::OneDay)
382            .await
383            .unwrap();
384
385        assert!(result.success_count() > 0);
386
387        // Verify spark data structure
388        if let Some(spark) = result.sparks.get("AAPL") {
389            assert!(!spark.closes.is_empty());
390            assert_eq!(spark.symbol, "AAPL");
391            // Verify helper methods work
392            assert!(spark.percent_change().is_some());
393        }
394    }
395}