Skip to main content

finance_query/fred/
mod.rs

1//! Macro-economic data sources: FRED API and US Treasury yield curve.
2//!
3//! Requires the **`macro`** feature flag.
4//!
5//! # FRED (Federal Reserve Economic Data)
6//!
7//! Access 800k+ macro time series (CPI, Fed Funds Rate, M2, GDP, etc.).
8//! Requires a free API key from <https://fred.stlouisfed.org/docs/api/api_key.html>.
9//!
10//! Call [`init`] once at startup before using [`series`].
11//!
12//! # US Treasury Yields
13//!
14//! Daily yield curve data from the US Treasury Department. No key required.
15//! Use [`treasury_yields`] directly.
16//!
17//! # Quick Start
18//!
19//! ```no_run
20//! use finance_query::fred;
21//!
22//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
23//! // FRED: initialize with API key, then query any series
24//! fred::init("your-fred-api-key")?;
25//! let cpi = fred::series("CPIAUCSL").await?;
26//! println!("CPI observations: {}", cpi.observations.len());
27//!
28//! // Treasury: no key required
29//! let yields = fred::treasury_yields(2025).await?;
30//! println!("Latest 10Y yield: {:?}", yields.last().and_then(|y| y.y10));
31//! # Ok(())
32//! # }
33//! ```
34
35mod client;
36pub mod models;
37mod treasury;
38
39use crate::error::{FinanceError, Result};
40use crate::rate_limiter::RateLimiter;
41use client::FredClientBuilder;
42use std::sync::{Arc, OnceLock};
43use std::time::Duration;
44
45pub use models::{MacroObservation, MacroSeries, TreasuryYield};
46
47/// FRED free-tier rate limit: 120 requests/minute = 2 req/sec.
48const FRED_RATE_PER_SEC: f64 = 2.0;
49
50/// Stable configuration stored in the FRED process-global singleton.
51///
52/// Only the API key, timeout, and rate-limiter are stored — NOT the
53/// `reqwest::Client`. `reqwest::Client` internally spawns hyper connection-pool
54/// tasks on whichever tokio runtime first uses them; when that runtime is
55/// dropped (e.g. at the end of a `#[tokio::test]`), those tasks die and
56/// subsequent calls from a different runtime receive `DispatchGone`. A fresh
57/// `reqwest::Client` is built per `series()` call via
58/// [`FredClientBuilder::build_with_limiter`], reusing this shared limiter so
59/// the 2 req/sec FRED rate limit is respected across all calls.
60struct FredSingleton {
61    api_key: String,
62    timeout: Duration,
63    limiter: Arc<RateLimiter>,
64}
65
66static FRED_SINGLETON: OnceLock<FredSingleton> = OnceLock::new();
67
68/// Initialize the global FRED client with an API key.
69///
70/// Must be called once before [`series`]. Subsequent calls return an error.
71///
72/// # Arguments
73///
74/// * `api_key` - Your FRED API key (free at <https://fred.stlouisfed.org/docs/api/api_key.html>)
75///
76/// # Errors
77///
78/// Returns [`FinanceError::InvalidParameter`] if already initialized.
79pub fn init(api_key: impl Into<String>) -> Result<()> {
80    FRED_SINGLETON
81        .set(FredSingleton {
82            api_key: api_key.into(),
83            timeout: Duration::from_secs(30),
84            limiter: Arc::new(RateLimiter::new(FRED_RATE_PER_SEC)),
85        })
86        .map_err(|_| FinanceError::InvalidParameter {
87            param: "fred".to_string(),
88            reason: "FRED client already initialized".to_string(),
89        })
90}
91
92/// Initialize the FRED client with a custom timeout.
93pub fn init_with_timeout(api_key: impl Into<String>, timeout: Duration) -> Result<()> {
94    FRED_SINGLETON
95        .set(FredSingleton {
96            api_key: api_key.into(),
97            timeout,
98            limiter: Arc::new(RateLimiter::new(FRED_RATE_PER_SEC)),
99        })
100        .map_err(|_| FinanceError::InvalidParameter {
101            param: "fred".to_string(),
102            reason: "FRED client already initialized".to_string(),
103        })
104}
105
106/// Fetch all observations for a FRED data series.
107///
108/// Common series IDs:
109/// - `"FEDFUNDS"` — Federal Funds Rate
110/// - `"CPIAUCSL"` — Consumer Price Index (all urban, seasonally adjusted)
111/// - `"UNRATE"` — Unemployment Rate
112/// - `"DGS10"` — 10-Year Treasury Constant Maturity Rate
113/// - `"M2SL"` — M2 Money Supply
114/// - `"GDP"` — US Gross Domestic Product
115///
116/// # Errors
117///
118/// Returns [`FinanceError::InvalidParameter`] if FRED has not been initialized.
119pub async fn series(series_id: &str) -> Result<MacroSeries> {
120    let s = FRED_SINGLETON
121        .get()
122        .ok_or_else(|| FinanceError::InvalidParameter {
123            param: "fred".to_string(),
124            reason: "FRED not initialized. Call fred::init(api_key) first.".to_string(),
125        })?;
126    let c = FredClientBuilder::new(&s.api_key)
127        .timeout(s.timeout)
128        .build_with_limiter(Arc::clone(&s.limiter))?;
129    c.series(series_id).await
130}
131
132/// Fetch US Treasury yield curve data for the given year.
133///
134/// No API key required. Data is published on each business day.
135///
136/// # Arguments
137///
138/// * `year` - Calendar year (e.g., `2025`). Pass the current year for recent data.
139pub async fn treasury_yields(year: u32) -> Result<Vec<TreasuryYield>> {
140    treasury::fetch_yields(year).await
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn test_init_errors_on_double_init() {
149        // First init may or may not succeed (could already be set from another test).
150        let _ = init("test-key-1");
151        let result = init("test-key-2");
152        assert!(matches!(result, Err(FinanceError::InvalidParameter { .. })));
153    }
154
155    #[test]
156    fn test_series_without_init_fails_gracefully() {
157        // If somehow the singleton is not set, series() must return an error.
158        // (This test only exercises the error path if FRED_SINGLETON isn't set yet,
159        //  which may not be the case if other tests run first.)
160        if FRED_SINGLETON.get().is_none() {
161            // We can't reset OnceLock in tests, but we can verify the error shape:
162            // Synthesise the error manually.
163            let err = FinanceError::InvalidParameter {
164                param: "fred".to_string(),
165                reason: "not initialized".to_string(),
166            };
167            assert!(matches!(err, FinanceError::InvalidParameter { .. }));
168        }
169    }
170}