Skip to main content

finance_query/models/corporate/transcript/
mod.rs

1//! Earnings call transcript models.
2//!
3//! Typed models for Yahoo Finance earnings call transcripts.
4
5use serde::{Deserialize, Serialize};
6
7/// Full transcript response from Yahoo Finance.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9#[serde(rename_all = "camelCase")]
10pub struct Transcript {
11    /// The transcript content including speakers and paragraphs.
12    pub transcript_content: TranscriptContent,
13    /// Metadata about the transcript.
14    pub transcript_metadata: TranscriptMetadata,
15}
16
17impl Transcript {
18    /// Get the full transcript text.
19    pub fn text(&self) -> &str {
20        self.transcript_content
21            .transcript
22            .as_ref()
23            .map(|t| t.text.as_str())
24            .unwrap_or("")
25    }
26
27    /// Get the fiscal quarter (e.g., "Q4").
28    pub fn quarter(&self) -> &str {
29        &self.transcript_metadata.fiscal_period
30    }
31
32    /// Get the fiscal year.
33    pub fn year(&self) -> i32 {
34        self.transcript_metadata.fiscal_year
35    }
36
37    /// Get speaker name by speaker ID.
38    pub fn speaker_name(&self, speaker_id: i32) -> Option<&str> {
39        self.transcript_content
40            .speaker_mapping
41            .iter()
42            .find(|s| s.speaker == speaker_id)
43            .map(|s| s.speaker_data.name.as_str())
44    }
45
46    /// Populate per-paragraph sentiment scores in place (VADER lexicon-based).
47    ///
48    /// Only available when the `sentiment` feature is enabled. Called
49    /// automatically when a transcript is fetched.
50    #[cfg(feature = "sentiment")]
51    pub(crate) fn score_sentiment(&mut self) {
52        if let Some(t) = self.transcript_content.transcript.as_mut() {
53            for p in t.paragraphs.iter_mut() {
54                p.sentiment = Some(crate::models::sentiment::analyze(&p.text));
55            }
56        }
57    }
58
59    /// Aggregate sentiment across the whole call, weighted by paragraph length.
60    ///
61    /// Returns a neutral, zero-confidence score for an empty transcript. Only
62    /// available when the `sentiment` feature is enabled.
63    #[cfg(feature = "sentiment")]
64    pub fn overall_sentiment(&self) -> crate::models::sentiment::Sentiment {
65        let texts: Vec<&str> = self
66            .transcript_content
67            .transcript
68            .as_ref()
69            .map(|t| t.paragraphs.iter().map(|p| p.text.as_str()).collect())
70            .unwrap_or_default();
71        crate::models::sentiment::aggregate_weighted(&texts)
72            .unwrap_or_else(crate::models::sentiment::Sentiment::neutral)
73    }
74
75    /// Get all paragraphs with speaker names resolved.
76    pub fn paragraphs_with_speakers(&self) -> Vec<(&Paragraph, Option<&str>)> {
77        self.transcript_content
78            .transcript
79            .as_ref()
80            .map(|t| {
81                t.paragraphs
82                    .iter()
83                    .map(|p| (p, self.speaker_name(p.speaker)))
84                    .collect()
85            })
86            .unwrap_or_default()
87    }
88}
89
90/// Transcript content including speakers and full transcript.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct TranscriptContent {
93    /// Company ID (quartrId).
94    pub company_id: i64,
95    /// Event ID for this transcript.
96    pub event_id: i64,
97    /// Version of the transcript format.
98    #[serde(default)]
99    pub version: Option<String>,
100    /// Mapping of speaker IDs to speaker information.
101    #[serde(default)]
102    pub speaker_mapping: Vec<SpeakerMapping>,
103    /// The full transcript data.
104    #[serde(default)]
105    pub transcript: Option<TranscriptData>,
106}
107
108/// Mapping of a speaker ID to speaker information.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct SpeakerMapping {
111    /// Speaker ID (referenced in paragraphs).
112    pub speaker: i32,
113    /// Speaker details.
114    pub speaker_data: SpeakerData,
115}
116
117/// Information about a speaker.
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct SpeakerData {
120    /// Company the speaker represents.
121    #[serde(default)]
122    pub company: Option<String>,
123    /// Speaker's name.
124    #[serde(default)]
125    pub name: String,
126    /// Speaker's role/title.
127    #[serde(default)]
128    pub role: Option<String>,
129}
130
131/// Full transcript data with paragraphs.
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct TranscriptData {
134    /// Number of speakers in the call.
135    #[serde(default)]
136    pub number_of_speakers: i32,
137    /// Full text of the transcript.
138    #[serde(default)]
139    pub text: String,
140    /// Paragraphs (sections spoken by each speaker).
141    #[serde(default)]
142    pub paragraphs: Vec<Paragraph>,
143}
144
145/// A paragraph (section spoken by one speaker).
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct Paragraph {
148    /// Speaker ID (use speaker_mapping to get name).
149    #[serde(default)]
150    pub speaker: i32,
151    /// Start time in seconds.
152    #[serde(default)]
153    pub start: f64,
154    /// End time in seconds.
155    #[serde(default)]
156    pub end: f64,
157    /// Full text of this paragraph.
158    #[serde(default)]
159    pub text: String,
160    /// Sentences in this paragraph.
161    #[serde(default)]
162    pub sentences: Vec<Sentence>,
163    /// Sentiment score for this paragraph (VADER lexicon-based).
164    /// Only present when the `sentiment` feature is enabled.
165    #[cfg(feature = "sentiment")]
166    #[serde(skip_serializing_if = "Option::is_none", default)]
167    pub sentiment: Option<crate::models::sentiment::Sentiment>,
168}
169
170/// A sentence within a paragraph.
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct Sentence {
173    /// Start time in seconds.
174    #[serde(default)]
175    pub start: f64,
176    /// End time in seconds.
177    #[serde(default)]
178    pub end: f64,
179    /// Text of the sentence.
180    #[serde(default)]
181    pub text: String,
182    /// Individual words with timing and confidence.
183    #[serde(default)]
184    pub words: Vec<Word>,
185}
186
187/// A word with timing and confidence information.
188#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct Word {
190    /// The word (lowercase).
191    #[serde(default)]
192    pub word: String,
193    /// The word with punctuation.
194    #[serde(default)]
195    pub punctuated_word: String,
196    /// Start time in seconds.
197    #[serde(default)]
198    pub start: f64,
199    /// End time in seconds.
200    #[serde(default)]
201    pub end: f64,
202    /// Confidence score (0.0 - 1.0).
203    #[serde(default)]
204    pub confidence: f64,
205}
206
207/// Metadata about the transcript.
208#[derive(Debug, Clone, Serialize, Deserialize)]
209#[serde(rename_all = "camelCase")]
210pub struct TranscriptMetadata {
211    /// Date of the earnings call (Unix timestamp).
212    #[serde(default)]
213    pub date: i64,
214    /// Event ID.
215    #[serde(default)]
216    pub event_id: i64,
217    /// Type of event (e.g., "Earnings Call").
218    #[serde(default)]
219    pub event_type: String,
220    /// Fiscal period (e.g., "Q4").
221    #[serde(default)]
222    pub fiscal_period: String,
223    /// Fiscal year.
224    #[serde(default)]
225    pub fiscal_year: i32,
226    /// Whether this is the latest transcript.
227    #[serde(default)]
228    pub is_latest: bool,
229    /// S3 URL for the transcript data.
230    #[serde(default)]
231    pub s3_url: String,
232    /// Title (e.g., "Q4 2025").
233    #[serde(default)]
234    pub title: String,
235    /// Transcript ID.
236    #[serde(default)]
237    pub transcript_id: i64,
238    /// Transcript type (e.g., "IN_HOUSE").
239    #[serde(default, rename = "type")]
240    pub transcript_type: String,
241    /// Last updated timestamp.
242    #[serde(default)]
243    pub updated: i64,
244}
245
246/// Transcript with metadata from the earnings call list.
247///
248/// Used when fetching multiple transcripts.
249#[derive(Debug, Clone, Serialize, Deserialize)]
250#[serde(rename_all = "camelCase")]
251pub struct TranscriptWithMeta {
252    /// Event ID.
253    pub event_id: String,
254    /// Fiscal quarter (e.g., "Q1", "Q2", "Q3", "Q4").
255    pub quarter: Option<String>,
256    /// Fiscal year.
257    pub year: Option<i32>,
258    /// Title of the earnings call.
259    pub title: String,
260    /// URL to the earnings call page.
261    pub url: String,
262    /// The full transcript.
263    pub transcript: Transcript,
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn test_deserialize_transcript() {
272        let json = r#"{
273            "transcriptContent": {
274                "company_id": 4742,
275                "event_id": 369370,
276                "version": "1.0.0",
277                "speaker_mapping": [
278                    {
279                        "speaker": 0,
280                        "speaker_data": {
281                            "company": "Apple",
282                            "name": "Tim Cook",
283                            "role": "CEO"
284                        }
285                    }
286                ],
287                "transcript": {
288                    "number_of_speakers": 15,
289                    "text": "Hello everyone...",
290                    "paragraphs": []
291                }
292            },
293            "transcriptMetadata": {
294                "date": 1761858000,
295                "eventId": 369370,
296                "eventType": "Earnings Call",
297                "fiscalPeriod": "Q4",
298                "fiscalYear": 2025,
299                "isLatest": true,
300                "title": "Q4 2025"
301            }
302        }"#;
303
304        let transcript: Transcript = serde_json::from_str(json).unwrap();
305        assert_eq!(transcript.transcript_content.company_id, 4742);
306        assert_eq!(transcript.quarter(), "Q4");
307        assert_eq!(transcript.year(), 2025);
308        assert_eq!(transcript.speaker_name(0), Some("Tim Cook"));
309    }
310}