diff --git a/src/utilities/FxMacroDataClient.cs b/src/utilities/FxMacroDataClient.cs new file mode 100644 index 0000000..8fc8042 --- /dev/null +++ b/src/utilities/FxMacroDataClient.cs @@ -0,0 +1,243 @@ +using System.Net; +using System.Text; +using System.Text.Json; + +namespace Utilities; + +public sealed class FxMacroDataClient +{ + private static readonly Uri DefaultBaseUri = new("https://fxmacrodata.com/api/v1/"); + + private readonly HttpClient _httpClient; + private readonly string _apiKey; + private readonly Uri _baseUri; + + public FxMacroDataClient(string apiKey, HttpClient? httpClient = null, Uri? baseUri = null) + { + if (string.IsNullOrWhiteSpace(apiKey)) + { + throw new ArgumentException("FXMacroData API key is required.", nameof(apiKey)); + } + + _apiKey = apiKey; + _httpClient = httpClient ?? new HttpClient(); + _baseUri = NormalizeBaseUri(baseUri ?? DefaultBaseUri); + } + + public static FxMacroDataClient FromEnvironment(HttpClient? httpClient = null, Uri? baseUri = null) + { + var apiKey = Environment.GetEnvironmentVariable("FXMACRODATA_API_KEY") + ?? Environment.GetEnvironmentVariable("FXMD_API_KEY"); + + if (string.IsNullOrWhiteSpace(apiKey)) + { + throw new InvalidOperationException("Set FXMACRODATA_API_KEY or FXMD_API_KEY."); + } + + return new FxMacroDataClient(apiKey, httpClient, baseUri); + } + + public Task GetAsync( + string path, + IReadOnlyDictionary? query = null, + CancellationToken cancellationToken = default) => + SendGetAsync(path, query, cancellationToken); + + public Task DataCatalogueAsync( + string currency, + IReadOnlyDictionary? query = null, + CancellationToken cancellationToken = default) => + SendGetAsync($"data_catalogue/{currency}", query, cancellationToken); + + public Task AnnouncementsAsync( + string currency, + string indicator, + IReadOnlyDictionary? query = null, + CancellationToken cancellationToken = default) => + SendGetAsync($"announcements/{currency}/{indicator}", query, cancellationToken); + + public Task LatestAnnouncementsAsync( + string currency, + IReadOnlyDictionary? query = null, + CancellationToken cancellationToken = default) => + SendGetAsync($"announcements/{currency}/latest", query, cancellationToken); + + public Task AnnouncementChangesAsync( + IReadOnlyDictionary? query = null, + CancellationToken cancellationToken = default) => + SendGetAsync("announcements/changes", query, cancellationToken); + + public Task CalendarAsync( + string currency, + IReadOnlyDictionary? query = null, + CancellationToken cancellationToken = default) => + SendGetAsync($"calendar/{currency}", query, cancellationToken); + + public Task PredictionsAsync( + string currency, + string indicator, + IReadOnlyDictionary? query = null, + CancellationToken cancellationToken = default) => + SendGetAsync($"predictions/{currency}/{indicator}", query, cancellationToken); + + public Task ForexAsync( + string baseCurrency, + string quoteCurrency, + IReadOnlyDictionary? query = null, + CancellationToken cancellationToken = default) => + SendGetAsync($"forex/{baseCurrency}/{quoteCurrency}", query, cancellationToken); + + public Task CotAsync( + string currency, + IReadOnlyDictionary? query = null, + CancellationToken cancellationToken = default) => + SendGetAsync($"cot/{currency}", query, cancellationToken); + + public Task CommodityAsync( + string indicator, + IReadOnlyDictionary? query = null, + CancellationToken cancellationToken = default) => + SendGetAsync($"commodities/{indicator}", query, cancellationToken); + + public Task CommoditiesLatestAsync( + IReadOnlyDictionary? query = null, + CancellationToken cancellationToken = default) => + SendGetAsync("commodities/latest", query, cancellationToken); + + public Task CurvesAsync( + string currency, + IReadOnlyDictionary? query = null, + CancellationToken cancellationToken = default) => + SendGetAsync($"curves/{currency}", query, cancellationToken); + + public Task CurveProxiesAsync( + string currency, + IReadOnlyDictionary? query = null, + CancellationToken cancellationToken = default) => + SendGetAsync($"curve_proxies/{currency}", query, cancellationToken); + + public Task ForwardCurvesAsync( + string currency, + IReadOnlyDictionary? query = null, + CancellationToken cancellationToken = default) => + SendGetAsync($"forward_curves/{currency}", query, cancellationToken); + + public Task RateDifferentialsAsync( + string baseCurrency, + string quoteCurrency, + IReadOnlyDictionary? query = null, + CancellationToken cancellationToken = default) => + SendGetAsync($"rate_differentials/{baseCurrency}/{quoteCurrency}", query, cancellationToken); + + public Task ForwardDifferentialsAsync( + string baseCurrency, + string quoteCurrency, + IReadOnlyDictionary? query = null, + CancellationToken cancellationToken = default) => + SendGetAsync($"forward_differentials/{baseCurrency}/{quoteCurrency}", query, cancellationToken); + + public Task MarketSessionsAsync( + IReadOnlyDictionary? query = null, + CancellationToken cancellationToken = default) => + SendGetAsync("market_sessions", query, cancellationToken); + + public Task RiskSentimentAsync( + IReadOnlyDictionary? query = null, + CancellationToken cancellationToken = default) => + SendGetAsync("risk_sentiment", query, cancellationToken); + + public Task NewsAsync( + string currency, + IReadOnlyDictionary? query = null, + CancellationToken cancellationToken = default) => + SendGetAsync($"news/{currency}", query, cancellationToken); + + public Task PressReleasesAsync( + string currency, + IReadOnlyDictionary? query = null, + CancellationToken cancellationToken = default) => + SendGetAsync($"press-releases/{currency}", query, cancellationToken); + + public async Task GraphQlAsync( + string query, + JsonElement? variables = null, + CancellationToken cancellationToken = default) + { + var body = JsonSerializer.Serialize(new + { + query, + variables + }); + + using var content = new StringContent(body, Encoding.UTF8, "application/json"); + using var response = await _httpClient + .PostAsync(BuildUri("graphql"), content, cancellationToken) + .ConfigureAwait(false); + + return await ParseJsonAsync(response, cancellationToken).ConfigureAwait(false); + } + + public Uri BuildUri(string path, IReadOnlyDictionary? query = null) + { + var relativePath = path.TrimStart('/'); + var uri = new Uri(_baseUri, relativePath); + + var parameters = new List> + { + new("api_key", _apiKey) + }; + + if (query is not null) + { + parameters.InsertRange(0, query); + } + + var queryString = string.Join( + "&", + parameters + .Where(pair => pair.Value is not null) + .Select(pair => $"{WebUtility.UrlEncode(pair.Key)}={WebUtility.UrlEncode(pair.Value)}")); + + return new UriBuilder(uri) + { + Query = queryString + }.Uri; + } + + private async Task SendGetAsync( + string path, + IReadOnlyDictionary? query, + CancellationToken cancellationToken) + { + using var response = await _httpClient + .GetAsync(BuildUri(path, query), cancellationToken) + .ConfigureAwait(false); + + return await ParseJsonAsync(response, cancellationToken).ConfigureAwait(false); + } + + private static async Task ParseJsonAsync( + HttpResponseMessage response, + CancellationToken cancellationToken) + { + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException( + $"FXMacroData HTTP {(int)response.StatusCode}: {body}", + null, + response.StatusCode); + } + + using var document = JsonDocument.Parse(body); + return document.RootElement.Clone(); + } + + private static Uri NormalizeBaseUri(Uri baseUri) + { + var value = baseUri.ToString(); + return value.EndsWith("/", StringComparison.Ordinal) + ? baseUri + : new Uri(value + "/"); + } +} diff --git a/tests/backtesting/UtilitesTests/FxMacroDataClientTests.cs b/tests/backtesting/UtilitesTests/FxMacroDataClientTests.cs new file mode 100644 index 0000000..9212ee5 --- /dev/null +++ b/tests/backtesting/UtilitesTests/FxMacroDataClientTests.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Utilities; +using Xunit; + +namespace tests.backtesting.UtilitesTests; + +public class FxMacroDataClientTests +{ + [Fact] + public async Task ForexAsyncAddsApiKeyAndQueryParameters() + { + var handler = new CaptureHandler(); + var client = new FxMacroDataClient( + "test-key", + new HttpClient(handler), + new Uri("https://example.com/api/v1/")); + + var result = await client.ForexAsync( + "eur", + "usd", + new Dictionary + { + ["limit"] = "1" + }); + + Assert.True(result.GetProperty("ok").GetBoolean()); + Assert.Equal(HttpMethod.Get, handler.LastRequest?.Method); + Assert.Equal( + "https://example.com/api/v1/forex/eur/usd?limit=1&api_key=test-key", + handler.LastRequest?.RequestUri?.ToString()); + } + + [Fact] + public void BuildUriPreservesFullEndpointSurface() + { + var client = new FxMacroDataClient( + "test-key", + new HttpClient(new CaptureHandler()), + new Uri("https://example.com/api/v1/")); + + var paths = new[] + { + "data_catalogue/usd", + "announcements/usd/non_farm_payrolls", + "announcements/usd/latest", + "announcements/changes", + "calendar/usd", + "predictions/usd/non_farm_payrolls", + "forex/eur/usd", + "cot/usd", + "commodities/brent", + "commodities/latest", + "curves/usd", + "curve_proxies/usd", + "forward_curves/usd", + "rate_differentials/eur/usd", + "forward_differentials/eur/usd", + "market_sessions", + "risk_sentiment", + "news/usd", + "press-releases/usd", + "graphql" + }; + + foreach (var path in paths) + { + var uri = client.BuildUri(path); + + Assert.StartsWith("https://example.com/api/v1/", uri.ToString()); + Assert.Contains("api_key=test-key", uri.Query); + } + } + + private sealed class CaptureHandler : HttpMessageHandler + { + public HttpRequestMessage? LastRequest { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + LastRequest = request; + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"ok\":true}") + }); + } + } +}