finance_query/adapters/fred/
mod.rs1pub(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
50const FRED_RATE_PER_SEC: f64 = 2.0;
52
53provider_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
63pub fn init(api_key: impl Into<String>) -> Result<()> {
76 init_with_timeout(api_key, Duration::from_secs(30))
77}
78
79pub fn init_with_timeout(api_key: impl Into<String>, timeout: Duration) -> Result<()> {
81 set_singleton(api_key, timeout)
82}
83
84pub 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
109fn 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
136pub 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
149pub(crate) async fn release_dates_between(from: &str, to: &str) -> Result<Vec<ReleaseDate>> {
152 build_client()?.release_dates(from, to).await
153}
154
155pub async fn treasury_yields(year: u32) -> Result<Vec<TreasuryYield>> {
163 economic::treasury::fetch_yields(year).await
164}
165
166pub 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
178fn 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
197pub 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
208fn 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 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 #[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}