From c52d67f13c63f3d28d73dcd58eee41785f5d0447 Mon Sep 17 00:00:00 2001 From: Robert Tidball Date: Thu, 9 Jul 2026 11:35:31 +1000 Subject: [PATCH] Add FXMacroData client --- Cargo.toml | 3 +- src/fxmacrodata.rs | 358 +++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 2 + 3 files changed, 362 insertions(+), 1 deletion(-) create mode 100644 src/fxmacrodata.rs diff --git a/Cargo.toml b/Cargo.toml index 5a1fd639..f3a32968 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,8 @@ chrono = { version = "0.4.45", features = ["now", "serde"] } futures = "0.3.32" lazy_static = "1.5.0" ratatui = "0.30.2" +reqwest = { version = "0.12.24", default-features = false, features = ["json", "rustls-tls"] } +serde_json = "1.0.150" sqlx = { version = "0.9.0", default-features = false, features = [ "chrono", "macros", @@ -41,4 +43,3 @@ strum = { version = "0.28.0", features = ["derive"] } [dev-dependencies] dotenvy = "0.15.7" -serde_json = "1.0.150" diff --git a/src/fxmacrodata.rs b/src/fxmacrodata.rs new file mode 100644 index 00000000..ce19c2a0 --- /dev/null +++ b/src/fxmacrodata.rs @@ -0,0 +1,358 @@ +use std::borrow::Cow; + +use serde_json::Value; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum FxMacroDataError { + #[error("missing required FXMacroData field `{0}`")] + MissingField(&'static str), + + #[error("unsupported FXMacroData endpoint `{0}`")] + UnsupportedEndpoint(String), + + #[error(transparent)] + Http(#[from] reqwest::Error), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FxMacroDataEndpoint { + DataCatalogue, + Announcements, + LatestAnnouncements, + AnnouncementChanges, + Calendar, + Predictions, + Forex, + Cot, + Commodity, + CommoditiesLatest, + Curves, + CurveProxies, + ForwardCurves, + RateDifferentials, + ForwardDifferentials, + MarketSessions, + RiskSentiment, + News, + PressReleases, + Graphql, + Custom, +} + +#[derive(Clone, Debug)] +pub struct FxMacroDataRequest { + pub endpoint: FxMacroDataEndpoint, + pub currency: Option, + pub indicator: Option, + pub base: Option, + pub quote: Option, + pub path: Option, + pub params: Vec<(String, String)>, + pub body: Option, +} + +impl FxMacroDataRequest { + #[must_use] + pub fn new(endpoint: FxMacroDataEndpoint) -> Self { + Self { + endpoint, + currency: None, + indicator: None, + base: None, + quote: None, + path: None, + params: Vec::new(), + body: None, + } + } +} + +#[derive(Clone, Debug)] +pub struct FxMacroDataClient { + base_url: String, + api_key: Option, + http: reqwest::Client, +} + +impl Default for FxMacroDataClient { + fn default() -> Self { + Self::new( + std::env::var("FXMACRODATA_API_KEY") + .or_else(|_| std::env::var("FXMD_API_KEY")) + .ok(), + ) + } +} + +impl FxMacroDataClient { + #[must_use] + pub fn new(api_key: Option) -> Self { + Self::with_base_url(api_key, "https://api.fxmacrodata.com/v1") + } + + #[must_use] + pub fn with_base_url(api_key: Option, base_url: impl Into) -> Self { + Self { + base_url: base_url.into().trim_end_matches('/').to_owned(), + api_key, + http: reqwest::Client::new(), + } + } + + pub async fn request_json( + &self, + request: FxMacroDataRequest, + ) -> Result { + let method = self.method(&request); + let url = self.build_url(&request)?; + let response = if method == "POST" { + self.http + .post(url) + .json(&request.body.unwrap_or(Value::Null)) + .send() + .await? + } else { + self.http.get(url).send().await? + }; + Ok(response.error_for_status()?.json().await?) + } + + pub async fn data_catalogue(&self, currency: &str) -> Result { + let mut request = FxMacroDataRequest::new(FxMacroDataEndpoint::DataCatalogue); + request.currency = Some(currency.to_owned()); + self.request_json(request).await + } + + pub async fn announcements( + &self, + currency: &str, + indicator: &str, + ) -> Result { + let mut request = FxMacroDataRequest::new(FxMacroDataEndpoint::Announcements); + request.currency = Some(currency.to_owned()); + request.indicator = Some(indicator.to_owned()); + self.request_json(request).await + } + + pub async fn latest_announcements(&self, currency: &str) -> Result { + let mut request = FxMacroDataRequest::new(FxMacroDataEndpoint::LatestAnnouncements); + request.currency = Some(currency.to_owned()); + self.request_json(request).await + } + + pub async fn calendar(&self, currency: &str) -> Result { + let mut request = FxMacroDataRequest::new(FxMacroDataEndpoint::Calendar); + request.currency = Some(currency.to_owned()); + self.request_json(request).await + } + + pub async fn predictions( + &self, + currency: &str, + indicator: &str, + ) -> Result { + let mut request = FxMacroDataRequest::new(FxMacroDataEndpoint::Predictions); + request.currency = Some(currency.to_owned()); + request.indicator = Some(indicator.to_owned()); + self.request_json(request).await + } + + pub async fn forex(&self, base: &str, quote: &str) -> Result { + let mut request = FxMacroDataRequest::new(FxMacroDataEndpoint::Forex); + request.base = Some(base.to_owned()); + request.quote = Some(quote.to_owned()); + self.request_json(request).await + } + + pub async fn graphql(&self, query: &str, variables: Value) -> Result { + let mut request = FxMacroDataRequest::new(FxMacroDataEndpoint::Graphql); + request.body = Some(serde_json::json!({ "query": query, "variables": variables })); + self.request_json(request).await + } + + pub fn build_url(&self, request: &FxMacroDataRequest) -> Result { + let mut params = request.params.clone(); + if let Some(api_key) = &self.api_key { + if !params.iter().any(|(key, _)| key == "api_key") { + params.push(("api_key".to_owned(), api_key.clone())); + } + } + + let mut url = format!("{}{}", self.base_url, self.path(request)?); + if !params.is_empty() { + url.push('?'); + url.push_str( + ¶ms + .iter() + .map(|(key, value)| format!("{}={}", encode(key), encode(value))) + .collect::>() + .join("&"), + ); + } + Ok(url) + } + + fn method(&self, request: &FxMacroDataRequest) -> &'static str { + if matches!(request.endpoint, FxMacroDataEndpoint::Graphql) + || (matches!(request.endpoint, FxMacroDataEndpoint::Custom) && request.body.is_some()) + { + "POST" + } else { + "GET" + } + } + + fn path(&self, request: &FxMacroDataRequest) -> Result { + let path = match request.endpoint { + FxMacroDataEndpoint::DataCatalogue => { + format!( + "/data_catalogue/{}", + segment(&request.currency, "currency")? + ) + } + FxMacroDataEndpoint::Announcements => format!( + "/announcements/{}/{}", + segment(&request.currency, "currency")?, + segment(&request.indicator, "indicator")? + ), + FxMacroDataEndpoint::LatestAnnouncements => { + format!( + "/announcements/{}/latest", + segment(&request.currency, "currency")? + ) + } + FxMacroDataEndpoint::AnnouncementChanges => "/announcements/changes".to_owned(), + FxMacroDataEndpoint::Calendar => { + format!("/calendar/{}", segment(&request.currency, "currency")?) + } + FxMacroDataEndpoint::Predictions => format!( + "/predictions/{}/{}", + segment(&request.currency, "currency")?, + segment(&request.indicator, "indicator")? + ), + FxMacroDataEndpoint::Forex => format!( + "/forex/{}/{}", + segment(&request.base, "base")?, + segment(&request.quote, "quote")? + ), + FxMacroDataEndpoint::Cot => format!("/cot/{}", segment(&request.currency, "currency")?), + FxMacroDataEndpoint::Commodity => { + format!("/commodities/{}", segment(&request.indicator, "indicator")?) + } + FxMacroDataEndpoint::CommoditiesLatest => "/commodities/latest".to_owned(), + FxMacroDataEndpoint::Curves => { + format!("/curves/{}", segment(&request.currency, "currency")?) + } + FxMacroDataEndpoint::CurveProxies => { + format!("/curve_proxies/{}", segment(&request.currency, "currency")?) + } + FxMacroDataEndpoint::ForwardCurves => { + format!( + "/forward_curves/{}", + segment(&request.currency, "currency")? + ) + } + FxMacroDataEndpoint::RateDifferentials => format!( + "/rate_differentials/{}/{}", + segment(&request.base, "base")?, + segment(&request.quote, "quote")? + ), + FxMacroDataEndpoint::ForwardDifferentials => format!( + "/forward_differentials/{}/{}", + segment(&request.base, "base")?, + segment(&request.quote, "quote")? + ), + FxMacroDataEndpoint::MarketSessions => "/market_sessions".to_owned(), + FxMacroDataEndpoint::RiskSentiment => "/risk_sentiment".to_owned(), + FxMacroDataEndpoint::News => { + format!("/news/{}", segment(&request.currency, "currency")?) + } + FxMacroDataEndpoint::PressReleases => { + format!( + "/press-releases/{}", + segment(&request.currency, "currency")? + ) + } + FxMacroDataEndpoint::Graphql => "/graphql".to_owned(), + FxMacroDataEndpoint::Custom => request + .path + .as_deref() + .map(|path| { + if path.starts_with('/') { + path.to_owned() + } else { + format!("/{path}") + } + }) + .ok_or(FxMacroDataError::MissingField("path"))?, + }; + Ok(path) + } +} + +fn segment(value: &Option, name: &'static str) -> Result { + value + .as_deref() + .map(str::to_lowercase) + .map(|value| encode(&value).into_owned()) + .ok_or(FxMacroDataError::MissingField(name)) +} + +fn encode(value: &str) -> Cow<'_, str> { + if value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~')) + { + Cow::Borrowed(value) + } else { + Cow::Owned( + value + .bytes() + .map(|b| { + if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') { + char::from(b).to_string() + } else { + format!("%{b:02X}") + } + }) + .collect::(), + ) + } +} + +#[cfg(test)] +mod tests { + use super::{FxMacroDataClient, FxMacroDataEndpoint, FxMacroDataRequest}; + + #[test] + fn builds_authenticated_macro_urls() { + let client = FxMacroDataClient::with_base_url( + Some("test-key".to_owned()), + "https://api.fxmacrodata.com/v1/", + ); + let mut request = FxMacroDataRequest::new(FxMacroDataEndpoint::Predictions); + request.currency = Some("USD".to_owned()); + request.indicator = Some("non_farm_payrolls".to_owned()); + request.params.push(("limit".to_owned(), "1".to_owned())); + + assert_eq!( + client.build_url(&request).unwrap(), + "https://api.fxmacrodata.com/v1/predictions/usd/non_farm_payrolls?limit=1&api_key=test-key" + ); + } + + #[test] + fn builds_cross_currency_market_urls() { + let client = FxMacroDataClient::new(Some("test-key".to_owned())); + let mut request = FxMacroDataRequest::new(FxMacroDataEndpoint::RateDifferentials); + request.base = Some("EUR".to_owned()); + request.quote = Some("USD".to_owned()); + request.params.push(("tenor".to_owned(), "2y".to_owned())); + + assert_eq!( + client.build_url(&request).unwrap(), + "https://api.fxmacrodata.com/v1/rate_differentials/eur/usd?tenor=2y&api_key=test-key" + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 31085c49..7900d386 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ #![doc = include_str!("../README.md")] mod db; +pub mod fxmacrodata; mod shared; /// Exports [`SignalEvaluator`] and other types related to signal evaluation. /// @@ -36,6 +37,7 @@ pub use db::{ Database, FundingSettlementsRepositoryRead, OhlcCandlesRepositoryRead, PriceTicksRepositoryRead, RunningTradesRepositoryRead, }; +pub use fxmacrodata::{FxMacroDataClient, FxMacroDataEndpoint, FxMacroDataError}; #[cfg(feature = "postgres")] pub use sqlx::postgres::PgPoolOptions; #[cfg(feature = "sqlite")]