finance_query/adapters/fred/
mod.rs1mod client;
36mod 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!(
62 name = FredSingleton,
63 static_name = FRED_SINGLETON,
64 rate_const = FRED_RATE_PER_SEC,
65 provider_key = "fred",
66 already_init_reason = "FRED client already initialized",
67);
68
69pub fn init(api_key: impl Into<String>) -> Result<()> {
81 init_with_timeout(api_key, Duration::from_secs(30))
82}
83
84pub fn init_with_timeout(api_key: impl Into<String>, timeout: Duration) -> Result<()> {
86 set_singleton(api_key, timeout)
87}
88
89pub async fn series(series_id: &str) -> Result<MacroSeries> {
103 let s = FRED_SINGLETON
104 .get()
105 .ok_or_else(|| FinanceError::InvalidParameter {
106 param: "fred".to_string(),
107 reason: "FRED not initialized. Call fred::init(api_key) first.".to_string(),
108 })?;
109 let c = FredClientBuilder::new(&s.api_key)
110 .timeout(s.timeout)
111 .build_with_limiter(Arc::clone(&s.limiter))?;
112 c.series(series_id).await
113}
114
115pub async fn release_dates() -> Result<Vec<ReleaseDate>> {
123 let s = FRED_SINGLETON
124 .get()
125 .ok_or_else(|| FinanceError::InvalidParameter {
126 param: "fred".to_string(),
127 reason: "FRED not initialized. Call fred::init(api_key) first.".to_string(),
128 })?;
129 let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
130 let c = FredClientBuilder::new(&s.api_key)
131 .timeout(s.timeout)
132 .build_with_limiter(Arc::clone(&s.limiter))?;
133 c.release_dates(&today).await
134}
135
136pub async fn treasury_yields(year: u32) -> Result<Vec<TreasuryYield>> {
144 economic::treasury::fetch_yields(year).await
145}
146
147pub async fn fetch_economic_series_response(
153 series_id: &str,
154) -> Result<crate::models::economic::EconomicSeries> {
155 let series = crate::adapters::fred::series(series_id).await?;
156 Ok(series_to_canonical(series))
157}
158
159fn series_to_canonical(series: MacroSeries) -> crate::models::economic::EconomicSeries {
162 crate::models::economic::EconomicSeries {
163 series_id: series.id,
164 title: None,
165 units: None,
166 frequency: None,
167 observations: series
168 .observations
169 .into_iter()
170 .map(|o| crate::models::economic::MacroObservation {
171 date: o.date,
172 value: o.value,
173 })
174 .collect(),
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181 use crate::rate_limiter::RateLimiter;
182
183 #[test]
184 fn test_init_errors_on_double_init() {
185 let _ = init("test-key-1");
187 let result = init("test-key-2");
188 assert!(matches!(result, Err(FinanceError::InvalidParameter { .. })));
189 }
190
191 fn test_client(base_url: &str) -> client::FredClient {
192 FredClientBuilder::new("test-key")
193 .timeout(Duration::from_secs(5))
194 .base_url(base_url)
195 .build_with_limiter(Arc::new(RateLimiter::new(100.0)))
196 .unwrap()
197 }
198
199 #[tokio::test]
202 async fn test_series_to_canonical_mock() {
203 let mut server = mockito::Server::new_async().await;
204 let _mock = server
205 .mock("GET", "/series/observations")
206 .match_query(mockito::Matcher::AllOf(vec![
207 mockito::Matcher::UrlEncoded("series_id".into(), "GDP".into()),
208 mockito::Matcher::UrlEncoded("api_key".into(), "test-key".into()),
209 mockito::Matcher::UrlEncoded("file_type".into(), "json".into()),
210 ]))
211 .with_status(200)
212 .with_header("content-type", "application/json")
213 .with_body(
214 serde_json::json!({
215 "observations": [
216 { "date": "2023-01-01", "value": "26144.956" },
217 { "date": "2023-04-01", "value": "." }
218 ]
219 })
220 .to_string(),
221 )
222 .create_async()
223 .await;
224
225 let series = test_client(&server.url()).series("GDP").await.unwrap();
226 assert_eq!(series.id, "GDP");
227 assert_eq!(series.observations.len(), 2);
228 assert_eq!(series.observations[0].date, "2023-01-01");
229 assert_eq!(series.observations[0].value, Some(26144.956));
230 assert_eq!(series.observations[1].value, None, "\".\" parses to None");
231
232 let canonical = series_to_canonical(series);
233 assert_eq!(canonical.series_id, "GDP");
234 assert_eq!(canonical.observations.len(), 2);
235 assert_eq!(canonical.observations[0].value, Some(26144.956));
236 assert_eq!(canonical.observations[1].value, None);
237 }
238
239 #[tokio::test]
240 async fn test_series_unknown_id_maps_400_to_invalid_parameter() {
241 let mut server = mockito::Server::new_async().await;
242 let _mock = server
243 .mock("GET", "/series/observations")
244 .match_query(mockito::Matcher::Any)
245 .with_status(400)
246 .create_async()
247 .await;
248
249 let err = test_client(&server.url())
250 .series("NOT_A_SERIES")
251 .await
252 .unwrap_err();
253 assert!(matches!(err, FinanceError::InvalidParameter { .. }));
254 }
255
256 #[tokio::test]
257 async fn test_series_missing_observations_errors() {
258 let mut server = mockito::Server::new_async().await;
259 let _mock = server
260 .mock("GET", "/series/observations")
261 .match_query(mockito::Matcher::Any)
262 .with_status(200)
263 .with_header("content-type", "application/json")
264 .with_body(serde_json::json!({"error": "unexpected shape"}).to_string())
265 .create_async()
266 .await;
267
268 let err = test_client(&server.url()).series("GDP").await.unwrap_err();
269 assert!(matches!(err, FinanceError::ResponseStructureError { .. }));
270 }
271
272 #[test]
273 fn test_series_without_init_fails_gracefully() {
274 if FRED_SINGLETON.get().is_none() {
278 let err = FinanceError::InvalidParameter {
281 param: "fred".to_string(),
282 reason: "not initialized".to_string(),
283 };
284 assert!(matches!(err, FinanceError::InvalidParameter { .. }));
285 }
286 }
287}