diff --git a/include/pineforge/engine.hpp b/include/pineforge/engine.hpp index 1949f96..69a4294 100644 --- a/include/pineforge/engine.hpp +++ b/include/pineforge/engine.hpp @@ -1520,8 +1520,10 @@ class BacktestEngine { // their first bucket). Whether the bucket was in progress is read from // the auxiliary 1m feed (did it trade between the bucket's nominal open // and the first chart bar? the NSE week whose Monday was a holiday opens - // on Tuesday and is kept), so only split-feed runs apply the cut; a - // single-feed run keeps its feed-start series. Lower-TF evaluators are + // on Tuesday and is kept). Historical intraday single-feed forex/cfd D + // requests also omit their partial first session, using its actual trading + // open rather than its label. Other single-feed series keep their feed-start + // behavior; native feeds retain their own rules. Lower-TF evaluators are // untouched (their slices begin at the first chart bar anyway), and the // flag above keeps its explicit epoch plus the EMA na-warmup semantics. int64_t security_first_chart_bar_ms_ = 0; @@ -3380,8 +3382,9 @@ class BacktestEngine { // `input_ts` belongs to an HTF bucket that opened before the cut -- // security_range_start_ms_ under the flag, else the run's first chart // bar for a coarser-than-chart / chart-timeframe evaluator - // (security_first_chart_bar_ms_, split-feed runs, the auxiliary feed - // proving the bucket traded before it; false for lower-TF evaluators). The + // (security_first_chart_bar_ms_, split-feed runs with the auxiliary feed + // proving prior trading, or single-feed historical intraday forex/cfd D + // requests keyed to their session's actual open; false for lower TFs). The // progressive feed and the historical lookahead projection builder must // agree on this predicate so projected child indexes line up with the // per-state feed cursor. diff --git a/src/engine_run.cpp b/src/engine_run.cpp index 01a3eeb..76c6b00 100644 --- a/src/engine_run.cpp +++ b/src/engine_run.cpp @@ -1604,16 +1604,16 @@ void BacktestEngine::prepare_historical_security_lookahead_projections( return input_bars[child].timestamp; }; int projection_begin = 0; - if (security_range_start_na_warmup_ - || (security_first_chart_bar_ms_ > 0 && aux_security_feed_enabled())) { - while (projection_begin < n_input - && security_input_precedes_range_start( - state, child_instant_ms(projection_begin))) { - ++projection_begin; - } - if (projection_begin >= n_input) { - continue; - } + // The shared predicate also covers the single-feed OTC daily cut. + // It is false for excluded evaluators, so no separate mode guard may + // let the producer retain children the consumer will discard. + while (projection_begin < n_input + && security_input_precedes_range_start( + state, child_instant_ms(projection_begin))) { + ++projection_begin; + } + if (projection_begin >= n_input) { + continue; } const int projection_count = n_input - projection_begin; diff --git a/src/engine_security.cpp b/src/engine_security.cpp index 5849e1a..6bc6e38 100644 --- a/src/engine_security.cpp +++ b/src/engine_security.cpp @@ -418,6 +418,28 @@ bool BacktestEngine::security_input_precedes_range_start( return state.aggregator.bucket_open_ms(input_ts) < security_range_start_ms_; } #ifdef PINEFORGE_HAS_AUX_SECURITY_FEED_V1 + // R19 OTC daily pins: a historical intraday chart beginning mid-session + // has no partial first D bar in request.security, even when this run has + // only the chart feed. XAUUSD from Apr1 00:00Z reads D time/close as na + // that day and ATR14[1] first becomes numeric Apr23; counting the partial + // Mar31 session seeds ATR a day early. Restrict the no-aux inference to + // the pinned daily OTC clock. W/M need trading-history evidence to retain + // a holiday-open period, and native/aux feeds keep their existing rules. + if (!aux_security_feed_enabled() && native_security_feeds_.empty() + && security_first_chart_bar_ms_ > 0 + && script_tf_seconds_ > 0 && script_tf_seconds_ < 86400 + && (state.tf == "D" || state.tf == "1D") + && !state.lower_tf_requested && !state.lower_tf_array_requested + && (syminfo_.type == "forex" || syminfo_.type == "cfd") + && !stream_warmup_mode_ && stream_phase_ == StreamPhase::IDLE) { + const int64_t stamp = session_period_open_ms( + input_ts, syminfo_.timezone, syminfo_.session, CalendarPeriod::DAY); + // Metals stamp the D bar at 17:00 ET but first trade at 18:00. + // Starting at that actual open keeps the complete first session. + const int64_t trading_open = session_covered_instant_ms( + stamp, syminfo_.timezone, syminfo_.session); + return trading_open < security_first_chart_bar_ms_; + } // Default cut (round 8, family P), split-feed runs only: a coarser-than- // chart or chart-timeframe series starts at the first bucket that OPENS at // or after the run's first chart bar -- the deep-backtest range start -- diff --git a/tests/test_security_range_start_bucket_gating.cpp b/tests/test_security_range_start_bucket_gating.cpp index c1be326..00d467c 100644 --- a/tests/test_security_range_start_bucket_gating.cpp +++ b/tests/test_security_range_start_bucket_gating.cpp @@ -43,6 +43,7 @@ #include #include +#include #include #include @@ -170,10 +171,11 @@ class BucketGateHarness : public BacktestEngine { std::vector completed_close[3]; explicit BucketGateHarness(const char* input_tf, - const char* tf0, const char* tf1, const char* tf2) { - register_security_eval(0, tf0, input_tf, false, false); - register_security_eval(1, tf1, input_tf, false, false); - register_security_eval(2, tf2, input_tf, false, false); + const char* tf0, const char* tf1, const char* tf2, + bool lookahead = false) { + register_security_eval(0, tf0, input_tf, lookahead, false); + register_security_eval(1, tf1, input_tf, lookahead, false); + register_security_eval(2, tf2, input_tf, lookahead, false); } void evaluate_security(int sec_id, const Bar& bar, bool is_complete) override { if (!is_complete || sec_id < 0 || sec_id > 2) return; @@ -613,6 +615,148 @@ static void test_flag_epoch_1d_chart_weekly_from_chart_dailies() { } #endif +#ifdef PINEFORGE_HAS_AUX_SECURITY_FEED_V1 +// R19 covered TV pins on XAUUSD: starting at Apr1 00:00 UTC omits the +// in-progress daily bar. time/close are na on Apr1 and ATR14[1] first reads +// Apr23. This holds without an auxiliary feed and for both lookahead modes. +// Keep W/M's existing no-history-evidence behavior outside the daily repair. +static void test_single_feed_otc_daily_partial_bucket() { + int before = failures; + const auto full = make_forex_15m_feed(); + const int64_t start = utc_ms(2025, 4, 1); + std::vector bars; + for (const Bar& bar : full) if (bar.timestamp >= start) bars.push_back(bar); + for (const std::string& kind : {std::string("forex"), std::string("cfd")}) { + for (bool lookahead : {false, true}) { + BucketGateHarness h("15", "D", "W", "M", lookahead); + h.set_syminfo_timezone(NY); + h.set_syminfo_session(FX); + h.set_syminfo_type(kind); + h.run(bars.data(), static_cast(bars.size()), "15", "15"); + CHECK(h.last_error().empty(), "single-feed OTC partial-day run succeeds"); + CHECK(!h.completed[0].empty(), "single-feed daily series completes"); + if (!h.completed[0].empty()) { + CHECK_EQ_MS(h.completed[0].front(), utc_ms(2025, 4, 1, 21), + "single-feed D starts at the first whole session"); + } + for (int i=1; i<3; ++i) { + CHECK(!h.completed[i].empty(), "single-feed W/M completes"); + if (!h.completed[i].empty()) CHECK_EQ_MS(h.completed[i].front(), + utc_ms(2025,3,31,21), + "single-feed W/M retains its existing feed-start series"); + } + } + } + std::printf("test_single_feed_otc_daily_partial_bucket: %s\n", + failures > before ? "FAIL" : "ok"); +} + +// OANDA's daily label is 17:00 ET but trading starts at 18:00. A feed +// beginning at that actual open contains the whole bar and must keep it. +static void test_single_feed_cfd_actual_open_is_not_partial() { + int before = failures; + const int64_t begin=utc_ms(2025,4,1,22), end=utc_ms(2025,4,4,21); + std::vector bars; + for (int64_t t=begin;t(bars.size()),"15","15"); + CHECK(h.last_error().empty(), "single-feed cfd aligned run succeeds"); + CHECK(!h.completed[0].empty(), "single-feed cfd aligned D completes"); + if (!h.completed[0].empty()) CHECK_EQ_MS(h.completed[0].front(), + utc_ms(2025,4,1,21), "keep whole cfd day whose stamp precedes trading"); + std::printf("test_single_feed_cfd_actual_open_is_not_partial: %s\n", + failures > before ? "FAIL" : "ok"); +} + +// Equivalent to request.security("D", ta.atr(14)[1]) plus D close. The +// synthetic bars have true range 8; a deliberately extreme initial partial +// day must never enter either the ATR seed or the projected daily roster. +class OtcDailyAtrHarness : public BacktestEngine { +public: + ta::ATR atr{14}; + std::vector atr_history; + struct Read { int64_t time; double close; double previous_atr; }; + std::vector reads; + double visible_close=na(), previous_atr=na(); + int64_t first_projected_child=0; + explicit OtcDailyAtrHarness(bool lookahead) { + register_security_eval(0,"D","15",lookahead,false); + set_syminfo_metadata("historical_security_lookahead_projection",1); + } + void evaluate_security(int,const Bar& bar,bool) override { + if (security_series_slot_is_new(0)) { + atr_history.push_back(atr.compute(bar.high,bar.low,bar.close)); + } else if (!atr_history.empty()) { + atr_history.back()=atr.recompute(bar.high,bar.low,bar.close); + } + previous_atr=atr_history.size()>1 + ? atr_history[atr_history.size()-2] : na(); + visible_close=bar.close; + } + void on_bar(const Bar& bar) override { + if (first_projected_child==0 + && !security_eval_states_[0].historical_projections.empty()) { + first_projected_child= + security_eval_states_[0].historical_projections.front().first_child_ms; + } + reads.push_back({bar.timestamp,visible_close,previous_atr}); + } +}; + +static void test_single_feed_projection_and_previous_atr() { + int before=failures; + for (bool cfd : {false,true}) { + const char* session=cfd ? "1800-1700" : "1700-1700"; + std::vector bars={ + {999,1999,0,999,1,utc_ms(2025,4,1)}, + {999,1999,0,999,1,utc_ms(2025,4,1,6)}, + {999,1999,0,999,1,utc_ms(2025,4,1,20,45)}}; + const int trading_days[]={2,3,4,7,8,9,10,11,14,15,16,17,18,21,22,23,24,25,28,29,30}; + int index=0; + for (int day : trading_days) { + if (cfd && day==18) continue; // XAU Good Friday; EUR trades + double base=100+index++; + bars.push_back({base,base+4,base-4,base,1, + utc_ms(2025,4,day-1,cfd?22:21)}); + bars.push_back({base,base+4,base-4,base+1,1,utc_ms(2025,4,day,6)}); + bars.push_back({base,base+4,base-4,base+2,1,utc_ms(2025,4,day,20,45)}); + } + for (bool lookahead : {false,true}) { + OtcDailyAtrHarness h(lookahead); + h.set_syminfo_timezone(NY);h.set_syminfo_session(session); + h.set_syminfo_type(cfd?"cfd":"forex"); + h.run(bars.data(),static_cast(bars.size()),"15","15"); + CHECK(h.last_error().empty(),"OTC previous ATR run succeeds"); + if (lookahead) CHECK_EQ_MS(h.first_projected_child, + utc_ms(2025,4,1,cfd?22:21),"projection roster starts at first whole day"); + const int numeric_day=cfd ? (lookahead?23:24) : (lookahead?22:23); + for (const auto& read : h.reads) { + if (read.time==utc_ms(2025,4,1,6)) + CHECK(is_na(read.close),"partial first daily close is absent"); + if (read.time==utc_ms(2025,4,2,6)) + CHECK(lookahead ? read.close==102 : is_na(read.close), + "first daily close leaks on first child only with lookahead"); + if (read.time==utc_ms(2025,4,3,6) && !lookahead) + CHECK(read.close==102,"lookahead-off exposes first completed daily close"); + if (read.time==utc_ms(2025,4,numeric_day-1,6)) + CHECK(is_na(read.previous_atr),"ATR14[1] remains absent one day before seed"); + if (read.time==utc_ms(2025,4,numeric_day,6)) + CHECK(read.previous_atr==8,"ATR14[1] reads 8 after fourteen whole daily bars"); + } + } + } + std::printf("test_single_feed_projection_and_previous_atr: %s\n", + failures>before?"FAIL":"ok"); +} +#endif + int main() { test_bucket_open_utc_grid(); test_bucket_open_forex_session(); @@ -626,6 +770,9 @@ int main() { test_default_cut_nse_holiday_monday_keeps_the_week(); test_default_cut_oanda_1d_weekly(); test_flag_epoch_1d_chart_weekly_from_chart_dailies(); + test_single_feed_otc_daily_partial_bucket(); + test_single_feed_cfd_actual_open_is_not_partial(); + test_single_feed_projection_and_previous_atr(); #endif if (failures) { std::printf("%d check(s) FAILED\n", failures);