Skip to content

fix: read a date-only timestamp as midnight US/Eastern - #114

Merged
MarketDataDev02 merged 3 commits into
mainfrom
fix/date-only-timestamp-us-eastern
Sep 23, 2026
Merged

MarketDataDev02 merged 3 commits into
mainfrom
fix/date-only-timestamp-us-eastern

Conversation

@MarketDataDev02

@MarketDataDev02 MarketDataDev02 commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #111

Summary

A date the API sends without a time (2026-09-21) now reads as midnight America/New_York of that day. On main it read as UTC midnight, so it came back as 20:00 (EDT) or 19:00 (EST) of the day before. That hit:

  • GetExpirationsAsync on the default date format. With no dateformat, the API sends expirations as dates ("2026-09-23"), so every expiration came back as the evening before.
  • Under DateFormat.Timestamp: the Time of stock and fund candles of a day or longer, the Date, ReportDate and Updated of an earning, the PublicationDate and Updated of the news, and the market status Date.

Datetimes with an offset (2026-09-21 14:46:05 -04:00) and Unix timestamps read as before.

Version impact

Version impact: minor (1.0.0 -> 1.1.0). The diff touches no public symbol: the change is inside the internal JsonResponseParser, plus the XML doc wording of DateFormat.Unix and DateFormat.Timestamp. Under the SDK review rules a non-breaking change takes the next minor. The affected values change: those are the values that were wrong.

The change

The API writes a date as %Y-%m-%d, meaning that calendar day in US/Eastern, and a datetime as %Y-%m-%d %H:%M:%S %:z (marketDataApi/common/util/date_helper.py::DateHelper.format_date). JsonResponseParser.ToDateTime parsed every string with DateTimeStyles.AssumeUniversal.

  • A string of exactly the yyyy-MM-dd shape is now midnight US/Eastern of that day, with the EST or EDT offset in force then. US/Eastern changes its clocks at 02:00, so that midnight always exists and is never ambiguous.
  • Every other string keeps the offset-aware parse, untouched. This is the rule of the Python fix, fix(dataframe): read timestamp and spreadsheet dates as the API writes them sdk-py#144.
  • A length test keeps datetime values off the exact date parse. Without it, a failed DateOnly.TryParseExact added about 200 ns to every datetime on .NET 10 (best-of-5 Stopwatch microbenchmark, not BenchmarkDotNet). With it, the datetime path measures as before and a date costs about 50–70 ns more; the decode allocates nothing new.
  • DateFormat.Unix no longer claims to be the default for every field, and DateFormat.Timestamp describes the two shapes the API sends instead of "ISO-8601".
  • .github/BUG_FINDING.md 4.1 now compares the default, unix and timestamp formats on daily candles and expirations. It ran DateFormat.Spreadsheet through a typed method, which rejects it, and only counted rows.

Left as it was, on purpose: a datetime without an offset (2026-09-21 14:46:05) still reads as UTC, and a test pins it. The API never sends that shape. The issue's definition of done says "a timestamp string without an offset"; this PR reads it as the date shape the API sends. Reading every offset-less string as Eastern wall-clock time was tried and rejected: through DateTime.TryParse with AdjustToUniversal, 0001-01-01 00:00:00 +01:00 goes from null to a value a day off (the UTC adjustment wraps negative ticks) and 9999-12-31 23:00:00 starts throwing.

Tests

  • JsonResponseParserInternalTests: the shapes of the definition of done (a date, a datetime with an offset in EDT and in EST), an EST date, both DST switch days (2026-03-08 → -05:00, 2026-11-01 → -04:00) and an offset-less datetime (UTC). The assertions compare wall-clock time and offset as text, because Assert.Equal on DateTimeOffset compares instants only. Also true, {} and the date-length not-a-date, each null.
  • JsonParsingTests: one test per endpoint the issue lists, through the public client under DateFormat.Timestamp: daily stock and fund candles t; earnings date, reportDate and updated; news publicationDate and the root-level updated; options expirations beside a datetime updated; markets status date. Plus GetExpirationsAsync on the default format with the default response shape: dates for expirations, a Unix updated, and no dateformat in the request.
  • OptionsIntegrationTests.Expirations_ReturnExpectedShape now checks that every live expiration is midnight with the US/Eastern offset of its date, so a value decoded as UTC midnight fails too. The AAPL list spans both offsets (-04:00 and -05:00, up to 2029-01-19). Run live against main, the midnight check fails; on this branch all 25 pass.
  • Against main, 11 of the new cases fail with the reported symptom (2026-09-20T20:00:00-04:00 for 2026-09-21). The datetime cases and the null cases pass there too, as expected.
  • The explicit non-string branch surfaces a path the old String && TryParse condition hid: coverlet shares one hit counter per jump target, so not-a-timestamp also counted for a non-string element. That path now has its own test.

Run on Windows (host zone UTC−3), .NET SDK 10.0.302: 323 passed on net8.0 and net10.0, 100% line and branch coverage on both, 0 build warnings, dotnet format --verify-no-changes clean.

Proof

Measured live on 2026-09-22 through the public client in demo mode (no token), AAPL. "Matches unix" compares every row, instant and offset, against the same call under DateFormat.Unix.

field format API sends before (main 3438f7f) after (this branch) matches unix, before → after
options/expirations (25 rows) default 2026-09-23 2026-09-22 20:00 -04:00 2026-09-23 00:00 -04:00 ✗ → ✓
options/expirations (25 rows) timestamp 2026-09-23 2026-09-22 20:00 -04:00 2026-09-23 00:00 -04:00 ✗ → ✓
stocks/candles daily t timestamp 2026-09-15 2026-09-14 20:00 -04:00 2026-09-15 00:00 -04:00 ✗ → ✓
stocks/earnings date timestamp 2026-09-30 2026-09-29 20:00 -04:00 2026-09-30 00:00 -04:00 ✗ → ✓
stocks/earnings reportDate timestamp 2026-10-29 2026-10-28 20:00 -04:00 2026-10-29 00:00 -04:00 ✗ → ✓
stocks/earnings updated timestamp 2026-09-17 2026-09-16 20:00 -04:00 2026-09-17 00:00 -04:00 ✗ → ✓
stocks/news publicationDate timestamp 2026-09-22 2026-09-21 20:00 -04:00 2026-09-22 00:00 -04:00 ✗ → ✓
stocks/news updated timestamp 2026-09-22 2026-09-21 20:00 -04:00 2026-09-22 00:00 -04:00 ✗ → ✓
stocks/candles 60-minute t (control) timestamp 2026-09-22 09:30:00 -04:00 2026-09-22 09:30 -04:00 2026-09-22 09:30 -04:00 ✓ → ✓

Weekly, monthly and yearly candles are sent as dates too under timestamp (2026-09-14, 2026-08-03, 2025-01-01) and go through the same path. On the default format, every other field checked (candles, earnings, news, the expirations updated) is a Unix number.

Not verified live: markets/status returns 401 without a token and funds/candles returned no rows in demo mode, so both rely on the unit tests. The live integration suite did not run here (no token on this machine); this PR's Integration Tests (live) job runs the new expirations check.

Cross-platform: the on-demand OS matrix passed on Windows and macOS on this head (2ded4b8), run 35861445686. On 051306c, the first run's Windows leg passed every step, both test runs under the coverage gate included, but was cancelled while closing: Codecov's comment started a new run of the same workflow, and its concurrency group cancels the run in progress, which the Report job counted as a failure. Its rerun passed. The ubuntu leg covers the IANA time-zone data.

Sibling SDKs

Date and time normalisation is shared logic. The API sends option expirations as dates even when no dateformat is requested, so that case is the one to check in each SDK.

The API writes a date as %Y-%m-%d, meaning that calendar day in US/Eastern:
on the default format for option expirations, and under dateformat=timestamp
for candles of a day or longer and for earnings, news and market status
dates. ToDateTime parsed every string with DateTimeStyles.AssumeUniversal,
so a date became UTC midnight and, once converted to America/New_York, 20:00
or 19:00 of the day before.

A string of exactly the yyyy-MM-dd shape is now midnight US/Eastern of that
day, with the EST or EDT offset in force then. Every other string keeps the
offset-aware parse, so datetimes with an offset and Unix timestamps read as
before. A length test keeps datetime values off the exact date parse, which
would otherwise cost about 200 ns per value when it fails.

The explicit non-string branch surfaces a path the old combined condition
hid from coverlet, which shares one hit counter per jump target; that path
now has its own test. The live expirations test now checks that every
expiration is a midnight, the DateFormat docs describe what the API sends,
and BUG_FINDING 4.1 compares the default, unix and timestamp formats instead
of running spreadsheet through a typed method, which rejects it.
@MarketDataDev02

Copy link
Copy Markdown
Collaborator Author

/run-all-os

@codecov

codecov Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (3438f7f) to head (d25bda2).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff            @@
##              main      #114   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           69        69           
  Lines         2187      2190    +3     
  Branches       272       274    +2     
=========================================
+ Hits          2187      2190    +3     
Files with missing lines Coverage Δ
src/MarketDataApp/DateFormat.cs 100.00% <ø> (ø)
src/MarketDataApp/JsonResponseParser.cs 100.00% <100.00%> (ø)

Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 3438f7f...d25bda2. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions

Copy link
Copy Markdown
Contributor

❌ On-demand OS matrix {windows, macOS} failed. View run.

@marketdata-code-review

marketdata-code-review Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

✅ Ready — Code review

Version impact: minor (1.0.0 -> 1.1.0). The internal parser now treats date-only API timestamps as midnight US/Eastern while preserving offset-bearing and Unix timestamp behavior, with endpoint-level, integration, documentation, and changelog updates. The same date-normalization defect can exist in the Python and PHP sibling SDKs; Java has a different spreadsheet-date defect, while Go and JavaScript do not perform this typed decoding.

Rating patch 6/6 (exemplary) · proof 6/6 (comprehensive) · overall 6/6


Reviewed d25bda2 · revision 3 · codex (gpt-5.6-sol) · claude unavailable · rules: default, sdk

@github-actions

Copy link
Copy Markdown
Contributor

✅ On-demand OS matrix {windows, macOS} passed. View run.

Midnight alone also accepts a value decoded as UTC midnight. Each live
expiration must carry the offset US/Eastern has on its date: -04:00 or
-05:00, both of which the AAPL list spans.
@MarketDataDev02

Copy link
Copy Markdown
Collaborator Author

/run-all-os

@github-actions

Copy link
Copy Markdown
Contributor

✅ On-demand OS matrix {windows, macOS} passed. View run.

@MarketDataDev02
MarketDataDev02 merged commit cb83a0f into main Sep 23, 2026
11 checks passed
@MarketDataDev02
MarketDataDev02 deleted the fix/date-only-timestamp-us-eastern branch September 23, 2026 13:40
This was referenced Oct 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DateFormat.Timestamp reads a date-only value one day early: AssumeUniversal makes it UTC midnight

1 participant