pub(crate) mod client;
pub(crate) 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> {
build_client()?.series(series_id).await
}
pub(crate) fn build_client() -> Result<client::FredClient> {
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(),
})?;
FredClientBuilder::new(&s.api_key)
.timeout(s.timeout)
.build_with_limiter(Arc::clone(&s.limiter))
}
pub(crate) async fn latest_observation(
series_id: &str,
) -> Result<Option<crate::models::economic::MacroObservation>> {
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.latest_observation(series_id).await
}
pub async fn release_dates() -> Result<Vec<ReleaseDate>> {
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
build_client()?.release_dates(&today, "9999-12-31").await
}
pub(crate) async fn release_dates_between(from: &str, to: &str) -> Result<Vec<ReleaseDate>> {
build_client()?.release_dates(from, to).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(),
}
}
pub async fn fetch_market_calendar_response(
from: &str,
to: &str,
) -> Result<Vec<crate::models::calendar::market::MarketCalendarEntry>> {
Ok(release_dates_to_calendar_entries(
release_dates_between(from, to).await?,
))
}
fn release_dates_to_calendar_entries(
dates: Vec<ReleaseDate>,
) -> Vec<crate::models::calendar::market::MarketCalendarEntry> {
use crate::models::calendar::market::{CalendarDetail, MarketCalendarEntry};
dates
.into_iter()
.map(|rd| MarketCalendarEntry {
symbol: None,
date: Some(rd.date),
detail: CalendarDetail::Economic {
event: Some(rd.release_name),
country: Some("US".to_string()),
actual: None,
previous: None,
estimate: None,
change: None,
change_percentage: None,
impact: None,
},
})
.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_invalid_key_in_400_body_maps_to_authentication_error() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/series/observations")
.match_query(mockito::Matcher::Any)
.with_status(400)
.with_header("content-type", "application/json")
.with_body(
serde_json::json!({
"error_code": 400,
"error_message": "Bad Request. The value for variable api_key is not registered."
})
.to_string(),
)
.create_async()
.await;
let err = test_client(&server.url()).series("GDP").await.unwrap_err();
assert!(matches!(err, FinanceError::AuthenticationFailed { .. }));
}
#[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 { .. }));
}
#[tokio::test]
async fn test_release_dates_mock() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/releases/dates")
.match_query(mockito::Matcher::AllOf(vec![
mockito::Matcher::UrlEncoded("realtime_start".into(), "2026-01-01".into()),
mockito::Matcher::UrlEncoded("realtime_end".into(), "2026-01-31".into()),
]))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
serde_json::json!({
"release_dates": [
{"release_id": 10, "release_name": "Consumer Price Index", "date": "2026-01-14"}
]
})
.to_string(),
)
.create_async()
.await;
let dates = test_client(&server.url())
.release_dates("2026-01-01", "2026-01-31")
.await
.unwrap();
assert_eq!(dates.len(), 1);
assert_eq!(dates[0].release_name, "Consumer Price Index");
assert_eq!(dates[0].date, "2026-01-14");
}
#[test]
fn release_dates_map_to_economic_calendar_entries() {
let dates = vec![ReleaseDate {
release_id: 10,
release_name: "Consumer Price Index".to_string(),
date: "2026-01-14".to_string(),
}];
let entries = release_dates_to_calendar_entries(dates);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].symbol, None);
assert_eq!(entries[0].date.as_deref(), Some("2026-01-14"));
match &entries[0].detail {
crate::models::calendar::market::CalendarDetail::Economic {
event, country, ..
} => {
assert_eq!(event.as_deref(), Some("Consumer Price Index"));
assert_eq!(country.as_deref(), Some("US"));
}
other => panic!("expected Economic detail, got {other:?}"),
}
}
#[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 { .. }));
}
}
}