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;