Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
243 changes: 243 additions & 0 deletions src/utilities/FxMacroDataClient.cs
Original file line number Diff line number Diff line change
@@ -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<JsonElement> GetAsync(
string path,
IReadOnlyDictionary<string, string?>? query = null,
CancellationToken cancellationToken = default) =>
SendGetAsync(path, query, cancellationToken);

public Task<JsonElement> DataCatalogueAsync(
string currency,
IReadOnlyDictionary<string, string?>? query = null,
CancellationToken cancellationToken = default) =>
SendGetAsync($"data_catalogue/{currency}", query, cancellationToken);

public Task<JsonElement> AnnouncementsAsync(
string currency,
string indicator,
IReadOnlyDictionary<string, string?>? query = null,
CancellationToken cancellationToken = default) =>
SendGetAsync($"announcements/{currency}/{indicator}", query, cancellationToken);

public Task<JsonElement> LatestAnnouncementsAsync(
string currency,
IReadOnlyDictionary<string, string?>? query = null,
CancellationToken cancellationToken = default) =>
SendGetAsync($"announcements/{currency}/latest", query, cancellationToken);

public Task<JsonElement> AnnouncementChangesAsync(
IReadOnlyDictionary<string, string?>? query = null,
CancellationToken cancellationToken = default) =>
SendGetAsync("announcements/changes", query, cancellationToken);

public Task<JsonElement> CalendarAsync(
string currency,
IReadOnlyDictionary<string, string?>? query = null,
CancellationToken cancellationToken = default) =>
SendGetAsync($"calendar/{currency}", query, cancellationToken);

public Task<JsonElement> PredictionsAsync(
string currency,
string indicator,
IReadOnlyDictionary<string, string?>? query = null,
CancellationToken cancellationToken = default) =>
SendGetAsync($"predictions/{currency}/{indicator}", query, cancellationToken);

public Task<JsonElement> ForexAsync(
string baseCurrency,
string quoteCurrency,
IReadOnlyDictionary<string, string?>? query = null,
CancellationToken cancellationToken = default) =>
SendGetAsync($"forex/{baseCurrency}/{quoteCurrency}", query, cancellationToken);

public Task<JsonElement> CotAsync(
string currency,
IReadOnlyDictionary<string, string?>? query = null,
CancellationToken cancellationToken = default) =>
SendGetAsync($"cot/{currency}", query, cancellationToken);

public Task<JsonElement> CommodityAsync(
string indicator,
IReadOnlyDictionary<string, string?>? query = null,
CancellationToken cancellationToken = default) =>
SendGetAsync($"commodities/{indicator}", query, cancellationToken);

public Task<JsonElement> CommoditiesLatestAsync(
IReadOnlyDictionary<string, string?>? query = null,
CancellationToken cancellationToken = default) =>
SendGetAsync("commodities/latest", query, cancellationToken);

public Task<JsonElement> CurvesAsync(
string currency,
IReadOnlyDictionary<string, string?>? query = null,
CancellationToken cancellationToken = default) =>
SendGetAsync($"curves/{currency}", query, cancellationToken);

public Task<JsonElement> CurveProxiesAsync(
string currency,
IReadOnlyDictionary<string, string?>? query = null,
CancellationToken cancellationToken = default) =>
SendGetAsync($"curve_proxies/{currency}", query, cancellationToken);

public Task<JsonElement> ForwardCurvesAsync(
string currency,
IReadOnlyDictionary<string, string?>? query = null,
CancellationToken cancellationToken = default) =>
SendGetAsync($"forward_curves/{currency}", query, cancellationToken);

public Task<JsonElement> RateDifferentialsAsync(
string baseCurrency,
string quoteCurrency,
IReadOnlyDictionary<string, string?>? query = null,
CancellationToken cancellationToken = default) =>
SendGetAsync($"rate_differentials/{baseCurrency}/{quoteCurrency}", query, cancellationToken);

public Task<JsonElement> ForwardDifferentialsAsync(
string baseCurrency,
string quoteCurrency,
IReadOnlyDictionary<string, string?>? query = null,
CancellationToken cancellationToken = default) =>
SendGetAsync($"forward_differentials/{baseCurrency}/{quoteCurrency}", query, cancellationToken);

public Task<JsonElement> MarketSessionsAsync(
IReadOnlyDictionary<string, string?>? query = null,
CancellationToken cancellationToken = default) =>
SendGetAsync("market_sessions", query, cancellationToken);

public Task<JsonElement> RiskSentimentAsync(
IReadOnlyDictionary<string, string?>? query = null,
CancellationToken cancellationToken = default) =>
SendGetAsync("risk_sentiment", query, cancellationToken);

public Task<JsonElement> NewsAsync(
string currency,
IReadOnlyDictionary<string, string?>? query = null,
CancellationToken cancellationToken = default) =>
SendGetAsync($"news/{currency}", query, cancellationToken);

public Task<JsonElement> PressReleasesAsync(
string currency,
IReadOnlyDictionary<string, string?>? query = null,
CancellationToken cancellationToken = default) =>
SendGetAsync($"press-releases/{currency}", query, cancellationToken);

public async Task<JsonElement> 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<string, string?>? query = null)
{
var relativePath = path.TrimStart('/');
var uri = new Uri(_baseUri, relativePath);

var parameters = new List<KeyValuePair<string, string?>>
{
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<JsonElement> SendGetAsync(
string path,
IReadOnlyDictionary<string, string?>? 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<JsonElement> 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 + "/");
}
}
95 changes: 95 additions & 0 deletions tests/backtesting/UtilitesTests/FxMacroDataClientTests.cs
Original file line number Diff line number Diff line change
@@ -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<string, string?>
{
["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<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
LastRequest = request;

return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"ok\":true}")
});
}
}
}