Skip to main content

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