fmp_rs/endpoints/
transcripts.rs

1//! Transcripts & Communications endpoints
2
3use crate::{client::FmpClient, error::Result, models::transcripts::*};
4use serde::Serialize;
5
6/// Transcripts & Communications API endpoints
7pub struct Transcripts {
8    client: FmpClient,
9}
10
11impl Transcripts {
12    pub(crate) fn new(client: FmpClient) -> Self {
13        Self { client }
14    }
15
16    /// Get earnings call transcripts list
17    ///
18    /// Returns a list of available earnings call transcripts for a company.
19    /// Useful for discovering available transcripts before fetching full content.
20    ///
21    /// # Arguments
22    /// * `symbol` - Company symbol (e.g., "AAPL")
23    /// * `year` - Optional year filter (e.g., 2024)
24    ///
25    /// # Example
26    /// ```no_run
27    /// # use fmp_rs::FmpClient;
28    /// # #[tokio::main]
29    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
30    /// let client = FmpClient::new()?;
31    /// let transcripts = client.transcripts().get_transcript_list("AAPL", Some(2024)).await?;
32    /// for transcript in transcripts.iter().take(5) {
33    ///     println!("{} {} {}: {}",
34    ///         transcript.symbol.as_deref().unwrap_or("N/A"),
35    ///         transcript.quarter.as_deref().unwrap_or("N/A"),
36    ///         transcript.year.unwrap_or(0),
37    ///         transcript.date.as_deref().unwrap_or("N/A"));
38    /// }
39    /// # Ok(())
40    /// # }
41    /// ```
42    pub async fn get_transcript_list(
43        &self,
44        symbol: &str,
45        year: Option<i32>,
46    ) -> Result<Vec<TranscriptSummary>> {
47        #[derive(Serialize)]
48        struct Query<'a> {
49            #[serde(skip_serializing_if = "Option::is_none")]
50            year: Option<i32>,
51            apikey: &'a str,
52        }
53
54        let url = self
55            .client
56            .build_url(&format!("/earning_call_transcript/{}", symbol));
57        self.client
58            .get_with_query(
59                &url,
60                &Query {
61                    year,
62                    apikey: self.client.api_key(),
63                },
64            )
65            .await
66    }
67
68    /// Get full earnings call transcript
69    ///
70    /// Returns the complete transcript content for a specific earnings call.
71    /// Contains the full Q&A session with management and analysts.
72    ///
73    /// # Arguments
74    /// * `symbol` - Company symbol (e.g., "AAPL")
75    /// * `year` - Year of the earnings call
76    /// * `quarter` - Quarter number (1, 2, 3, or 4)
77    ///
78    /// # Example
79    /// ```no_run
80    /// # use fmp_rs::FmpClient;
81    /// # #[tokio::main]
82    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
83    /// let client = FmpClient::new()?;
84    /// let transcript = client.transcripts().get_earnings_transcript("AAPL", 2024, 1).await?;
85    /// if let Some(call) = transcript.first() {
86    ///     println!("Transcript for {} {} {}:",
87    ///         call.symbol.as_deref().unwrap_or("N/A"),
88    ///         call.quarter.as_deref().unwrap_or("N/A"),
89    ///         call.year.unwrap_or(0));
90    ///     if let Some(content) = &call.content {
91    ///         println!("Content length: {} characters", content.len());
92    ///     }
93    /// }
94    /// # Ok(())
95    /// # }
96    /// ```
97    pub async fn get_earnings_transcript(
98        &self,
99        symbol: &str,
100        year: i32,
101        quarter: i32,
102    ) -> Result<Vec<EarningsTranscript>> {
103        #[derive(Serialize)]
104        struct Query<'a> {
105            year: i32,
106            quarter: i32,
107            apikey: &'a str,
108        }
109
110        let url = self
111            .client
112            .build_url(&format!("/earning_call_transcript/{}", symbol));
113        self.client
114            .get_with_query(
115                &url,
116                &Query {
117                    year,
118                    quarter,
119                    apikey: self.client.api_key(),
120                },
121            )
122            .await
123    }
124
125    /// Get press releases for a company
126    ///
127    /// Returns recent press releases and corporate announcements.
128    /// Useful for staying updated on company news and developments.
129    ///
130    /// # Arguments
131    /// * `symbol` - Company symbol (e.g., "AAPL")
132    /// * `limit` - Optional limit on number of results (default: 100)
133    ///
134    /// # Example
135    /// ```no_run
136    /// # use fmp_rs::FmpClient;
137    /// # #[tokio::main]
138    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
139    /// let client = FmpClient::new()?;
140    /// let releases = client.transcripts().get_press_releases("AAPL", Some(10)).await?;
141    /// for release in releases.iter().take(5) {
142    ///     println!("{}: {}",
143    ///         release.date.as_deref().unwrap_or("N/A"),
144    ///         release.title.as_deref().unwrap_or("No title"));
145    ///     if let Some(summary) = &release.summary {
146    ///         println!("  Summary: {}", summary);
147    ///     }
148    /// }
149    /// # Ok(())
150    /// # }
151    /// ```
152    pub async fn get_press_releases(
153        &self,
154        symbol: &str,
155        limit: Option<i32>,
156    ) -> Result<Vec<PressRelease>> {
157        #[derive(Serialize)]
158        struct Query<'a> {
159            #[serde(skip_serializing_if = "Option::is_none")]
160            limit: Option<i32>,
161            apikey: &'a str,
162        }
163
164        let url = self
165            .client
166            .build_url(&format!("/press-releases/{}", symbol));
167        self.client
168            .get_with_query(
169                &url,
170                &Query {
171                    limit,
172                    apikey: self.client.api_key(),
173                },
174            )
175            .await
176    }
177
178    /// Get conference call schedule
179    ///
180    /// Returns upcoming and recent conference calls across companies.
181    /// Useful for tracking earnings dates and investor events.
182    ///
183    /// # Arguments
184    /// * `from_date` - Optional start date (YYYY-MM-DD format)
185    /// * `to_date` - Optional end date (YYYY-MM-DD format)
186    ///
187    /// # Example
188    /// ```no_run
189    /// # use fmp_rs::FmpClient;
190    /// # #[tokio::main]
191    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
192    /// let client = FmpClient::new()?;
193    /// let calls = client.transcripts()
194    ///     .get_conference_schedule(Some("2024-01-01"), Some("2024-01-31")).await?;
195    /// for call in calls.iter().take(10) {
196    ///     println!("{}: {} - {}",
197    ///         call.date_time.as_deref().unwrap_or("N/A"),
198    ///         call.symbol.as_deref().unwrap_or("N/A"),
199    ///         call.title.as_deref().unwrap_or("No title"));
200    /// }
201    /// # Ok(())
202    /// # }
203    /// ```
204    pub async fn get_conference_schedule(
205        &self,
206        from_date: Option<&str>,
207        to_date: Option<&str>,
208    ) -> Result<Vec<ConferenceCall>> {
209        #[derive(Serialize)]
210        struct Query<'a> {
211            #[serde(skip_serializing_if = "Option::is_none")]
212            from: Option<&'a str>,
213            #[serde(skip_serializing_if = "Option::is_none")]
214            to: Option<&'a str>,
215            apikey: &'a str,
216        }
217
218        let url = self.client.build_url("/earning_calendar");
219        self.client
220            .get_with_query(
221                &url,
222                &Query {
223                    from: from_date,
224                    to: to_date,
225                    apikey: self.client.api_key(),
226                },
227            )
228            .await
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    fn create_test_client() -> FmpClient {
237        FmpClient::builder().api_key("test_key").build().unwrap()
238    }
239
240    #[test]
241    fn test_new() {
242        let client = create_test_client();
243        let transcripts = Transcripts::new(client);
244        // Test passes if no panic occurs
245    }
246
247    #[tokio::test]
248    #[ignore] // Requires API key
249    async fn test_get_transcript_list() {
250        let client = FmpClient::new().unwrap();
251        let result = client
252            .transcripts()
253            .get_transcript_list("AAPL", Some(2024))
254            .await;
255        assert!(result.is_ok());
256
257        let transcripts = result.unwrap();
258        if !transcripts.is_empty() {
259            let first_transcript = &transcripts[0];
260            assert!(first_transcript.symbol.is_some());
261            assert!(first_transcript.quarter.is_some() || first_transcript.year.is_some());
262            println!("Found {} transcript summaries for AAPL", transcripts.len());
263        }
264    }
265
266    #[tokio::test]
267    #[ignore] // Requires API key
268    async fn test_get_transcript_list_no_year() {
269        let client = FmpClient::new().unwrap();
270        let result = client.transcripts().get_transcript_list("AAPL", None).await;
271        assert!(result.is_ok());
272
273        let transcripts = result.unwrap();
274        println!(
275            "Found {} total transcript summaries for AAPL",
276            transcripts.len()
277        );
278    }
279
280    #[tokio::test]
281    #[ignore] // Requires API key
282    async fn test_get_earnings_transcript() {
283        let client = FmpClient::new().unwrap();
284        let result = client
285            .transcripts()
286            .get_earnings_transcript("AAPL", 2023, 4)
287            .await;
288        assert!(result.is_ok());
289
290        let transcripts = result.unwrap();
291        if let Some(transcript) = transcripts.first() {
292            assert_eq!(transcript.symbol.as_deref(), Some("AAPL"));
293            assert_eq!(transcript.year, Some(2023));
294
295            if let Some(content) = &transcript.content {
296                assert!(!content.is_empty());
297                println!("Transcript content length: {} characters", content.len());
298            }
299        }
300    }
301
302    #[tokio::test]
303    #[ignore] // Requires API key
304    async fn test_get_press_releases() {
305        let client = FmpClient::new().unwrap();
306        let result = client
307            .transcripts()
308            .get_press_releases("AAPL", Some(10))
309            .await;
310        assert!(result.is_ok());
311
312        let releases = result.unwrap();
313        if !releases.is_empty() {
314            let first_release = &releases[0];
315            assert!(first_release.symbol.is_some() || first_release.company_name.is_some());
316            assert!(first_release.title.is_some());
317            assert!(first_release.date.is_some());
318            println!("Found {} press releases for AAPL", releases.len());
319        }
320    }
321
322    #[tokio::test]
323    #[ignore] // Requires API key
324    async fn test_get_press_releases_no_limit() {
325        let client = FmpClient::new().unwrap();
326        let result = client.transcripts().get_press_releases("MSFT", None).await;
327        assert!(result.is_ok());
328
329        let releases = result.unwrap();
330        println!("Found {} total press releases for MSFT", releases.len());
331    }
332
333    #[tokio::test]
334    #[ignore] // Requires API key
335    async fn test_get_conference_schedule() {
336        let client = FmpClient::new().unwrap();
337        let result = client
338            .transcripts()
339            .get_conference_schedule(Some("2024-01-01"), Some("2024-01-31"))
340            .await;
341        assert!(result.is_ok());
342
343        let calls = result.unwrap();
344        if !calls.is_empty() {
345            let first_call = &calls[0];
346            assert!(first_call.symbol.is_some());
347            assert!(first_call.date_time.is_some() || first_call.date_time.is_some());
348            println!("Found {} conference calls in January 2024", calls.len());
349        }
350    }
351
352    #[tokio::test]
353    #[ignore] // Requires API key
354    async fn test_get_conference_schedule_no_dates() {
355        let client = FmpClient::new().unwrap();
356        let result = client
357            .transcripts()
358            .get_conference_schedule(None, None)
359            .await;
360        assert!(result.is_ok());
361
362        let calls = result.unwrap();
363        println!("Found {} total upcoming conference calls", calls.len());
364    }
365
366    #[test]
367    fn test_transcript_models_serialization() {
368        use serde_json;
369
370        // Test EarningsTranscript model
371        let transcript = EarningsTranscript {
372            symbol: Some("AAPL".to_string()),
373            quarter: Some("Q1".to_string()),
374            year: Some(2024),
375            date: Some("2024-02-01".to_string()),
376            content: Some("Thank you for joining Apple's Q1 2024 earnings call...".to_string()),
377            company_name: Some("Apple Inc.".to_string()),
378            fiscal_quarter: Some("Q1".to_string()),
379            fiscal_year: Some(2024),
380            transcript_id: Some("AAPL-2024-Q1".to_string()),
381            language: Some("English".to_string()),
382            duration: Some(60),
383            analyst_count: Some(15),
384            call_type: Some("Earnings".to_string()),
385        };
386
387        let json = serde_json::to_string(&transcript).unwrap();
388        let deserialized: EarningsTranscript = serde_json::from_str(&json).unwrap();
389        assert_eq!(deserialized.symbol, Some("AAPL".to_string()));
390        assert_eq!(deserialized.year, Some(2024));
391
392        // Test PressRelease model
393        let release = PressRelease {
394            symbol: Some("AAPL".to_string()),
395            company_name: Some("Apple Inc.".to_string()),
396            title: Some("Apple Reports Record Q1 Results".to_string()),
397            date: Some("2024-02-01".to_string()),
398            content: Some("Apple today announced financial results for Q1...".to_string()),
399            release_type: Some("Earnings".to_string()),
400            source: Some("Business Wire".to_string()),
401            url: Some("https://investor.apple.com/news/press-release-details/2024/Apple-Reports-Record-Q1-Results/default.aspx".to_string()),
402            language: Some("English".to_string()),
403            word_count: Some(1250),
404            summary: Some("Apple reported record Q1 revenue of $119.6 billion...".to_string()),
405            related_symbols: Some(vec!["AAPL".to_string(), "NASDAQ".to_string()]),
406            tags: Some(vec!["Earnings".to_string(), "Technology".to_string()]),
407        };
408
409        let json = serde_json::to_string(&release).unwrap();
410        let deserialized: PressRelease = serde_json::from_str(&json).unwrap();
411        assert_eq!(deserialized.symbol, Some("AAPL".to_string()));
412        assert_eq!(
413            deserialized.title,
414            Some("Apple Reports Record Q1 Results".to_string())
415        );
416    }
417
418    #[test]
419    fn test_conference_call_model() {
420        let call = ConferenceCall {
421            symbol: Some("AAPL".to_string()),
422            company_name: Some("Apple Inc.".to_string()),
423            title: Some("Q1 2024 Earnings Call".to_string()),
424            date_time: Some("2024-02-01 17:00:00".to_string()),
425            call_type: Some("Earnings".to_string()),
426            quarter: Some("Q1".to_string()),
427            fiscal_year: Some(2024),
428            year: Some(2024),
429            timezone: Some("EST".to_string()),
430            dial_in_info: Some("1-800-123-4567, Conference ID: 12345".to_string()),
431            webcast_url: Some("https://investor.apple.com/webcast".to_string()),
432            estimated_duration: Some(60),
433            participants: Some(vec![
434                "Tim Cook - CEO".to_string(),
435                "Luca Maestri - CFO".to_string(),
436            ]),
437            status: Some("Scheduled".to_string()),
438            industry: Some("Technology".to_string()),
439            market_cap_category: Some("Large Cap".to_string()),
440        };
441
442        // Verify all fields are accessible
443        assert_eq!(call.symbol, Some("AAPL".to_string()));
444        assert_eq!(call.call_type, Some("Earnings".to_string()));
445        assert_eq!(call.estimated_duration, Some(60));
446
447        let json = serde_json::to_string(&call).unwrap();
448        let deserialized: ConferenceCall = serde_json::from_str(&json).unwrap();
449        assert_eq!(deserialized.symbol, Some("AAPL".to_string()));
450    }
451
452    #[test]
453    fn test_date_parameter_validation() {
454        // Test that date parameters are properly formatted
455        let start_date = "2024-01-01";
456        let end_date = "2024-12-31";
457
458        // These should be valid ISO date formats
459        assert!(chrono::NaiveDate::parse_from_str(start_date, "%Y-%m-%d").is_ok());
460        assert!(chrono::NaiveDate::parse_from_str(end_date, "%Y-%m-%d").is_ok());
461    }
462
463    #[test]
464    fn test_quarter_validation() {
465        let valid_quarters = [1, 2, 3, 4];
466        for quarter in valid_quarters {
467            assert!(
468                quarter >= 1 && quarter <= 4,
469                "Quarter {} should be valid",
470                quarter
471            );
472        }
473
474        let invalid_quarters = [0, 5, -1, 13];
475        for quarter in invalid_quarters {
476            assert!(
477                !(quarter >= 1 && quarter <= 4),
478                "Quarter {} should be invalid",
479                quarter
480            );
481        }
482    }
483}