diff --git a/include/pineforge/timeframe.hpp b/include/pineforge/timeframe.hpp index 32eb43e..da79d8a 100644 --- a/include/pineforge/timeframe.hpp +++ b/include/pineforge/timeframe.hpp @@ -196,6 +196,18 @@ class TimeframeAggregator { /// aggregation state, so callers may query it before feeding the bar. int64_t bucket_open_ms(int64_t ms) const; + /// Timestamp of the target-TF bar OPENED by an input bar stamped `ms` + /// — what TradingView dates the aggregated bar, and what feed() stamps + /// on every bucket it starts (finding 473). RATIO -> bucket_open_ms + /// (the session-anchored grid open, whether or not the grid-opening + /// sub-bar traded: a forex 1m tape that starts at 17:04 ET still yields + /// the 17:00 chart bar); CALENDAR -> the open of the session-day holding + /// `ms` (the D/W/M bar is dated by its first TRADED session-day, so a + /// holiday-Monday week stays Tuesday's bar, but never by a thin-open + /// sub-bar inside that day); PASSTHROUGH -> `ms`. Gap-free feeds are + /// bit-identical: there the first sub-bar IS the bucket open. + int64_t bar_label_ms(int64_t ms) const; + private: enum class Mode { PASSTHROUGH, RATIO, CALENDAR }; diff --git a/src/engine_run.cpp b/src/engine_run.cpp index a049068..cd2ed1e 100644 --- a/src/engine_run.cpp +++ b/src/engine_run.cpp @@ -1551,7 +1551,8 @@ void BacktestEngine::run_aggregation_bar_loop(const Bar* input_bars, int n_input if (ab.is_complete) { // Script-bar label for the equity curve: ab.bar.timestamp — the - // aggregator's first-present sub-bar ts of the COMPLETED bucket. + // aggregator's bucket label of the COMPLETED bucket (its grid / + // session-day open, see TimeframeAggregator::bar_label_ms). // The aggregator is fed identically with magnifier on and off, so // this label is magnifier-invariant by construction. Captured // here because run_magnified_bar overwrites @@ -1588,23 +1589,17 @@ void BacktestEngine::run_aggregation_bar_loop(const Bar* input_bars, int n_input } else { // No magnifier: use aggregated bar directly. // - // ab.bar.timestamp is the FIRST-PRESENT sub-bar's timestamp - // (TimeframeAggregator keeps the opening sub-bar's ts through - // the merge). When a feed gap eats the bucket-opening sub-bar(s) - // — e.g. an exchange outage at HH:00 — this label drifts forward - // (09:00 bucket whose first surviving bar is 09:03 → ts=09:03, - // where TV would label it 09:00). - // - // We intentionally do NOT floor to the bucket boundary - // ((ts/bucket_ms)*bucket_ms). That floor assumes UTC-epoch-aligned - // buckets, which is only true for 24/7 instruments. Session-anchored - // TFs (e.g. US-equity 4h anchored to the 09:30 session open) are - // NOT UTC-aligned, so flooring would mislabel every bar there — - // worse than the rare gap-at-open drift. First-present is also the - // truthful timestamp: it is the real first bar that traded in the - // bucket. The drift never moves PnL (cosmetic entry/exit time + - // time-gated logic only); the sole risk is a strategy gating on an - // exact bucket-open instant, which is not a pattern TV scripts use. + // ab.bar.timestamp is the bucket's LABEL — its open on the + // symbol-clock grid (TimeframeAggregator::bar_label_ms), not + // the first-present sub-bar's ts. When a feed gap eats the + // bucket-opening sub-bar(s) — OANDA's 1m tape prints nothing + // for the first minutes of every 17:00 ET forex session — the + // first-present label drifted forward (17:04 where TV dates + // the chart bar 17:00) and every trade booked on that bar + // missed exact closed-trade identity by four minutes even + // though price and PnL matched (finding 473). The label is + // the session-anchored grid open, so US-equity 4h buckets + // (09:30-anchored, not UTC-aligned) label correctly too. current_bar_ = ab.bar; // Update session predicates. session_ismarket_ = pine_session_ismarket(syminfo_.session, syminfo_.timezone, diff --git a/src/timeframe.cpp b/src/timeframe.cpp index 7c12abe..1d87b20 100644 --- a/src/timeframe.cpp +++ b/src/timeframe.cpp @@ -590,6 +590,7 @@ TimeframeAggregator::TimeframeAggregator(const std::string& target_tf, void TimeframeAggregator::reset_current(const Bar& bar) { current_bar_ = bar; + current_bar_.timestamp = bar_label_ms(bar.timestamp); sub_bar_count_ = 1; current_emitted_complete_ = false; } @@ -623,10 +624,21 @@ struct FeedState { bool& has_completed; const std::string* anchor_tz = nullptr; const std::string* anchor_session = nullptr; + // Owner, for bar_label_ms(): the bucket a sub-bar opens is stamped with + // its grid / session-day open, never with the sub-bar's own timestamp. + const TimeframeAggregator* agg = nullptr; }; void feed_reset_current(FeedState s, const Bar& bar) { s.current_bar = bar; + // Bucket label (finding 473): TradingView dates an aggregated bar by the + // bucket it occupies on the symbol-clock grid, not by the first sub-bar + // that happened to trade in it. A 1m tape whose forex session opens at + // 17:04 ET (OANDA prints nothing for the first minutes) still yields a + // 17:00 chart bar on TV; keeping 17:04 here moved every entry / exit + // booked on that bar four minutes late and broke trade identity. + // Gap-free feeds are untouched: their first sub-bar IS the bucket open. + if (s.agg) s.current_bar.timestamp = s.agg->bar_label_ms(bar.timestamp); s.sub_bar_count = 1; s.current_emitted_complete = false; } @@ -920,7 +932,7 @@ AggregatedBar feed_calendar_mode(const Bar& input_bar, FeedState s, AggregatedBar TimeframeAggregator::feed(const Bar& input_bar) { FeedState s{current_bar_, sub_bar_count_, current_emitted_complete_, last_completed_bar_, has_completed_, - &anchor_tz_, &anchor_session_}; + &anchor_tz_, &anchor_session_, this}; switch (mode_) { case Mode::PASSTHROUGH: return feed_passthrough_mode(input_bar, s); @@ -974,4 +986,24 @@ int64_t TimeframeAggregator::bucket_open_ms(int64_t ms) const { return ms; } +int64_t TimeframeAggregator::bar_label_ms(int64_t ms) const { + switch (mode_) { + case Mode::RATIO: + // The session-anchored intraday grid open (bucket_open_ms), which + // is `ms` itself whenever the grid-opening sub-bar traded. + return bucket_open_ms(ms); + case Mode::CALENDAR: + // The D/W/M bar is dated by its first TRADED session-day (a + // holiday-Monday equity week is Tuesday's bar, exactly as the + // first-present sub-bar already implied), but within that + // session-day by the session OPEN: the forex daily / weekly bar + // whose tape starts at 17:04 ET is still the 17:00 ET bar. + return session_period_open_ms(ms, anchor_tz_, anchor_session_, + CalendarPeriod::DAY); + case Mode::PASSTHROUGH: + return ms; + } + return ms; +} + } // namespace pineforge diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5243757..ee50d86 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -69,6 +69,7 @@ set(TEST_SOURCES test_calendar_aggregation_wm test_calendar_wm_open_utc_fastpath test_htf_session_close_completion + test_chart_bar_bucket_label test_htf_bucket_real_end test_adversarial_ohlcv test_report_trace diff --git a/tests/test_chart_bar_bucket_label.cpp b/tests/test_chart_bar_bucket_label.cpp new file mode 100644 index 0000000..a7035b4 --- /dev/null +++ b/tests/test_chart_bar_bucket_label.cpp @@ -0,0 +1,290 @@ +// Aggregated bars are dated by the bucket they occupy on the symbol-clock +// grid, never by the first sub-bar that happened to trade in them +// (finding 473). +// +// OANDA's 1m EURUSD tape prints nothing for the first minutes of every +// 17:00 ET forex session (1263 of 1299 session-days open at 17:01-17:04), +// while TradingView's own 15m chart dates that bar 17:00. Aggregating the +// 15m chart from the 1m feed therefore labelled the bar 17:04, and every +// entry / exit booked on it missed exact closed-trade identity by four +// minutes with identical price and PnL. +// +// Rules pinned here: +// A. a thin-open intraday bucket is labelled by its session-anchored grid +// open (17:04 ET first sub-bar -> 17:00 ET bar), on 15m and on 240m; +// B. gap-free 24x7 feeds are bit-identical to the first-sub-bar label, +// whichever constructor built the aggregator; +// C. a bucket whose grid-opening sub-bar is the ONLY one present keeps +// that (already correct) label; +// D. the label follows the exchange clock across a DST edge (EST 22:00Z +// vs EDT 21:00Z session opens); +// E. calendar buckets (D / W) are dated by the session OPEN of their first +// traded session-day: the forex daily bar opening at 17:04 ET is the +// 17:00 ET bar, and a holiday-Monday equity week stays Tuesday's bar. +#include +#include +#include + +#include +#include + +using namespace pineforge; + +static int tests_passed = 0; +static int tests_failed = 0; + +#define CHECK(expr) \ + do { \ + if (!(expr)) { \ + std::printf(" FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \ + ++tests_failed; \ + } else { \ + ++tests_passed; \ + } \ + } while (0) + +#define CHECK_EQ_MS(actual, expected) \ + do { \ + const int64_t _a = (actual), _e = (expected); \ + if (_a != _e) { \ + std::printf(" FAIL %s:%d %s == %s (got %lld, want %lld)\n", \ + __FILE__, __LINE__, #actual, #expected, \ + (long long)_a, (long long)_e); \ + ++tests_failed; \ + } else { \ + ++tests_passed; \ + } \ + } while (0) + +namespace { + +// Unix ms of a UTC civil date-time (Howard Hinnant's days_from_civil). +int64_t utc_ms(int y, int m, int d, int h = 0, int mi = 0) { + y -= (m <= 2); + long era = (y >= 0 ? y : y - 399) / 400; + unsigned yoe = (unsigned)(y - era * 400); + unsigned doy = (153u * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1; + unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + long days = era * 146097L + (long)doe - 719468L; + return (static_cast(days) * 86400 + h * 3600 + mi * 60) * 1000; +} + +const std::string NY = "America/New_York"; +const std::string RTH = "0930-1600"; +const std::string FX = "1700-1700"; +const int64_t k1m = 60 * 1000; +const int64_t k15m = 15 * k1m; + +Bar bar_at(int64_t ts, double px = 100.0) { + Bar b; + b.timestamp = ts; + b.open = px; b.high = px + 1.0; b.low = px - 1.0; b.close = px + 0.5; + b.volume = 1.0; + return b; +} + +// Feed `n` consecutive input bars of `step` ms starting at `from`; return the +// completed bars emitted along the way. +std::vector feed_run(TimeframeAggregator& agg, int64_t from, + int64_t step, int n, double px = 100.0) { + std::vector out; + for (int i = 0; i < n; ++i) { + AggregatedBar ab = agg.feed(bar_at(from + i * step, px + i)); + if (ab.is_complete) out.push_back(ab); + } + return out; +} + +// A. Thin session open: the first present 1m bar is 17:04 ET (21:04Z EDT); +// the 15m chart bar is the 17:00 ET bar (finalized on the 17:14 bar whose +// end reaches the bucket end, finding 467), the following one 17:15 ET. +void test_thin_open_bucket_labelled_at_grid_start() { + std::printf("A. thin-open 15m bucket -> grid start\n"); + // 2026-04-12 is a Sunday in EDT: session opens 17:00 ET = 21:00Z. + const int64_t open_z = utc_ms(2026, 4, 12, 21, 0); + TimeframeAggregator agg("15", "1", NY, FX); + // 21:04 .. 21:14 (11 bars): the bucket completes on the 21:14 bar. + std::vector done = feed_run(agg, open_z + 4 * k1m, k1m, 11); + CHECK(done.size() == 1); + if (!done.empty()) { + const AggregatedBar& first = done.front(); + CHECK_EQ_MS(first.bar.timestamp, open_z); // 21:00Z, not 21:04Z + CHECK(first.sub_bar_count == 11); + CHECK(first.bar.open == 100.0); // OHLC from the 17:04 bar + CHECK(first.bar.close == 110.5); // last present sub-bar + CHECK(first.bar.high == 111.0); + CHECK(first.bar.low == 99.0); + } + CHECK_EQ_MS(agg.last_completed().timestamp, open_z); + // 21:15 opens the next bucket, labelled 21:15 (present), still open. + AggregatedBar next = agg.feed(bar_at(open_z + k15m, 200.0)); + CHECK(!next.is_complete); + CHECK_EQ_MS(next.bar.timestamp, open_z + k15m); + CHECK_EQ_MS(agg.current().timestamp, open_z + k15m); + // Fill it: 14 more bars complete it by count with the 21:15 label. + done = feed_run(agg, open_z + k15m + k1m, k1m, 14, 201.0); + CHECK(done.size() == 1); + if (!done.empty()) { + CHECK_EQ_MS(done.back().bar.timestamp, open_z + k15m); + CHECK(done.back().sub_bar_count == 15); + } + + // Same rule on the session-anchored 240m grid (17:00 / 21:00 / 01:00 ET): + // 21:04Z .. 00:59Z completes on the 00:59Z bar, dated 21:00Z. + TimeframeAggregator agg4h("240", "1", NY, FX); + done = feed_run(agg4h, open_z + 4 * k1m, k1m, 236); + CHECK(done.size() == 1); + if (!done.empty()) { + CHECK_EQ_MS(done.front().bar.timestamp, open_z); // 17:00 ET bucket + CHECK(done.front().sub_bar_count == 236); + } + AggregatedBar h4 = agg4h.feed(bar_at(utc_ms(2026, 4, 13, 1, 0))); + CHECK(!h4.is_complete); + CHECK_EQ_MS(agg4h.current().timestamp, utc_ms(2026, 4, 13, 1, 0)); + + // A bucket whose gap is INSIDE the session (17:31 first present of the + // 17:30 bucket, the finding's "21:31 variant") labels 17:30 too. + TimeframeAggregator agg_mid("15", "1", NY, FX); + done = feed_run(agg_mid, open_z + 31 * k1m, k1m, 14); // 21:31 .. 21:44 + CHECK(done.size() == 1); + if (!done.empty()) CHECK_EQ_MS(done.front().bar.timestamp, open_z + 30 * k1m); + CHECK_EQ_MS(agg_mid.bar_label_ms(open_z + 31 * k1m), open_z + 30 * k1m); +} + +// B. Gap-free 24x7 feed: labels are the first sub-bar's ts (== grid), and +// the tz/session constructor is bit-identical to the tz-less one. +void test_gap_free_feed_identity() { + std::printf("B. gap-free 24x7 identity\n"); + const int64_t from = utc_ms(2024, 1, 1, 0, 0); + TimeframeAggregator plain("15", "1"); + TimeframeAggregator utc("15", "1", "UTC", ""); + TimeframeAggregator none("15", "1", "UTC", "24x7"); + std::vector a = feed_run(plain, from, k1m, 6 * 60); + std::vector b = feed_run(utc, from, k1m, 6 * 60); + std::vector c = feed_run(none, from, k1m, 6 * 60); + CHECK(a.size() == 24); + CHECK(a.size() == b.size() && a.size() == c.size()); + for (size_t i = 0; i < a.size(); ++i) { + CHECK_EQ_MS(a[i].bar.timestamp, from + static_cast(i) * k15m); + CHECK_EQ_MS(b[i].bar.timestamp, a[i].bar.timestamp); + CHECK_EQ_MS(c[i].bar.timestamp, a[i].bar.timestamp); + CHECK(b[i].bar.open == a[i].bar.open && b[i].bar.close == a[i].bar.close); + CHECK(b[i].sub_bar_count == a[i].sub_bar_count); + } + // Session symbol, gap-free RTH 1m tape: 09:30 ET bucket labelled 09:30. + TimeframeAggregator rth("15", "1", NY, RTH); + const int64_t rth_open = utc_ms(2026, 4, 13, 13, 30); // Mon 09:30 EDT + std::vector r = feed_run(rth, rth_open, k1m, 45); + CHECK(r.size() == 3); + for (size_t i = 0; i < r.size(); ++i) { + CHECK_EQ_MS(r[i].bar.timestamp, rth_open + static_cast(i) * k15m); + } + // bar_label_ms is the identity on a present grid-opening sub-bar. + CHECK_EQ_MS(rth.bar_label_ms(rth_open), rth_open); + CHECK_EQ_MS(utc.bar_label_ms(from + 3 * k15m), from + 3 * k15m); +} + +// C. Only the grid-opening sub-bar is present: label unchanged (17:00). +void test_first_sub_bar_only_bucket() { + std::printf("C. bucket holding only its first sub-bar\n"); + const int64_t open_z = utc_ms(2026, 4, 12, 21, 0); + TimeframeAggregator agg("15", "1", NY, FX); + AggregatedBar p = agg.feed(bar_at(open_z)); + CHECK(!p.is_complete); + CHECK_EQ_MS(agg.current().timestamp, open_z); + AggregatedBar done = agg.feed(bar_at(open_z + k15m + 2 * k1m)); // 21:17Z + CHECK(done.is_complete); + CHECK_EQ_MS(done.bar.timestamp, open_z); + CHECK(done.sub_bar_count == 1); + // ...and the bucket the 21:17Z bar opened is the 21:15Z bucket. + CHECK_EQ_MS(agg.current().timestamp, open_z + k15m); +} + +// D. DST: the label follows the exchange clock. Sunday 2026-03-01 opens at +// 17:00 EST = 22:00Z; Sunday 2026-03-08 (EDT begins) at 17:00 EDT = 21:00Z. +void test_dst_session_open_label() { + std::printf("D. DST session opens\n"); + { + const int64_t est_open = utc_ms(2026, 3, 1, 22, 0); + TimeframeAggregator agg("15", "1", NY, FX); + std::vector done = feed_run(agg, est_open + 4 * k1m, k1m, 11); + CHECK(done.size() == 1); + if (!done.empty()) CHECK_EQ_MS(done.front().bar.timestamp, est_open); + CHECK_EQ_MS(agg.bar_label_ms(est_open + 4 * k1m), est_open); + // 21:04Z on the EST Sunday is 16:04 ET, the previous session's last + // 15m bucket (16:00 ET): the grid is on the local clock. + CHECK_EQ_MS(agg.bar_label_ms(utc_ms(2026, 3, 1, 21, 4)), + utc_ms(2026, 3, 1, 21, 0)); + } + { + const int64_t edt_open = utc_ms(2026, 3, 8, 21, 0); + TimeframeAggregator agg("15", "1", NY, FX); + std::vector done = feed_run(agg, edt_open + 3 * k1m, k1m, 12); + CHECK(done.size() == 1); + if (!done.empty()) CHECK_EQ_MS(done.front().bar.timestamp, edt_open); + CHECK_EQ_MS(agg.bar_label_ms(edt_open + 3 * k1m), edt_open); + // 240m on the EDT Sunday: 17:00 ET bucket. + TimeframeAggregator agg4h("240", "1", NY, FX); + CHECK_EQ_MS(agg4h.bar_label_ms(edt_open + 4 * k1m), edt_open); + CHECK_EQ_MS(agg4h.bar_label_ms(utc_ms(2026, 3, 9, 1, 7)), + utc_ms(2026, 3, 9, 1, 0)); + } +} + +// E. Calendar buckets: session-day open of the first traded session-day. +void test_calendar_bucket_label() { + std::printf("E. calendar (D / W) labels\n"); + { + // Forex daily from 1m: tape starts 17:04 ET Sunday; the daily bar is + // the 17:00 ET (21:00Z) bar, completing when Monday 17:00 ET arrives. + const int64_t open_z = utc_ms(2026, 4, 12, 21, 0); + TimeframeAggregator agg("D", "1", NY, FX); + feed_run(agg, open_z + 4 * k1m, k1m, 60); + CHECK_EQ_MS(agg.current().timestamp, open_z); + AggregatedBar d = agg.feed(bar_at(utc_ms(2026, 4, 13, 21, 3))); + CHECK(d.is_complete); + CHECK_EQ_MS(d.bar.timestamp, open_z); + CHECK(d.sub_bar_count == 60); + // The next session-day, itself thin-open, is the Monday 21:00Z bar. + CHECK_EQ_MS(agg.current().timestamp, utc_ms(2026, 4, 13, 21, 0)); + } + { + // Equity week from 15m whose Monday (2026-01-19, MLK day) never + // trades: the week is dated Tuesday 09:30 ET (14:30Z EST), not the + // nominal Monday. + const int64_t tue_open = utc_ms(2026, 1, 20, 14, 30); + TimeframeAggregator agg("W", "15", NY, RTH); + feed_run(agg, tue_open, k15m, 26); // Tuesday session + CHECK_EQ_MS(agg.current().timestamp, tue_open); + AggregatedBar w = agg.feed(bar_at(utc_ms(2026, 1, 26, 14, 30))); + CHECK(w.is_complete); + CHECK_EQ_MS(w.bar.timestamp, tue_open); + // A thin-open Tuesday (first 15m bar at 09:45) is still dated 09:30. + TimeframeAggregator thin("W", "15", NY, RTH); + thin.feed(bar_at(tue_open + k15m)); + CHECK_EQ_MS(thin.current().timestamp, tue_open); + } + { + // 24x7 UTC daily from 1m with the 00:00 bar present: unchanged. + const int64_t day = utc_ms(2024, 1, 1, 0, 0); + TimeframeAggregator agg("D", "1", "UTC", ""); + feed_run(agg, day, k1m, 30); + CHECK_EQ_MS(agg.current().timestamp, day); + TimeframeAggregator plain("D", "1"); + feed_run(plain, day + 7 * k1m, k1m, 3); // tz-less, thin open + CHECK_EQ_MS(plain.current().timestamp, day); // UTC midnight + } +} + +} // namespace + +int main() { + std::printf("test_chart_bar_bucket_label\n"); + test_thin_open_bucket_labelled_at_grid_start(); + test_gap_free_feed_identity(); + test_first_sub_bar_only_bucket(); + test_dst_session_open_label(); + test_calendar_bucket_label(); + std::printf("%d passed, %d failed\n", tests_passed, tests_failed); + return tests_failed == 0 ? 0 : 1; +} diff --git a/tests/test_htf_bucket_real_end.cpp b/tests/test_htf_bucket_real_end.cpp index c049431..0423a65 100644 --- a/tests/test_htf_bucket_real_end.cpp +++ b/tests/test_htf_bucket_real_end.cpp @@ -138,10 +138,11 @@ static void test_fx_thin_60_completes_on_last_present_minute() { CHECK(full != nullptr); if (full) { CHECK(full->subs == 60); CHECK_EQ_I64(full->bucket_ts, utc_ms(2025, 12, 25, 21, 0)); } // The thin bucket completes on 22:59Z — its last present minute, whose - // end is the bucket end — not on the 23:00Z boundary bar. + // end is the bucket end — not on the 23:00Z boundary bar. It is dated by + // its grid open (22:00Z), not by its first present minute (finding 473). const Completion* thin = completion_at(c, utc_ms(2025, 12, 25, 22, 59)); CHECK(thin != nullptr); - if (thin) { CHECK(thin->subs == 56); CHECK_EQ_I64(thin->bucket_ts, utc_ms(2025, 12, 25, 22, 4)); } + if (thin) { CHECK(thin->subs == 56); CHECK_EQ_I64(thin->bucket_ts, utc_ms(2025, 12, 25, 22, 0)); } CHECK(!has_completion_at(c, utc_ms(2025, 12, 25, 23, 0))); CHECK(has_completion_at(c, utc_ms(2025, 12, 25, 23, 59))); // 20:59, 21:59, 22:59, 23:59, 00:59, 01:59 — one completion per bucket. @@ -149,17 +150,18 @@ static void test_fx_thin_60_completes_on_last_present_minute() { CHECK(distinct_buckets(c)); // '240' on the same feed: buckets anchor on the 17:00 ET session open - // (22:00Z). The partial leading bucket (13:00-17:00 ET, fed from 20:00Z) - // completes on 21:59Z with 120 sub-bars; the thin 17:00-21:00 ET bucket - // (236 of 240) completes on 01:59Z, not on 02:00Z. + // (22:00Z). The partial leading bucket (13:00-17:00 ET, fed from 20:00Z, + // dated by its 18:00Z grid open) completes on 21:59Z with 120 sub-bars; + // the thin 17:00-21:00 ET bucket (236 of 240, dated 22:00Z) completes on + // 01:59Z, not on 02:00Z. TimeframeAggregator agg4("240", "1", NY, FX); auto c4 = drive(agg4, ts); const Completion* lead = completion_at(c4, utc_ms(2025, 12, 25, 21, 59)); CHECK(lead != nullptr); - if (lead) { CHECK(lead->subs == 120); CHECK_EQ_I64(lead->bucket_ts, utc_ms(2025, 12, 25, 20, 0)); } + if (lead) { CHECK(lead->subs == 120); CHECK_EQ_I64(lead->bucket_ts, utc_ms(2025, 12, 25, 18, 0)); } const Completion* thin4 = completion_at(c4, utc_ms(2025, 12, 26, 1, 59)); CHECK(thin4 != nullptr); - if (thin4) { CHECK(thin4->subs == 236); CHECK_EQ_I64(thin4->bucket_ts, utc_ms(2025, 12, 25, 22, 4)); } + if (thin4) { CHECK(thin4->subs == 236); CHECK_EQ_I64(thin4->bucket_ts, utc_ms(2025, 12, 25, 22, 0)); } CHECK(!has_completion_at(c4, utc_ms(2025, 12, 25, 23, 0))); CHECK(!has_completion_at(c4, utc_ms(2025, 12, 26, 2, 0))); CHECK(c4.size() == 2); @@ -266,10 +268,10 @@ static void test_last_minute_missing_completes_on_boundary_before_chart_close() auto v = drive_engine_order(sec, chart, ts2); const ChartView* cv = chart_view(v, utc_ms(2025, 12, 25, 22, 45)); CHECK(cv != nullptr); - if (cv) CHECK_EQ_I64(cv->sec_last_completed_bucket_ts, utc_ms(2025, 12, 25, 22, 4)); + if (cv) CHECK_EQ_I64(cv->sec_last_completed_bucket_ts, utc_ms(2025, 12, 25, 22, 0)); const ChartView* next = chart_view(v, utc_ms(2025, 12, 25, 23, 0)); CHECK(next != nullptr); - if (next) CHECK_EQ_I64(next->sec_last_completed_bucket_ts, utc_ms(2025, 12, 25, 22, 4)); + if (next) CHECK_EQ_I64(next->sec_last_completed_bucket_ts, utc_ms(2025, 12, 25, 22, 0)); } }