diff --git a/CHANGELOG.md b/CHANGELOG.md index e659857..fa56ce8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 dependency and keeps unrelated environment values out of the in-memory configuration. Contributed by [@omidkrad](https://github.com/omidkrad). +### Fixed + +- Under `DateFormat.Timestamp`, a value the API sends as a date alone (`2026-09-21`) is + now midnight America/New_York of that day. It was read as UTC midnight, so it came back + as 20:00 or 19:00 of the day before: the `Time` of a daily stock or fund candle, the + `Date`, `ReportDate` and `Updated` of an earning, the `PublicationDate` and `Updated` + of the news, the options expirations and the market status `Date`. Datetimes with an + offset (`2026-09-21 14:46:05 -04:00`) and `DateFormat.Unix` read as before. (#111) + ## [1.0.0] - 2026-08-21 First stable release of the C#/.NET SDK. The public API is covered by semantic diff --git a/README.md b/README.md index 616af1a..b518a27 100644 --- a/README.md +++ b/README.md @@ -338,7 +338,8 @@ structured SDK diagnostics. Tokens are always redacted in log output. ## Request and response model API Unix timestamps and timestamp strings are returned as `DateTimeOffset` values -normalized to the `America/New_York`/US Eastern time zone. +normalized to the `America/New_York`/US Eastern time zone. A date the API sends without +a time (`2026-09-21` under `DateFormat.Timestamp`) is midnight US/Eastern of that day. ### Simple endpoint calls diff --git a/src/MarketDataApp.Tests/Parsing/JsonParsingTests.cs b/src/MarketDataApp.Tests/Parsing/JsonParsingTests.cs index 51dcc5c..99318d9 100644 --- a/src/MarketDataApp.Tests/Parsing/JsonParsingTests.cs +++ b/src/MarketDataApp.Tests/Parsing/JsonParsingTests.cs @@ -1,5 +1,7 @@ +using System.Globalization; using MarketDataApp; using MarketDataApp.Exceptions; +using MarketDataApp.Funds; using MarketDataApp.Options; using MarketDataApp.Stocks; using MarketDataApp.Tests.TestSupport; @@ -7,14 +9,23 @@ namespace MarketDataApp.Tests.Parsing; /// -/// Exercises JsonResponseParser edge cases through the public typed endpoints: ISO-8601 and -/// unparseable timestamps, null array elements, non-array parallel fields, and non-string status. +/// Exercises JsonResponseParser edge cases through the public typed endpoints: ISO-8601, +/// date-only and unparseable timestamps, null array elements, non-array parallel fields, and +/// non-string status. /// public sealed class JsonParsingTests { + private static readonly MarketDataRequestOptions TimestampFormat = new() { DateFormat = DateFormat.Timestamp }; + private static MarketDataClient Client(string body) => MarketDataTestClient.Create(new StubHttpMessageHandler(_ => MarketDataTestClient.JsonResponse(body))); + /// Renders a timestamp as its wall-clock time and offset, so a test pins both. + /// The decoded timestamp. + /// The timestamp as yyyy-MM-ddTHH:mm:sszzz, or when absent. + private static string? WithOffset(DateTimeOffset? value) => + value?.ToString("yyyy-MM-dd'T'HH:mm:sszzz", CultureInfo.InvariantCulture); + [Fact] public async Task Timestamp_ParsesIso8601StringIntoEasternInstant() { @@ -52,6 +63,104 @@ public async Task Timestamp_UnparseableStringDecodesToNull() Assert.Null(price.Updated); } + /// Verifies that a daily stock candle's date reads as midnight US/Eastern of that day. + [Fact] + public async Task Timestamp_DailyStockCandleDateIsUsEasternMidnight() + { + var client = Client("""{"s":"ok","t":["2026-09-21"],"c":[190.25]}"""); + + var candle = (await client.Stocks.GetCandlesAsync( + StockResolution.Daily, "AAPL", countback: 1, options: TimestampFormat)).Values[0]; + + Assert.Equal("2026-09-21T00:00:00-04:00", WithOffset(candle.Time)); + } + + /// Verifies that a daily fund candle's date reads as midnight US/Eastern of that day. + [Fact] + public async Task Timestamp_DailyFundCandleDateIsUsEasternMidnight() + { + var client = Client("""{"s":"ok","t":["2025-03-03"],"c":[451.25]}"""); + + var candle = (await client.Funds.GetCandlesAsync( + FundResolution.Daily, "VFINX", countback: 1, options: TimestampFormat)).Values[0]; + + Assert.Equal("2025-03-03T00:00:00-05:00", WithOffset(candle.Time)); + } + + /// Verifies that an earning's date, report date and update date read as midnight US/Eastern. + [Fact] + public async Task Timestamp_EarningDatesAreUsEasternMidnight() + { + var client = Client(""" + { + "s": "ok", + "symbol": ["AAPL"], + "date": ["2025-12-31"], + "reportDate": ["2026-01-29"], + "updated": ["2026-09-21"] + } + """); + + var earning = (await client.Stocks.GetEarningsAsync("AAPL", countback: 1, options: TimestampFormat)).Values[0]; + + Assert.Equal("2025-12-31T00:00:00-05:00", WithOffset(earning.Date)); + Assert.Equal("2026-01-29T00:00:00-05:00", WithOffset(earning.ReportDate)); + Assert.Equal("2026-09-21T00:00:00-04:00", WithOffset(earning.Updated)); + } + + /// Verifies that a news article's publication date and the response's update date read as midnight US/Eastern. + [Fact] + public async Task Timestamp_NewsDatesAreUsEasternMidnight() + { + var client = Client(""" + { + "s": "ok", + "symbol": ["AAPL"], + "headline": ["Headline"], + "content": ["Content"], + "source": ["https://example.com/story"], + "publicationDate": ["2026-09-18"], + "updated": "2026-09-21" + } + """); + + var response = await client.Stocks.GetNewsAsync("AAPL", countback: 1, options: TimestampFormat); + + Assert.Equal("2026-09-18T00:00:00-04:00", WithOffset(response.Values[0].PublicationDate)); + Assert.Equal("2026-09-21T00:00:00-04:00", WithOffset(response.Updated)); + } + + /// Verifies that option expirations read as midnight US/Eastern while a datetime keeps its offset. + [Fact] + public async Task Timestamp_OptionExpirationDatesAreUsEasternMidnight() + { + var client = Client(""" + { + "s": "ok", + "expirations": ["2026-09-25", "2027-01-15"], + "updated": "2026-09-21 14:46:05 -04:00" + } + """); + + var response = await client.Options.GetExpirationsAsync("AAPL", options: TimestampFormat); + + Assert.Equal( + new string?[] { "2026-09-25T00:00:00-04:00", "2027-01-15T00:00:00-05:00" }, + response.Values.Select(expiration => WithOffset(expiration))); + Assert.Equal("2026-09-21T14:46:05-04:00", WithOffset(response.Updated)); + } + + /// Verifies that a market status date reads as midnight US/Eastern of that day. + [Fact] + public async Task Timestamp_MarketStatusDateIsUsEasternMidnight() + { + var client = Client("""{"s":"ok","date":["2026-09-21"],"status":["open"]}"""); + + var status = (await client.Markets.GetStatusAsync(countback: 1, options: TimestampFormat)).Values[0]; + + Assert.Equal("2026-09-21T00:00:00-04:00", WithOffset(status.Date)); + } + [Fact] public async Task NullArrayElement_DecodesToNullField() { diff --git a/src/MarketDataApp.Tests/Parsing/JsonResponseParserInternalTests.cs b/src/MarketDataApp.Tests/Parsing/JsonResponseParserInternalTests.cs index 7aa8f6b..d38666a 100644 --- a/src/MarketDataApp.Tests/Parsing/JsonResponseParserInternalTests.cs +++ b/src/MarketDataApp.Tests/Parsing/JsonResponseParserInternalTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text.Json; using MarketDataApp; @@ -7,7 +8,8 @@ namespace MarketDataApp.Tests.Parsing; /// Direct tests for JsonResponseParser internals reached via InternalsVisibleTo: /// the typed accessors (each returns /// for a wrong-kind element, an out-of-range number, a null element, an -/// absent field, or a field that is present but not an array) and the parse-failure classifier. +/// absent field, or a field that is present but not an array), the US/Eastern reading of the +/// API's dateformat=timestamp shapes, and the parse-failure classifier. /// public sealed class JsonResponseParserInternalTests { @@ -59,6 +61,35 @@ public void Accessors_ReturnNullWhenFieldIsPresentButNotAnArray() Assert.Null(Row("""{"x":123}""").Long("x")); } + /// + /// Verifies that a date alone reads as midnight US/Eastern of that day, including on both + /// daylight-saving switch days, and that a datetime keeps the instant its offset gives. + /// + /// The value as the API writes it under dateformat=timestamp. + /// The US/Eastern wall-clock time and offset the value must read as. + [Theory] + [InlineData("2026-09-21", "2026-09-21T00:00:00-04:00")] + [InlineData("2025-03-03", "2025-03-03T00:00:00-05:00")] + [InlineData("2026-03-08", "2026-03-08T00:00:00-05:00")] + [InlineData("2026-11-01", "2026-11-01T00:00:00-04:00")] + [InlineData("2026-09-21 14:46:05 -04:00", "2026-09-21T14:46:05-04:00")] + [InlineData("2025-03-03 09:30:00 -05:00", "2025-03-03T09:30:00-05:00")] + public void Timestamp_ReadsTheApiTimestampShapesInUsEastern(string sent, string expected) + { + var timestamp = Row($$"""{"x":["{{sent}}"]}""").Timestamp("x"); + + Assert.Equal(expected, timestamp?.ToString("yyyy-MM-dd'T'HH:mm:sszzz", CultureInfo.InvariantCulture)); + } + + /// Verifies that a timestamp element reads as null when it is neither a number nor a readable string. + [Fact] + public void Timestamp_ReturnsNullForAnElementThatIsNotATimestamp() + { + Assert.Null(Row("""{"x":[true]}""").Timestamp("x")); + Assert.Null(Row("""{"x":[{}]}""").Timestamp("x")); + Assert.Null(Row("""{"x":["not-a-date"]}""").Timestamp("x")); // as long as a date, but not one + } + [Fact] public void IsParseFailure_ClassifiesParseExceptionTypesAndRejectsUnrelatedOnes() { diff --git a/src/MarketDataApp/JsonResponseParser.cs b/src/MarketDataApp/JsonResponseParser.cs index cf9e2f2..04e2de9 100644 --- a/src/MarketDataApp/JsonResponseParser.cs +++ b/src/MarketDataApp/JsonResponseParser.cs @@ -7,6 +7,8 @@ namespace MarketDataApp; internal static class JsonResponseParser { + private const string DateOnlyFormat = "yyyy-MM-dd"; + private static readonly Lazy EasternTimeZone = new(ResolveEasternTimeZone); // Excluded: the "Eastern Standard Time" fallback is only reached on platforms that lack the @@ -115,6 +117,14 @@ public readonly struct ParallelArrayRow(JsonElement root, int index) } } + /// + /// Decodes a response timestamp into a US/Eastern . A date + /// alone (yyyy-MM-dd) is midnight US/Eastern of that day; any other string is read + /// with its UTC offset, or as UTC when it has none. + /// + /// A JSON number of Unix seconds or a JSON string; when the field is absent. + /// The timestamp in US/Eastern, or when the value is absent, of another JSON kind, or unreadable. + /// The number of Unix seconds is outside the range of . public static DateTimeOffset? ToDateTime(JsonElement? value) { if (value is null) @@ -128,12 +138,20 @@ public readonly struct ParallelArrayRow(JsonElement root, int index) return ToEastern(DateTimeOffset.UnixEpoch.AddSeconds(number)); } - if (value.Value.ValueKind == JsonValueKind.String - && DateTimeOffset.TryParse( - value.Value.GetString(), - CultureInfo.InvariantCulture, - DateTimeStyles.AssumeUniversal, - out var timestamp)) + if (value.Value.ValueKind != JsonValueKind.String) + { + return null; + } + + var text = value.Value.GetString()!; + // Without the length test, every datetime value would pay for a failed date parse. + if (text.Length == DateOnlyFormat.Length + && DateOnly.TryParseExact(text, DateOnlyFormat, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) + { + return AtEasternMidnight(date); + } + + if (DateTimeOffset.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var timestamp)) { return ToEastern(timestamp); } @@ -144,6 +162,15 @@ public readonly struct ParallelArrayRow(JsonElement root, int index) private static DateTimeOffset ToEastern(DateTimeOffset timestamp) => TimeZoneInfo.ConvertTime(timestamp, EasternTimeZone.Value); + /// Returns the first instant of a calendar day in US/Eastern. + /// The US/Eastern calendar day. + /// Midnight of , carrying the EST or EDT offset in force at that moment. + private static DateTimeOffset AtEasternMidnight(DateOnly date) + { + var midnight = date.ToDateTime(TimeOnly.MinValue); + return new DateTimeOffset(midnight, EasternTimeZone.Value.GetUtcOffset(midnight)); + } + public static DateTimeOffset? Timestamp(JsonElement root, string name) => root.TryGetProperty(name, out var value) ? ToDateTime(value) : null;