finance_query/edgar/mod.rs
1//! SEC EDGAR API client.
2//!
3//! Provides access to SEC EDGAR data including filing history,
4//! structured XBRL financial data, and full-text search.
5//!
6//! All requests are rate-limited to 10 per second as required by SEC.
7//! Rate limiting, HTTP connection pooling, and CIK caching are managed
8//! internally via a process-global singleton.
9//!
10//! # Quick Start
11//!
12//! Initialize once at application startup, then use anywhere:
13//!
14//! ```no_run
15//! use finance_query::edgar;
16//!
17//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
18//! // Initialize once (required)
19//! edgar::init("user@example.com")?;
20//!
21//! // Use anywhere
22//! let cik = edgar::resolve_cik("AAPL").await?;
23//! let submissions = edgar::submissions(cik).await?;
24//! let facts = edgar::company_facts(cik).await?;
25//!
26//! // Search filings
27//! let results = edgar::search(
28//! "artificial intelligence",
29//! Some(&["10-K"]),
30//! Some("2024-01-01"),
31//! None,
32//! None,
33//! None,
34//! ).await?;
35//! # Ok(())
36//! # }
37//! ```
38
39mod client;
40mod rate_limiter;
41
42use crate::error::{FinanceError, Result};
43use crate::models::edgar::{CompanyFacts, EdgarFilingIndex, EdgarSearchResults, EdgarSubmissions};
44use client::{EdgarClient, EdgarClientBuilder};
45use std::sync::OnceLock;
46use std::time::Duration;
47
48/// Global EDGAR client singleton.
49static EDGAR_CLIENT: OnceLock<EdgarClient> = OnceLock::new();
50
51/// Initialize the global EDGAR client with a contact email.
52///
53/// This function must be called once before using any EDGAR functions.
54/// The SEC requires all automated requests to include a User-Agent header
55/// with a contact email address.
56///
57/// # Arguments
58///
59/// * `email` - Contact email address (included in User-Agent header)
60///
61/// # Example
62///
63/// ```no_run
64/// use finance_query::edgar;
65///
66/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
67/// edgar::init("user@example.com")?;
68/// # Ok(())
69/// # }
70/// ```
71///
72/// # Errors
73///
74/// Returns an error if:
75/// - EDGAR has already been initialized
76/// - The HTTP client cannot be constructed
77pub fn init(email: impl Into<String>) -> Result<()> {
78 let client = EdgarClientBuilder::new(email).build()?;
79 EDGAR_CLIENT
80 .set(client)
81 .map_err(|_| FinanceError::InvalidParameter {
82 param: "edgar".to_string(),
83 reason: "EDGAR client already initialized".to_string(),
84 })
85}
86
87/// Initialize the global EDGAR client with full configuration.
88///
89/// Use this for custom app name and timeout settings.
90///
91/// # Arguments
92///
93/// * `email` - Contact email address (required by SEC)
94/// * `app_name` - Application name (included in User-Agent)
95/// * `timeout` - HTTP request timeout duration
96///
97/// # Example
98///
99/// ```no_run
100/// use finance_query::edgar;
101/// use std::time::Duration;
102///
103/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
104/// edgar::init_with_config(
105/// "user@example.com",
106/// "my-app",
107/// Duration::from_secs(60),
108/// )?;
109/// # Ok(())
110/// # }
111/// ```
112pub fn init_with_config(
113 email: impl Into<String>,
114 app_name: impl Into<String>,
115 timeout: Duration,
116) -> Result<()> {
117 let client = EdgarClientBuilder::new(email)
118 .app_name(app_name)
119 .timeout(timeout)
120 .build()?;
121 EDGAR_CLIENT
122 .set(client)
123 .map_err(|_| FinanceError::InvalidParameter {
124 param: "edgar".to_string(),
125 reason: "EDGAR client already initialized".to_string(),
126 })
127}
128
129/// Get a reference to the global EDGAR client.
130fn client() -> Result<&'static EdgarClient> {
131 EDGAR_CLIENT
132 .get()
133 .ok_or_else(|| FinanceError::InvalidParameter {
134 param: "edgar".to_string(),
135 reason: "EDGAR not initialized. Call edgar::init(email) first.".to_string(),
136 })
137}
138
139fn accession_parts(accession_number: &str) -> Result<(String, String)> {
140 let cik_part = accession_number
141 .split('-')
142 .next()
143 .unwrap_or("")
144 .trim_start_matches('0')
145 .to_string();
146 let accession_no_dashes = accession_number.replace('-', "");
147
148 if cik_part.is_empty() || accession_no_dashes.is_empty() {
149 return Err(FinanceError::InvalidParameter {
150 param: "accession_number".to_string(),
151 reason: "Invalid accession number format".to_string(),
152 });
153 }
154
155 Ok((cik_part, accession_no_dashes))
156}
157
158/// Resolve a ticker symbol to its SEC CIK number.
159///
160/// The ticker-to-CIK mapping is fetched once and cached process-wide.
161/// Lookups are case-insensitive.
162///
163/// # Example
164///
165/// ```no_run
166/// use finance_query::edgar;
167///
168/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
169/// edgar::init("user@example.com")?;
170/// let cik = edgar::resolve_cik("AAPL").await?;
171/// assert_eq!(cik, 320193);
172/// # Ok(())
173/// # }
174/// ```
175///
176/// # Errors
177///
178/// Returns an error if:
179/// - EDGAR has not been initialized (call `init()` first)
180/// - Symbol not found in SEC database
181/// - Network request fails
182pub async fn resolve_cik(symbol: &str) -> Result<u64> {
183 client()?.resolve_cik(symbol).await
184}
185
186/// Fetch filing history and company metadata for a CIK.
187///
188/// Returns the most recent ~1000 filings inline, with references to
189/// additional history files for older filings.
190///
191/// # Example
192///
193/// ```no_run
194/// use finance_query::edgar;
195///
196/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
197/// edgar::init("user@example.com")?;
198/// let cik = edgar::resolve_cik("AAPL").await?;
199/// let submissions = edgar::submissions(cik).await?;
200/// println!("Company: {:?}", submissions.name);
201/// # Ok(())
202/// # }
203/// ```
204pub async fn submissions(cik: u64) -> Result<EdgarSubmissions> {
205 client()?.submissions(cik).await
206}
207
208/// Fetch structured XBRL financial data for a CIK.
209///
210/// Returns all extracted XBRL facts organized by taxonomy (us-gaap, ifrs, dei).
211/// This can be a large response (several MB for major companies).
212///
213/// # Example
214///
215/// ```no_run
216/// use finance_query::edgar;
217///
218/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
219/// edgar::init("user@example.com")?;
220/// let cik = edgar::resolve_cik("AAPL").await?;
221/// let facts = edgar::company_facts(cik).await?;
222/// println!("Entity: {:?}", facts.entity_name);
223/// # Ok(())
224/// # }
225/// ```
226pub async fn company_facts(cik: u64) -> Result<CompanyFacts> {
227 client()?.company_facts(cik).await
228}
229
230/// Fetch the filing index for a specific accession number.
231///
232/// This provides the file list for a filing, which can be used to locate
233/// the primary HTML document and file sizes.
234///
235/// # Example
236///
237/// ```no_run
238/// use finance_query::edgar;
239///
240/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
241/// edgar::init("user@example.com")?;
242/// let index = edgar::filing_index("0000320193-24-000123").await?;
243/// println!("Files: {}", index.directory.item.len());
244/// # Ok(())
245/// # }
246/// ```
247pub async fn filing_index(accession_number: &str) -> Result<EdgarFilingIndex> {
248 client()?.filing_index(accession_number).await
249}
250
251/// Search SEC EDGAR filings by text content.
252///
253/// # Arguments
254///
255/// * `query` - Search term or phrase
256/// * `forms` - Optional form type filter (e.g., `&["10-K", "10-Q"]`)
257/// * `start_date` - Optional start date (YYYY-MM-DD)
258/// * `end_date` - Optional end date (YYYY-MM-DD)
259/// * `from` - Optional pagination offset (default: 0)
260/// * `size` - Optional page size (default: 100, max: 100)
261///
262/// # Example
263///
264/// ```no_run
265/// use finance_query::edgar;
266///
267/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
268/// edgar::init("user@example.com")?;
269/// let results = edgar::search(
270/// "artificial intelligence",
271/// Some(&["10-K"]),
272/// Some("2024-01-01"),
273/// None,
274/// Some(0),
275/// Some(100),
276/// ).await?;
277/// if let Some(hits_container) = &results.hits {
278/// println!("Found {} results", hits_container.total.as_ref().and_then(|t| t.value).unwrap_or(0));
279/// }
280/// # Ok(())
281/// # }
282/// ```
283pub async fn search(
284 query: &str,
285 forms: Option<&[&str]>,
286 start_date: Option<&str>,
287 end_date: Option<&str>,
288 from: Option<usize>,
289 size: Option<usize>,
290) -> Result<EdgarSearchResults> {
291 client()?
292 .search(query, forms, start_date, end_date, from, size)
293 .await
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299
300 #[test]
301 fn test_init_sets_singleton() {
302 // Note: This test cannot be run in parallel with other tests that use init()
303 // since OnceLock cannot be reset.
304 let result = init("test@example.com");
305 assert!(result.is_ok() || result.is_err()); // May already be initialized
306 }
307
308 #[test]
309 fn test_double_init_fails() {
310 let _ = init("first@example.com");
311 let result = init("second@example.com");
312 // Second init should fail
313 assert!(matches!(result, Err(FinanceError::InvalidParameter { .. })));
314 }
315
316 #[test]
317 fn test_singleton_is_set_after_init() {
318 let _ = init("test@example.com");
319 // Once init succeeds (or was already called), the singleton must be populated.
320 assert!(EDGAR_CLIENT.get().is_some());
321 }
322}