finance_query/models/financials/
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)]
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
69impl FinancialStatement {
70    /// Parse from raw Yahoo Finance JSON response
71    ///
72    /// Converts the nested Yahoo Finance response structure into a clean,
73    /// user-friendly format by extracting data from timeseries.result[].
74    pub(crate) fn from_response(
75        raw: &serde_json::Value,
76        symbol: &str,
77        statement_type: StatementType,
78        frequency: Frequency,
79    ) -> Result<Self> {
80        let raw_response: RawTimeseriesResponse =
81            serde_json::from_value(raw.clone()).map_err(|e| {
82                crate::error::YahooError::ResponseStructureError {
83                    field: "timeseries".to_string(),
84                    context: format!("Failed to parse financials response: {}", e),
85                }
86            })?;
87
88        if raw_response.timeseries.result.is_empty() {
89            return Err(crate::error::YahooError::SymbolNotFound {
90                symbol: Some(symbol.to_string()),
91                context: format!(
92                    "No {} {} data found",
93                    frequency.as_str(),
94                    statement_type.as_str()
95                ),
96            });
97        }
98
99        let mut statement: HashMap<String, HashMap<String, f64>> = HashMap::new();
100
101        for result in raw_response.timeseries.result {
102            // Get the metric name from meta.type (e.g., "annualTotalRevenue")
103            let metric_name_with_prefix =
104                result.meta.data_type.first().cloned().unwrap_or_default();
105
106            if metric_name_with_prefix.is_empty() {
107                continue;
108            }
109
110            // Remove frequency prefix (annual/quarterly/trailing) for storage
111            let metric_name = strip_frequency_prefix(&metric_name_with_prefix);
112
113            // Get the data array using the full metric name as key
114            let data_points = match result.data.get(&metric_name_with_prefix) {
115                Some(serde_json::Value::Array(arr)) => arr,
116                _ => continue,
117            };
118
119            let mut date_values: HashMap<String, f64> = HashMap::new();
120
121            for point in data_points {
122                if point.is_null() {
123                    continue;
124                }
125
126                let as_of_date = point
127                    .get("asOfDate")
128                    .and_then(|v| v.as_str())
129                    .unwrap_or_default();
130
131                if as_of_date.is_empty() {
132                    continue;
133                }
134
135                // Extract the raw value, handling Yahoo's nested structure
136                let value = extract_value(point.get("reportedValue"));
137
138                if let Some(v) = value {
139                    date_values.insert(as_of_date.to_string(), v);
140                }
141            }
142
143            if !date_values.is_empty() {
144                statement.insert(metric_name, date_values);
145            }
146        }
147
148        if statement.is_empty() {
149            return Err(crate::error::YahooError::SymbolNotFound {
150                symbol: Some(symbol.to_string()),
151                context: format!(
152                    "No {} {} data found",
153                    frequency.as_str(),
154                    statement_type.as_str()
155                ),
156            });
157        }
158
159        Ok(Self {
160            symbol: symbol.to_uppercase(),
161            statement_type: statement_type.as_str().to_string(),
162            frequency: frequency.as_str().to_string(),
163            statement,
164        })
165    }
166}
167
168/// Strip frequency prefix from metric name
169/// "annualTotalRevenue" -> "TotalRevenue"
170/// "quarterlyNetIncome" -> "NetIncome"
171fn strip_frequency_prefix(name: &str) -> String {
172    for prefix in &["annual", "quarterly", "trailing"] {
173        if let Some(stripped) = name.strip_prefix(prefix) {
174            return stripped.to_string();
175        }
176    }
177    name.to_string()
178}
179
180/// Extract numeric value from Yahoo's reportedValue structure
181/// Handles both simple: { "raw": 123.45 }
182/// And nested: { "raw": { "parsedValue": 123456789 } }
183fn extract_value(reported_value: Option<&serde_json::Value>) -> Option<f64> {
184    let rv = reported_value?;
185
186    // Try direct raw field first
187    if let Some(raw) = rv.get("raw") {
188        // Check if raw is a number
189        if let Some(n) = raw.as_f64() {
190            return Some(n);
191        }
192        // Check if raw is an object with parsedValue
193        if let Some(parsed) = raw.get("parsedValue") {
194            return parsed
195                .as_f64()
196                .or_else(|| parsed.as_i64().map(|i| i as f64));
197        }
198    }
199
200    None
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn test_strip_frequency_prefix() {
209        assert_eq!(strip_frequency_prefix("annualTotalRevenue"), "TotalRevenue");
210        assert_eq!(strip_frequency_prefix("quarterlyNetIncome"), "NetIncome");
211        assert_eq!(strip_frequency_prefix("trailingMarketCap"), "MarketCap");
212        assert_eq!(strip_frequency_prefix("SomeOther"), "SomeOther");
213    }
214
215    #[test]
216    fn test_extract_value_simple() {
217        let json: serde_json::Value = serde_json::json!({
218            "raw": 123.45,
219            "fmt": "123.45"
220        });
221        assert_eq!(extract_value(Some(&json)), Some(123.45));
222    }
223
224    #[test]
225    fn test_extract_value_nested() {
226        let json: serde_json::Value = serde_json::json!({
227            "raw": {
228                "source": "1.23E12",
229                "parsedValue": 1230000000000_i64
230            },
231            "fmt": "1.23T"
232        });
233        assert_eq!(extract_value(Some(&json)), Some(1230000000000.0));
234    }
235
236    #[test]
237    fn test_from_response() {
238        let json: serde_json::Value = serde_json::json!({
239            "timeseries": {
240                "result": [
241                    {
242                        "meta": {
243                            "symbol": ["AAPL"],
244                            "type": ["annualTotalRevenue"]
245                        },
246                        "annualTotalRevenue": [
247                            {
248                                "asOfDate": "2024-09-30",
249                                "periodType": "12M",
250                                "currencyCode": "USD",
251                                "reportedValue": {
252                                    "raw": 391035000000.0,
253                                    "fmt": "391.04B"
254                                }
255                            },
256                            {
257                                "asOfDate": "2023-09-30",
258                                "periodType": "12M",
259                                "currencyCode": "USD",
260                                "reportedValue": {
261                                    "raw": 383285000000.0,
262                                    "fmt": "383.29B"
263                                }
264                            }
265                        ]
266                    },
267                    {
268                        "meta": {
269                            "symbol": ["AAPL"],
270                            "type": ["annualNetIncome"]
271                        },
272                        "annualNetIncome": [
273                            {
274                                "asOfDate": "2024-09-30",
275                                "periodType": "12M",
276                                "currencyCode": "USD",
277                                "reportedValue": {
278                                    "raw": 100913000000.0,
279                                    "fmt": "100.91B"
280                                }
281                            }
282                        ]
283                    }
284                ],
285                "error": null
286            }
287        });
288
289        let result = FinancialStatement::from_response(
290            &json,
291            "AAPL",
292            StatementType::Income,
293            Frequency::Annual,
294        );
295        assert!(result.is_ok());
296
297        let statement = result.unwrap();
298        assert_eq!(statement.symbol, "AAPL");
299        assert_eq!(statement.statement_type, "income");
300        assert_eq!(statement.frequency, "annual");
301        assert!(statement.statement.contains_key("TotalRevenue"));
302        assert!(statement.statement.contains_key("NetIncome"));
303
304        let revenue = statement.statement.get("TotalRevenue").unwrap();
305        assert_eq!(revenue.get("2024-09-30"), Some(&391035000000.0));
306        assert_eq!(revenue.get("2023-09-30"), Some(&383285000000.0));
307    }
308
309    #[test]
310    fn test_from_response_empty() {
311        let json: serde_json::Value = serde_json::json!({
312            "timeseries": {
313                "result": [],
314                "error": null
315            }
316        });
317
318        let result = FinancialStatement::from_response(
319            &json,
320            "INVALID",
321            StatementType::Income,
322            Frequency::Annual,
323        );
324        assert!(result.is_err());
325    }
326}