Skip to content
Closed
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
113 changes: 111 additions & 2 deletions src/MarketDataApp.Tests/Parsing/JsonParsingTests.cs
Original file line number Diff line number Diff line change
@@ -1,20 +1,31 @@
using System.Globalization;
using MarketDataApp;
using MarketDataApp.Exceptions;
using MarketDataApp.Funds;
using MarketDataApp.Options;
using MarketDataApp.Stocks;
using MarketDataApp.Tests.TestSupport;

namespace MarketDataApp.Tests.Parsing;

/// <summary>
/// Exercises <c>JsonResponseParser</c> edge cases through the public typed endpoints: ISO-8601 and
/// unparseable timestamps, null array elements, non-array parallel fields, and non-string status.
/// Exercises <c>JsonResponseParser</c> 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.
/// </summary>
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)));

/// <summary>Renders a timestamp as its wall-clock time and offset, so a test pins both.</summary>
/// <param name="value">The decoded timestamp.</param>
/// <returns>The timestamp as <c>yyyy-MM-ddTHH:mm:sszzz</c>, or <see langword="null"/> when absent.</returns>
private static string? WithOffset(DateTimeOffset? value) =>
value?.ToString("yyyy-MM-dd'T'HH:mm:sszzz", CultureInfo.InvariantCulture);

[Fact]
public async Task Timestamp_ParsesIso8601StringIntoEasternInstant()
{
Expand Down Expand Up @@ -52,6 +63,104 @@ public async Task Timestamp_UnparseableStringDecodesToNull()
Assert.Null(price.Updated);
}

/// <summary>Verifies that a daily stock candle's date reads as midnight US/Eastern of that day.</summary>
[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));
}

/// <summary>Verifies that a daily fund candle's date reads as midnight US/Eastern of that day.</summary>
[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));
}

/// <summary>Verifies that an earning's date, report date and update date read as midnight US/Eastern.</summary>
[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));
}

/// <summary>Verifies that a news article's publication date and the response's update date read as midnight US/Eastern.</summary>
[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));
}

/// <summary>Verifies that option expirations read as midnight US/Eastern while a datetime keeps its offset.</summary>
[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));
}

/// <summary>Verifies that a market status date reads as midnight US/Eastern of that day.</summary>
[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()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Globalization;
using System.Text.Json;
using MarketDataApp;

Expand All @@ -7,7 +8,8 @@ namespace MarketDataApp.Tests.Parsing;
/// Direct tests for <c>JsonResponseParser</c> internals reached via <c>InternalsVisibleTo</c>:
/// the <see cref="JsonResponseParser.ParallelArrayRow"/> typed accessors (each returns
/// <see langword="null"/> 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 <c>dateformat=timestamp</c> shapes, and the parse-failure classifier.
/// </summary>
public sealed class JsonResponseParserInternalTests
{
Expand Down Expand Up @@ -59,6 +61,35 @@ public void Accessors_ReturnNullWhenFieldIsPresentButNotAnArray()
Assert.Null(Row("""{"x":123}""").Long("x"));
}

/// <summary>
/// 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.
/// </summary>
/// <param name="sent">The value as the API writes it under <c>dateformat=timestamp</c>.</param>
/// <param name="expected">The US/Eastern wall-clock time and offset the value must read as.</param>
[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));
}

/// <summary>Verifies that a timestamp element reads as null when it is neither a number nor a readable string.</summary>
[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()
{
Expand Down
39 changes: 33 additions & 6 deletions src/MarketDataApp/JsonResponseParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ namespace MarketDataApp;

internal static class JsonResponseParser
{
private const string DateOnlyFormat = "yyyy-MM-dd";

private static readonly Lazy<TimeZoneInfo> EasternTimeZone = new(ResolveEasternTimeZone);

// Excluded: the "Eastern Standard Time" fallback is only reached on platforms that lack the
Expand Down Expand Up @@ -115,6 +117,14 @@ public readonly struct ParallelArrayRow(JsonElement root, int index)
}
}

/// <summary>
/// Decodes a response timestamp into a US/Eastern <see cref="DateTimeOffset"/>. A date
/// alone (<c>yyyy-MM-dd</c>) is midnight US/Eastern of that day; any other string is read
/// with its UTC offset, or as UTC when it has none.
/// </summary>
/// <param name="value">A JSON number of Unix seconds or a JSON string; <see langword="null"/> when the field is absent.</param>
/// <returns>The timestamp in US/Eastern, or <see langword="null"/> when the value is absent, of another JSON kind, or unreadable.</returns>
/// <exception cref="ArgumentOutOfRangeException">The number of Unix seconds is outside the range of <see cref="DateTimeOffset"/>.</exception>
public static DateTimeOffset? ToDateTime(JsonElement? value)
{
if (value is null)
Expand All @@ -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);
}
Expand All @@ -144,6 +162,15 @@ public readonly struct ParallelArrayRow(JsonElement root, int index)
private static DateTimeOffset ToEastern(DateTimeOffset timestamp) =>
TimeZoneInfo.ConvertTime(timestamp, EasternTimeZone.Value);

/// <summary>Returns the first instant of a calendar day in US/Eastern.</summary>
/// <param name="date">The US/Eastern calendar day.</param>
/// <returns>Midnight of <paramref name="date"/>, carrying the EST or EDT offset in force at that moment.</returns>
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;

Expand Down
Loading