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
12 changes: 12 additions & 0 deletions include/pineforge/timeframe.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down
31 changes: 13 additions & 18 deletions src/engine_run.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
34 changes: 33 additions & 1 deletion src/timeframe.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading