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}