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}