mod client;
mod economic;
pub mod models;
use crate::adapters::singleton::provider_singleton_state;
use crate::error::{FinanceError, Result};
use client::FredClientBuilder;
use std::sync::Arc;
use std::time::Duration;
pub use crate::models::economic::{MacroSeries, TreasuryYield};
pub use models::ReleaseDate;
const FRED_RATE_PER_SEC: f64 = 2.0;
provider_singleton_state!(
name = FredSingleton,
static_name = FRED_SINGLETON,
rate_const = FRED_RATE_PER_SEC,
provider_key = "fred",
already_init_reason = "FRED client already initialized",
);
pub fn init(api_key: impl Into<String>) -> Result<()> {
init_with_timeout(api_key, Duration::from_secs(30))
}
pub fn init_with_timeout(api_key: impl Into<String>, timeout: Duration) -> Result<()> {
set_singleton(api_key, timeout)
}
pub async fn series(series_id: &str) -> Result<MacroSeries> {
let s = FRED_SINGLETON
.get()
.ok_or_else(|| FinanceError::InvalidParameter {
param: "fred".to_string(),
reason: "FRED not initialized. Call fred::init(api_key) first.".to_string(),
})?;
let c = FredClientBuilder::new(&s.api_key)
.timeout(s.timeout)
.build_with_limiter(Arc::clone(&s.limiter))?;
c.series(series_id).await
}
pub async fn release_dates() -> Result<Vec<ReleaseDate>> {
let s = FRED_SINGLETON
.get()
.ok_or_else(|| FinanceError::InvalidParameter {
param: "fred".to_string(),
reason: "FRED not initialized. Call fred::init(api_key) first.".to_string(),
})?;
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
let c = FredClientBuilder::new(&s.api_key)
.timeout(s.timeout)
.build_with_limiter(Arc::clone(&s.limiter))?;
c.release_dates(&today).await
}
pub async fn treasury_yields(year: u32) -> Result<Vec<TreasuryYield>> {
economic::treasury::fetch_yields(year).await
}
pub async fn fetch_economic_series_response(
series_id: &str,
) -> Result<crate::models::economic::EconomicSeries> {
let series = crate::adapters::fred::series(series_id).await?;
Ok(series_to_canonical(series))
}
fn series_to_canonical(series: MacroSeries) -> crate::models::economic::EconomicSeries {
crate::models::economic::EconomicSeries {
series_id: series.id,
title: None,
units: None,
frequency: None,
observations: series
.observations
.into_iter()
.map(|o| crate::models::economic::MacroObservation {
date: o.date,
value: o.value,
})
.collect(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rate_limiter::RateLimiter;
#[test]
fn test_init_errors_on_double_init() {
let _ = init("test-key-1");
let result = init("test-key-2");
assert!(matches!(result, Err(FinanceError::InvalidParameter { .. })));
}
fn test_client(base_url: &str) -> client::FredClient {
FredClientBuilder::new("test-key")
.timeout(Duration::from_secs(5))
.base_url(base_url)
.build_with_limiter(Arc::new(RateLimiter::new(100.0)))
.unwrap()
}
#[tokio::test]
async fn test_series_to_canonical_mock() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/series/observations")
.match_query(mockito::Matcher::AllOf(vec![
mockito::Matcher::UrlEncoded("series_id".into(), "GDP".into()),
mockito::Matcher::UrlEncoded("api_key".into(), "test-key".into()),
mockito::Matcher::UrlEncoded("file_type".into(), "json".into()),
]))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
serde_json::json!({
"observations": [
{ "date": "2023-01-01", "value": "26144.956" },
{ "date": "2023-04-01", "value": "." }
]
})
.to_string(),
)
.create_async()
.await;
let series = test_client(&server.url()).series("GDP").await.unwrap();
assert_eq!(series.id, "GDP");
assert_eq!(series.observations.len(), 2);
assert_eq!(series.observations[0].date, "2023-01-01");
assert_eq!(series.observations[0].value, Some(26144.956));
assert_eq!(series.observations[1].value, None, "\".\" parses to None");
let canonical = series_to_canonical(series);
assert_eq!(canonical.series_id, "GDP");
assert_eq!(canonical.observations.len(), 2);
assert_eq!(canonical.observations[0].value, Some(26144.956));
assert_eq!(canonical.observations[1].value, None);
}
#[tokio::test]
async fn test_series_unknown_id_maps_400_to_invalid_parameter() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/series/observations")
.match_query(mockito::Matcher::Any)
.with_status(400)
.create_async()
.await;
let err = test_client(&server.url())
.series("NOT_A_SERIES")
.await
.unwrap_err();
assert!(matches!(err, FinanceError::InvalidParameter { .. }));
}
#[tokio::test]
async fn test_series_missing_observations_errors() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/series/observations")
.match_query(mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(serde_json::json!({"error": "unexpected shape"}).to_string())
.create_async()
.await;
let err = test_client(&server.url()).series("GDP").await.unwrap_err();
assert!(matches!(err, FinanceError::ResponseStructureError { .. }));
}
#[test]
fn test_series_without_init_fails_gracefully() {
if FRED_SINGLETON.get().is_none() {
let err = FinanceError::InvalidParameter {
param: "fred".to_string(),
reason: "not initialized".to_string(),
};
assert!(matches!(err, FinanceError::InvalidParameter { .. }));
}
}
}