fmp_rs/endpoints/
analyst.rs

1//! Analyst endpoints
2
3use crate::Result;
4use crate::client::FmpClient;
5use crate::models::analyst::{AnalystEstimates, AnalystGrade, ConsensusSummary, PriceTarget};
6use crate::models::common::Period;
7use serde::Serialize;
8
9/// Analyst API endpoints
10pub struct Analyst {
11    client: FmpClient,
12}
13
14#[derive(Debug, Clone, Serialize)]
15struct AnalystQuery {
16    #[serde(skip_serializing_if = "Option::is_none")]
17    period: Option<String>,
18    #[serde(skip_serializing_if = "Option::is_none")]
19    limit: Option<u32>,
20}
21
22impl Analyst {
23    pub(crate) fn new(client: FmpClient) -> Self {
24        Self { client }
25    }
26
27    /// Get analyst estimates for a symbol
28    ///
29    /// # Arguments
30    /// * `symbol` - Stock symbol (e.g., "AAPL")
31    /// * `period` - Period (annual or quarter, optional)
32    /// * `limit` - Number of results (optional)
33    ///
34    /// # Example
35    /// ```no_run
36    /// # use fmp_rs::FmpClient;
37    /// # use fmp_rs::models::common::Period;
38    /// # #[tokio::main]
39    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
40    /// let client = FmpClient::new()?;
41    /// let estimates = client.analyst().get_estimates("AAPL", Some(Period::Annual), Some(10)).await?;
42    /// for estimate in estimates {
43    ///     println!("{}: Estimated EPS {:.2}", estimate.date, estimate.estimated_eps_avg);
44    /// }
45    /// # Ok(())
46    /// # }
47    /// ```
48    pub async fn get_estimates(
49        &self,
50        symbol: &str,
51        period: Option<Period>,
52        limit: Option<u32>,
53    ) -> Result<Vec<AnalystEstimates>> {
54        let query = AnalystQuery {
55            period: period.map(|p| p.to_string()),
56            limit,
57        };
58        self.client
59            .get_with_query(&format!("v3/analyst-estimates/{}", symbol), &query)
60            .await
61    }
62
63    /// Get price targets for a symbol
64    ///
65    /// # Arguments
66    /// * `symbol` - Stock symbol (e.g., "AAPL")
67    ///
68    /// # Example
69    /// ```no_run
70    /// # use fmp_rs::FmpClient;
71    /// # #[tokio::main]
72    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
73    /// let client = FmpClient::new()?;
74    /// let targets = client.analyst().get_price_targets("AAPL").await?;
75    /// for target in targets {
76    ///     println!("{} from {}: ${:.2}", target.published_date, target.analyst_company, target.price_target);
77    /// }
78    /// # Ok(())
79    /// # }
80    /// ```
81    pub async fn get_price_targets(&self, symbol: &str) -> Result<Vec<PriceTarget>> {
82        self.client
83            .get_with_query(&format!("v4/price-target?symbol={}", symbol), &())
84            .await
85    }
86
87    /// Get analyst price target consensus for a symbol
88    ///
89    /// # Arguments
90    /// * `symbol` - Stock symbol (e.g., "AAPL")
91    ///
92    /// # Example
93    /// ```no_run
94    /// # use fmp_rs::FmpClient;
95    /// # #[tokio::main]
96    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
97    /// let client = FmpClient::new()?;
98    /// let consensus = client.analyst().get_price_target_consensus("AAPL").await?;
99    /// println!("Target High: ${:.2}, Target Low: ${:.2}",
100    ///     consensus.first().unwrap().target_high,
101    ///     consensus.first().unwrap().target_low);
102    /// # Ok(())
103    /// # }
104    /// ```
105    pub async fn get_price_target_consensus(
106        &self,
107        symbol: &str,
108    ) -> Result<Vec<PriceTargetConsensus>> {
109        self.client
110            .get_with_query(&format!("v4/price-target-consensus?symbol={}", symbol), &())
111            .await
112    }
113
114    /// Get analyst recommendations/upgrades for a symbol
115    ///
116    /// # Arguments
117    /// * `symbol` - Stock symbol (e.g., "AAPL")
118    ///
119    /// # Example
120    /// ```no_run
121    /// # use fmp_rs::FmpClient;
122    /// # #[tokio::main]
123    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
124    /// let client = FmpClient::new()?;
125    /// let grades = client.analyst().get_grades("AAPL").await?;
126    /// for grade in grades {
127    ///     println!("{} from {}: {} -> {}",
128    ///         grade.published_date,
129    ///         grade.grading_company,
130    ///         grade.previous_grade.unwrap_or_default(),
131    ///         grade.new_grade);
132    /// }
133    /// # Ok(())
134    /// # }
135    /// ```
136    pub async fn get_grades(&self, symbol: &str) -> Result<Vec<AnalystGrade>> {
137        self.client
138            .get_with_query(&format!("v3/grade/{}", symbol), &())
139            .await
140    }
141
142    /// Get analyst recommendation consensus for a symbol
143    ///
144    /// # Arguments
145    /// * `symbol` - Stock symbol (e.g., "AAPL")
146    ///
147    /// # Example
148    /// ```no_run
149    /// # use fmp_rs::FmpClient;
150    /// # #[tokio::main]
151    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
152    /// let client = FmpClient::new()?;
153    /// let consensus = client.analyst().get_recommendation_consensus("AAPL").await?;
154    /// for rec in consensus {
155    ///     println!("Strong Buy: {}, Buy: {}, Hold: {}, Sell: {}, Strong Sell: {}",
156    ///         rec.strong_buy, rec.buy, rec.hold, rec.sell, rec.strong_sell);
157    ///     println!("Consensus: {}", rec.consensus);
158    /// }
159    /// # Ok(())
160    /// # }
161    /// ```
162    pub async fn get_recommendation_consensus(
163        &self,
164        symbol: &str,
165    ) -> Result<Vec<ConsensusSummary>> {
166        self.client
167            .get_with_query(&format!("v3/rating/{}", symbol), &())
168            .await
169    }
170}
171
172/// Price target consensus
173#[derive(Debug, Clone, Serialize, serde::Deserialize, PartialEq)]
174#[serde(rename_all = "camelCase")]
175pub struct PriceTargetConsensus {
176    pub symbol: String,
177    pub target_high: f64,
178    pub target_low: f64,
179    pub target_consensus: f64,
180    pub target_median: f64,
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn test_new() {
189        let client = FmpClient::builder().api_key("test_key").build().unwrap();
190        let analyst = Analyst::new(client);
191        assert!(std::ptr::addr_of!(analyst.client).is_null() == false);
192    }
193
194    #[tokio::test]
195    #[ignore] // Requires API key
196    async fn test_get_estimates() {
197        let client = FmpClient::new().unwrap();
198        let result = client
199            .analyst()
200            .get_estimates("AAPL", Some(Period::Annual), Some(5))
201            .await;
202        assert!(result.is_ok());
203    }
204
205    #[tokio::test]
206    #[ignore] // Requires API key
207    async fn test_get_price_targets() {
208        let client = FmpClient::new().unwrap();
209        let result = client.analyst().get_price_targets("AAPL").await;
210        assert!(result.is_ok());
211    }
212
213    #[tokio::test]
214    #[ignore] // Requires API key
215    async fn test_get_grades() {
216        let client = FmpClient::new().unwrap();
217        let result = client.analyst().get_grades("AAPL").await;
218        assert!(result.is_ok());
219    }
220
221    #[tokio::test]
222    #[ignore] // Requires API key
223    async fn test_get_recommendation_consensus() {
224        let client = FmpClient::new().unwrap();
225        let result = client.analyst().get_recommendation_consensus("AAPL").await;
226        assert!(result.is_ok());
227    }
228
229    // Missing endpoint test
230    #[tokio::test]
231    #[ignore] // Requires API key
232    async fn test_get_price_target_consensus() {
233        let client = FmpClient::new().unwrap();
234        let result = client.analyst().get_price_target_consensus("AAPL").await;
235        assert!(result.is_ok());
236        let consensus = result.unwrap();
237
238        if !consensus.is_empty() {
239            let target = &consensus[0];
240            assert_eq!(target.symbol, "AAPL".to_string());
241            // Validate price target ranges
242            assert!(target.target_high >= target.target_low); // High should be >= low
243            assert!(target.target_high > 0.0 && target.target_low > 0.0); // Prices should be positive
244        }
245    }
246
247    // Edge case tests
248    #[tokio::test]
249    #[ignore] // Requires API key
250    async fn test_analyst_data_with_invalid_symbol() {
251        let client = FmpClient::new().unwrap();
252
253        let invalid_symbol = "INVALID_STOCK_12345";
254
255        // Test all endpoints with invalid symbol
256        let estimates_result = client
257            .analyst()
258            .get_estimates(invalid_symbol, Some(Period::Annual), Some(5))
259            .await;
260        let targets_result = client.analyst().get_price_targets(invalid_symbol).await;
261        let consensus_result = client
262            .analyst()
263            .get_price_target_consensus(invalid_symbol)
264            .await;
265        let grades_result = client.analyst().get_grades(invalid_symbol).await;
266        let rec_consensus_result = client
267            .analyst()
268            .get_recommendation_consensus(invalid_symbol)
269            .await;
270
271        // All should handle gracefully - either empty results or errors
272        match estimates_result {
273            Ok(data) => assert!(data.is_empty()),
274            Err(_) => {} // Error is acceptable
275        }
276
277        match targets_result {
278            Ok(data) => assert!(data.is_empty()),
279            Err(_) => {} // Error is acceptable
280        }
281
282        match consensus_result {
283            Ok(data) => assert!(data.is_empty()),
284            Err(_) => {} // Error is acceptable
285        }
286
287        match grades_result {
288            Ok(data) => assert!(data.is_empty()),
289            Err(_) => {} // Error is acceptable
290        }
291
292        match rec_consensus_result {
293            Ok(data) => assert!(data.is_empty()),
294            Err(_) => {} // Error is acceptable
295        }
296    }
297
298    #[tokio::test]
299    #[ignore] // Requires API key
300    async fn test_estimates_with_different_periods() {
301        let client = FmpClient::new().unwrap();
302
303        // Test both annual and quarterly periods
304        let annual_result = client
305            .analyst()
306            .get_estimates("AAPL", Some(Period::Annual), Some(3))
307            .await;
308        let quarterly_result = client
309            .analyst()
310            .get_estimates("AAPL", Some(Period::Quarter), Some(3))
311            .await;
312
313        assert!(annual_result.is_ok());
314        assert!(quarterly_result.is_ok());
315
316        let annual_estimates = annual_result.unwrap();
317        let quarterly_estimates = quarterly_result.unwrap();
318
319        // Should have different data for different periods
320        if !annual_estimates.is_empty() && !quarterly_estimates.is_empty() {
321            // Data should be structured correctly
322            assert_eq!(annual_estimates[0].symbol, "AAPL".to_string());
323            assert_eq!(quarterly_estimates[0].symbol, "AAPL".to_string());
324        }
325    }
326
327    #[tokio::test]
328    #[ignore] // Requires API key
329    async fn test_estimates_limit_parameter() {
330        let client = FmpClient::new().unwrap();
331
332        // Test with different limits
333        let small_limit_result = client
334            .analyst()
335            .get_estimates("AAPL", Some(Period::Annual), Some(2))
336            .await;
337        let large_limit_result = client
338            .analyst()
339            .get_estimates("AAPL", Some(Period::Annual), Some(10))
340            .await;
341
342        assert!(small_limit_result.is_ok());
343        assert!(large_limit_result.is_ok());
344
345        let small_estimates = small_limit_result.unwrap();
346        let large_estimates = large_limit_result.unwrap();
347
348        // Small limit should return <= 2 items
349        assert!(small_estimates.len() <= 2);
350
351        // Large limit should return more data (if available)
352        if !small_estimates.is_empty() && !large_estimates.is_empty() {
353            // Large limit should return at least as much data as small limit
354            assert!(large_estimates.len() >= small_estimates.len());
355        }
356    }
357
358    #[tokio::test]
359    #[ignore] // Requires API key
360    async fn test_price_targets_data_validation() {
361        let client = FmpClient::new().unwrap();
362        let result = client.analyst().get_price_targets("AAPL").await;
363        assert!(result.is_ok());
364
365        let targets = result.unwrap();
366        if !targets.is_empty() {
367            let target = &targets[0];
368
369            // Validate required fields
370            assert!(!target.symbol.is_empty());
371
372            // Validate price target values
373            assert!(target.price_target > 0.0); // Should be positive
374
375            // Validate analyst name
376            assert!(!target.analyst_name.is_empty()); // Should not be empty string
377
378            // Validate company name
379            assert!(!target.analyst_company.is_empty()); // Should not be empty string
380        }
381    }
382
383    #[tokio::test]
384    #[ignore] // Requires API key
385    async fn test_grades_data_validation() {
386        let client = FmpClient::new().unwrap();
387        let result = client.analyst().get_grades("AAPL").await;
388        assert!(result.is_ok());
389
390        let grades = result.unwrap();
391        if !grades.is_empty() {
392            let grade = &grades[0];
393
394            // Validate symbol
395            assert_eq!(grade.symbol, "AAPL".to_string());
396
397            // Validate grade values
398            let new_grade = &grade.new_grade;
399            // Common analyst grades
400            let _valid_grades = vec![
401                "BUY",
402                "SELL",
403                "HOLD",
404                "STRONG_BUY",
405                "STRONG_SELL",
406                "OUTPERFORM",
407                "UNDERPERFORM",
408                "NEUTRAL",
409                "OVERWEIGHT",
410                "UNDERWEIGHT",
411            ];
412            // Should be a recognized grade or at least not empty
413            assert!(!new_grade.is_empty());
414        }
415    }
416
417    #[tokio::test]
418    #[ignore] // Requires API key
419    async fn test_multiple_symbols_consistency() {
420        let client = FmpClient::new().unwrap();
421
422        let symbols = vec!["AAPL", "MSFT", "GOOGL"];
423
424        for symbol in symbols {
425            let result = client.analyst().get_price_targets(symbol).await;
426            assert!(result.is_ok());
427
428            let targets = result.unwrap();
429            if !targets.is_empty() {
430                // Each result should be for the correct symbol
431                assert_eq!(targets[0].symbol, symbol.to_string());
432            }
433        }
434    }
435}