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
98fn not_configured() -> FinanceError {
99    FinanceError::ProviderNotConfigured {
100        provider: "FRED".to_string(),
101        env_var: "FRED_API_KEY".to_string(),
102    }
103}
104
105pub(crate) fn build_client() -> Result<client::FredClient> {
106    let s = FRED_SINGLETON.get().ok_or_else(not_configured)?;
107    FredClientBuilder::new(&s.api_key)
108        .timeout(s.timeout)
109        .build_with_limiter(Arc::clone(&s.limiter))
110}
111
112pub(crate) async fn latest_observation(
113    series_id: &str,
114) -> Result<Option<crate::models::economic::MacroObservation>> {
115    let s = FRED_SINGLETON.get().ok_or_else(not_configured)?;
116    let c = FredClientBuilder::new(&s.api_key)
117        .timeout(s.timeout)
118        .build_with_limiter(Arc::clone(&s.limiter))?;
119    c.latest_observation(series_id).await
120}
121
122/// Fetch upcoming scheduled economic-data release dates (CPI, NFP, GDP, FOMC, …).
123///
124/// Returns releases scheduled from today onward, sorted ascending.
125///
126/// # Errors
127///
128/// Returns [`FinanceError::InvalidParameter`] if FRED has not been initialized.
129pub async fn release_dates() -> Result<Vec<ReleaseDate>> {
130    let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
131    build_client()?.release_dates(&today, "9999-12-31").await
132}
133
134/// Fetch scheduled economic-data release dates over `[from, to]`
135/// (`YYYY-MM-DD` dates).
136pub(crate) async fn release_dates_between(from: &str, to: &str) -> Result<Vec<ReleaseDate>> {
137    build_client()?.release_dates(from, to).await
138}
139
140/// Fetch US Treasury yield curve data for the given year.
141///
142/// No API key required. Data is published on each business day.
143///
144/// # Arguments
145///
146/// * `year` - Calendar year (e.g., `2025`). Pass the current year for recent data.
147pub async fn treasury_yields(year: u32) -> Result<Vec<TreasuryYield>> {
148    economic::treasury::fetch_yields(year).await
149}
150
151// ============================================================================
152// Canonical model conversion functions
153// ============================================================================
154
155/// Fetch canonical EconomicSeries for a FRED series ID.
156pub async fn fetch_economic_series_response(
157    series_id: &str,
158) -> Result<crate::models::economic::EconomicSeries> {
159    let series = crate::adapters::fred::series(series_id).await?;
160    Ok(series_to_canonical(series))
161}
162
163/// Map a FRED [`MacroSeries`] to the canonical
164/// [`EconomicSeries`](crate::models::economic::EconomicSeries).
165fn series_to_canonical(series: MacroSeries) -> crate::models::economic::EconomicSeries {
166    crate::models::economic::EconomicSeries {
167        series_id: series.id,
168        title: None,
169        units: None,
170        frequency: None,
171        observations: series
172            .observations
173            .into_iter()
174            .map(|o| crate::models::economic::MacroObservation {
175                date: o.date,
176                value: o.value,
177            })
178            .collect(),
179    }
180}
181
182/// Fetch scheduled economic-data releases over `[from, to]` and map them to
183/// provider-neutral calendar entries.
184pub async fn fetch_market_calendar_response(
185    from: &str,
186    to: &str,
187) -> Result<Vec<crate::models::calendar::market::MarketCalendarEntry>> {
188    Ok(release_dates_to_calendar_entries(
189        release_dates_between(from, to).await?,
190    ))
191}
192
193/// Map FRED release dates to the canonical `Economic` calendar detail.
194/// FRED's release calendar reports when a release happens, not its value, so
195/// every field besides `event`/`country` stays `None`.
196fn release_dates_to_calendar_entries(
197    dates: Vec<ReleaseDate>,
198) -> Vec<crate::models::calendar::market::MarketCalendarEntry> {
199    use crate::models::calendar::market::{CalendarDetail, MarketCalendarEntry};
200
201    dates
202        .into_iter()
203        .map(|rd| MarketCalendarEntry {
204            symbol: None,
205            date: Some(rd.date),
206            detail: CalendarDetail::Economic {
207                event: Some(rd.release_name),
208                country: Some("US".to_string()),
209                actual: None,
210                previous: None,
211                estimate: None,
212                change: None,
213                change_percentage: None,
214                impact: None,
215            },
216        })
217        .collect()
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use crate::rate_limiter::RateLimiter;
224
225    #[test]
226    fn test_init_errors_on_double_init() {
227        // First init may or may not succeed (could already be set from another test).
228        let _ = init("test-key-1");
229        let result = init("test-key-2");
230        assert!(matches!(result, Err(FinanceError::InvalidParameter { .. })));
231    }
232
233    fn test_client(base_url: &str) -> client::FredClient {
234        FredClientBuilder::new("test-key")
235            .timeout(Duration::from_secs(5))
236            .base_url(base_url)
237            .build_with_limiter(Arc::new(RateLimiter::new(100.0)))
238            .unwrap()
239    }
240
241    /// Mocked HTTP → `FredClient::series` → `series_to_canonical`, covering the
242    /// full `fetch_economic_series_response` pipeline without a network call.
243    #[tokio::test]
244    async fn test_series_to_canonical_mock() {
245        let mut server = mockito::Server::new_async().await;
246        let _mock = server
247            .mock("GET", "/series/observations")
248            .match_query(mockito::Matcher::AllOf(vec![
249                mockito::Matcher::UrlEncoded("series_id".into(), "GDP".into()),
250                mockito::Matcher::UrlEncoded("api_key".into(), "test-key".into()),
251                mockito::Matcher::UrlEncoded("file_type".into(), "json".into()),
252            ]))
253            .with_status(200)
254            .with_header("content-type", "application/json")
255            .with_body(
256                serde_json::json!({
257                    "observations": [
258                        { "date": "2023-01-01", "value": "26144.956" },
259                        { "date": "2023-04-01", "value": "." }
260                    ]
261                })
262                .to_string(),
263            )
264            .create_async()
265            .await;
266
267        let series = test_client(&server.url()).series("GDP").await.unwrap();
268        assert_eq!(series.id, "GDP");
269        assert_eq!(series.observations.len(), 2);
270        assert_eq!(series.observations[0].date, "2023-01-01");
271        assert_eq!(series.observations[0].value, Some(26144.956));
272        assert_eq!(series.observations[1].value, None, "\".\" parses to None");
273
274        let canonical = series_to_canonical(series);
275        assert_eq!(canonical.series_id, "GDP");
276        assert_eq!(canonical.observations.len(), 2);
277        assert_eq!(canonical.observations[0].value, Some(26144.956));
278        assert_eq!(canonical.observations[1].value, None);
279    }
280
281    #[tokio::test]
282    async fn test_series_unknown_id_maps_400_to_invalid_parameter() {
283        let mut server = mockito::Server::new_async().await;
284        let _mock = server
285            .mock("GET", "/series/observations")
286            .match_query(mockito::Matcher::Any)
287            .with_status(400)
288            .create_async()
289            .await;
290
291        let err = test_client(&server.url())
292            .series("NOT_A_SERIES")
293            .await
294            .unwrap_err();
295        assert!(matches!(err, FinanceError::InvalidParameter { .. }));
296    }
297
298    #[tokio::test]
299    async fn test_invalid_key_in_400_body_maps_to_authentication_error() {
300        let mut server = mockito::Server::new_async().await;
301        let _mock = server
302            .mock("GET", "/series/observations")
303            .match_query(mockito::Matcher::Any)
304            .with_status(400)
305            .with_header("content-type", "application/json")
306            .with_body(
307                serde_json::json!({
308                    "error_code": 400,
309                    "error_message": "Bad Request. The value for variable api_key is not registered."
310                })
311                .to_string(),
312            )
313            .create_async()
314            .await;
315
316        let err = test_client(&server.url()).series("GDP").await.unwrap_err();
317        assert!(matches!(err, FinanceError::AuthenticationFailed { .. }));
318    }
319
320    #[tokio::test]
321    async fn test_series_missing_observations_errors() {
322        let mut server = mockito::Server::new_async().await;
323        let _mock = server
324            .mock("GET", "/series/observations")
325            .match_query(mockito::Matcher::Any)
326            .with_status(200)
327            .with_header("content-type", "application/json")
328            .with_body(serde_json::json!({"error": "unexpected shape"}).to_string())
329            .create_async()
330            .await;
331
332        let err = test_client(&server.url()).series("GDP").await.unwrap_err();
333        assert!(matches!(err, FinanceError::ResponseStructureError { .. }));
334    }
335
336    #[tokio::test]
337    async fn test_release_dates_mock() {
338        let mut server = mockito::Server::new_async().await;
339        let _mock = server
340            .mock("GET", "/releases/dates")
341            .match_query(mockito::Matcher::AllOf(vec![
342                mockito::Matcher::UrlEncoded("realtime_start".into(), "2026-01-01".into()),
343                mockito::Matcher::UrlEncoded("realtime_end".into(), "2026-01-31".into()),
344            ]))
345            .with_status(200)
346            .with_header("content-type", "application/json")
347            .with_body(
348                serde_json::json!({
349                    "release_dates": [
350                        {"release_id": 10, "release_name": "Consumer Price Index", "date": "2026-01-14"}
351                    ]
352                })
353                .to_string(),
354            )
355            .create_async()
356            .await;
357
358        let dates = test_client(&server.url())
359            .release_dates("2026-01-01", "2026-01-31")
360            .await
361            .unwrap();
362        assert_eq!(dates.len(), 1);
363        assert_eq!(dates[0].release_name, "Consumer Price Index");
364        assert_eq!(dates[0].date, "2026-01-14");
365    }
366
367    #[test]
368    fn release_dates_map_to_economic_calendar_entries() {
369        let dates = vec![ReleaseDate {
370            release_id: 10,
371            release_name: "Consumer Price Index".to_string(),
372            date: "2026-01-14".to_string(),
373        }];
374        let entries = release_dates_to_calendar_entries(dates);
375        assert_eq!(entries.len(), 1);
376        assert_eq!(entries[0].symbol, None);
377        assert_eq!(entries[0].date.as_deref(), Some("2026-01-14"));
378        match &entries[0].detail {
379            crate::models::calendar::market::CalendarDetail::Economic {
380                event, country, ..
381            } => {
382                assert_eq!(event.as_deref(), Some("Consumer Price Index"));
383                assert_eq!(country.as_deref(), Some("US"));
384            }
385            other => panic!("expected Economic detail, got {other:?}"),
386        }
387    }
388
389    #[test]
390    fn an_unconfigured_fred_names_the_variable_that_configures_it() {
391        assert_eq!(
392            not_configured().to_string(),
393            "FRED is not configured; set FRED_API_KEY"
394        );
395    }
396}