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 units: Vec<String> = Vec::new();
175        let mut facts: Vec<FactUnit> = Vec::new();
176        for (unit, data_points) in &self.units {
177            for point in data_points {
178                units.push(unit.clone());
179                facts.push(point.clone());
180            }
181        }
182
183        if facts.is_empty() {
184            // Return empty DataFrame with correct schema
185            return Ok(DataFrame::empty());
186        }
187
188        // Convert facts to DataFrame
189        let mut df = FactUnit::vec_to_dataframe(&facts)?;
190
191        // Add unit column at the beginning
192        let unit_series = Series::new("unit".into(), units);
193        df.insert_column(0, unit_series.into())?;
194
195        Ok(df)
196    }
197}
198
199/// A single data point for an XBRL fact.
200///
201/// Represents one reported value from a specific filing and period.
202#[derive(Debug, Clone, Serialize, Deserialize)]
203#[cfg_attr(feature = "dataframe", derive(crate::ToDataFrame))]
204#[non_exhaustive]
205pub struct FactUnit {
206    /// Start date of the reporting period (for duration facts, e.g., revenue)
207    #[serde(default)]
208    pub start: Option<String>,
209
210    /// End date of the period (for duration facts) or instant date (for point-in-time facts)
211    #[serde(default)]
212    pub end: Option<String>,
213
214    /// The reported value
215    #[serde(default)]
216    pub val: Option<f64>,
217
218    /// Accession number of the filing that reported this value
219    #[serde(default)]
220    pub accn: Option<String>,
221
222    /// Fiscal year
223    #[serde(default)]
224    pub fy: Option<i32>,
225
226    /// Fiscal period (FY, Q1, Q2, Q3, Q4)
227    #[serde(default)]
228    pub fp: Option<String>,
229
230    /// Form type (10-K, 10-Q, etc.)
231    #[serde(default)]
232    pub form: Option<String>,
233
234    /// Date the filing was filed
235    #[serde(default)]
236    pub filed: Option<String>,
237
238    /// Frame identifier (e.g., "CY2023Q4I")
239    #[serde(default)]
240    pub frame: Option<String>,
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    #[cfg(feature = "dataframe")]
249    fn test_fact_concept_dataframe_conversion() {
250        let mut units = HashMap::new();
251        units.insert(
252            "USD".to_string(),
253            vec![FactUnit {
254                start: Some("2023-10-01".to_string()),
255                end: Some("2024-09-30".to_string()),
256                val: Some(391035000000.0),
257                accn: Some("0000320193-24-000123".to_string()),
258                fy: Some(2024),
259                fp: Some("FY".to_string()),
260                form: Some("10-K".to_string()),
261                filed: Some("2024-11-01".to_string()),
262                frame: Some("CY2024".to_string()),
263            }],
264        );
265
266        let concept = FactConcept {
267            label: Some("Revenue".to_string()),
268            description: Some("Total revenue".to_string()),
269            units,
270        };
271
272        // Test single unit conversion
273        let df = concept.to_dataframe_for_unit("USD").unwrap().unwrap();
274        assert_eq!(df.height(), 1);
275        let col_names = df.get_column_names_owned();
276        assert!(col_names.iter().any(|n| n.as_str() == "val"));
277        assert!(col_names.iter().any(|n| n.as_str() == "fy"));
278
279        // Test all units conversion (includes unit column)
280        let df = concept.to_dataframe().unwrap();
281        assert_eq!(df.height(), 1);
282        let col_names = df.get_column_names_owned();
283        assert!(col_names.iter().any(|n| n.as_str() == "unit"));
284        assert!(col_names.iter().any(|n| n.as_str() == "val"));
285    }
286
287    #[test]
288    fn test_deserialize_company_facts() {
289        let json = r#"{
290            "cik": 320193,
291            "entityName": "Apple Inc.",
292            "facts": {
293                "us-gaap": {
294                    "Revenue": {
295                        "label": "Revenue",
296                        "description": "Amount of revenue recognized.",
297                        "units": {
298                            "USD": [
299                                {
300                                    "start": "2023-10-01",
301                                    "end": "2024-09-28",
302                                    "val": 391035000000.0,
303                                    "accn": "0000320193-24-000123",
304                                    "fy": 2024,
305                                    "fp": "FY",
306                                    "form": "10-K",
307                                    "filed": "2024-11-01",
308                                    "frame": "CY2024"
309                                },
310                                {
311                                    "start": "2022-09-25",
312                                    "end": "2023-09-30",
313                                    "val": 383285000000.0,
314                                    "accn": "0000320193-23-000106",
315                                    "fy": 2023,
316                                    "fp": "FY",
317                                    "form": "10-K",
318                                    "filed": "2023-11-03"
319                                }
320                            ]
321                        }
322                    },
323                    "Assets": {
324                        "label": "Assets",
325                        "description": "Sum of the carrying amounts.",
326                        "units": {
327                            "USD": [
328                                {
329                                    "end": "2024-09-28",
330                                    "val": 364980000000.0,
331                                    "accn": "0000320193-24-000123",
332                                    "fy": 2024,
333                                    "fp": "FY",
334                                    "form": "10-K",
335                                    "filed": "2024-11-01"
336                                }
337                            ]
338                        }
339                    }
340                }
341            }
342        }"#;
343
344        let facts: CompanyFacts = serde_json::from_str(json).unwrap();
345        assert_eq!(facts.cik, Some(320193));
346        assert_eq!(facts.entity_name.as_deref(), Some("Apple Inc."));
347
348        // US-GAAP access
349        let gaap = facts.us_gaap().unwrap();
350        assert!(gaap.0.contains_key("Revenue"));
351        assert!(gaap.0.contains_key("Assets"));
352
353        // Convenience method
354        let revenue = facts.get_us_gaap_fact("Revenue").unwrap();
355        assert_eq!(revenue.label.as_deref(), Some("Revenue"));
356        let usd_values = revenue.units.get("USD").unwrap();
357        assert_eq!(usd_values.len(), 2);
358        assert_eq!(usd_values[0].val, Some(391035000000.0));
359        assert_eq!(usd_values[0].fy, Some(2024));
360        assert_eq!(usd_values[0].fp.as_deref(), Some("FY"));
361    }
362}