diff --git a/source/shared/utilities/fxMacroDataClient.cpp b/source/shared/utilities/fxMacroDataClient.cpp new file mode 100644 index 0000000..33c60da --- /dev/null +++ b/source/shared/utilities/fxMacroDataClient.cpp @@ -0,0 +1,244 @@ +// Backtesting Engine in C++ +// +// (c) 2026 Ryan McCaffery | https://mccaffers.com +// This code is licensed under MIT license (see LICENSE.txt for details) +// --------------------------------------- + +#include "shared/utilities/fxMacroDataClient.hpp" + +#include "shared/utilities/env.hpp" + +#include + +#include +#include +#include +#include +#include + +namespace { + +std::string trimTrailingSlash(std::string value) { + while (!value.empty() && value.back() == '/') { + value.pop_back(); + } + return value; +} + +std::string ensureLeadingSlash(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(); +} + +size_t writeCallback(char* ptr, size_t size, size_t nmemb, void* userdata) { + auto* out = static_cast(userdata); + out->append(ptr, size * nmemb); + return size * nmemb; +} + +std::string performHttp(const std::string& method, + const std::string& url, + const std::string& body) { + CURL* curl = curl_easy_init(); + if (!curl) { + throw std::runtime_error("Failed to initialize CURL"); + } + + std::string response; + curl_slist* headers = nullptr; + headers = curl_slist_append(headers, "Accept: application/json"); + + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_USERAGENT, "backtesting-engine-cpp-fxmacrodata/1.0"); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeCallback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L); + + if (method == "POST") { + headers = curl_slist_append(headers, "Content-Type: application/json"); + curl_easy_setopt(curl, CURLOPT_POST, 1L); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body.c_str()); + } + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + + const CURLcode rc = curl_easy_perform(curl); + long status = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status); + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (rc != CURLE_OK) { + throw std::runtime_error(std::string("FXMacroData request failed: ") + curl_easy_strerror(rc)); + } + if (status < 200 || status >= 300) { + throw std::runtime_error("FXMacroData request returned HTTP " + std::to_string(status)); + } + return response; +} + +} // namespace + +FxMacroDataClient::FxMacroDataClient(std::string apiKey, + std::string baseUrl, + Transport transport) + : apiKey_(apiKey.empty() + ? env::getOr("FXMACRODATA_API_KEY", env::getOr("FXMD_API_KEY", "")) + : std::move(apiKey)), + baseUrl_(trimTrailingSlash(std::move(baseUrl))), + transport_(std::move(transport)) {} + +std::string FxMacroDataClient::buildUrl(const std::string& path, QueryParams params) const { + if (!apiKey_.empty()) { + params.emplace_back("api_key", apiKey_); + } + + std::ostringstream url; + url << baseUrl_ << ensureLeadingSlash(path); + char separator = '?'; + for (const auto& [key, value] : params) { + url << separator << encode(key) << '=' << encode(value); + separator = '&'; + } + return url.str(); +} + +nlohmann::json FxMacroDataClient::requestJson(const std::string& method, + const std::string& path, + QueryParams params, + const nlohmann::json& body) const { + const std::string bodyText = method == "POST" ? body.dump() : std::string{}; + const std::string text = transport_ + ? transport_(method, buildUrl(path, std::move(params)), bodyText) + : performHttp(method, buildUrl(path, std::move(params)), bodyText); + return nlohmann::json::parse(text); +} + +nlohmann::json FxMacroDataClient::dataCatalogue(const std::string& currency, + QueryParams params) const { + return requestJson("GET", "/data_catalogue/" + encode(lower(currency)), std::move(params)); +} + +nlohmann::json FxMacroDataClient::announcements(const std::string& currency, + const std::string& indicator, + QueryParams params) const { + return requestJson("GET", "/announcements/" + encode(lower(currency)) + "/" + encode(indicator), std::move(params)); +} + +nlohmann::json FxMacroDataClient::latestAnnouncements(const std::string& currency, + QueryParams params) const { + return requestJson("GET", "/announcements/" + encode(lower(currency)) + "/latest", std::move(params)); +} + +nlohmann::json FxMacroDataClient::announcementChanges(QueryParams params) const { + return requestJson("GET", "/announcements/changes", std::move(params)); +} + +nlohmann::json FxMacroDataClient::calendar(const std::string& currency, + QueryParams params) const { + return requestJson("GET", "/calendar/" + encode(lower(currency)), std::move(params)); +} + +nlohmann::json FxMacroDataClient::predictions(const std::string& currency, + const std::string& indicator, + QueryParams params) const { + return requestJson("GET", "/predictions/" + encode(lower(currency)) + "/" + encode(indicator), std::move(params)); +} + +nlohmann::json FxMacroDataClient::forex(const std::string& base, + const std::string& quote, + QueryParams params) const { + return requestJson("GET", "/forex/" + encode(lower(base)) + "/" + encode(lower(quote)), std::move(params)); +} + +nlohmann::json FxMacroDataClient::cot(const std::string& currency, + QueryParams params) const { + return requestJson("GET", "/cot/" + encode(lower(currency)), std::move(params)); +} + +nlohmann::json FxMacroDataClient::commodity(const std::string& indicator, + QueryParams params) const { + return requestJson("GET", "/commodities/" + encode(indicator), std::move(params)); +} + +nlohmann::json FxMacroDataClient::commoditiesLatest(QueryParams params) const { + return requestJson("GET", "/commodities/latest", std::move(params)); +} + +nlohmann::json FxMacroDataClient::curves(const std::string& currency, + QueryParams params) const { + return requestJson("GET", "/curves/" + encode(lower(currency)), std::move(params)); +} + +nlohmann::json FxMacroDataClient::curveProxies(const std::string& currency, + QueryParams params) const { + return requestJson("GET", "/curve_proxies/" + encode(lower(currency)), std::move(params)); +} + +nlohmann::json FxMacroDataClient::forwardCurves(const std::string& currency, + QueryParams params) const { + return requestJson("GET", "/forward_curves/" + encode(lower(currency)), std::move(params)); +} + +nlohmann::json FxMacroDataClient::rateDifferentials(const std::string& base, + const std::string& quote, + QueryParams params) const { + return requestJson("GET", "/rate_differentials/" + encode(lower(base)) + "/" + encode(lower(quote)), std::move(params)); +} + +nlohmann::json FxMacroDataClient::forwardDifferentials(const std::string& base, + const std::string& quote, + QueryParams params) const { + return requestJson("GET", "/forward_differentials/" + encode(lower(base)) + "/" + encode(lower(quote)), std::move(params)); +} + +nlohmann::json FxMacroDataClient::marketSessions(QueryParams params) const { + return requestJson("GET", "/market_sessions", std::move(params)); +} + +nlohmann::json FxMacroDataClient::riskSentiment(QueryParams params) const { + return requestJson("GET", "/risk_sentiment", std::move(params)); +} + +nlohmann::json FxMacroDataClient::news(const std::string& currency, + QueryParams params) const { + return requestJson("GET", "/news/" + encode(lower(currency)), std::move(params)); +} + +nlohmann::json FxMacroDataClient::pressReleases(const std::string& currency, + QueryParams params) const { + return requestJson("GET", "/press-releases/" + encode(lower(currency)), std::move(params)); +} + +nlohmann::json FxMacroDataClient::graphql(const std::string& query, + nlohmann::json variables) const { + return requestJson("POST", "/graphql", {}, {{"query", query}, {"variables", std::move(variables)}}); +} + +nlohmann::json FxMacroDataClient::request(const std::string& path, + QueryParams params, + std::string method, + nlohmann::json body) const { + return requestJson(std::move(method), path, std::move(params), body); +} diff --git a/source/shared/utilities/fxMacroDataClient.hpp b/source/shared/utilities/fxMacroDataClient.hpp new file mode 100644 index 0000000..abc11c4 --- /dev/null +++ b/source/shared/utilities/fxMacroDataClient.hpp @@ -0,0 +1,85 @@ +// Backtesting Engine in C++ +// +// (c) 2026 Ryan McCaffery | https://mccaffers.com +// This code is licensed under MIT license (see LICENSE.txt for details) +// --------------------------------------- + +#pragma once + +#include +#include +#include +#include + +#include + +class FxMacroDataClient { +public: + using QueryParams = std::vector>; + using Transport = std::function; + + explicit FxMacroDataClient( + std::string apiKey = {}, + std::string baseUrl = "https://api.fxmacrodata.com/v1", + Transport transport = {}); + + [[nodiscard]] nlohmann::json dataCatalogue(const std::string& currency, + QueryParams params = {}) const; + [[nodiscard]] nlohmann::json announcements(const std::string& currency, + const std::string& indicator, + QueryParams params = {}) const; + [[nodiscard]] nlohmann::json latestAnnouncements(const std::string& currency, + QueryParams params = {}) const; + [[nodiscard]] nlohmann::json announcementChanges(QueryParams params = {}) const; + [[nodiscard]] nlohmann::json calendar(const std::string& currency, + QueryParams params = {}) const; + [[nodiscard]] nlohmann::json predictions(const std::string& currency, + const std::string& indicator, + QueryParams params = {}) const; + [[nodiscard]] nlohmann::json forex(const std::string& base, + const std::string& quote, + QueryParams params = {}) const; + [[nodiscard]] nlohmann::json cot(const std::string& currency, + QueryParams params = {}) const; + [[nodiscard]] nlohmann::json commodity(const std::string& indicator, + QueryParams params = {}) const; + [[nodiscard]] nlohmann::json commoditiesLatest(QueryParams params = {}) const; + [[nodiscard]] nlohmann::json curves(const std::string& currency, + QueryParams params = {}) const; + [[nodiscard]] nlohmann::json curveProxies(const std::string& currency, + QueryParams params = {}) const; + [[nodiscard]] nlohmann::json forwardCurves(const std::string& currency, + QueryParams params = {}) const; + [[nodiscard]] nlohmann::json rateDifferentials(const std::string& base, + const std::string& quote, + QueryParams params = {}) const; + [[nodiscard]] nlohmann::json forwardDifferentials(const std::string& base, + const std::string& quote, + QueryParams params = {}) const; + [[nodiscard]] nlohmann::json marketSessions(QueryParams params = {}) const; + [[nodiscard]] nlohmann::json riskSentiment(QueryParams params = {}) const; + [[nodiscard]] nlohmann::json news(const std::string& currency, + QueryParams params = {}) const; + [[nodiscard]] nlohmann::json pressReleases(const std::string& currency, + QueryParams params = {}) const; + [[nodiscard]] nlohmann::json graphql(const std::string& query, + nlohmann::json variables = nlohmann::json::object()) const; + [[nodiscard]] nlohmann::json request(const std::string& path, + QueryParams params = {}, + std::string method = "GET", + nlohmann::json body = nlohmann::json::object()) const; + + [[nodiscard]] std::string buildUrl(const std::string& path, QueryParams params = {}) const; + +private: + std::string apiKey_; + std::string baseUrl_; + Transport transport_; + + [[nodiscard]] nlohmann::json requestJson(const std::string& method, + const std::string& path, + QueryParams params, + const nlohmann::json& body = nlohmann::json::object()) const; +}; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index bba6641..ecd788e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -12,6 +12,7 @@ add_executable(unit_tests sweep.cpp tradeManager.cpp tickPacket.cpp + fxMacroDataClient.cpp ) target_link_libraries(unit_tests PRIVATE diff --git a/tests/fxMacroDataClient.cpp b/tests/fxMacroDataClient.cpp new file mode 100644 index 0000000..b9d442b --- /dev/null +++ b/tests/fxMacroDataClient.cpp @@ -0,0 +1,34 @@ +#include + +#include + +#include "shared/utilities/fxMacroDataClient.hpp" + +TEST_CASE("FxMacroDataClient builds authenticated macro endpoint requests", "[fxmacrodata]") { + std::string method; + std::string url; + std::string body; + + 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 R"({"ok":true})"; + }}; + + const auto result = client.predictions("USD", "non_farm_payrolls", {{"limit", "1"}}); + CHECK(result.at("ok").get()); + CHECK(method == "GET"); + CHECK(url == "https://api.fxmacrodata.com/v1/predictions/usd/non_farm_payrolls?limit=1&api_key=test-key"); + + client.rateDifferentials("EUR", "USD", {{"tenor", "2y"}}); + CHECK(url == "https://api.fxmacrodata.com/v1/rate_differentials/eur/usd?tenor=2y&api_key=test-key"); + + client.graphql("query { marketSessions { name } }"); + CHECK(method == "POST"); + CHECK(url == "https://api.fxmacrodata.com/v1/graphql?api_key=test-key"); + CHECK(body.find("marketSessions") != std::string::npos); +}