From 394bc9dda865138216beebc78cea957ecd9b7347 Mon Sep 17 00:00:00 2001 From: Robert Tidball Date: Thu, 9 Jul 2026 11:35:32 +1000 Subject: [PATCH] Add FXMacroData data client --- crates/wickra-backtest-data/Cargo.toml | 3 + .../wickra-backtest-data/src/fxmacrodata.rs | 297 ++++++++++++++++++ crates/wickra-backtest-data/src/lib.rs | 2 + crates/wickra-backtest/Cargo.toml | 3 + crates/wickra-backtest/src/lib.rs | 2 + 5 files changed, 307 insertions(+) create mode 100644 crates/wickra-backtest-data/src/fxmacrodata.rs diff --git a/crates/wickra-backtest-data/Cargo.toml b/crates/wickra-backtest-data/Cargo.toml index b995f5e..25c9204 100644 --- a/crates/wickra-backtest-data/Cargo.toml +++ b/crates/wickra-backtest-data/Cargo.toml @@ -32,6 +32,9 @@ parquet = ["dep:parquet", "dep:arrow-array"] # Fetch historical candles from the Binance REST API. Off by default (pulls an # HTTP client). The response *parser* is always available and tested offline. binance = ["dep:ureq"] +# Fetch JSON payloads from FXMacroData macro, FX, calendar, COT, commodity, +# curve, session and news endpoints. +fxmacrodata = ["dep:ureq"] [lints] workspace = true diff --git a/crates/wickra-backtest-data/src/fxmacrodata.rs b/crates/wickra-backtest-data/src/fxmacrodata.rs new file mode 100644 index 0000000..eb472d0 --- /dev/null +++ b/crates/wickra-backtest-data/src/fxmacrodata.rs @@ -0,0 +1,297 @@ +use wickra_backtest_core::{BacktestError, Result}; + +#[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, +} + +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, + } + } + + 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) + } + + #[cfg(feature = "fxmacrodata")] + pub fn fetch_json(&self, request: FxMacroDataRequest) -> Result { + let url = self.build_url(&request)?; + if matches!(request.endpoint, FxMacroDataEndpoint::Graphql) + || (matches!(request.endpoint, FxMacroDataEndpoint::Custom) && request.body.is_some()) + { + let body = request.body.unwrap_or(serde_json::Value::Null).to_string(); + let text = ureq::post(&url) + .set("Content-Type", "application/json") + .send_string(&body) + .map_err(|e| { + BacktestError::InvalidData(format!("FXMacroData request failed: {e}")) + })? + .into_string() + .map_err(|e| BacktestError::InvalidData(format!("FXMacroData response: {e}")))?; + return serde_json::from_str(&text) + .map_err(|e| BacktestError::InvalidData(format!("FXMacroData JSON: {e}"))); + } + + let text = ureq::get(&url) + .call() + .map_err(|e| BacktestError::InvalidData(format!("FXMacroData request failed: {e}")))? + .into_string() + .map_err(|e| BacktestError::InvalidData(format!("FXMacroData response: {e}")))?; + serde_json::from_str(&text) + .map_err(|e| BacktestError::InvalidData(format!("FXMacroData JSON: {e}"))) + } + + pub fn calendar(&self, currency: &str) -> FxMacroDataRequest { + let mut request = FxMacroDataRequest::new(FxMacroDataEndpoint::Calendar); + request.currency = Some(currency.to_owned()); + request + } + + pub fn predictions(&self, currency: &str, indicator: &str) -> FxMacroDataRequest { + let mut request = FxMacroDataRequest::new(FxMacroDataEndpoint::Predictions); + request.currency = Some(currency.to_owned()); + request.indicator = Some(indicator.to_owned()); + request + } + + pub fn forex(&self, base: &str, quote: &str) -> FxMacroDataRequest { + let mut request = FxMacroDataRequest::new(FxMacroDataEndpoint::Forex); + request.base = Some(base.to_owned()); + request.quote = Some(quote.to_owned()); + request + } + + 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_else(|| BacktestError::InvalidData("FXMacroData path is required".into()))?, + }; + Ok(path) + } +} + +fn segment(value: &Option, name: &str) -> Result { + value + .as_deref() + .map(str::to_lowercase) + .map(|value| encode(&value)) + .ok_or_else(|| BacktestError::InvalidData(format!("FXMacroData {name} is required"))) +} + +fn encode(value: &str) -> String { + 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 = client.predictions("USD", "non_farm_payrolls"); + 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/crates/wickra-backtest-data/src/lib.rs b/crates/wickra-backtest-data/src/lib.rs index 57075e5..c997bc3 100644 --- a/crates/wickra-backtest-data/src/lib.rs +++ b/crates/wickra-backtest-data/src/lib.rs @@ -13,6 +13,8 @@ use std::path::Path; use wickra_backtest_core::{BacktestError, Candle, Result}; +pub mod fxmacrodata; + /// Load candles from a file, choosing the parser by extension /// (`.jsonl`/`.ndjson` → JSON Lines, `.json` → JSON array, `.parquet` → Parquet /// when the `parquet` feature is on, anything else → CSV). diff --git a/crates/wickra-backtest/Cargo.toml b/crates/wickra-backtest/Cargo.toml index e1132e0..c1cab7a 100644 --- a/crates/wickra-backtest/Cargo.toml +++ b/crates/wickra-backtest/Cargo.toml @@ -15,5 +15,8 @@ categories.workspace = true wickra-backtest-core.workspace = true wickra-backtest-data.workspace = true +[features] +fxmacrodata = ["wickra-backtest-data/fxmacrodata"] + [lints] workspace = true diff --git a/crates/wickra-backtest/src/lib.rs b/crates/wickra-backtest/src/lib.rs index 6364b3f..8f35e97 100644 --- a/crates/wickra-backtest/src/lib.rs +++ b/crates/wickra-backtest/src/lib.rs @@ -23,6 +23,8 @@ pub use wickra_backtest_core::{ run_with_ref, run_with_trades, strategy_spec_schema, BacktestError, BacktestReport, Candle, DerivativesTick, EquityPoint, OrderBook, Result, StrategySpec, StreamingBacktest, TradePrint, }; +#[cfg(feature = "fxmacrodata")] +pub use wickra_backtest_data::fxmacrodata; /// The crate version, surfaced for diagnostics. #[must_use]