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