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    init_with_rate(email, app_name, timeout, EDGAR_RATE_PER_SEC)
136}
137
138/// Initialize the global EDGAR client with a request rate below SEC's limit.
139///
140/// Long bulk downloads may prefer a margin under the 10 requests a second SEC
141/// allows. Rates above that limit are capped to it.
142///
143/// # Example
144///
145/// ```no_run
146/// use finance_query::edgar;
147/// use std::time::Duration;
148///
149/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
150/// edgar::init_with_rate("user@example.com", "my-app", Duration::from_secs(60), 8.0)?;
151/// # Ok(())
152/// # }
153/// ```
154///
155/// # Errors
156///
157/// Returns an error if EDGAR has already been initialized or the rate isn't positive.
158pub fn init_with_rate(
159    email: impl Into<String>,
160    app_name: impl Into<String>,
161    timeout: Duration,
162    requests_per_second: f64,
163) -> Result<()> {
164    if requests_per_second.is_nan() || requests_per_second <= 0.0 {
165        return Err(FinanceError::InvalidParameter {
166            param: "requests_per_second".to_string(),
167            reason: "must be positive".to_string(),
168        });
169    }
170    EDGAR_SINGLETON
171        .set(EdgarSingleton {
172            email: email.into(),
173            app_name: app_name.into(),
174            timeout,
175            rate_limiter: Arc::new(RateLimiter::new(
176                requests_per_second.min(EDGAR_RATE_PER_SEC),
177            )),
178            cik_cache: Arc::new(RwLock::new(None)),
179        })
180        .map_err(|_| FinanceError::InvalidParameter {
181            param: "edgar".to_string(),
182            reason: "EDGAR client already initialized".to_string(),
183        })
184}
185
186/// Build a fresh [`EdgarClient`](client::EdgarClient) from the singleton's
187/// config, reusing the shared rate limiter and CIK cache.
188///
189/// If EDGAR hasn't been explicitly initialized, falls back to the `EDGAR_EMAIL`
190/// environment variable as a convenience (consistent with other adapters).
191fn build_client() -> Result<client::EdgarClient> {
192    if EDGAR_SINGLETON.get().is_none()
193        && let Ok(email) = std::env::var("EDGAR_EMAIL")
194    {
195        let _ = EDGAR_SINGLETON.set(EdgarSingleton {
196            email,
197            app_name: "finance-query".to_string(),
198            timeout: Duration::from_secs(30),
199            rate_limiter: Arc::new(RateLimiter::new(EDGAR_RATE_PER_SEC)),
200            cik_cache: Arc::new(RwLock::new(None)),
201        });
202    }
203    let s = EDGAR_SINGLETON
204        .get()
205        .ok_or_else(|| FinanceError::InvalidParameter {
206            param: "edgar".to_string(),
207            reason: "EDGAR_EMAIL not set. Call edgar::init(email) or set EDGAR_EMAIL env var."
208                .to_string(),
209        })?;
210    EdgarClientBuilder::new(&s.email)
211        .app_name(&s.app_name)
212        .timeout(s.timeout)
213        .build_with_shared_state(Arc::clone(&s.rate_limiter), Arc::clone(&s.cik_cache))
214}
215
216fn accession_parts(accession_number: &str) -> Result<(String, String)> {
217    let cik_part = accession_number
218        .split('-')
219        .next()
220        .unwrap_or("")
221        .trim_start_matches('0')
222        .to_string();
223    let accession_no_dashes = accession_number.replace('-', "");
224
225    if cik_part.is_empty() || accession_no_dashes.is_empty() {
226        return Err(FinanceError::InvalidParameter {
227            param: "accession_number".to_string(),
228            reason: "Invalid accession number format".to_string(),
229        });
230    }
231
232    Ok((cik_part, accession_no_dashes))
233}
234
235/// Resolve a ticker symbol to its SEC CIK number.
236///
237/// The ticker-to-CIK mapping is fetched once and cached process-wide.
238/// Lookups are case-insensitive.
239///
240/// # Example
241///
242/// ```no_run
243/// use finance_query::edgar;
244///
245/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
246/// edgar::init("user@example.com")?;
247/// let cik = edgar::resolve_cik("AAPL").await?;
248/// assert_eq!(cik, 320193);
249/// # Ok(())
250/// # }
251/// ```
252///
253/// # Errors
254///
255/// Returns an error if:
256/// - EDGAR has not been initialized (call `init()` first)
257/// - Symbol not found in SEC database
258/// - Network request fails
259pub async fn resolve_cik(symbol: &str) -> Result<u64> {
260    build_client()?.resolve_cik(symbol).await
261}
262
263/// Fetch filing history and company metadata for a CIK.
264///
265/// Returns the most recent ~1000 filings inline, with references to
266/// additional history files for older filings.
267///
268/// # Example
269///
270/// ```no_run
271/// use finance_query::edgar;
272///
273/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
274/// edgar::init("user@example.com")?;
275/// let cik = edgar::resolve_cik("AAPL").await?;
276/// let submissions = edgar::submissions(cik).await?;
277/// println!("Company: {:?}", submissions.name);
278/// # Ok(())
279/// # }
280/// ```
281pub async fn submissions(cik: u64) -> Result<EdgarSubmissions> {
282    build_client()?.submissions(cik).await
283}
284
285/// Fetch structured XBRL financial data for a CIK.
286///
287/// Returns all extracted XBRL facts organized by taxonomy (us-gaap, ifrs, dei).
288/// This can be a large response (several MB for major companies).
289///
290/// # Example
291///
292/// ```no_run
293/// use finance_query::edgar;
294///
295/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
296/// edgar::init("user@example.com")?;
297/// let cik = edgar::resolve_cik("AAPL").await?;
298/// let facts = edgar::company_facts(cik).await?;
299/// println!("Entity: {:?}", facts.entity_name);
300/// # Ok(())
301/// # }
302/// ```
303pub async fn company_facts(cik: u64) -> Result<CompanyFacts> {
304    build_client()?.company_facts(cik).await
305}
306
307/// Fetch any EDGAR file as bytes, within the shared rate limit.
308///
309/// Covers what the typed functions don't: filing documents and archives under
310/// `https://www.sec.gov/Archives/` (index pages, XBRL zips, instance documents,
311/// primary documents, the quarterly and daily indexes) and the JSON under
312/// `https://data.sec.gov/` (including older submissions pages), and the standard
313/// taxonomy schemas filings import (`xbrl.sec.gov`, `xbrl.fasb.org`,
314/// `taxonomies.xbrl.us`). Other hosts are refused, so the SEC User-Agent and
315/// rate limit aren't spent elsewhere.
316///
317/// # Example
318///
319/// ```no_run
320/// use finance_query::edgar;
321///
322/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
323/// edgar::init("user@example.com")?;
324/// let index = edgar::archive("https://www.sec.gov/Archives/edgar/full-index/2025/QTR1/xbrl.idx").await?;
325/// println!("{} bytes", index.len());
326/// # Ok(())
327/// # }
328/// ```
329///
330/// # Errors
331///
332/// Returns an error for a URL outside those locations, a missing file
333/// (`SymbolNotFound`), rate limiting (`RateLimited`) or a server error.
334pub async fn archive(url: &str) -> Result<Vec<u8>> {
335    if !is_archive_url(url) {
336        return Err(FinanceError::InvalidParameter {
337            param: "url".to_string(),
338            reason: "must be an EDGAR archive, data.sec.gov or standard taxonomy URL".to_string(),
339        });
340    }
341    build_client()?.get_document(url).await
342}
343
344const ARCHIVE_PREFIXES: &[&str] = &[
345    "https://www.sec.gov/Archives/",
346    "https://data.sec.gov/",
347    "https://xbrl.sec.gov/",
348    "https://xbrl.fasb.org/",
349    "https://taxonomies.xbrl.us/",
350];
351
352/// Checks the parsed form, since the request resolves `..` segments before it is sent.
353fn is_archive_url(url: &str) -> bool {
354    reqwest::Url::parse(url).is_ok_and(|parsed| {
355        ARCHIVE_PREFIXES
356            .iter()
357            .any(|prefix| parsed.as_str().starts_with(prefix))
358    })
359}
360
361/// Fetch the filing index for a specific accession number.
362///
363/// This provides the file list for a filing, which can be used to locate
364/// the primary HTML document and file sizes.
365///
366/// # Example
367///
368/// ```no_run
369/// use finance_query::edgar;
370///
371/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
372/// edgar::init("user@example.com")?;
373/// let index = edgar::filing_index("0000320193-24-000123").await?;
374/// println!("Files: {}", index.directory.item.len());
375/// # Ok(())
376/// # }
377/// ```
378pub async fn filing_index(accession_number: &str) -> Result<EdgarFilingIndex> {
379    build_client()?.filing_index(accession_number).await
380}
381
382/// Search SEC EDGAR filings by text content.
383///
384/// # Arguments
385///
386/// * `query` - Search term or phrase
387/// * `forms` - Optional form type filter (e.g., `&["10-K", "10-Q"]`)
388/// * `start_date` - Optional start date (YYYY-MM-DD)
389/// * `end_date` - Optional end date (YYYY-MM-DD)
390/// * `from` - Optional pagination offset (default: 0)
391/// * `size` - Optional page size (default: 100, max: 100)
392///
393/// # Example
394///
395/// ```no_run
396/// use finance_query::edgar;
397///
398/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
399/// edgar::init("user@example.com")?;
400/// let results = edgar::search(
401///     "artificial intelligence",
402///     Some(&["10-K"]),
403///     Some("2024-01-01"),
404///     None,
405///     Some(0),
406///     Some(100),
407/// ).await?;
408/// if let Some(hits_container) = &results.hits {
409///     println!("Found {} results", hits_container.total.as_ref().and_then(|t| t.value).unwrap_or(0));
410/// }
411/// # Ok(())
412/// # }
413/// ```
414pub async fn search(
415    query: &str,
416    forms: Option<&[&str]>,
417    start_date: Option<&str>,
418    end_date: Option<&str>,
419    from: Option<usize>,
420    size: Option<usize>,
421) -> Result<EdgarSearchResults> {
422    build_client()?
423        .search(query, forms, start_date, end_date, from, size)
424        .await
425}
426
427// ============================================================================
428// Compound operations
429// ============================================================================
430
431/// Resolve `symbol` to a CIK and fetch its submissions using a single client.
432///
433/// Collapses the two HTTP connection pools (and TLS handshakes) that calling
434/// [`resolve_cik`] then [`submissions`] would otherwise pay for into one.
435pub(crate) async fn submissions_for_symbol(symbol: &str) -> Result<EdgarSubmissions> {
436    submissions_for_symbol_with(&build_client()?, symbol).await
437}
438
439/// [`submissions_for_symbol`] against a caller-supplied client.
440///
441/// Callers that make further EDGAR requests off the same submissions list keep
442/// one connection pool for the whole sequence instead of one per request.
443async fn submissions_for_symbol_with(
444    client: &client::EdgarClient,
445    symbol: &str,
446) -> Result<EdgarSubmissions> {
447    let cik = client.resolve_cik(symbol).await?;
448    client.submissions(cik).await
449}
450
451/// Resolve `symbol` to a CIK and fetch its XBRL company facts using a single client.
452pub(crate) async fn company_facts_for_symbol(symbol: &str) -> Result<CompanyFacts> {
453    let client = build_client()?;
454    let cik = client.resolve_cik(symbol).await?;
455    client.company_facts(cik).await
456}
457
458// ============================================================================
459// Canonical model conversion functions
460// ============================================================================
461
462/// Fetch canonical ProviderFilings for a ticker symbol.
463pub async fn fetch_filings_response(
464    symbol: &str,
465) -> Result<crate::models::filings::ProviderFilings> {
466    use crate::models::filings::{ProviderFiling, ProviderFilings};
467
468    let subs = submissions_for_symbol(symbol).await?;
469
470    let cik = subs.cik.clone().unwrap_or_default();
471    let company_name = subs.name.clone();
472    let filings = subs
473        .filings
474        .and_then(|f| f.recent)
475        .map(|r| r.to_filings())
476        .unwrap_or_default()
477        .into_iter()
478        .map(|f| {
479            let accession_no_dashes = f.accession_number.replace('-', "");
480            let url = if !cik.is_empty()
481                && !accession_no_dashes.is_empty()
482                && !f.primary_document.is_empty()
483            {
484                Some(format!(
485                    "https://www.sec.gov/Archives/edgar/data/{}/{}/{}",
486                    cik.trim_start_matches('0'),
487                    accession_no_dashes,
488                    f.primary_document
489                ))
490            } else {
491                None
492            };
493            ProviderFiling {
494                accession_number: Some(f.accession_number),
495                filing_date: Some(f.filing_date),
496                filing_type: Some(f.form),
497                filing_url: url,
498                company_name: company_name.clone(),
499                cik: Some(cik.clone()),
500            }
501        })
502        .collect();
503
504    Ok(ProviderFilings {
505        symbol: symbol.to_string(),
506        filings,
507    })
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513
514    #[test]
515    fn test_init_sets_singleton() {
516        let result = init("test@example.com");
517        assert!(result.is_ok() || result.is_err()); // May already be initialized
518    }
519
520    #[test]
521    fn test_double_init_fails() {
522        let _ = init("first@example.com");
523        let result = init("second@example.com");
524        assert!(matches!(result, Err(FinanceError::InvalidParameter { .. })));
525    }
526
527    #[test]
528    fn test_singleton_is_set_after_init() {
529        let _ = init("test@example.com");
530        assert!(EDGAR_SINGLETON.get().is_some());
531    }
532
533    #[test]
534    fn test_init_with_rate_rejects_non_positive_rates() {
535        for rate in [0.0, -1.0, f64::NAN] {
536            assert!(matches!(
537                init_with_rate("test@example.com", "test", Duration::from_secs(1), rate),
538                Err(FinanceError::InvalidParameter { .. })
539            ));
540        }
541    }
542
543    #[test]
544    fn test_archive_urls_stay_on_sec_hosts() {
545        assert!(is_archive_url(
546            "https://www.sec.gov/Archives/edgar/full-index/2025/QTR1/xbrl.idx"
547        ));
548        assert!(is_archive_url(
549            "https://data.sec.gov/submissions/CIK0000320193-submissions-001.json"
550        ));
551        assert!(is_archive_url(
552            "https://xbrl.fasb.org/us-gaap/2026/elts/us-gaap-2026.xsd"
553        ));
554        assert!(is_archive_url("https://xbrl.sec.gov/dei/2026/dei-2026.xsd"));
555        assert!(!is_archive_url(
556            "http://xbrl.fasb.org/us-gaap/2026/elts/us-gaap-2026.xsd"
557        ));
558        assert!(!is_archive_url("https://www.sec.gov/cgi-bin/browse-edgar"));
559        assert!(!is_archive_url(
560            "https://www.sec.gov/Archives/../cgi-bin/browse-edgar"
561        ));
562        assert!(!is_archive_url(
563            "https://www.sec.gov/Archives/%2e%2e/cgi-bin/browse-edgar"
564        ));
565        assert!(!is_archive_url(
566            "http://www.sec.gov/Archives/edgar/data/1/2/x.htm"
567        ));
568        assert!(!is_archive_url("https://example.com/Archives/"));
569    }
570
571    #[tokio::test]
572    async fn test_archive_refuses_other_hosts_without_a_request() {
573        assert!(matches!(
574            archive("https://example.com/file").await,
575            Err(FinanceError::InvalidParameter { .. })
576        ));
577    }
578}