Skip to content

Commit 47e9829

Browse files
committed
fix(security): latch finer-than-chart request.security history reads to calling-bar boundary
request.security(sym, tf, ta.<fn>(...)[k], lookahead=barmerge.lookahead_on) where tf is FINER than the script/chart TF (e.g. so2TF="5" read from a 15m chart) fed the read-before-push history-offset machinery (added for #30/#31) one native-security-period value late instead of one CALLING-bar late: the security's own aggregator completes R = script_seconds/requested_seconds times per calling bar, and hist.push() fired (unconditionally on is_complete) on every one of those completions, so hist[0] silently drifted to "one security-period behind the LAST completion of THIS calling bar" instead of "the last completion of the PREVIOUS calling bar". Add SecurityEvalState::publish_gate_tf_seconds (validate_security_timeframes): nonzero only for a plain (non-security_lower_tf) request.security whose target TF is strictly finer than script_tf and evenly divides it. In feed_security_eval_state's aggregator branch, gate the is_complete flag passed to evaluate_security() on wall-clock bucket-end alignment with the script_tf boundary, instead of passing the raw completion flag through unconditionally. The underlying TA state (compute/recompute dispatch) is driven by current_sub_bar_count, independent of this flag, so intermediate completions still advance the indicator correctly -- only the exposed history buffer's publish cadence changes. No-op (field stays 0) for every coarser-or-equal-TF security and every request.security_lower_tf call. 3commas-3commas-triple-rsi-dca-long-risk-adjusted-averaging-strategy (so2Rsi = request.security(sym, "5", ta.rsi(close,7)[1], lookahead= barmerge.lookahead_on) on a 15m chart): 50.5% -> 100.0% match vs TradingView (108/108 trades, 100% exact price). Full corpus (252 strategies) + ctest (77) unchanged: excellent=251/anomaly=1, tier-for-tier identical to baseline. Spot-checked every other lookahead_on + history-offset request.security call site in the standard corpus (6 other slugs, all coarser-than-chart target TFs, e.g. "D"/"W"/ 240/60) -- byte-identical engine_trades.csv/engine_verify.json output, confirming zero blast radius outside this one finer-than-chart case.
1 parent 8d5bad8 commit 47e9829

2 files changed

Lines changed: 68 additions & 1 deletion

File tree

include/pineforge/engine.hpp

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1076,6 +1076,39 @@ class BacktestEngine {
10761076
bool lower_tf_use_input = false;
10771077
int lower_tf_input_aggregation_ratio = 1;
10781078
std::vector<Bar> lower_tf_input_buffer;
1079+
// Plain ``request.security`` (not ``_lower_tf``) with a requested TF
1080+
// STRICTLY FINER than script_tf (e.g. so2TF="5" read from a 15m
1081+
// chart): the security's own aggregator completes multiple times
1082+
// (script_seconds / requested_seconds) per calling/script bar. A
1083+
// history-offset read (``expr[1]`` inside the security call, see
1084+
// the ``*_hist`` push/read machinery in codegen) is meant to expose
1085+
// "the value already confirmed as of the close of the PREVIOUS
1086+
// calling bar" — i.e. the publish granularity is the CALLING bar,
1087+
// not the security's own (finer) period. Without this, the
1088+
// read-before-push ``hist[0]`` gets refreshed on every one of the
1089+
// R completions inside the current calling bar, so by the time
1090+
// on_bar() reads it the value has silently drifted to "one
1091+
// security-period behind the LAST completion of THIS SAME calling
1092+
// bar" (e.g. the middle of 3 sub-periods) instead of "the last
1093+
// completion of the PREVIOUS calling bar" — an aliasing bug
1094+
// confirmed against TradingView-exported trades on a triple-RSI
1095+
// DCA strategy using so2Rsi = request.security(sym, "5",
1096+
// ta.rsi(close,7)[1], lookahead=barmerge.lookahead_on) on a 15m
1097+
// chart (finer target under lookahead + offset).
1098+
//
1099+
// When nonzero, this holds the requested TF's duration in seconds
1100+
// (script_seconds % this == 0 verified at validate time) and gates
1101+
// ``feed_security_eval_state``'s aggregator branch: only the
1102+
// completion whose bucket END aligns to a script_tf boundary is
1103+
// passed through to ``evaluate_security`` as ``is_complete = true``
1104+
// (letting codegen's ``hist.push()`` fire); all other completions
1105+
// within the same calling bar are still evaluated (so the
1106+
// underlying TA state keeps advancing at native/security
1107+
// resolution) but are passed ``is_complete = false`` so they do not
1108+
// advance the exposed history buffer. Zero (the default) means "not
1109+
// applicable" (target TF is coarser than or equal to script_tf, the
1110+
// already-correct case) and leaves behavior unchanged.
1111+
int publish_gate_tf_seconds = 0;
10791112
};
10801113

10811114
std::vector<SecurityEvalState> security_eval_states_;

src/engine_security.cpp

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ void BacktestEngine::validate_security_timeframes(const std::string& input_tf) {
116116
state.lower_tf_use_input = false;
117117
state.lower_tf_input_aggregation_ratio = 1;
118118
state.lower_tf_input_buffer.clear();
119+
state.publish_gate_tf_seconds = 0;
119120
if (state.tf.empty()) continue;
120121

121122
int lower_ratio = 0;
@@ -222,6 +223,19 @@ void BacktestEngine::validate_security_timeframes(const std::string& input_tf) {
222223
requested_seconds / input_seconds;
223224
state.lower_tf_ratio = script_seconds / requested_seconds;
224225
state.lower_tf_seconds = requested_seconds;
226+
} else if (!is_calendar_month && script_seconds > 0
227+
&& requested_seconds < script_seconds
228+
&& script_seconds % requested_seconds == 0) {
229+
// Plain request.security with a target TF finer than the
230+
// script/chart TF (e.g. so2TF="5" on a 15m chart): the
231+
// security's own aggregator completes multiple times per
232+
// calling bar. A history-offset read (``expr[1]``) must expose
233+
// the value confirmed as of the PREVIOUS calling bar, not
234+
// whatever native sub-period last completed inside the
235+
// CURRENT calling bar. Gate publication (see
236+
// feed_security_eval_state) to only the completion whose
237+
// bucket end aligns with a script_tf boundary.
238+
state.publish_gate_tf_seconds = requested_seconds;
225239
}
226240
}
227241
}
@@ -405,7 +419,27 @@ void BacktestEngine::feed_security_eval_state(SecurityEvalState& state, const Ba
405419
if (state.heikinashi) apply_ha(ab.bar, /*commit=*/true);
406420
state.current_bar = ab.bar;
407421
state.eval_complete_count++;
408-
evaluate_security(state.sec_id, ab.bar, true);
422+
// For a plain request.security whose target TF is strictly finer
423+
// than script_tf (publish_gate_tf_seconds > 0), the security's own
424+
// aggregator completes multiple times per calling/script bar.
425+
// Only the completion whose bucket END lands on a script_tf
426+
// boundary is "the last completion of THIS calling bar" — that's
427+
// the one a history-offset read (``expr[1]``) should latch as
428+
// "confirmed as of the previous calling bar" the NEXT time the
429+
// calling script reads it. Suppress ``is_complete`` (so codegen's
430+
// gated hist.push() does not fire) for every other, intermediate
431+
// completion; the underlying TA state keeps advancing regardless
432+
// (compute()/recompute() dispatch is driven by
433+
// current_sub_bar_count, not by this flag) — only the exposed
434+
// history buffer's advance is gated. eval_complete_count/current_bar
435+
// bookkeeping above stays driven by the real completion.
436+
bool publish = true;
437+
if (state.publish_gate_tf_seconds > 0 && script_tf_seconds_ > 0) {
438+
int64_t bucket_end_sec =
439+
ab.bar.timestamp / 1000 + state.publish_gate_tf_seconds;
440+
publish = (bucket_end_sec % script_tf_seconds_) == 0;
441+
}
442+
evaluate_security(state.sec_id, ab.bar, publish);
409443
} else if (state.lookahead_on) {
410444
if (state.heikinashi) apply_ha(ab.bar, /*commit=*/false);
411445
state.current_bar = ab.bar;

0 commit comments

Comments
 (0)