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
35pub(crate) mod client;
36pub(crate) mod 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// Only the API key, timeout, and rate-limiter are stored — NOT the reqwest::Client
52// (runtime-bound; must be rebuilt per call but share the limiter for rate limits).
53provider_singleton_state!(
54    name = FredSingleton,
55    static_name = FRED_SINGLETON,
56    rate_const = FRED_RATE_PER_SEC,
57    provider_key = "fred",
58    already_init_reason = "FRED client already initialized",
59);
60
61/// Initialize the global FRED client with an API key.
62///
63/// Must be called once before [`series`]. Subsequent calls return an error.
64///
65/// # Arguments
66///
67/// * `api_key` - Your FRED API key (free at <https://fred.stlouisfed.org/docs/api/api_key.html>)
68///
69/// # Errors
70///
71/// Returns [`FinanceError::InvalidParameter`] if already initialized.
72pub fn init(api_key: impl Into<String>) -> Result<()> {
73    init_with_timeout(api_key, Duration::from_secs(30))
74}
75
76/// Initialize the FRED client with a custom timeout.
77pub fn init_with_timeout(api_key: impl Into<String>, timeout: Duration) -> Result<()> {
78    set_singleton(api_key, timeout)
79}
80
81/// Fetch all observations for a FRED data series.
82///
83/// Common series IDs:
84/// - `"FEDFUNDS"` — Federal Funds Rate
85/// - `"CPIAUCSL"` — Consumer Price Index (all urban, seasonally adjusted)
86/// - `"UNRATE"` — Unemployment Rate
87/// - `"DGS10"` — 10-Year Treasury Constant Maturity Rate
88/// - `"M2SL"` — M2 Money Supply
89/// - `"GDP"` — US Gross Domestic Product
90///
91/// # Errors
92///
93/// Returns [`FinanceError::InvalidParameter`] if FRED has not been initialized.
94pub async fn series(series_id: &str) -> Result<MacroSeries> {
95    build_client()?.series(series_id).await
96}
97
98pub(crate) fn build_client() -> Result<client::FredClient> {
99    let s = FRED_SINGLETON
100        .get()
101        .ok_or_else(|| FinanceError::InvalidParameter {
102            param: "fred".to_string(),
103            reason: "FRED not initialized. Call fred::init(api_key) first.".to_string(),
104        })?;
105    FredClientBuilder::new(&s.api_key)
106        .timeout(s.timeout)
107        .build_with_limiter(Arc::clone(&s.limiter))
108}
109
110pub(crate) async fn latest_observation(
111    series_id: &str,
112) -> Result<Option<crate::models::economic::MacroObservation>> {
113    let s = FRED_SINGLETON
114        .get()
115        .ok_or_else(|| FinanceError::InvalidParameter {
116            param: "fred".to_string(),
117            reason: "FRED not initialized. Call fred::init(api_key) first.".to_string(),
118        })?;
119    let c = FredClientBuilder::new(&s.api_key)
120        .timeout(s.timeout)
121        .build_with_limiter(Arc::clone(&s.limiter))?;
122    c.latest_observation(series_id).await
123}
124
125/// Fetch upcoming scheduled economic-data release dates (CPI, NFP, GDP, FOMC, …).
126///
127/// Returns releases scheduled from today onward, sorted ascending.
128///
129/// # Errors
130///
131/// Returns [`FinanceError::InvalidParameter`] if FRED has not been initialized.
132pub async fn release_dates() -> Result<Vec<ReleaseDate>> {
133    let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
134    build_client()?.release_dates(&today, "9999-12-31").await
135}
136
137/// Fetch scheduled economic-data release dates over `[from, to]`
138/// (`YYYY-MM-DD` dates).
139pub(crate) async fn release_dates_between(from: &str, to: &str) -> Result<Vec<ReleaseDate>> {
140    build_client()?.release_dates(from, to).await
141}
142
143/// Fetch US Treasury yield curve data for the given year.
144///
145/// No API key required. Data is published on each business day.
146///
147/// # Arguments
148///
149/// * `year` - Calendar year (e.g., `2025`). Pass the current year for recent data.
150pub async fn treasury_yields(year: u32) -> Result<Vec<TreasuryYield>> {
151    economic::treasury::fetch_yields(year).await
152}
153
154// ============================================================================
155// Canonical model conversion functions
156// ============================================================================
157
158/// Fetch canonical EconomicSeries for a FRED series ID.
159pub async fn fetch_economic_series_response(
160    series_id: &str,
161) -> Result<crate::models::economic::EconomicSeries> {
162    let series = crate::adapters::fred::series(series_id).await?;
163    Ok(series_to_canonical(series))
164}
165
166/// Map a FRED [`MacroSeries`] to the canonical
167/// [`EconomicSeries`](crate::models::economic::EconomicSeries).
168fn series_to_canonical(series: MacroSeries) -> crate::models::economic::EconomicSeries {
169    crate::models::economic::EconomicSeries {
170        series_id: series.id,
171        title: None,
172        units: None,
173        frequency: None,
174        observations: series
175            .observations
176            .into_iter()
177            .map(|o| crate::models::economic::MacroObservation {
178                date: o.date,
179                value: o.value,
180            })
181            .collect(),
182    }
183}
184
185/// Fetch scheduled economic-data releases over `[from, to]` and map them to
186/// provider-neutral calendar entries.
187pub async fn fetch_market_calendar_response(
188    from: &str,
189    to: &str,
190) -> Result<Vec<crate::models::calendar::market::MarketCalendarEntry>> {
191    Ok(release_dates_to_calendar_entries(
192        release_dates_between(from, to).await?,
193    ))
194}
195
196/// Map FRED release dates to the canonical `Economic` calendar detail.
197/// FRED's release calendar reports when a release happens, not its value, so
198/// every field besides `event`/`country` stays `None`.
199fn release_dates_to_calendar_entries(
200    dates: Vec<ReleaseDate>,
201) -> Vec<crate::models::calendar::market::MarketCalendarEntry> {
202    use crate::models::calendar::market::{CalendarDetail, MarketCalendarEntry};
203
204    dates
205        .into_iter()
206        .map(|rd| MarketCalendarEntry {
207            symbol: None,
208            date: Some(rd.date),
209            detail: CalendarDetail::Economic {
210                event: Some(rd.release_name),
211                country: Some("US".to_string()),
212                actual: None,
213                previous: None,
214                estimate: None,
215                change: None,
216                change_percentage: None,
217                impact: None,
218            },
219        })
220        .collect()
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use crate::rate_limiter::RateLimiter;
227
228    #[test]
229    fn test_init_errors_on_double_init() {
230        // First init may or may not succeed (could already be set from another test).
231        let _ = init("test-key-1");
232        let result = init("test-key-2");
233        assert!(matches!(result, Err(FinanceError::InvalidParameter { .. })));
234    }
235
236    fn test_client(base_url: &str) -> client::FredClient {
237        FredClientBuilder::new("test-key")
238            .timeout(Duration::from_secs(5))
239            .base_url(base_url)
240            .build_with_limiter(Arc::new(RateLimiter::new(100.0)))
241            .unwrap()
242    }
243
244    /// Mocked HTTP → `FredClient::series` → `series_to_canonical`, covering the
245    /// full `fetch_economic_series_response` pipeline without a network call.
246    #[tokio::test]
247    async fn test_series_to_canonical_mock() {
248        let mut server = mockito::Server::new_async().await;
249        let _mock = server
250            .mock("GET", "/series/observations")
251            .match_query(mockito::Matcher::AllOf(vec![
252                mockito::Matcher::UrlEncoded("series_id".into(), "GDP".into()),
253                mockito::Matcher::UrlEncoded("api_key".into(), "test-key".into()),
254                mockito::Matcher::UrlEncoded("file_type".into(), "json".into()),
255            ]))
256            .with_status(200)
257            .with_header("content-type", "application/json")
258            .with_body(
259                serde_json::json!({
260                    "observations": [
261                        { "date": "2023-01-01", "value": "26144.956" },
262                        { "date": "2023-04-01", "value": "." }
263                    ]
264                })
265                .to_string(),
266            )
267            .create_async()
268            .await;
269
270        let series = test_client(&server.url()).series("GDP").await.unwrap();
271        assert_eq!(series.id, "GDP");
272        assert_eq!(series.observations.len(), 2);
273        assert_eq!(series.observations[0].date, "2023-01-01");
274        assert_eq!(series.observations[0].value, Some(26144.956));
275        assert_eq!(series.observations[1].value, None, "\".\" parses to None");
276
277        let canonical = series_to_canonical(series);
278        assert_eq!(canonical.series_id, "GDP");
279        assert_eq!(canonical.observations.len(), 2);
280        assert_eq!(canonical.observations[0].value, Some(26144.956));
281        assert_eq!(canonical.observations[1].value, None);
282    }
283
284    #[tokio::test]
285    async fn test_series_unknown_id_maps_400_to_invalid_parameter() {
286        let mut server = mockito::Server::new_async().await;
287        let _mock = server
288            .mock("GET", "/series/observations")
289            .match_query(mockito::Matcher::Any)
290            .with_status(400)
291            .create_async()
292            .await;
293
294        let err = test_client(&server.url())
295            .series("NOT_A_SERIES")
296            .await
297            .unwrap_err();
298        assert!(matches!(err, FinanceError::InvalidParameter { .. }));
299    }
300
301    #[tokio::test]
302    async fn test_invalid_key_in_400_body_maps_to_authentication_error() {
303        let mut server = mockito::Server::new_async().await;
304        let _mock = server
305            .mock("GET", "/series/observations")
306            .match_query(mockito::Matcher::Any)
307            .with_status(400)
308            .with_header("content-type", "application/json")
309            .with_body(
310                serde_json::json!({
311                    "error_code": 400,
312                    "error_message": "Bad Request. The value for variable api_key is not registered."
313                })
314                .to_string(),
315            )
316            .create_async()
317            .await;
318
319        let err = test_client(&server.url()).series("GDP").await.unwrap_err();
320        assert!(matches!(err, FinanceError::AuthenticationFailed { .. }));
321    }
322
323    #[tokio::test]
324    async fn test_series_missing_observations_errors() {
325        let mut server = mockito::Server::new_async().await;
326        let _mock = server
327            .mock("GET", "/series/observations")
328            .match_query(mockito::Matcher::Any)
329            .with_status(200)
330            .with_header("content-type", "application/json")
331            .with_body(serde_json::json!({"error": "unexpected shape"}).to_string())
332            .create_async()
333            .await;
334
335        let err = test_client(&server.url()).series("GDP").await.unwrap_err();
336        assert!(matches!(err, FinanceError::ResponseStructureError { .. }));
337    }
338
339    #[tokio::test]
340    async fn test_release_dates_mock() {
341        let mut server = mockito::Server::new_async().await;
342        let _mock = server
343            .mock("GET", "/releases/dates")
344            .match_query(mockito::Matcher::AllOf(vec![
345                mockito::Matcher::UrlEncoded("realtime_start".into(), "2026-01-01".into()),
346                mockito::Matcher::UrlEncoded("realtime_end".into(), "2026-01-31".into()),
347            ]))
348            .with_status(200)
349            .with_header("content-type", "application/json")
350            .with_body(
351                serde_json::json!({
352                    "release_dates": [
353                        {"release_id": 10, "release_name": "Consumer Price Index", "date": "2026-01-14"}
354                    ]
355                })
356                .to_string(),
357            )
358            .create_async()
359            .await;
360
361        let dates = test_client(&server.url())
362            .release_dates("2026-01-01", "2026-01-31")
363            .await
364            .unwrap();
365        assert_eq!(dates.len(), 1);
366        assert_eq!(dates[0].release_name, "Consumer Price Index");
367        assert_eq!(dates[0].date, "2026-01-14");
368    }
369
370    #[test]
371    fn release_dates_map_to_economic_calendar_entries() {
372        let dates = vec![ReleaseDate {
373            release_id: 10,
374            release_name: "Consumer Price Index".to_string(),
375            date: "2026-01-14".to_string(),
376        }];
377        let entries = release_dates_to_calendar_entries(dates);
378        assert_eq!(entries.len(), 1);
379        assert_eq!(entries[0].symbol, None);
380        assert_eq!(entries[0].date.as_deref(), Some("2026-01-14"));
381        match &entries[0].detail {
382            crate::models::calendar::market::CalendarDetail::Economic {
383                event, country, ..
384            } => {
385                assert_eq!(event.as_deref(), Some("Consumer Price Index"));
386                assert_eq!(country.as_deref(), Some("US"));
387            }
388            other => panic!("expected Economic detail, got {other:?}"),
389        }
390    }
391
392    #[test]
393    fn test_series_without_init_fails_gracefully() {
394        // If somehow the singleton is not set, series() must return an error.
395        // (This test only exercises the error path if FRED_SINGLETON isn't set yet,
396        //  which may not be the case if other tests run first.)
397        if FRED_SINGLETON.get().is_none() {
398            // We can't reset OnceLock in tests, but we can verify the error shape:
399            // Synthesise the error manually.
400            let err = FinanceError::InvalidParameter {
401                param: "fred".to_string(),
402                reason: "not initialized".to_string(),
403            };
404            assert!(matches!(err, FinanceError::InvalidParameter { .. }));
405        }
406    }
407}