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
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
125pub 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
137pub(crate) async fn release_dates_between(from: &str, to: &str) -> Result<Vec<ReleaseDate>> {
140 build_client()?.release_dates(from, to).await
141}
142
143pub async fn treasury_yields(year: u32) -> Result<Vec<TreasuryYield>> {
151 economic::treasury::fetch_yields(year).await
152}
153
154pub 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
166fn 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
185pub 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
196fn 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 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 #[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 FRED_SINGLETON.get().is_none() {
398 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}