Skip to main content

finance_query/models/edgar/
company_facts.rs

1//! EDGAR Company Facts (XBRL) models.
2//!
3//! Models for structured XBRL financial data from
4//! `https://data.sec.gov/api/xbrl/companyfacts/CIK{padded}.json`.
5//!
6//! This data includes historical financial statement values (revenue, assets,
7//! liabilities, etc.) extracted from 10-K and 10-Q filings.
8
9use serde::{Deserialize, Deserializer, Serialize};
10use std::collections::HashMap;
11
12/// Deserialize CIK that may come as a number or a zero-padded string.
13fn deserialize_cik<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<u64>, D::Error> {
14    #[derive(Deserialize)]
15    #[serde(untagged)]
16    enum CikValue {
17        Num(u64),
18        Str(String),
19    }
20
21    match Option::<CikValue>::deserialize(deserializer)? {
22        Some(CikValue::Num(n)) => Ok(Some(n)),
23        Some(CikValue::Str(s)) => s
24            .trim_start_matches('0')
25            .parse::<u64>()
26            .map(Some)
27            .map_err(serde::de::Error::custom),
28        None => Ok(None),
29    }
30}
31
32/// Complete company facts response containing all XBRL financial data.
33///
34/// Facts are organized by taxonomy (e.g., `us-gaap`, `ifrs-full`, `dei`).
35/// Use the convenience methods to access common taxonomies.
36///
37/// # Example
38///
39/// ```no_run
40/// # use finance_query::CompanyFacts;
41/// # fn example(facts: CompanyFacts) {
42/// // Get US-GAAP revenue data
43/// if let Some(revenue) = facts.get_us_gaap_fact("Revenue") {
44///     for (unit, values) in &revenue.units {
45///         println!("Unit: {}, data points: {}", unit, values.len());
46///     }
47/// }
48/// # }
49/// ```
50#[derive(Debug, Clone, Serialize, Deserialize)]
51#[non_exhaustive]
52pub struct CompanyFacts {
53    /// CIK number (SEC returns this as either a number or a zero-padded string)
54    #[serde(default, deserialize_with = "deserialize_cik")]
55    pub cik: Option<u64>,
56
57    /// Company name
58    #[serde(default, rename = "entityName")]
59    pub entity_name: Option<String>,
60
61    /// Facts organized by taxonomy (e.g., "us-gaap", "ifrs-full", "dei")
62    #[serde(default)]
63    pub facts: HashMap<String, FactsByTaxonomy>,
64}
65
66impl CompanyFacts {
67    /// Get US-GAAP facts (most common for US-listed companies).
68    pub fn us_gaap(&self) -> Option<&FactsByTaxonomy> {
69        self.facts.get("us-gaap")
70    }
71
72    /// Get a specific fact concept from the US-GAAP taxonomy.
73    ///
74    /// Common concepts: `"Revenue"`, `"Assets"`, `"Liabilities"`,
75    /// `"NetIncomeLoss"`, `"EarningsPerShareBasic"`, `"StockholdersEquity"`.
76    pub fn get_us_gaap_fact(&self, concept: &str) -> Option<&FactConcept> {
77        self.us_gaap().and_then(|gaap| gaap.0.get(concept))
78    }
79
80    /// Get IFRS facts (for companies reporting under International Financial Reporting Standards).
81    pub fn ifrs(&self) -> Option<&FactsByTaxonomy> {
82        self.facts.get("ifrs-full")
83    }
84
85    /// Get DEI (Document and Entity Information) facts.
86    pub fn dei(&self) -> Option<&FactsByTaxonomy> {
87        self.facts.get("dei")
88    }
89}
90
91/// Facts within a single taxonomy (e.g., "us-gaap").
92///
93/// Maps concept names (e.g., "Revenue", "Assets") to their [`FactConcept`].
94#[derive(Debug, Clone, Serialize, Deserialize)]
95#[non_exhaustive]
96pub struct FactsByTaxonomy(pub HashMap<String, FactConcept>);
97
98/// A single XBRL concept (e.g., "Revenue") with all reported values.
99///
100/// Values are organized by unit of measure (e.g., "USD", "shares", "pure").
101#[derive(Debug, Clone, Serialize, Deserialize)]
102#[non_exhaustive]
103pub struct FactConcept {
104    /// Human-readable label
105    #[serde(default)]
106    pub label: Option<String>,
107
108    /// Description of this concept
109    #[serde(default)]
110    pub description: Option<String>,
111
112    /// Values organized by unit type (e.g., "USD" -> vec of data points)
113    #[serde(default)]
114    pub units: HashMap<String, Vec<FactUnit>>,
115}
116
117#[cfg(feature = "dataframe")]
118impl FactConcept {
119    /// Convert all data points from a specific unit to a polars DataFrame.
120    ///
121    /// # Arguments
122    ///
123    /// * `unit` - The unit of measure (e.g., "USD", "shares", "pure")
124    ///
125    /// # Example
126    ///
127    /// ```no_run
128    /// # #[cfg(feature = "dataframe")]
129    /// # use finance_query::CompanyFacts;
130    /// # #[cfg(feature = "dataframe")]
131    /// # fn example(facts: CompanyFacts) -> Result<(), Box<dyn std::error::Error>> {
132    /// if let Some(revenue) = facts.get_us_gaap_fact("Revenue") {
133    ///     // Convert USD revenue data to DataFrame
134    ///     if let Some(df) = revenue.to_dataframe_for_unit("USD")? {
135    ///         println!("Revenue in USD: {:?}", df);
136    ///     }
137    /// }
138    /// # Ok(())
139    /// # }
140    /// ```
141    pub fn to_dataframe_for_unit(
142        &self,
143        unit: &str,
144    ) -> ::polars::prelude::PolarsResult<Option<::polars::prelude::DataFrame>> {
145        if let Some(data_points) = self.units.get(unit) {
146            Ok(Some(FactUnit::vec_to_dataframe(data_points)?))
147        } else {
148            Ok(None)
149        }
150    }
151
152    /// Convert all data points from all units to a single polars DataFrame.
153    ///
154    /// Adds a "unit" column to distinguish between different units of measure.
155    ///
156    /// # Example
157    ///
158    /// ```no_run
159    /// # #[cfg(feature = "dataframe")]
160    /// # use finance_query::CompanyFacts;
161    /// # #[cfg(feature = "dataframe")]
162    /// # fn example(facts: CompanyFacts) -> Result<(), Box<dyn std::error::Error>> {
163    /// if let Some(revenue) = facts.get_us_gaap_fact("Revenue") {
164    ///     let df = revenue.to_dataframe()?;
165    ///     println!("All revenue data: {:?}", df);
166    /// }
167    /// # Ok(())
168    /// # }
169    /// ```
170    pub fn to_dataframe(&self) -> ::polars::prelude::PolarsResult<::polars::prelude::DataFrame> {
171        use ::polars::prelude::*;
172
173        // Collect all data points with their unit labels
174        let mut all_data: Vec<(String, FactUnit)> = Vec::new();
175        for (unit, data_points) in &self.units {
176            for point in data_points {
177                all_data.push((unit.clone(), point.clone()));
178            }
179        }
180
181        if all_data.is_empty() {
182            // Return empty DataFrame with correct schema
183            return Ok(DataFrame::empty());
184        }
185
186        // Extract unit column
187        let units: Vec<String> = all_data.iter().map(|(u, _)| u.clone()).collect();
188
189        // Extract fact units (without unit field)
190        let facts: Vec<FactUnit> = all_data.into_iter().map(|(_, f)| f).collect();
191
192        // Convert facts to DataFrame
193        let mut df = FactUnit::vec_to_dataframe(&facts)?;
194
195        // Add unit column at the beginning
196        let unit_series = Series::new("unit".into(), units);
197        df.insert_column(0, unit_series.into())?;
198
199        Ok(df)
200    }
201}
202
203/// A single data point for an XBRL fact.
204///
205/// Represents one reported value from a specific filing and period.
206#[derive(Debug, Clone, Serialize, Deserialize)]
207#[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))]
208#[non_exhaustive]
209pub struct FactUnit {
210    /// Start date of the reporting period (for duration facts, e.g., revenue)
211    #[serde(default)]
212    pub start: Option<String>,
213
214    /// End date of the period (for duration facts) or instant date (for point-in-time facts)
215    #[serde(default)]
216    pub end: Option<String>,
217
218    /// The reported value
219    #[serde(default)]
220    pub val: Option<f64>,
221
222    /// Accession number of the filing that reported this value
223    #[serde(default)]
224    pub accn: Option<String>,
225
226    /// Fiscal year
227    #[serde(default)]
228    pub fy: Option<i32>,
229
230    /// Fiscal period (FY, Q1, Q2, Q3, Q4)
231    #[serde(default)]
232    pub fp: Option<String>,
233
234    /// Form type (10-K, 10-Q, etc.)
235    #[serde(default)]
236    pub form: Option<String>,
237
238    /// Date the filing was filed
239    #[serde(default)]
240    pub filed: Option<String>,
241
242    /// Frame identifier (e.g., "CY2023Q4I")
243    #[serde(default)]
244    pub frame: Option<String>,
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    #[cfg(feature = "dataframe")]
253    fn test_fact_concept_dataframe_conversion() {
254        let mut units = HashMap::new();
255        units.insert(
256            "USD".to_string(),
257            vec![FactUnit {
258                start: Some("2023-10-01".to_string()),
259                end: Some("2024-09-30".to_string()),
260                val: Some(391035000000.0),
261                accn: Some("0000320193-24-000123".to_string()),
262                fy: Some(2024),
263                fp: Some("FY".to_string()),
264                form: Some("10-K".to_string()),
265                filed: Some("2024-11-01".to_string()),
266                frame: Some("CY2024".to_string()),
267            }],
268        );
269
270        let concept = FactConcept {
271            label: Some("Revenue".to_string()),
272            description: Some("Total revenue".to_string()),
273            units,
274        };
275
276        // Test single unit conversion
277        let df = concept.to_dataframe_for_unit("USD").unwrap().unwrap();
278        assert_eq!(df.height(), 1);
279        let col_names = df.get_column_names_owned();
280        assert!(col_names.iter().any(|n| n.as_str() == "val"));
281        assert!(col_names.iter().any(|n| n.as_str() == "fy"));
282
283        // Test all units conversion (includes unit column)
284        let df = concept.to_dataframe().unwrap();
285        assert_eq!(df.height(), 1);
286        let col_names = df.get_column_names_owned();
287        assert!(col_names.iter().any(|n| n.as_str() == "unit"));
288        assert!(col_names.iter().any(|n| n.as_str() == "val"));
289    }
290
291    #[test]
292    fn test_deserialize_company_facts() {
293        let json = r#"{
294            "cik": 320193,
295            "entityName": "Apple Inc.",
296            "facts": {
297                "us-gaap": {
298                    "Revenue": {
299                        "label": "Revenue",
300                        "description": "Amount of revenue recognized.",
301                        "units": {
302                            "USD": [
303                                {
304                                    "start": "2023-10-01",
305                                    "end": "2024-09-28",
306                                    "val": 391035000000.0,
307                                    "accn": "0000320193-24-000123",
308                                    "fy": 2024,
309                                    "fp": "FY",
310                                    "form": "10-K",
311                                    "filed": "2024-11-01",
312                                    "frame": "CY2024"
313                                },
314                                {
315                                    "start": "2022-09-25",
316                                    "end": "2023-09-30",
317                                    "val": 383285000000.0,
318                                    "accn": "0000320193-23-000106",
319                                    "fy": 2023,
320                                    "fp": "FY",
321                                    "form": "10-K",
322                                    "filed": "2023-11-03"
323                                }
324                            ]
325                        }
326                    },
327                    "Assets": {
328                        "label": "Assets",
329                        "description": "Sum of the carrying amounts.",
330                        "units": {
331                            "USD": [
332                                {
333                                    "end": "2024-09-28",
334                                    "val": 364980000000.0,
335                                    "accn": "0000320193-24-000123",
336                                    "fy": 2024,
337                                    "fp": "FY",
338                                    "form": "10-K",
339                                    "filed": "2024-11-01"
340                                }
341                            ]
342                        }
343                    }
344                }
345            }
346        }"#;
347
348        let facts: CompanyFacts = serde_json::from_str(json).unwrap();
349        assert_eq!(facts.cik, Some(320193));
350        assert_eq!(facts.entity_name.as_deref(), Some("Apple Inc."));
351
352        // US-GAAP access
353        let gaap = facts.us_gaap().unwrap();
354        assert!(gaap.0.contains_key("Revenue"));
355        assert!(gaap.0.contains_key("Assets"));
356
357        // Convenience method
358        let revenue = facts.get_us_gaap_fact("Revenue").unwrap();
359        assert_eq!(revenue.label.as_deref(), Some("Revenue"));
360        let usd_values = revenue.units.get("USD").unwrap();
361        assert_eq!(usd_values.len(), 2);
362        assert_eq!(usd_values[0].val, Some(391035000000.0));
363        assert_eq!(usd_values[0].fy, Some(2024));
364        assert_eq!(usd_values[0].fp.as_deref(), Some("FY"));
365    }
366}