Skip to main content

finance_query/adapters/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;
36mod economic;
37pub mod models;
38
39use crate::adapters::singleton::provider_singleton_state;
40use crate::error::{FinanceError, Result};
41use client::FredClientBuilder;
42use std::sync::Arc;
43use std::time::Duration;
44
45pub use crate::models::economic::{MacroSeries, TreasuryYield};
46pub use models::ReleaseDate;
47
48/// FRED free-tier rate limit: 120 requests/minute = 2 req/sec.
49const FRED_RATE_PER_SEC: f64 = 2.0;
50
51// Stable configuration stored in the FRED process-global singleton.
52//
53// Only the API key, timeout, and rate-limiter are stored — NOT the
54// `reqwest::Client`. `reqwest::Client` internally spawns hyper connection-pool
55// tasks on whichever tokio runtime first uses them; when that runtime is
56// dropped (e.g. at the end of a `#[tokio::test]`), those tasks die and
57// subsequent calls from a different runtime receive `DispatchGone`. A fresh
58// `reqwest::Client` is built per `series()` call via
59// `FredClientBuilder::build_with_limiter`, reusing this shared limiter so
60// the 2 req/sec FRED rate limit is respected across all calls.
61provider_singleton_state!(
62    name = FredSingleton,
63    static_name = FRED_SINGLETON,
64    rate_const = FRED_RATE_PER_SEC,
65    provider_key = "fred",
66    already_init_reason = "FRED client already initialized",
67);
68
69/// Initialize the global FRED client with an API key.
70///
71/// Must be called once before [`series`]. Subsequent calls return an error.
72///
73/// # Arguments
74///
75/// * `api_key` - Your FRED API key (free at <https://fred.stlouisfed.org/docs/api/api_key.html>)
76///
77/// # Errors
78///
79/// Returns [`FinanceError::InvalidParameter`] if already initialized.
80pub fn init(api_key: impl Into<String>) -> Result<()> {
81    init_with_timeout(api_key, Duration::from_secs(30))
82}
83
84/// Initialize the FRED client with a custom timeout.
85pub fn init_with_timeout(api_key: impl Into<String>, timeout: Duration) -> Result<()> {
86    set_singleton(api_key, timeout)
87}
88
89/// Fetch all observations for a FRED data series.
90///
91/// Common series IDs:
92/// - `"FEDFUNDS"` — Federal Funds Rate
93/// - `"CPIAUCSL"` — Consumer Price Index (all urban, seasonally adjusted)
94/// - `"UNRATE"` — Unemployment Rate
95/// - `"DGS10"` — 10-Year Treasury Constant Maturity Rate
96/// - `"M2SL"` — M2 Money Supply
97/// - `"GDP"` — US Gross Domestic Product
98///
99/// # Errors
100///
101/// Returns [`FinanceError::InvalidParameter`] if FRED has not been initialized.
102pub async fn series(series_id: &str) -> Result<MacroSeries> {
103    let s = FRED_SINGLETON
104        .get()
105        .ok_or_else(|| FinanceError::InvalidParameter {
106            param: "fred".to_string(),
107            reason: "FRED not initialized. Call fred::init(api_key) first.".to_string(),
108        })?;
109    let c = FredClientBuilder::new(&s.api_key)
110        .timeout(s.timeout)
111        .build_with_limiter(Arc::clone(&s.limiter))?;
112    c.series(series_id).await
113}
114
115/// Fetch upcoming scheduled economic-data release dates (CPI, NFP, GDP, FOMC, …).
116///
117/// Returns releases scheduled from today onward, sorted ascending.
118///
119/// # Errors
120///
121/// Returns [`FinanceError::InvalidParameter`] if FRED has not been initialized.
122pub async fn release_dates() -> Result<Vec<ReleaseDate>> {
123    let s = FRED_SINGLETON
124        .get()
125        .ok_or_else(|| FinanceError::InvalidParameter {
126            param: "fred".to_string(),
127            reason: "FRED not initialized. Call fred::init(api_key) first.".to_string(),
128        })?;
129    let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
130    let c = FredClientBuilder::new(&s.api_key)
131        .timeout(s.timeout)
132        .build_with_limiter(Arc::clone(&s.limiter))?;
133    c.release_dates(&today).await
134}
135
136/// Fetch US Treasury yield curve data for the given year.
137///
138/// No API key required. Data is published on each business day.
139///
140/// # Arguments
141///
142/// * `year` - Calendar year (e.g., `2025`). Pass the current year for recent data.
143pub async fn treasury_yields(year: u32) -> Result<Vec<TreasuryYield>> {
144    economic::treasury::fetch_yields(year).await
145}
146
147// ============================================================================
148// Canonical model conversion functions
149// ============================================================================
150
151/// Fetch canonical EconomicSeries for a FRED series ID.
152pub async fn fetch_economic_series_response(
153    series_id: &str,
154) -> Result<crate::models::economic::EconomicSeries> {
155    let series = crate::adapters::fred::series(series_id).await?;
156    Ok(series_to_canonical(series))
157}
158
159/// Map a FRED [`MacroSeries`] to the canonical
160/// [`EconomicSeries`](crate::models::economic::EconomicSeries).
161fn series_to_canonical(series: MacroSeries) -> crate::models::economic::EconomicSeries {
162    crate::models::economic::EconomicSeries {
163        series_id: series.id,
164        title: None,
165        units: None,
166        frequency: None,
167        observations: series
168            .observations
169            .into_iter()
170            .map(|o| crate::models::economic::MacroObservation {
171                date: o.date,
172                value: o.value,
173            })
174            .collect(),
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::rate_limiter::RateLimiter;
182
183    #[test]
184    fn test_init_errors_on_double_init() {
185        // First init may or may not succeed (could already be set from another test).
186        let _ = init("test-key-1");
187        let result = init("test-key-2");
188        assert!(matches!(result, Err(FinanceError::InvalidParameter { .. })));
189    }
190
191    fn test_client(base_url: &str) -> client::FredClient {
192        FredClientBuilder::new("test-key")
193            .timeout(Duration::from_secs(5))
194            .base_url(base_url)
195            .build_with_limiter(Arc::new(RateLimiter::new(100.0)))
196            .unwrap()
197    }
198
199    /// Mocked HTTP → `FredClient::series` → `series_to_canonical`, covering the
200    /// full `fetch_economic_series_response` pipeline without a network call.
201    #[tokio::test]
202    async fn test_series_to_canonical_mock() {
203        let mut server = mockito::Server::new_async().await;
204        let _mock = server
205            .mock("GET", "/series/observations")
206            .match_query(mockito::Matcher::AllOf(vec![
207                mockito::Matcher::UrlEncoded("series_id".into(), "GDP".into()),
208                mockito::Matcher::UrlEncoded("api_key".into(), "test-key".into()),
209                mockito::Matcher::UrlEncoded("file_type".into(), "json".into()),
210            ]))
211            .with_status(200)
212            .with_header("content-type", "application/json")
213            .with_body(
214                serde_json::json!({
215                    "observations": [
216                        { "date": "2023-01-01", "value": "26144.956" },
217                        { "date": "2023-04-01", "value": "." }
218                    ]
219                })
220                .to_string(),
221            )
222            .create_async()
223            .await;
224
225        let series = test_client(&server.url()).series("GDP").await.unwrap();
226        assert_eq!(series.id, "GDP");
227        assert_eq!(series.observations.len(), 2);
228        assert_eq!(series.observations[0].date, "2023-01-01");
229        assert_eq!(series.observations[0].value, Some(26144.956));
230        assert_eq!(series.observations[1].value, None, "\".\" parses to None");
231
232        let canonical = series_to_canonical(series);
233        assert_eq!(canonical.series_id, "GDP");
234        assert_eq!(canonical.observations.len(), 2);
235        assert_eq!(canonical.observations[0].value, Some(26144.956));
236        assert_eq!(canonical.observations[1].value, None);
237    }
238
239    #[tokio::test]
240    async fn test_series_unknown_id_maps_400_to_invalid_parameter() {
241        let mut server = mockito::Server::new_async().await;
242        let _mock = server
243            .mock("GET", "/series/observations")
244            .match_query(mockito::Matcher::Any)
245            .with_status(400)
246            .create_async()
247            .await;
248
249        let err = test_client(&server.url())
250            .series("NOT_A_SERIES")
251            .await
252            .unwrap_err();
253        assert!(matches!(err, FinanceError::InvalidParameter { .. }));
254    }
255
256    #[tokio::test]
257    async fn test_series_missing_observations_errors() {
258        let mut server = mockito::Server::new_async().await;
259        let _mock = server
260            .mock("GET", "/series/observations")
261            .match_query(mockito::Matcher::Any)
262            .with_status(200)
263            .with_header("content-type", "application/json")
264            .with_body(serde_json::json!({"error": "unexpected shape"}).to_string())
265            .create_async()
266            .await;
267
268        let err = test_client(&server.url()).series("GDP").await.unwrap_err();
269        assert!(matches!(err, FinanceError::ResponseStructureError { .. }));
270    }
271
272    #[test]
273    fn test_series_without_init_fails_gracefully() {
274        // If somehow the singleton is not set, series() must return an error.
275        // (This test only exercises the error path if FRED_SINGLETON isn't set yet,
276        //  which may not be the case if other tests run first.)
277        if FRED_SINGLETON.get().is_none() {
278            // We can't reset OnceLock in tests, but we can verify the error shape:
279            // Synthesise the error manually.
280            let err = FinanceError::InvalidParameter {
281                param: "fred".to_string(),
282                reason: "not initialized".to_string(),
283            };
284            assert!(matches!(err, FinanceError::InvalidParameter { .. }));
285        }
286    }
287}