Skip to main content

finance_query/tickers/core/
analysis.rs

1#[cfg(feature = "indicators")]
2use super::BatchIndicatorsResponse;
3use super::Tickers;
4#[cfg(feature = "backtesting")]
5use crate::backtesting;
6use crate::constants::{Interval, TimeRange};
7#[cfg(feature = "indicators")]
8use crate::error::Result;
9#[cfg(any(feature = "backtesting", feature = "indicators"))]
10use crate::indicators;
11#[cfg(feature = "indicators")]
12use std::sync::Arc;
13
14impl Tickers {
15    /// Batch calculate all technical indicators for all symbols
16    ///
17    /// Calculates complete indicator summaries for all symbols from their chart data.
18    /// Indicators are cached per (symbol, interval, range) tuple.
19    ///
20    /// # Arguments
21    ///
22    /// * `interval` - The time interval for each candle
23    /// * `range` - The time range to fetch data for
24    ///
25    /// # Example
26    ///
27    /// ```no_run
28    /// use finance_query::{Tickers, Interval, TimeRange};
29    ///
30    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
31    /// let tickers = Tickers::new(["AAPL", "MSFT"]).await?;
32    /// let indicators = tickers.indicators(Interval::OneDay, TimeRange::ThreeMonths).await?;
33    ///
34    /// for (symbol, ind) in &indicators.indicators {
35    ///     println!("{}: RSI(14) = {:?}, SMA(20) = {:?}", symbol, ind.rsi_14, ind.sma_20);
36    /// }
37    /// # Ok(())
38    /// # }
39    /// ```
40    #[cfg(feature = "indicators")]
41    pub async fn indicators(
42        &self,
43        interval: Interval,
44        range: TimeRange,
45    ) -> Result<BatchIndicatorsResponse> {
46        let cache_key_for = |symbol: &Arc<str>| (symbol.clone(), interval, range);
47
48        // Fast path: check if all symbols are cached
49        {
50            let cache = self.indicators_cache.read().await;
51            if self.all_cached(&cache, self.symbols.iter().map(&cache_key_for)) {
52                let mut response = BatchIndicatorsResponse::with_capacity(self.symbols.len());
53                for symbol in &self.symbols {
54                    if let Some(entry) = cache.get(&cache_key_for(symbol)) {
55                        response
56                            .indicators
57                            .insert(symbol.to_string(), entry.value.clone());
58                    }
59                }
60                return Ok(response);
61            }
62        }
63
64        // Slow path: acquire fetch guard to prevent duplicate concurrent calculations
65        let fetch_guard = Self::get_fetch_guard(&self.indicators_fetch, (interval, range)).await;
66        let _guard = fetch_guard.lock().await;
67
68        // Double-check: another task may have computed while we waited
69        {
70            let cache = self.indicators_cache.read().await;
71            if self.all_cached(&cache, self.symbols.iter().map(&cache_key_for)) {
72                let mut response = BatchIndicatorsResponse::with_capacity(self.symbols.len());
73                for symbol in &self.symbols {
74                    if let Some(entry) = cache.get(&cache_key_for(symbol)) {
75                        response
76                            .indicators
77                            .insert(symbol.to_string(), entry.value.clone());
78                    }
79                }
80                return Ok(response);
81            }
82        }
83
84        // Fetch charts first (which may already be cached, has its own deduplication)
85        let charts_response = self.charts(interval, range).await?;
86
87        let mut response = BatchIndicatorsResponse::with_capacity(self.symbols.len());
88
89        // Calculate all indicators first (no lock held)
90        let mut calculated_indicators: Vec<(String, indicators::IndicatorsSummary)> = Vec::new();
91
92        for (symbol, chart) in &charts_response.charts {
93            let indicators = indicators::summary::calculate_indicators(&chart.candles);
94            calculated_indicators.push((symbol.to_string(), indicators));
95        }
96
97        // Now acquire write lock briefly for batch cache insertion
98        if self.cache_mode.enabled() {
99            let mut cache = self.indicators_cache.write().await;
100            for (symbol, indicators) in &calculated_indicators {
101                let key: Arc<str> = symbol.as_str().into();
102                self.cache_insert(&mut cache, cache_key_for(&key), indicators.clone());
103            }
104        }
105
106        // Populate response (no lock needed)
107        for (symbol, indicators) in calculated_indicators {
108            response.indicators.insert(symbol, indicators);
109        }
110
111        // Add errors from chart fetch
112        for (symbol, error) in &charts_response.errors {
113            response.errors.insert(symbol.to_string(), error.clone());
114        }
115
116        Ok(response)
117    }
118
119    // ========================================================================
120    // Portfolio Backtesting
121    // ========================================================================
122
123    /// Run a multi-symbol portfolio backtest across all tracked symbols.
124    ///
125    /// Fetches charts and dividends for each symbol concurrently, then runs
126    /// the portfolio engine with the given strategy factory. Capital is shared
127    /// across all symbols according to the [`PortfolioConfig`] allocation rules.
128    ///
129    /// `factory` is called once per symbol to produce an independent strategy
130    /// instance:
131    ///
132    /// ```no_run
133    /// use finance_query::{Tickers, Interval, TimeRange};
134    /// use finance_query::backtesting::{SmaCrossover, BacktestConfig};
135    /// use finance_query::backtesting::portfolio::{PortfolioConfig, RebalanceMode};
136    ///
137    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
138    /// let tickers = Tickers::new(["AAPL", "MSFT", "NVDA"]).await?;
139    ///
140    /// let config = PortfolioConfig::new(BacktestConfig::default())
141    ///     .max_total_positions(2)
142    ///     .rebalance(RebalanceMode::EqualWeight);
143    ///
144    /// let result = tickers.backtest(
145    ///     Interval::OneDay,
146    ///     TimeRange::TwoYears,
147    ///     Some(config),
148    ///     |_sym| SmaCrossover::new(10, 50),
149    /// ).await?;
150    ///
151    /// println!("Portfolio return: {:.2}%", result.portfolio_metrics.total_return_pct);
152    /// # Ok(())
153    /// # }
154    /// ```
155    ///
156    /// [`PortfolioConfig`]: backtesting::portfolio::PortfolioConfig
157    #[cfg(feature = "backtesting")]
158    pub async fn backtest<S, F>(
159        &self,
160        interval: Interval,
161        range: TimeRange,
162        config: Option<backtesting::portfolio::PortfolioConfig>,
163        factory: F,
164    ) -> backtesting::Result<backtesting::portfolio::PortfolioResult>
165    where
166        S: backtesting::Strategy,
167        F: Fn(&str) -> S,
168    {
169        use crate::backtesting::portfolio::{PortfolioEngine, SymbolData};
170
171        let config = config.unwrap_or_default();
172        config.validate(self.symbols.len())?;
173
174        // Charts and dividends hit disjoint caches and disjoint capabilities
175        // (CHART vs CORPORATE), so neither warms the other.
176        let (charts, dividends_map) =
177            tokio::join!(self.charts(interval, range), self.dividends(range));
178        let charts = charts.map_err(|e| backtesting::BacktestError::ChartError(e.to_string()))?;
179        // Treat errors as "no dividends" — dividend processing is best-effort
180        let dividends_map = dividends_map.map(|b| b.dividends).unwrap_or_default();
181
182        // Assemble SymbolData slices — skip symbols with no chart data
183        let symbol_data: Vec<SymbolData> = self
184            .symbols
185            .iter()
186            .filter_map(|sym| {
187                charts.charts.get(sym.as_ref()).map(|chart| {
188                    let divs = dividends_map.get(sym.as_ref()).cloned().unwrap_or_default();
189                    SymbolData::new(sym.as_ref(), chart.candles.clone()).with_dividends(divs)
190                })
191            })
192            .collect();
193
194        let engine = PortfolioEngine::new(config);
195        engine.run(&symbol_data, factory)
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[tokio::test]
204    #[ignore = "requires network access"]
205    #[cfg(feature = "indicators")]
206    async fn test_tickers_indicators() {
207        let tickers = Tickers::new(["AAPL", "MSFT"]).await.unwrap();
208        let result = tickers
209            .indicators(Interval::OneDay, TimeRange::ThreeMonths)
210            .await
211            .unwrap();
212
213        assert!(result.success_count() > 0);
214
215        // Verify indicators structure
216        for ind in result.indicators.values() {
217            // Check that at least some indicators are present
218            assert!(ind.rsi_14.is_some() || ind.sma_20.is_some());
219        }
220    }
221}