Skip to main content

finance_query/models/fundamentals/
response.rs

1//! Financial Statement Response Models
2//!
3//! Flattened, user-friendly financial statement response.
4
5use crate::constants::{Frequency, StatementType};
6use crate::error::Result;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10/// Raw response structure from Yahoo Finance fundamentals-timeseries API
11#[derive(Debug, Clone, Deserialize)]
12struct RawTimeseriesResponse {
13    timeseries: RawTimeseries,
14}
15
16#[derive(Debug, Clone, Deserialize)]
17struct RawTimeseries {
18    result: Vec<RawTimeseriesResult>,
19    #[allow(dead_code)] // serde completeness field; never read
20    error: Option<serde_json::Value>,
21}
22
23#[derive(Debug, Clone, Deserialize)]
24struct RawTimeseriesResult {
25    meta: RawMeta,
26    #[serde(flatten)]
27    data: HashMap<String, serde_json::Value>,
28}
29
30#[derive(Debug, Clone, Deserialize)]
31struct RawMeta {
32    #[serde(rename = "type")]
33    data_type: Vec<String>,
34}
35
36/// A flattened, user-friendly financial statement
37///
38/// Transforms Yahoo Finance's complex nested response into a simple structure:
39/// ```json
40/// {
41///   "symbol": "AAPL",
42///   "statementType": "income",
43///   "frequency": "annual",
44///   "statement": {
45///     "TotalRevenue": { "2024-09-30": 391035000000, "2023-09-30": 383285000000 },
46///     "NetIncome": { "2024-09-30": 100913000000, "2023-09-30": 96995000000 }
47///   }
48/// }
49/// ```
50///
51/// This matches the Python finance-query API response format.
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
53#[serde(rename_all = "camelCase")]
54pub struct FinancialStatement {
55    /// Stock symbol
56    pub symbol: String,
57
58    /// Type of financial statement (income, balance, cashflow)
59    pub statement_type: String,
60
61    /// Frequency (annual or quarterly)
62    pub frequency: String,
63
64    /// Financial data: metric name -> (date -> value)
65    /// Example: { "TotalRevenue": { "2024-09-30": 391035000000 } }
66    pub statement: HashMap<String, HashMap<String, f64>>,
67
68    /// Which provider supplied this data (None = Yahoo Finance default)
69    pub provider_id: Option<crate::providers::Provider>,
70}
71
72impl FinancialStatement {
73    /// Parse from raw Yahoo Finance JSON response
74    ///
75    /// Converts the nested Yahoo Finance response structure into a clean,
76    /// user-friendly format by extracting data from timeseries.result[].
77    pub(crate) fn from_response(
78        raw: serde_json::Value,
79        symbol: &str,
80        statement_type: StatementType,
81        frequency: Frequency,
82    ) -> Result<Self> {
83        let raw_response: RawTimeseriesResponse = serde_json::from_value(raw).map_err(|e| {
84            crate::error::FinanceError::ResponseStructureError {
85                field: "timeseries".to_string(),
86                context: format!("Failed to parse financials response: {}", e),
87            }
88        })?;
89
90        if raw_response.timeseries.result.is_empty() {
91            return Err(crate::error::FinanceError::SymbolNotFound {
92                symbol: Some(symbol.to_string()),
93                context: format!(
94                    "No {} {} data found",
95                    frequency.as_str(),
96                    statement_type.as_str()
97                ),
98            });
99        }
100
101        let mut statement: HashMap<String, HashMap<String, f64>> = HashMap::new();
102
103        for result in raw_response.timeseries.result {
104            // Get the metric name from meta.type (e.g., "annualTotalRevenue")
105            let metric_name_with_prefix =
106                result.meta.data_type.first().cloned().unwrap_or_default();
107
108            if metric_name_with_prefix.is_empty() {
109                continue;
110            }
111
112            // Remove frequency prefix (annual/quarterly/trailing) for storage
113            let metric_name = strip_frequency_prefix(&metric_name_with_prefix);
114
115            // Get the data array using the full metric name as key
116            let data_points = match result.data.get(&metric_name_with_prefix) {
117                Some(serde_json::Value::Array(arr)) => arr,
118                _ => continue,
119            };
120
121            let mut date_values: HashMap<String, f64> = HashMap::new();
122
123            for point in data_points {
124                if point.is_null() {
125                    continue;
126                }
127
128                let as_of_date = point
129                    .get("asOfDate")
130                    .and_then(|v| v.as_str())
131                    .unwrap_or_default();
132
133                if as_of_date.is_empty() {
134                    continue;
135                }
136
137                // Extract the raw value, handling Yahoo's nested structure
138                let value = extract_value(point.get("reportedValue"));
139
140                if let Some(v) = value {
141                    date_values.insert(as_of_date.to_string(), v);
142                }
143            }
144
145            if !date_values.is_empty() {
146                statement.insert(metric_name, date_values);
147            }
148        }
149
150        if statement.is_empty() {
151            return Err(crate::error::FinanceError::SymbolNotFound {
152                symbol: Some(symbol.to_string()),
153                context: format!(
154                    "No {} {} data found",
155                    frequency.as_str(),
156                    statement_type.as_str()
157                ),
158            });
159        }
160
161        Ok(Self {
162            symbol: symbol.to_uppercase(),
163            statement_type: statement_type.as_str().to_string(),
164            frequency: frequency.as_str().to_string(),
165            statement,
166            provider_id: None,
167        })
168    }
169}
170
171/// Strip frequency prefix from metric name
172/// "annualTotalRevenue" -> "TotalRevenue"
173/// "quarterlyNetIncome" -> "NetIncome"
174fn strip_frequency_prefix(name: &str) -> String {
175    for prefix in &["annual", "quarterly", "trailing"] {
176        if let Some(stripped) = name.strip_prefix(prefix) {
177            return stripped.to_string();
178        }
179    }
180    name.to_string()
181}
182
183/// Extract numeric value from Yahoo's reportedValue structure
184/// Handles both simple: { "raw": 123.45 }
185/// And nested: { "raw": { "parsedValue": 123456789 } }
186fn extract_value(reported_value: Option<&serde_json::Value>) -> Option<f64> {
187    let rv = reported_value?;
188
189    // Try direct raw field first
190    if let Some(raw) = rv.get("raw") {
191        // Check if raw is a number
192        if let Some(n) = raw.as_f64() {
193            return Some(n);
194        }
195        // Check if raw is an object with parsedValue
196        if let Some(parsed) = raw.get("parsedValue") {
197            return parsed
198                .as_f64()
199                .or_else(|| parsed.as_i64().map(|i| i as f64));
200        }
201    }
202
203    None
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn test_strip_frequency_prefix() {
212        assert_eq!(strip_frequency_prefix("annualTotalRevenue"), "TotalRevenue");
213        assert_eq!(strip_frequency_prefix("quarterlyNetIncome"), "NetIncome");
214        assert_eq!(strip_frequency_prefix("trailingMarketCap"), "MarketCap");
215        assert_eq!(strip_frequency_prefix("SomeOther"), "SomeOther");
216    }
217
218    #[test]
219    fn test_extract_value_simple() {
220        let json: serde_json::Value = serde_json::json!({
221            "raw": 123.45,
222            "fmt": "123.45"
223        });
224        assert_eq!(extract_value(Some(&json)), Some(123.45));
225    }
226
227    #[test]
228    fn test_extract_value_nested() {
229        let json: serde_json::Value = serde_json::json!({
230            "raw": {
231                "source": "1.23E12",
232                "parsedValue": 1230000000000_i64
233            },
234            "fmt": "1.23T"
235        });
236        assert_eq!(extract_value(Some(&json)), Some(1230000000000.0));
237    }
238
239    #[test]
240    fn test_from_response() {
241        let json: serde_json::Value = serde_json::json!({
242            "timeseries": {
243                "result": [
244                    {
245                        "meta": {
246                            "symbol": ["AAPL"],
247                            "type": ["annualTotalRevenue"]
248                        },
249                        "annualTotalRevenue": [
250                            {
251                                "asOfDate": "2024-09-30",
252                                "periodType": "12M",
253                                "currencyCode": "USD",
254                                "reportedValue": {
255                                    "raw": 391035000000.0,
256                                    "fmt": "391.04B"
257                                }
258                            },
259                            {
260                                "asOfDate": "2023-09-30",
261                                "periodType": "12M",
262                                "currencyCode": "USD",
263                                "reportedValue": {
264                                    "raw": 383285000000.0,
265                                    "fmt": "383.29B"
266                                }
267                            }
268                        ]
269                    },
270                    {
271                        "meta": {
272                            "symbol": ["AAPL"],
273                            "type": ["annualNetIncome"]
274                        },
275                        "annualNetIncome": [
276                            {
277                                "asOfDate": "2024-09-30",
278                                "periodType": "12M",
279                                "currencyCode": "USD",
280                                "reportedValue": {
281                                    "raw": 100913000000.0,
282                                    "fmt": "100.91B"
283                                }
284                            }
285                        ]
286                    }
287                ],
288                "error": null
289            }
290        });
291
292        let result = FinancialStatement::from_response(
293            json,
294            "AAPL",
295            StatementType::Income,
296            Frequency::Annual,
297        );
298        assert!(result.is_ok());
299
300        let statement = result.unwrap();
301        assert_eq!(statement.symbol, "AAPL");
302        assert_eq!(statement.statement_type, "income");
303        assert_eq!(statement.frequency, "annual");
304        assert!(statement.statement.contains_key("TotalRevenue"));
305        assert!(statement.statement.contains_key("NetIncome"));
306
307        let revenue = statement.statement.get("TotalRevenue").unwrap();
308        assert_eq!(revenue.get("2024-09-30"), Some(&391035000000.0));
309        assert_eq!(revenue.get("2023-09-30"), Some(&383285000000.0));
310    }
311
312    #[test]
313    fn test_from_response_empty() {
314        let json: serde_json::Value = serde_json::json!({
315            "timeseries": {
316                "result": [],
317                "error": null
318            }
319        });
320
321        let result = FinancialStatement::from_response(
322            json,
323            "INVALID",
324            StatementType::Income,
325            Frequency::Annual,
326        );
327        assert!(result.is_err());
328    }
329}