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}