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