From db6a6f186bc18e4e77b6e800be0806af5acacd8c Mon Sep 17 00:00:00 2001 From: Robert Tidball Date: Thu, 9 Jul 2026 11:35:32 +1000 Subject: [PATCH] Add FXMacroData client --- CMakeLists.txt | 1 + include/pineforge/fxmacrodata.hpp | 77 ++++++++++++ src/fxmacrodata.cpp | 194 ++++++++++++++++++++++++++++++ tests/CMakeLists.txt | 1 + tests/test_fxmacrodata.cpp | 45 +++++++ 5 files changed, 318 insertions(+) create mode 100644 include/pineforge/fxmacrodata.hpp create mode 100644 src/fxmacrodata.cpp create mode 100644 tests/test_fxmacrodata.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 36503e5..76a4ebe 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -99,6 +99,7 @@ add_library(pineforge STATIC src/ta_volatility_trend.cpp src/timeframe.cpp src/timezone.cpp + src/fxmacrodata.cpp ) # Canonical alias for downstream find_package(PineForge) consumers. diff --git a/include/pineforge/fxmacrodata.hpp b/include/pineforge/fxmacrodata.hpp new file mode 100644 index 0000000..874fc38 --- /dev/null +++ b/include/pineforge/fxmacrodata.hpp @@ -0,0 +1,77 @@ +#pragma once + +#include +#include +#include +#include + +namespace pineforge { + +class FxMacroDataClient { +public: + using QueryParams = std::vector>; + using Transport = std::function; + + explicit FxMacroDataClient(std::string api_key = {}, + std::string base_url = "https://api.fxmacrodata.com/v1", + Transport transport = {}); + + std::string data_catalogue(const std::string& currency, + QueryParams params = {}) const; + std::string announcements(const std::string& currency, + const std::string& indicator, + QueryParams params = {}) const; + std::string latest_announcements(const std::string& currency, + QueryParams params = {}) const; + std::string announcement_changes(QueryParams params = {}) const; + std::string calendar(const std::string& currency, + QueryParams params = {}) const; + std::string predictions(const std::string& currency, + const std::string& indicator, + QueryParams params = {}) const; + std::string forex(const std::string& base, + const std::string& quote, + QueryParams params = {}) const; + std::string cot(const std::string& currency, QueryParams params = {}) const; + std::string commodity(const std::string& indicator, + QueryParams params = {}) const; + std::string commodities_latest(QueryParams params = {}) const; + std::string curves(const std::string& currency, QueryParams params = {}) const; + std::string curve_proxies(const std::string& currency, + QueryParams params = {}) const; + std::string forward_curves(const std::string& currency, + QueryParams params = {}) const; + std::string rate_differentials(const std::string& base, + const std::string& quote, + QueryParams params = {}) const; + std::string forward_differentials(const std::string& base, + const std::string& quote, + QueryParams params = {}) const; + std::string market_sessions(QueryParams params = {}) const; + std::string risk_sentiment(QueryParams params = {}) const; + std::string news(const std::string& currency, QueryParams params = {}) const; + std::string press_releases(const std::string& currency, + QueryParams params = {}) const; + std::string graphql(const std::string& query, + const std::string& variables_json = "{}") const; + std::string request(const std::string& path, + QueryParams params = {}, + std::string method = "GET", + std::string body = {}) const; + + std::string build_url(const std::string& path, QueryParams params = {}) const; + +private: + std::string api_key_; + std::string base_url_; + Transport transport_; + + std::string send(const std::string& method, + const std::string& path, + QueryParams params, + const std::string& body = {}) const; +}; + +} // namespace pineforge diff --git a/src/fxmacrodata.cpp b/src/fxmacrodata.cpp new file mode 100644 index 0000000..39e3f9d --- /dev/null +++ b/src/fxmacrodata.cpp @@ -0,0 +1,194 @@ +#include + +#include +#include +#include +#include + +namespace pineforge { +namespace { + +std::string trim_trailing_slash(std::string value) { + while (!value.empty() && value.back() == '/') { + value.pop_back(); + } + return value; +} + +std::string ensure_leading_slash(const std::string& path) { + if (!path.empty() && path.front() == '/') { + return path; + } + return "/" + path; +} + +std::string lower(std::string value) { + for (char& ch : value) { + ch = static_cast(std::tolower(static_cast(ch))); + } + return value; +} + +std::string encode(std::string value) { + std::ostringstream out; + out << std::uppercase << std::hex; + for (unsigned char ch : value) { + if (std::isalnum(ch) || ch == '-' || ch == '_' || ch == '.' || ch == '~') { + out << static_cast(ch); + } else { + out << '%' << std::setw(2) << std::setfill('0') << static_cast(ch); + } + } + return out.str(); +} + +} // namespace + +FxMacroDataClient::FxMacroDataClient(std::string api_key, + std::string base_url, + Transport transport) + : api_key_(std::move(api_key)), + base_url_(trim_trailing_slash(std::move(base_url))), + transport_(std::move(transport)) {} + +std::string FxMacroDataClient::build_url(const std::string& path, + QueryParams params) const { + if (!api_key_.empty()) { + params.emplace_back("api_key", api_key_); + } + + std::ostringstream url; + url << base_url_ << ensure_leading_slash(path); + char separator = '?'; + for (const auto& [key, value] : params) { + url << separator << encode(key) << '=' << encode(value); + separator = '&'; + } + return url.str(); +} + +std::string FxMacroDataClient::send(const std::string& method, + const std::string& path, + QueryParams params, + const std::string& body) const { + if (!transport_) { + throw std::runtime_error("FxMacroDataClient requires a transport callback"); + } + return transport_(method, build_url(path, std::move(params)), body); +} + +std::string FxMacroDataClient::data_catalogue(const std::string& currency, + QueryParams params) const { + return send("GET", "/data_catalogue/" + encode(lower(currency)), std::move(params)); +} + +std::string FxMacroDataClient::announcements(const std::string& currency, + const std::string& indicator, + QueryParams params) const { + return send("GET", "/announcements/" + encode(lower(currency)) + "/" + encode(indicator), + std::move(params)); +} + +std::string FxMacroDataClient::latest_announcements(const std::string& currency, + QueryParams params) const { + return send("GET", "/announcements/" + encode(lower(currency)) + "/latest", + std::move(params)); +} + +std::string FxMacroDataClient::announcement_changes(QueryParams params) const { + return send("GET", "/announcements/changes", std::move(params)); +} + +std::string FxMacroDataClient::calendar(const std::string& currency, + QueryParams params) const { + return send("GET", "/calendar/" + encode(lower(currency)), std::move(params)); +} + +std::string FxMacroDataClient::predictions(const std::string& currency, + const std::string& indicator, + QueryParams params) const { + return send("GET", "/predictions/" + encode(lower(currency)) + "/" + encode(indicator), + std::move(params)); +} + +std::string FxMacroDataClient::forex(const std::string& base, + const std::string& quote, + QueryParams params) const { + return send("GET", "/forex/" + encode(lower(base)) + "/" + encode(lower(quote)), + std::move(params)); +} + +std::string FxMacroDataClient::cot(const std::string& currency, + QueryParams params) const { + return send("GET", "/cot/" + encode(lower(currency)), std::move(params)); +} + +std::string FxMacroDataClient::commodity(const std::string& indicator, + QueryParams params) const { + return send("GET", "/commodities/" + encode(indicator), std::move(params)); +} + +std::string FxMacroDataClient::commodities_latest(QueryParams params) const { + return send("GET", "/commodities/latest", std::move(params)); +} + +std::string FxMacroDataClient::curves(const std::string& currency, + QueryParams params) const { + return send("GET", "/curves/" + encode(lower(currency)), std::move(params)); +} + +std::string FxMacroDataClient::curve_proxies(const std::string& currency, + QueryParams params) const { + return send("GET", "/curve_proxies/" + encode(lower(currency)), std::move(params)); +} + +std::string FxMacroDataClient::forward_curves(const std::string& currency, + QueryParams params) const { + return send("GET", "/forward_curves/" + encode(lower(currency)), std::move(params)); +} + +std::string FxMacroDataClient::rate_differentials(const std::string& base, + const std::string& quote, + QueryParams params) const { + return send("GET", "/rate_differentials/" + encode(lower(base)) + "/" + encode(lower(quote)), + std::move(params)); +} + +std::string FxMacroDataClient::forward_differentials(const std::string& base, + const std::string& quote, + QueryParams params) const { + return send("GET", "/forward_differentials/" + encode(lower(base)) + "/" + encode(lower(quote)), + std::move(params)); +} + +std::string FxMacroDataClient::market_sessions(QueryParams params) const { + return send("GET", "/market_sessions", std::move(params)); +} + +std::string FxMacroDataClient::risk_sentiment(QueryParams params) const { + return send("GET", "/risk_sentiment", std::move(params)); +} + +std::string FxMacroDataClient::news(const std::string& currency, + QueryParams params) const { + return send("GET", "/news/" + encode(lower(currency)), std::move(params)); +} + +std::string FxMacroDataClient::press_releases(const std::string& currency, + QueryParams params) const { + return send("GET", "/press-releases/" + encode(lower(currency)), std::move(params)); +} + +std::string FxMacroDataClient::graphql(const std::string& query, + const std::string& variables_json) const { + return send("POST", "/graphql", {}, "{\"query\":\"" + query + "\",\"variables\":" + variables_json + "}"); +} + +std::string FxMacroDataClient::request(const std::string& path, + QueryParams params, + std::string method, + std::string body) const { + return send(std::move(method), path, std::move(params), body); +} + +} // namespace pineforge diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 551cf08..bbf3049 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -79,6 +79,7 @@ set(TEST_SOURCES test_metrics test_drawing test_margin_call + test_fxmacrodata ) find_package(Threads REQUIRED) diff --git a/tests/test_fxmacrodata.cpp b/tests/test_fxmacrodata.cpp new file mode 100644 index 0000000..5ad8f5d --- /dev/null +++ b/tests/test_fxmacrodata.cpp @@ -0,0 +1,45 @@ +#include + +#include +#include + +static void require(bool condition, const std::string& message) { + if (!condition) { + throw std::runtime_error(message); + } +} + +int main() { + std::string method; + std::string url; + std::string body; + + pineforge::FxMacroDataClient client( + "test-key", + "https://api.fxmacrodata.com/v1/", + [&](const std::string& m, const std::string& u, const std::string& b) { + method = m; + url = u; + body = b; + return std::string{"{\"ok\":true}"}; + }); + + require(client.calendar("USD", {{"days_ahead", "30"}}) == "{\"ok\":true}", + "transport result should be returned"); + require(method == "GET", "calendar should use GET"); + require(url == "https://api.fxmacrodata.com/v1/calendar/usd?days_ahead=30&api_key=test-key", + "calendar URL should include normalized currency and auth"); + + client.rate_differentials("EUR", "USD", {{"tenor", "2y"}}); + require(url == "https://api.fxmacrodata.com/v1/rate_differentials/eur/usd?tenor=2y&api_key=test-key", + "rate differential endpoint should include both currencies"); + + client.graphql("query { marketSessions { name } }"); + require(method == "POST", "GraphQL should use POST"); + require(url == "https://api.fxmacrodata.com/v1/graphql?api_key=test-key", + "GraphQL URL should include auth"); + require(body.find("marketSessions") != std::string::npos, + "GraphQL query should be placed in the request body"); + + return 0; +}