From bf4a9899aa729d1eace02a585d1fc1308fece322 Mon Sep 17 00:00:00 2001 From: Robert Tidball Date: Wed, 8 Jul 2026 23:46:06 +1000 Subject: [PATCH 1/6] Add FXMacroData release calendar integration --- FxMacroDataReleaseCalendar.cs | 175 +++++++++++++++++++++++++ FxMacroDataReleaseCalendarAlgorithm.py | 39 ++++++ 2 files changed, 214 insertions(+) create mode 100644 FxMacroDataReleaseCalendar.cs create mode 100644 FxMacroDataReleaseCalendarAlgorithm.py diff --git a/FxMacroDataReleaseCalendar.cs b/FxMacroDataReleaseCalendar.cs new file mode 100644 index 0000000..2036e80 --- /dev/null +++ b/FxMacroDataReleaseCalendar.cs @@ -0,0 +1,175 @@ +/* + * QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. + * Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * +*/ + +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using NodaTime; +using QuantConnect.Configuration; +using QuantConnect.Data; + +namespace QuantConnect.DataSource +{ + /// + /// FXMacroData economic release calendar data. Subscribe with a currency symbol + /// such as "USD", "EUR", or "JPY" to receive scheduled macro and central-bank + /// events for event-aware algorithms. + /// + public class FxMacroDataReleaseCalendar : BaseData + { + /// + /// Time when the release became available. + /// + public override DateTime EndTime { get; set; } + + public string Release { get; set; } + public string Name { get; set; } + public string Currency { get; set; } + public int? MarketTier { get; set; } + public bool TopTierForCurrency { get; set; } + public string Source { get; set; } + public string SourceUrl { get; set; } + + public override SubscriptionDataSource GetSource(SubscriptionDataConfig config, DateTime date, bool isLiveMode) + { + var currency = config.Symbol.Value.ToLowerInvariant(); + var source = $"https://fxmacrodata.com/api/v1/calendar/{currency}?limit=100"; + var apiKey = Config.Get("fxmacrodata-api-key"); + if (!string.IsNullOrWhiteSpace(apiKey)) + { + source += $"&api_key={Uri.EscapeDataString(apiKey)}"; + } + return new SubscriptionDataSource(source, SubscriptionTransportMedium.RemoteFile, FileFormat.UnfoldingCollection); + } + + public override BaseData Reader(SubscriptionDataConfig config, string line, DateTime date, bool isLiveMode) + { + var payload = JsonConvert.DeserializeObject(line); + var entries = (payload?.Data ?? new List()).Select(row => new FxMacroDataReleaseCalendar + { + Symbol = config.Symbol, + Time = row.Date.Date, + EndTime = ResolveEndTime(row), + Release = row.Release, + Name = row.Name, + Currency = row.Currency, + MarketTier = row.MarketTier, + TopTierForCurrency = row.TopTierForCurrency, + Source = row.Source, + SourceUrl = row.SourceUrl, + Value = row.MarketTier ?? 0 + }); + + return new BaseDataCollection(date, config.Symbol, entries); + } + + public override BaseData Clone() + { + return new FxMacroDataReleaseCalendar + { + Symbol = Symbol, + Time = Time, + EndTime = EndTime, + Release = Release, + Name = Name, + Currency = Currency, + MarketTier = MarketTier, + TopTierForCurrency = TopTierForCurrency, + Source = Source, + SourceUrl = SourceUrl, + Value = Value + }; + } + + public override bool RequiresMapping() + { + return false; + } + + public override bool IsSparseData() + { + return true; + } + + public override Resolution DefaultResolution() + { + return Resolution.Daily; + } + + public override List SupportedResolutions() + { + return DailyResolution; + } + + public override DateTimeZone DataTimeZone() + { + return DateTimeZone.Utc; + } + + private static DateTime ResolveEndTime(CalendarRow row) + { + if (row.AnnouncementDateTimeUtc.HasValue) + { + return DateTime.SpecifyKind(row.AnnouncementDateTimeUtc.Value, DateTimeKind.Utc); + } + if (row.AnnouncementUnix.HasValue) + { + return DateTimeOffset.FromUnixTimeSeconds(row.AnnouncementUnix.Value).UtcDateTime; + } + return DateTime.SpecifyKind(row.Date.Date, DateTimeKind.Utc); + } + + private sealed class CalendarPayload + { + [JsonProperty("data")] + public List Data { get; set; } = new List(); + } + + private sealed class CalendarRow + { + [JsonProperty("date")] + public DateTime Date { get; set; } + + [JsonProperty("release")] + public string Release { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("currency")] + public string Currency { get; set; } + + [JsonProperty("market_tier")] + public int? MarketTier { get; set; } + + [JsonProperty("top_tier_for_currency")] + public bool TopTierForCurrency { get; set; } + + [JsonProperty("announcement_datetime")] + public long? AnnouncementUnix { get; set; } + + [JsonProperty("announcement_datetime_utc")] + public DateTime? AnnouncementDateTimeUtc { get; set; } + + [JsonProperty("source")] + public string Source { get; set; } + + [JsonProperty("source_url")] + public string SourceUrl { get; set; } + } + } +} diff --git a/FxMacroDataReleaseCalendarAlgorithm.py b/FxMacroDataReleaseCalendarAlgorithm.py new file mode 100644 index 0000000..e852165 --- /dev/null +++ b/FxMacroDataReleaseCalendarAlgorithm.py @@ -0,0 +1,39 @@ +# QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. +# Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from AlgorithmImports import * +from QuantConnect.DataSource import * + + +class FxMacroDataReleaseCalendarAlgorithm(QCAlgorithm): + def initialize(self): + self.set_start_date(2026, 1, 1) + self.set_end_date(2026, 12, 31) + self.set_cash(100000) + + self.spy = self.add_equity("SPY", Resolution.DAILY).symbol + self.usd_calendar = self.add_data( + FxMacroDataReleaseCalendar, "USD", Resolution.DAILY + ).symbol + + def on_data(self, data: Slice): + event = data.get(FxMacroDataReleaseCalendar, self.usd_calendar) + if event is None: + return + + self.debug( + f"{self.time.date()} {event.name} tier={event.market_tier} " + f"source={event.source}" + ) + if event.market_tier == 1: + self.set_holdings(self.spy, 0.25) From f0efbeda991d0e400a0f7aaaf284f1aa4c6b4574 Mon Sep 17 00:00:00 2001 From: Robert Tidball Date: Thu, 9 Jul 2026 00:28:05 +1000 Subject: [PATCH 2/6] Expand FXMacroData read endpoint coverage --- FxMacroDataClient.cs | 312 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100644 FxMacroDataClient.cs diff --git a/FxMacroDataClient.cs b/FxMacroDataClient.cs new file mode 100644 index 0000000..43c01fc --- /dev/null +++ b/FxMacroDataClient.cs @@ -0,0 +1,312 @@ +/* + * QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. + * Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + */ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Newtonsoft.Json; +using QuantConnect.Configuration; + +namespace QuantConnect.DataSource +{ + /// + /// Raw JSON client for FXMacroData's public read/data endpoints. + /// + public sealed class FxMacroDataClient : IDisposable + { + public const string DefaultBaseUrl = "https://fxmacrodata.com/api/v1"; + + private readonly HttpClient _httpClient; + private readonly bool _ownsClient; + private readonly string _baseUrl; + private readonly string _apiKey; + + public FxMacroDataClient(HttpClient httpClient = null, string baseUrl = DefaultBaseUrl, string apiKey = null) + { + _httpClient = httpClient ?? new HttpClient(); + _ownsClient = httpClient == null; + _baseUrl = baseUrl.EndsWith("/") ? baseUrl.Substring(0, baseUrl.Length - 1) : baseUrl; + _apiKey = string.IsNullOrWhiteSpace(apiKey) ? Config.Get("fxmacrodata-api-key") : apiKey; + } + + public Task GetDataCatalogueAsync(string currency = "usd", bool includeCapabilities = false, + bool includeCoverage = false, string indicator = null) + { + return GetAsync($"data_catalogue/{currency.ToLowerInvariant()}", new Dictionary + { + ["include_capabilities"] = includeCapabilities, + ["include_coverage"] = includeCoverage, + ["indicator"] = indicator + }); + } + + public Task GetAnnouncementsAsync(string currency, string indicator, DateTime? startDate = null, + DateTime? endDate = null, int limit = 20, int offset = 0, string seriesMode = "raw", + string revisions = "latest", bool officialOnly = true) + { + return GetAsync($"announcements/{currency.ToLowerInvariant()}/{indicator}", new Dictionary + { + ["start_date"] = FormatDate(startDate), + ["end_date"] = FormatDate(endDate), + ["series_mode"] = seriesMode, + ["limit"] = limit, + ["offset"] = offset, + ["revisions"] = revisions, + ["official_only"] = officialOnly + }); + } + + public Task GetLatestAnnouncementsAsync(string currency = "usd") + { + return GetAsync($"announcements/{currency.ToLowerInvariant()}/latest"); + } + + public Task GetAnnouncementChangesAsync(string currencies = null, string indicators = null, + string since = null, int limit = 100, string payload = "compact") + { + return GetAsync("announcements/changes", new Dictionary + { + ["currencies"] = currencies, + ["indicators"] = indicators, + ["since"] = since, + ["limit"] = limit, + ["payload"] = payload + }); + } + + public Task GetPredictionsAsync(string currency, string indicator, DateTime? startDate = null, + DateTime? endDate = null, int limit = 20, int offset = 0, string predictionType = null, + string predictionSource = null) + { + return GetAsync($"predictions/{currency.ToLowerInvariant()}/{indicator}", new Dictionary + { + ["prediction_type"] = predictionType, + ["prediction_source"] = predictionSource, + ["start_date"] = FormatDate(startDate), + ["end_date"] = FormatDate(endDate), + ["limit"] = limit, + ["offset"] = offset + }); + } + + public Task GetReleaseCalendarAsync(string currency = "usd", string indicator = null, + DateTime? startDate = null, DateTime? endDate = null, string timezone = null) + { + return GetAsync($"calendar/{currency.ToLowerInvariant()}", new Dictionary + { + ["indicator"] = indicator, + ["start_date"] = FormatDate(startDate), + ["end_date"] = FormatDate(endDate), + ["timezone"] = timezone + }); + } + + public Task GetForexAsync(string baseCurrency, string quoteCurrency, DateTime? startDate = null, + DateTime? endDate = null, int limit = 20, int offset = 0, string indicators = null) + { + return GetAsync($"forex/{baseCurrency.ToLowerInvariant()}/{quoteCurrency.ToLowerInvariant()}", + new Dictionary + { + ["start_date"] = FormatDate(startDate), + ["end_date"] = FormatDate(endDate), + ["limit"] = limit, + ["offset"] = offset, + ["indicators"] = indicators + }); + } + + public Task GetCotAsync(string currency, DateTime? startDate = null, DateTime? endDate = null, + int limit = 20, int offset = 0) + { + return GetAsync($"cot/{currency.ToLowerInvariant()}", new Dictionary + { + ["start_date"] = FormatDate(startDate), + ["end_date"] = FormatDate(endDate), + ["limit"] = limit, + ["offset"] = offset + }); + } + + public Task GetCommodityAsync(string indicator, DateTime? startDate = null, DateTime? endDate = null, + int limit = 20, int offset = 0) + { + return GetAsync($"commodities/{indicator}", new Dictionary + { + ["start_date"] = FormatDate(startDate), + ["end_date"] = FormatDate(endDate), + ["limit"] = limit, + ["offset"] = offset + }); + } + + public Task GetLatestCommoditiesAsync() + { + return GetAsync("commodities/latest"); + } + + public Task GetCurvesAsync(string currency, string curveFamily = "government_nominal", + string metric = "spot", DateTime? date = null) + { + return GetAsync($"curves/{currency.ToLowerInvariant()}", new Dictionary + { + ["curve_family"] = curveFamily, + ["metric"] = metric, + ["date"] = FormatDate(date) + }); + } + + public Task GetCurveProxiesAsync(string currency, string curveFamily = "government_nominal", + DateTime? date = null) + { + return GetAsync($"curve_proxies/{currency.ToLowerInvariant()}", new Dictionary + { + ["curve_family"] = curveFamily, + ["date"] = FormatDate(date) + }); + } + + public Task GetForwardCurvesAsync(string currency, string curveFamily = "government_nominal", + string method = "derived_from_spot_nodes", DateTime? date = null) + { + return GetAsync($"forward_curves/{currency.ToLowerInvariant()}", new Dictionary + { + ["curve_family"] = curveFamily, + ["method"] = method, + ["date"] = FormatDate(date) + }); + } + + public Task GetRateDifferentialsAsync(string baseCurrency, string quoteCurrency, string measure = "auto", + DateTime? startDate = null, DateTime? endDate = null, int limit = 20, int offset = 0) + { + return GetAsync($"rate_differentials/{baseCurrency.ToLowerInvariant()}/{quoteCurrency.ToLowerInvariant()}", + new Dictionary + { + ["measure"] = measure, + ["start_date"] = FormatDate(startDate), + ["end_date"] = FormatDate(endDate), + ["limit"] = limit, + ["offset"] = offset + }); + } + + public Task GetForwardDifferentialsAsync(string baseCurrency, string quoteCurrency, + string curveFamily = "government_nominal", decimal startTenorYears = 2m, decimal endTenorYears = 5m, + DateTime? startDate = null, DateTime? endDate = null, int limit = 20, int offset = 0) + { + return GetAsync($"forward_differentials/{baseCurrency.ToLowerInvariant()}/{quoteCurrency.ToLowerInvariant()}", + new Dictionary + { + ["curve_family"] = curveFamily, + ["start_tenor_years"] = startTenorYears, + ["end_tenor_years"] = endTenorYears, + ["start_date"] = FormatDate(startDate), + ["end_date"] = FormatDate(endDate), + ["limit"] = limit, + ["offset"] = offset + }); + } + + public Task GetMarketSessionsAsync(string at = null) + { + return GetAsync("market_sessions", new Dictionary { ["at"] = at }); + } + + public Task GetRiskSentimentAsync(DateTime? startDate = null, DateTime? endDate = null, + int limit = 20, int offset = 0) + { + return GetAsync("risk_sentiment", new Dictionary + { + ["start_date"] = FormatDate(startDate), + ["end_date"] = FormatDate(endDate), + ["limit"] = limit, + ["offset"] = offset + }); + } + + public Task GetNewsAsync(string currency, int limit = 20, int offset = 0) + { + return GetAsync($"news/{currency.ToLowerInvariant()}", new Dictionary + { + ["limit"] = limit, + ["offset"] = offset + }); + } + + public Task GetPressReleasesAsync(string currency, int limit = 20, int offset = 0) + { + return GetAsync($"press-releases/{currency.ToLowerInvariant()}", new Dictionary + { + ["limit"] = limit, + ["offset"] = offset + }); + } + + public async Task GraphQlAsync(string query, object variables = null) + { + var payload = JsonConvert.SerializeObject(new + { + query, + variables = variables ?? new { } + }); + using var content = new StringContent(payload, Encoding.UTF8, "application/json"); + var response = await _httpClient.PostAsync($"{_baseUrl}/graphql", content).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStringAsync().ConfigureAwait(false); + } + + public void Dispose() + { + if (_ownsClient) + { + _httpClient.Dispose(); + } + } + + private Task GetAsync(string path, IDictionary query = null) + { + return _httpClient.GetStringAsync(BuildUri(path, query)); + } + + private Uri BuildUri(string path, IDictionary query) + { + var parameters = new Dictionary(query ?? new Dictionary()); + if (!string.IsNullOrWhiteSpace(_apiKey)) + { + parameters["api_key"] = _apiKey; + } + var url = $"{_baseUrl}/{path.TrimStart('/')}"; + var queryString = string.Join("&", parameters + .Where(pair => pair.Value != null) + .Select(pair => $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(FormatValue(pair.Value))}")); + return new Uri(string.IsNullOrWhiteSpace(queryString) ? url : $"{url}?{queryString}"); + } + + private static string FormatDate(DateTime? value) + { + return value?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + } + + private static string FormatValue(object value) + { + return value switch + { + bool boolean => boolean ? "true" : "false", + DateTime dateTime => dateTime.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), + _ => value.ToString() + }; + } + } +} + From 50b090598669ff714c5e473fd3f61dec07a00243 Mon Sep 17 00:00:00 2001 From: Robert Tidball Date: Wed, 26 Aug 2026 12:13:32 +1000 Subject: [PATCH 3/6] Use the documented FXMacroData API host Switches the base URL from the undocumented fxmacrodata.com/api/v1 alias to the published api.fxmacrodata.com/v1 host, so the client matches the public API reference. --- FxMacroDataClient.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FxMacroDataClient.cs b/FxMacroDataClient.cs index 43c01fc..5c73c9c 100644 --- a/FxMacroDataClient.cs +++ b/FxMacroDataClient.cs @@ -24,7 +24,7 @@ namespace QuantConnect.DataSource /// public sealed class FxMacroDataClient : IDisposable { - public const string DefaultBaseUrl = "https://fxmacrodata.com/api/v1"; + public const string DefaultBaseUrl = "https://api.fxmacrodata.com/v1"; private readonly HttpClient _httpClient; private readonly bool _ownsClient; From c33039c0d3b4ab4c1e7cb00dfee46b4483a263c2 Mon Sep 17 00:00:00 2001 From: Robert Tidball Date: Wed, 26 Aug 2026 12:13:33 +1000 Subject: [PATCH 4/6] Use the documented FXMacroData API host Switches the base URL from the undocumented fxmacrodata.com/api/v1 alias to the published api.fxmacrodata.com/v1 host, so the client matches the public API reference. --- FxMacroDataReleaseCalendar.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FxMacroDataReleaseCalendar.cs b/FxMacroDataReleaseCalendar.cs index 2036e80..249c529 100644 --- a/FxMacroDataReleaseCalendar.cs +++ b/FxMacroDataReleaseCalendar.cs @@ -47,7 +47,7 @@ public class FxMacroDataReleaseCalendar : BaseData public override SubscriptionDataSource GetSource(SubscriptionDataConfig config, DateTime date, bool isLiveMode) { var currency = config.Symbol.Value.ToLowerInvariant(); - var source = $"https://fxmacrodata.com/api/v1/calendar/{currency}?limit=100"; + var source = $"https://api.fxmacrodata.com/v1/calendar/{currency}?limit=100"; var apiKey = Config.Get("fxmacrodata-api-key"); if (!string.IsNullOrWhiteSpace(apiKey)) { From 2a350516bb5c601fad13c9730e2eec347841b688 Mon Sep 17 00:00:00 2001 From: Robert Tidball Date: Mon, 28 Sep 2026 12:59:40 +1000 Subject: [PATCH 5/6] Send the FXMacroData key in the X-API-Key header --- FxMacroDataClient.cs | 27 ++++++++++++++++++--------- FxMacroDataReleaseCalendar.cs | 6 ++++-- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/FxMacroDataClient.cs b/FxMacroDataClient.cs index 5c73c9c..e54eecb 100644 --- a/FxMacroDataClient.cs +++ b/FxMacroDataClient.cs @@ -259,10 +259,11 @@ public async Task GraphQlAsync(string query, object variables = null) query, variables = variables ?? new { } }); - using var content = new StringContent(payload, Encoding.UTF8, "application/json"); - var response = await _httpClient.PostAsync($"{_baseUrl}/graphql", content).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - return await response.Content.ReadAsStringAsync().ConfigureAwait(false); + using var request = new HttpRequestMessage(HttpMethod.Post, $"{_baseUrl}/graphql") + { + Content = new StringContent(payload, Encoding.UTF8, "application/json") + }; + return await SendAsync(request).ConfigureAwait(false); } public void Dispose() @@ -273,18 +274,26 @@ public void Dispose() } } - private Task GetAsync(string path, IDictionary query = null) + private async Task GetAsync(string path, IDictionary query = null) { - return _httpClient.GetStringAsync(BuildUri(path, query)); + using var request = new HttpRequestMessage(HttpMethod.Get, BuildUri(path, query)); + return await SendAsync(request).ConfigureAwait(false); } - private Uri BuildUri(string path, IDictionary query) + private async Task SendAsync(HttpRequestMessage request) { - var parameters = new Dictionary(query ?? new Dictionary()); if (!string.IsNullOrWhiteSpace(_apiKey)) { - parameters["api_key"] = _apiKey; + request.Headers.Add("X-API-Key", _apiKey); } + using var response = await _httpClient.SendAsync(request).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStringAsync().ConfigureAwait(false); + } + + private Uri BuildUri(string path, IDictionary query) + { + var parameters = query ?? new Dictionary(); var url = $"{_baseUrl}/{path.TrimStart('/')}"; var queryString = string.Join("&", parameters .Where(pair => pair.Value != null) diff --git a/FxMacroDataReleaseCalendar.cs b/FxMacroDataReleaseCalendar.cs index 249c529..acf4f06 100644 --- a/FxMacroDataReleaseCalendar.cs +++ b/FxMacroDataReleaseCalendar.cs @@ -21,6 +21,7 @@ using NodaTime; using QuantConnect.Configuration; using QuantConnect.Data; +using QuantConnect.Data.UniverseSelection; namespace QuantConnect.DataSource { @@ -48,12 +49,13 @@ public override SubscriptionDataSource GetSource(SubscriptionDataConfig config, { var currency = config.Symbol.Value.ToLowerInvariant(); var source = $"https://api.fxmacrodata.com/v1/calendar/{currency}?limit=100"; + var headers = new List>(); var apiKey = Config.Get("fxmacrodata-api-key"); if (!string.IsNullOrWhiteSpace(apiKey)) { - source += $"&api_key={Uri.EscapeDataString(apiKey)}"; + headers.Add(new KeyValuePair("X-API-Key", apiKey)); } - return new SubscriptionDataSource(source, SubscriptionTransportMedium.RemoteFile, FileFormat.UnfoldingCollection); + return new SubscriptionDataSource(source, SubscriptionTransportMedium.RemoteFile, FileFormat.UnfoldingCollection, headers); } public override BaseData Reader(SubscriptionDataConfig config, string line, DateTime date, bool isLiveMode) From 24a3608f639ff7c9dfb1b4246b92e1393b97ebb9 Mon Sep 17 00:00:00 2001 From: Robert Tidball Date: Mon, 5 Oct 2026 20:52:40 +1100 Subject: [PATCH 6/6] Do not follow redirects with the FXMacroData key and tolerate malformed calendar bodies --- FxMacroDataClient.cs | 16 ++++++++++++++-- FxMacroDataReleaseCalendar.cs | 11 ++++++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/FxMacroDataClient.cs b/FxMacroDataClient.cs index e54eecb..6c6e67a 100644 --- a/FxMacroDataClient.cs +++ b/FxMacroDataClient.cs @@ -33,10 +33,16 @@ public sealed class FxMacroDataClient : IDisposable public FxMacroDataClient(HttpClient httpClient = null, string baseUrl = DefaultBaseUrl, string apiKey = null) { - _httpClient = httpClient ?? new HttpClient(); + // Redirects are not followed so the API key header is never replayed to another host. + _httpClient = httpClient ?? new HttpClient(new HttpClientHandler { AllowAutoRedirect = false }); _ownsClient = httpClient == null; _baseUrl = baseUrl.EndsWith("/") ? baseUrl.Substring(0, baseUrl.Length - 1) : baseUrl; - _apiKey = string.IsNullOrWhiteSpace(apiKey) ? Config.Get("fxmacrodata-api-key") : apiKey; + _apiKey = (string.IsNullOrWhiteSpace(apiKey) ? Config.Get("fxmacrodata-api-key") : apiKey)?.Trim(); + if (!string.IsNullOrEmpty(_apiKey) && _apiKey.Any(c => char.IsWhiteSpace(c) || char.IsControl(c))) + { + throw new ArgumentException("The FXMacroData API key must not contain whitespace or control characters.", + nameof(apiKey)); + } } public Task GetDataCatalogueAsync(string currency = "usd", bool includeCapabilities = false, @@ -287,6 +293,12 @@ private async Task SendAsync(HttpRequestMessage request) request.Headers.Add("X-API-Key", _apiKey); } using var response = await _httpClient.SendAsync(request).ConfigureAwait(false); + var statusCode = (int)response.StatusCode; + if (statusCode >= 300 && statusCode < 400) + { + throw new HttpRequestException( + $"FXMacroData returned an unexpected redirect ({statusCode}); redirects are not followed."); + } response.EnsureSuccessStatusCode(); return await response.Content.ReadAsStringAsync().ConfigureAwait(false); } diff --git a/FxMacroDataReleaseCalendar.cs b/FxMacroDataReleaseCalendar.cs index acf4f06..e468944 100644 --- a/FxMacroDataReleaseCalendar.cs +++ b/FxMacroDataReleaseCalendar.cs @@ -60,7 +60,16 @@ public override SubscriptionDataSource GetSource(SubscriptionDataConfig config, public override BaseData Reader(SubscriptionDataConfig config, string line, DateTime date, bool isLiveMode) { - var payload = JsonConvert.DeserializeObject(line); + CalendarPayload payload; + try + { + payload = JsonConvert.DeserializeObject(line); + } + catch (JsonException) + { + // Error bodies or unexpected shapes yield no rows instead of crashing the reader. + payload = null; + } var entries = (payload?.Data ?? new List()).Select(row => new FxMacroDataReleaseCalendar { Symbol = config.Symbol,