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}