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}