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::error::{FinanceError, Result};
40use crate::rate_limiter::RateLimiter;
41use client::FredClientBuilder;
42use std::sync::{Arc, OnceLock};
43use std::time::Duration;
44
45pub use crate::models::economic::{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 init_with_timeout(api_key, Duration::from_secs(30))
81}
82
83/// Initialize the FRED client with a custom timeout.
84pub fn init_with_timeout(api_key: impl Into<String>, timeout: Duration) -> Result<()> {
85 FRED_SINGLETON
86 .set(FredSingleton {
87 api_key: api_key.into(),
88 timeout,
89 limiter: Arc::new(RateLimiter::new(FRED_RATE_PER_SEC)),
90 })
91 .map_err(|_| FinanceError::InvalidParameter {
92 param: "fred".to_string(),
93 reason: "FRED client already initialized".to_string(),
94 })
95}
96
97/// Fetch all observations for a FRED data series.
98///
99/// Common series IDs:
100/// - `"FEDFUNDS"` — Federal Funds Rate
101/// - `"CPIAUCSL"` — Consumer Price Index (all urban, seasonally adjusted)
102/// - `"UNRATE"` — Unemployment Rate
103/// - `"DGS10"` — 10-Year Treasury Constant Maturity Rate
104/// - `"M2SL"` — M2 Money Supply
105/// - `"GDP"` — US Gross Domestic Product
106///
107/// # Errors
108///
109/// Returns [`FinanceError::InvalidParameter`] if FRED has not been initialized.
110pub async fn series(series_id: &str) -> Result<MacroSeries> {
111 let s = FRED_SINGLETON
112 .get()
113 .ok_or_else(|| FinanceError::InvalidParameter {
114 param: "fred".to_string(),
115 reason: "FRED not initialized. Call fred::init(api_key) first.".to_string(),
116 })?;
117 let c = FredClientBuilder::new(&s.api_key)
118 .timeout(s.timeout)
119 .build_with_limiter(Arc::clone(&s.limiter))?;
120 c.series(series_id).await
121}
122
123/// Fetch US Treasury yield curve data for the given year.
124///
125/// No API key required. Data is published on each business day.
126///
127/// # Arguments
128///
129/// * `year` - Calendar year (e.g., `2025`). Pass the current year for recent data.
130pub async fn treasury_yields(year: u32) -> Result<Vec<TreasuryYield>> {
131 economic::treasury::fetch_yields(year).await
132}
133
134// ============================================================================
135// Canonical model conversion functions
136// ============================================================================
137
138/// Fetch canonical EconomicSeries for a FRED series ID.
139pub async fn fetch_economic_series_response(
140 series_id: &str,
141) -> Result<crate::models::economic::EconomicSeries> {
142 let series = crate::adapters::fred::series(series_id).await?;
143 Ok(crate::models::economic::EconomicSeries {
144 series_id: series.id,
145 title: None,
146 units: None,
147 frequency: None,
148 observations: series
149 .observations
150 .into_iter()
151 .map(|o| crate::models::economic::MacroObservation {
152 date: o.date,
153 value: o.value,
154 })
155 .collect(),
156 })
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 #[test]
164 fn test_init_errors_on_double_init() {
165 // First init may or may not succeed (could already be set from another test).
166 let _ = init("test-key-1");
167 let result = init("test-key-2");
168 assert!(matches!(result, Err(FinanceError::InvalidParameter { .. })));
169 }
170
171 #[test]
172 fn test_series_without_init_fails_gracefully() {
173 // If somehow the singleton is not set, series() must return an error.
174 // (This test only exercises the error path if FRED_SINGLETON isn't set yet,
175 // which may not be the case if other tests run first.)
176 if FRED_SINGLETON.get().is_none() {
177 // We can't reset OnceLock in tests, but we can verify the error shape:
178 // Synthesise the error manually.
179 let err = FinanceError::InvalidParameter {
180 param: "fred".to_string(),
181 reason: "not initialized".to_string(),
182 };
183 assert!(matches!(err, FinanceError::InvalidParameter { .. }));
184 }
185 }
186}