Skip to main content

finance_query/adapters/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;
39pub(crate) mod discovery; // DISCOVERY
40mod endpoints;
41pub(crate) mod filings; // FILINGS
42
43use crate::error::{FinanceError, Result};
44use crate::models::filings::{
45    CompanyFacts, EdgarFilingIndex, EdgarSearchResults, EdgarSubmissions,
46};
47use crate::rate_limiter::RateLimiter;
48use client::EdgarClientBuilder;
49use std::collections::HashMap;
50use std::sync::{Arc, OnceLock};
51use std::time::Duration;
52use tokio::sync::RwLock;
53
54/// SEC EDGAR rate limit: 10 requests per second.
55const EDGAR_RATE_PER_SEC: f64 = 10.0;
56
57/// Stable configuration stored in the EDGAR process-global singleton.
58///
59/// Only configuration, the rate limiter, and the CIK cache are stored — NOT
60/// the `reqwest::Client`. `reqwest::Client` internally spawns hyper
61/// connection-pool tasks on whichever tokio runtime first uses them; when that
62/// runtime is dropped (e.g. at the end of a `#[tokio::test]`), those tasks die
63/// and subsequent calls from a different runtime receive `DispatchGone`. A fresh
64/// `reqwest::Client` is built per public function call via
65/// [`EdgarClientBuilder::build_with_shared_state`], reusing the shared rate
66/// limiter and CIK cache.
67struct EdgarSingleton {
68    email: String,
69    app_name: String,
70    timeout: Duration,
71    rate_limiter: Arc<RateLimiter>,
72    cik_cache: Arc<RwLock<Option<HashMap<String, u64>>>>,
73}
74
75static EDGAR_SINGLETON: OnceLock<EdgarSingleton> = OnceLock::new();
76
77/// Initialize the global EDGAR client with a contact email.
78///
79/// This function must be called once before using any EDGAR functions.
80/// The SEC requires all automated requests to include a User-Agent header
81/// with a contact email address.
82///
83/// # Arguments
84///
85/// * `email` - Contact email address (included in User-Agent header)
86///
87/// # Example
88///
89/// ```no_run
90/// use finance_query::edgar;
91///
92/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
93/// edgar::init("user@example.com")?;
94/// # Ok(())
95/// # }
96/// ```
97///
98/// # Errors
99///
100/// Returns an error if EDGAR has already been initialized.
101pub fn init(email: impl Into<String>) -> Result<()> {
102    init_with_config(email, "finance-query", Duration::from_secs(30))
103}
104
105/// Initialize the global EDGAR client with full configuration.
106///
107/// Use this for custom app name and timeout settings.
108///
109/// # Arguments
110///
111/// * `email` - Contact email address (required by SEC)
112/// * `app_name` - Application name (included in User-Agent)
113/// * `timeout` - HTTP request timeout duration
114///
115/// # Example
116///
117/// ```no_run
118/// use finance_query::edgar;
119/// use std::time::Duration;
120///
121/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
122/// edgar::init_with_config(
123///     "user@example.com",
124///     "my-app",
125///     Duration::from_secs(60),
126/// )?;
127/// # Ok(())
128/// # }
129/// ```
130pub fn init_with_config(
131    email: impl Into<String>,
132    app_name: impl Into<String>,
133    timeout: Duration,
134) -> Result<()> {
135    EDGAR_SINGLETON
136        .set(EdgarSingleton {
137            email: email.into(),
138            app_name: app_name.into(),
139            timeout,
140            rate_limiter: Arc::new(RateLimiter::new(EDGAR_RATE_PER_SEC)),
141            cik_cache: Arc::new(RwLock::new(None)),
142        })
143        .map_err(|_| FinanceError::InvalidParameter {
144            param: "edgar".to_string(),
145            reason: "EDGAR client already initialized".to_string(),
146        })
147}
148
149/// Build a fresh [`EdgarClient`](client::EdgarClient) from the singleton's
150/// config, reusing the shared rate limiter and CIK cache.
151///
152/// If EDGAR hasn't been explicitly initialized, falls back to the `EDGAR_EMAIL`
153/// environment variable as a convenience (consistent with other adapters).
154fn build_client() -> Result<client::EdgarClient> {
155    if EDGAR_SINGLETON.get().is_none()
156        && let Ok(email) = std::env::var("EDGAR_EMAIL")
157    {
158        let _ = EDGAR_SINGLETON.set(EdgarSingleton {
159            email,
160            app_name: "finance-query".to_string(),
161            timeout: Duration::from_secs(30),
162            rate_limiter: Arc::new(RateLimiter::new(EDGAR_RATE_PER_SEC)),
163            cik_cache: Arc::new(RwLock::new(None)),
164        });
165    }
166    let s = EDGAR_SINGLETON
167        .get()
168        .ok_or_else(|| FinanceError::InvalidParameter {
169            param: "edgar".to_string(),
170            reason: "EDGAR_EMAIL not set. Call edgar::init(email) or set EDGAR_EMAIL env var."
171                .to_string(),
172        })?;
173    EdgarClientBuilder::new(&s.email)
174        .app_name(&s.app_name)
175        .timeout(s.timeout)
176        .build_with_shared_state(Arc::clone(&s.rate_limiter), Arc::clone(&s.cik_cache))
177}
178
179fn accession_parts(accession_number: &str) -> Result<(String, String)> {
180    let cik_part = accession_number
181        .split('-')
182        .next()
183        .unwrap_or("")
184        .trim_start_matches('0')
185        .to_string();
186    let accession_no_dashes = accession_number.replace('-', "");
187
188    if cik_part.is_empty() || accession_no_dashes.is_empty() {
189        return Err(FinanceError::InvalidParameter {
190            param: "accession_number".to_string(),
191            reason: "Invalid accession number format".to_string(),
192        });
193    }
194
195    Ok((cik_part, accession_no_dashes))
196}
197
198/// Resolve a ticker symbol to its SEC CIK number.
199///
200/// The ticker-to-CIK mapping is fetched once and cached process-wide.
201/// Lookups are case-insensitive.
202///
203/// # Example
204///
205/// ```no_run
206/// use finance_query::edgar;
207///
208/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
209/// edgar::init("user@example.com")?;
210/// let cik = edgar::resolve_cik("AAPL").await?;
211/// assert_eq!(cik, 320193);
212/// # Ok(())
213/// # }
214/// ```
215///
216/// # Errors
217///
218/// Returns an error if:
219/// - EDGAR has not been initialized (call `init()` first)
220/// - Symbol not found in SEC database
221/// - Network request fails
222pub async fn resolve_cik(symbol: &str) -> Result<u64> {
223    build_client()?.resolve_cik(symbol).await
224}
225
226/// Fetch filing history and company metadata for a CIK.
227///
228/// Returns the most recent ~1000 filings inline, with references to
229/// additional history files for older filings.
230///
231/// # Example
232///
233/// ```no_run
234/// use finance_query::edgar;
235///
236/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
237/// edgar::init("user@example.com")?;
238/// let cik = edgar::resolve_cik("AAPL").await?;
239/// let submissions = edgar::submissions(cik).await?;
240/// println!("Company: {:?}", submissions.name);
241/// # Ok(())
242/// # }
243/// ```
244pub async fn submissions(cik: u64) -> Result<EdgarSubmissions> {
245    build_client()?.submissions(cik).await
246}
247
248/// Fetch structured XBRL financial data for a CIK.
249///
250/// Returns all extracted XBRL facts organized by taxonomy (us-gaap, ifrs, dei).
251/// This can be a large response (several MB for major companies).
252///
253/// # Example
254///
255/// ```no_run
256/// use finance_query::edgar;
257///
258/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
259/// edgar::init("user@example.com")?;
260/// let cik = edgar::resolve_cik("AAPL").await?;
261/// let facts = edgar::company_facts(cik).await?;
262/// println!("Entity: {:?}", facts.entity_name);
263/// # Ok(())
264/// # }
265/// ```
266pub async fn company_facts(cik: u64) -> Result<CompanyFacts> {
267    build_client()?.company_facts(cik).await
268}
269
270/// Fetch the filing index for a specific accession number.
271///
272/// This provides the file list for a filing, which can be used to locate
273/// the primary HTML document and file sizes.
274///
275/// # Example
276///
277/// ```no_run
278/// use finance_query::edgar;
279///
280/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
281/// edgar::init("user@example.com")?;
282/// let index = edgar::filing_index("0000320193-24-000123").await?;
283/// println!("Files: {}", index.directory.item.len());
284/// # Ok(())
285/// # }
286/// ```
287pub async fn filing_index(accession_number: &str) -> Result<EdgarFilingIndex> {
288    build_client()?.filing_index(accession_number).await
289}
290
291/// Search SEC EDGAR filings by text content.
292///
293/// # Arguments
294///
295/// * `query` - Search term or phrase
296/// * `forms` - Optional form type filter (e.g., `&["10-K", "10-Q"]`)
297/// * `start_date` - Optional start date (YYYY-MM-DD)
298/// * `end_date` - Optional end date (YYYY-MM-DD)
299/// * `from` - Optional pagination offset (default: 0)
300/// * `size` - Optional page size (default: 100, max: 100)
301///
302/// # Example
303///
304/// ```no_run
305/// use finance_query::edgar;
306///
307/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
308/// edgar::init("user@example.com")?;
309/// let results = edgar::search(
310///     "artificial intelligence",
311///     Some(&["10-K"]),
312///     Some("2024-01-01"),
313///     None,
314///     Some(0),
315///     Some(100),
316/// ).await?;
317/// if let Some(hits_container) = &results.hits {
318///     println!("Found {} results", hits_container.total.as_ref().and_then(|t| t.value).unwrap_or(0));
319/// }
320/// # Ok(())
321/// # }
322/// ```
323pub async fn search(
324    query: &str,
325    forms: Option<&[&str]>,
326    start_date: Option<&str>,
327    end_date: Option<&str>,
328    from: Option<usize>,
329    size: Option<usize>,
330) -> Result<EdgarSearchResults> {
331    build_client()?
332        .search(query, forms, start_date, end_date, from, size)
333        .await
334}
335
336// ============================================================================
337// Compound operations
338// ============================================================================
339
340/// Resolve `symbol` to a CIK and fetch its submissions using a single client.
341///
342/// Collapses the two HTTP connection pools (and TLS handshakes) that calling
343/// [`resolve_cik`] then [`submissions`] would otherwise pay for into one.
344pub(crate) async fn submissions_for_symbol(symbol: &str) -> Result<EdgarSubmissions> {
345    submissions_for_symbol_with(&build_client()?, symbol).await
346}
347
348/// [`submissions_for_symbol`] against a caller-supplied client.
349///
350/// Callers that make further EDGAR requests off the same submissions list keep
351/// one connection pool for the whole sequence instead of one per request.
352async fn submissions_for_symbol_with(
353    client: &client::EdgarClient,
354    symbol: &str,
355) -> Result<EdgarSubmissions> {
356    let cik = client.resolve_cik(symbol).await?;
357    client.submissions(cik).await
358}
359
360/// Resolve `symbol` to a CIK and fetch its XBRL company facts using a single client.
361pub(crate) async fn company_facts_for_symbol(symbol: &str) -> Result<CompanyFacts> {
362    let client = build_client()?;
363    let cik = client.resolve_cik(symbol).await?;
364    client.company_facts(cik).await
365}
366
367// ============================================================================
368// Canonical model conversion functions
369// ============================================================================
370
371/// Fetch canonical ProviderFilings for a ticker symbol.
372pub async fn fetch_filings_response(
373    symbol: &str,
374) -> Result<crate::models::filings::ProviderFilings> {
375    use crate::models::filings::{ProviderFiling, ProviderFilings};
376
377    let subs = submissions_for_symbol(symbol).await?;
378
379    let cik = subs.cik.clone().unwrap_or_default();
380    let company_name = subs.name.clone();
381    let filings = subs
382        .filings
383        .and_then(|f| f.recent)
384        .map(|r| r.to_filings())
385        .unwrap_or_default()
386        .into_iter()
387        .map(|f| {
388            let accession_no_dashes = f.accession_number.replace('-', "");
389            let url = if !cik.is_empty()
390                && !accession_no_dashes.is_empty()
391                && !f.primary_document.is_empty()
392            {
393                Some(format!(
394                    "https://www.sec.gov/Archives/edgar/data/{}/{}/{}",
395                    cik.trim_start_matches('0'),
396                    accession_no_dashes,
397                    f.primary_document
398                ))
399            } else {
400                None
401            };
402            ProviderFiling {
403                accession_number: Some(f.accession_number),
404                filing_date: Some(f.filing_date),
405                filing_type: Some(f.form),
406                filing_url: url,
407                company_name: company_name.clone(),
408                cik: Some(cik.clone()),
409            }
410        })
411        .collect();
412
413    Ok(ProviderFilings {
414        symbol: symbol.to_string(),
415        filings,
416    })
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422
423    #[test]
424    fn test_init_sets_singleton() {
425        let result = init("test@example.com");
426        assert!(result.is_ok() || result.is_err()); // May already be initialized
427    }
428
429    #[test]
430    fn test_double_init_fails() {
431        let _ = init("first@example.com");
432        let result = init("second@example.com");
433        assert!(matches!(result, Err(FinanceError::InvalidParameter { .. })));
434    }
435
436    #[test]
437    fn test_singleton_is_set_after_init() {
438        let _ = init("test@example.com");
439        assert!(EDGAR_SINGLETON.get().is_some());
440    }
441}