Skip to content
Merged
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
23 changes: 11 additions & 12 deletions .github/BUG_FINDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,22 +311,21 @@ await Task.WhenAll(tasks);
#### 4.1 Date format round-trip

```csharp
foreach (var df in new[] { DateFormat.Timestamp, DateFormat.Unix, DateFormat.Spreadsheet })
// null sends no dateformat, so the API picks the format: the SDK's default.
foreach (var df in new DateFormat?[] { null, DateFormat.Unix, DateFormat.Timestamp })
{
var options = new MarketDataRequestOptions { DateFormat = df };
var candles = await client.Stocks.GetCandlesAsync(
new StockCandlesRequest
{
Resolution = StockResolution.Daily,
Symbol = "AAPL",
Countback = 5,
},
new MarketDataRequestOptions { DateFormat = df });

Console.WriteLine($"{df}: {candles.Values.Count} rows");
StockResolution.Daily, "AAPL", countback: 5, options: options);
var expirations = await client.Options.GetExpirationsAsync("AAPL", options: options);

Console.WriteLine($"{df?.ToString() ?? "default"}: {candles.Values[0].Time:O} {expirations.Values[0]:O}");
}

// Verify: every encoding decodes to the SAME instants.
// Bug indicator: a format that throws, or rows that shift by hours between formats.
// Verify: every encoding decodes to the SAME instants and offsets, and a date the API
// sends without a time (a daily candle, an expiration) is midnight US/Eastern.
// Bug indicator: rows that shift by hours between formats, or a date at 20:00 or 19:00
// of the day before. DateFormat.Spreadsheet is CSV-only; typed methods reject it.
```

#### 4.2 Year boundary
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ 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).


## [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`) is midnight US/Eastern of that day.

### Simple endpoint calls

Expand Down
10 changes: 10 additions & 0 deletions src/MarketDataApp.IntegrationTests/OptionsIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,24 @@ namespace MarketDataApp.IntegrationTests;

public sealed class OptionsIntegrationTests : IntegrationTestBase
{
/// <summary>
/// Verifies that live expirations are returned and that each one is midnight US/Eastern of
/// its day, carrying the EST or EDT offset in force on that date.
/// </summary>
[IntegrationFact]
public async Task Expirations_ReturnExpectedShape()
{
var response = await Client.Options.GetExpirationsAsync(
new OptionsExpirationsRequest("AAPL"));
var eastern = TimeZoneInfo.FindSystemTimeZoneById("America/New_York");

AssertSuccess(response.StatusCode);
Assert.NotEmpty(response.Values);
Assert.All(response.Values, expiration =>
{
Assert.Equal(TimeSpan.Zero, expiration.TimeOfDay);
Assert.Equal(eastern.GetUtcOffset(expiration.DateTime), expiration.Offset);
});
}

[IntegrationFact]
Expand Down
133 changes: 131 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,124 @@ 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 option expirations read as midnight US/Eastern on the default format, where the API sends them as dates.</summary>
[Fact]
public async Task DefaultFormat_OptionExpirationDatesAreUsEasternMidnight()
{
var handler = new StubHttpMessageHandler(_ => MarketDataTestClient.JsonResponse("""
{
"s": "ok",
"expirations": ["2026-09-25"],
"updated": 1790102263
}
"""));
var client = MarketDataTestClient.Create(handler);

var response = await client.Options.GetExpirationsAsync("AAPL");

Assert.DoesNotContain("dateformat", handler.LastRequest!.RequestUri!.Query);
Assert.Equal("2026-09-25T00:00:00-04:00", WithOffset(response.Values[0]));
Assert.Equal("2026-09-22T14:37:43-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,37 @@ 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, or
/// reads as UTC without one.
/// </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")]
[InlineData("2026-09-21 14:46:05", "2026-09-21T10:46:05-04: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
6 changes: 4 additions & 2 deletions src/MarketDataApp/DateFormat.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ namespace MarketDataApp;
/// <summary>Date serialization format applied to all date/timestamp fields in a response.</summary>
public enum DateFormat
{
/// <summary>Unix epoch seconds (default).</summary>
/// <summary>Unix epoch seconds. The API also uses it for most fields when no format is
/// requested; option expirations then come as dates.</summary>
Unix,

/// <summary>ISO-8601 timestamp string.</summary>
/// <summary>US/Eastern text: a datetime with its offset (<c>2026-09-21 14:46:05 -04:00</c>)
/// or a date alone (<c>2026-09-21</c>), which typed responses read as midnight US/Eastern.</summary>
Timestamp,

/// <summary>Excel/spreadsheet serial date number. Supported by the <c>*CsvAsync</c> methods
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