finance_query/adapters/fred/
mod.rs1pub(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
48const FRED_RATE_PER_SEC: f64 = 2.0;
50
51provider_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
61pub fn init(api_key: impl Into<String>) -> Result<()> {
73 init_with_timeout(api_key, Duration::from_secs(30))
74}
75
76pub fn init_with_timeout(api_key: impl Into<String>, timeout: Duration) -> Result<()> {
78 set_singleton(api_key, timeout)
79}
80
81pub 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
122pub 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
134pub(crate) async fn release_dates_between(from: &str, to: &str) -> Result<Vec<ReleaseDate>> {
137 build_client()?.release_dates(from, to).await
138}
139
140pub async fn treasury_yields(year: u32) -> Result<Vec<TreasuryYield>> {
148 economic::treasury::fetch_yields(year).await
149}
150
151pub 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
163fn 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
182pub 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
193fn 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 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 #[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}