Skip to content

Reconcile time zones before the as-of join, so trade enrichment stops failing silently - #97

Merged
stefan-jansen merged 3 commits into
mainfrom
fix/enrich-timezone-mismatch
Sep 15, 2026
Merged

stefan-jansen merged 3 commits into
mainfrom
fix/enrich-timezone-mismatch

Conversation

@stefan-jansen

Copy link
Copy Markdown
Contributor

enrich_trades_with_signals joins trade times against signal timestamps, and the two sides reach it from different places: trade times come out of the engine carrying the bar clock without its zone, while a prediction panel is read from parquet with its zone intact. join_asof refuses a naive left key against an aware right key.

That would be loud, except BacktestProfile.prediction_enriched_trades_df wraps the call in a try and degrades to an empty frame with a UserWarning. So the reader does not get a traceback; they get a tear sheet quietly missing its ML trade-alignment charts.

Found in the published nasdaq100_microstructure/20_strategy_analysis notebook, whose committed output carries:

Prediction enrichment failed: datatypes of join keys don't match - entry_time: datetime[μs] on left does not match timestamp: datetime[μs, UTC] on right (and no other type was available to cast to). ML trade-alignment charts will be unavailable.

The direction matters more than the fix

A naive column here is the same wall clock with the zone dropped, so it is localized and never shifted. Measured on that case study: reading the naive trade times as UTC matches 703 of 905 trades to an exact (symbol, instant) row in the prediction panel; reading them as America/New_York matches 199. The residual 202 are trades entered between two prediction instants, which is what a backward as-of join is for.

Getting this backwards would still return rows, so the wrong direction fails silently where the current bug fails loudly. Where both sides are aware and disagree, the signals are converted, because two aware columns already name instants.

Tests

Six cases plus a negative control. The control neutralizes _align_join_time_zones with a monkeypatch and asserts the join still raises, so the other six are evidence this path is load-bearing today rather than assertions polars would satisfy on its own. tests/test_result.py is 78 passed.

… failing silently

enrich_trades_with_signals joins trade times against signal timestamps, and the two sides
reach it from different places: trade times come out of the engine carrying the bar clock
without its zone, while a prediction panel is read from parquet with its zone intact.
join_asof refuses a naive left key against an aware right key, and BacktestProfile wraps
this call in a try that degrades to an empty frame with a UserWarning - so the reader does
not get a traceback, they get a tear sheet quietly missing its ML trade-alignment charts.

Found in case_studies/nasdaq100_microstructure/20_strategy_analysis.ipynb, whose committed
output carries 'Prediction enrichment failed: datatypes of join keys don't match -
entry_time: datetime[us] on left does not match timestamp: datetime[us, UTC] on right'.

A naive column here is the same wall clock with the zone dropped, so it is localized and
never shifted. Measured on that case study: reading the naive trade times as UTC matches 703
of 905 trades to an exact (symbol, instant) row in the prediction panel, and reading them as
America/New_York matches 199; the residual 202 are trades entered between two prediction
instants, which is what a backward as-of join is for. Shifting instead of localizing would
still return rows, so getting this backwards would be silent rather than loud. Where both
sides are aware and disagree the signals are converted, because two aware columns already
name instants.

The negative control neutralizes the helper with a monkeypatch and asserts the join still
raises, so the other six tests are evidence that this path is load-bearing rather than
assertions polars would satisfy on its own.
Copilot AI lite review requested due to automatic review settings September 15, 2026 11:28

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…source

The timezone-alignment change moves the engine tree digest, and provenance.py stamps
that digest into every retained record, so all 68 read stale against the working tree.
Correctness is the kind of evidence that is genuinely re-derived rather than certified:
it is cheap and tolerant of a contended machine.

66 pass, 2 unsupported, unchanged from the previous matrix. Compared leaf by leaf against
the committed copy: 231,740 values, 201 differ, and every one of the 201 is a digest, a
commit or a timestamp. No correctness value moved.

Every record is stamped dirty:false, so this was produced from a clean tree.
…ify the timings against it

The timezone-alignment change moves the engine tree digest, and the three retained evidence
files answer to it in three different ways. Correctness was re-derived in 281370c. This is
the other two.

REAL_STRATEGY_RESULTS.json has a hard equality on the digest and no certification path, so
it is rebuilt from runs: all 17 required pairs regenerated and compared, 17/17 pass, and the
promoted file carries commit 281370c with dirty false. The first rebuild today was refused
by its own gate - it ran while CORRECTNESS_RESULTS.json was still uncommitted, and
provenance.py stamps dirty from git status taken at build time - so the driver that produced
this one refuses on a dirty tree rather than discovering it eighteen minutes later.

REAL_STRATEGY_PERFORMANCE.json is certified rather than re-timed. The timed region is the
single line result = engine.run() at validation/real_strategy_runner.py:386, the declared
timing_policy excludes result extraction and reporting, and enrich_trades_with_signals - the
only caller of the new _align_join_time_zones - has no caller anywhere in src/ and runs after
that call returns. So the added code cannot execute inside the measured interval. That
mechanism is the stored reason; correctness parity alone would not be sufficient and
certify_source's own docstring says so.

generate_parity_claims.py --check passes against the regenerated claims.
@stefan-jansen
stefan-jansen merged commit 17887d6 into main Sep 15, 2026
43 checks passed
@stefan-jansen
stefan-jansen deleted the fix/enrich-timezone-mismatch branch September 15, 2026 13:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants