PineForge v0.13.1-379-g9b50973
Deterministic PineScript v6 backtest runtime — C ABI reference
Loading...
Searching...
No Matches
pine_security_eval.cpp
Go to the documentation of this file.
2/*
3 * pine_security_eval.cpp — Pine's request.security evaluator semantics
4 *
5 * The kernel owns the higher-timeframe feed store and its routing: the
6 * evaluator registry, the timeframe aggregator, the native feed store, the
7 * dispatch into evaluate_security(). What TradingView does with a completed
8 * or in-progress bucket -- barmerge.lookahead / barmerge.gaps publication,
9 * ticker.heikinashi substitution, the range-start warmup, the historical
10 * lookahead projection, request.security_lower_tf emulation, the calling
11 * chart bar's publication boundary -- is this source host's, composed here
12 * around the kernel's primitives.
13 */
14
15#include "../engine_internal.hpp"
16
17#include <pineforge/ta.hpp>
19
20#include <algorithm>
21#include <cmath>
22#include <iterator>
23#include <stdexcept>
24
25namespace pineforge {
26
27using namespace internal;
28
29namespace {
30
31// TradingView's request.security_lower_tf accepts only the default merge
32// flags: lookahead_on and gaps_on are refused at validation. This is a rule
33// of the source language, not of the kernel's sub-bar synthesis, so it lives
34// here beside the evaluator that applies it (R5 lane N14: it used to be
35// internal::ensure_supported_lower_tf_emulation_flags in engine_lower_tf.cpp;
36// the message and the refusal are unchanged).
37void ensure_supported_lower_tf_emulation_flags(bool lookahead_on, bool gaps_on) {
38 if (lookahead_on || gaps_on) {
39 throw std::runtime_error(
40 "request.security lower TF emulation only supports lookahead=barmerge.lookahead_off and gaps=barmerge.gaps_off"
41 );
42 }
43}
44
45} // namespace
46
47
48// --- register_security_eval ---
50 int sec_id, const std::string& requested_tf, const std::string& input_tf,
51 bool lookahead_on, bool gaps_on, bool heikinashi) {
52 const std::size_t before = security_eval_states_.size();
53 // Generated configure_security_evaluators() opens with
54 // security_eval_states_.clear(): the first site registered into an empty
55 // registry starts this host's per-site table over with it.
56 if (before == 0) pine_security_states_.clear();
57 BacktestEngine::register_security_eval(sec_id, requested_tf, input_tf);
58 if (security_eval_states_.size() <= before) return;
59 SecurityEvalState& state = security_eval_states_.back();
61 pine = PineSecurityEvalState{};
62 pine.gaps_on = gaps_on;
63 pine.lookahead_on = lookahead_on;
64 pine.heikinashi = heikinashi;
65
66 const std::string& evaluator_input_tf =
67 security_input_tf_.empty() ? input_tf : security_input_tf_;
68 if (evaluator_input_tf.empty()) return;
69 int lower_ratio = 0;
70 int lower_seconds = 0;
71 if (supports_lower_tf_emulation(evaluator_input_tf, requested_tf,
72 &lower_ratio, &lower_seconds)) {
73 // Registration precedes the final input-timeframe validation and
74 // cannot yet distinguish request.security_lower_tf from a plain
75 // request.security evaluator. The latter retains its own
76 // lookahead/gaps contract, so the lower-TF-array restriction is
77 // applied only after that identity is known below.
78 pine.lower_tf_requested = true;
79 pine.lower_tf_emulation = true;
80 pine.lower_tf_ratio = lower_ratio;
81 pine.lower_tf_seconds = lower_seconds;
82 // A lower-timeframe site synthesizes its sub-bars; it has no
83 // aggregator of its own.
84 state.aggregator = TimeframeAggregator();
85 }
86}
87
88
89// --- register_security_lower_tf_eval ---
90// ``request.security_lower_tf`` is a strict subset of ``request.security``:
91// the requested TF must be finer than the chart's input TF and lookahead /
92// gaps are pinned off (TV does not expose them on this builtin). We reuse
93// the existing eval-state plumbing and set ``lower_tf_array_requested``
94// so ``validate_security_timeframes`` can produce a precise diagnostic
95// when the chart's input TF makes lower-TF emulation impossible.
97 int sec_id,
98 const std::string& requested_tf,
99 const std::string& input_tf
100) {
101 auto before = security_eval_states_.size();
102 register_security_eval(sec_id, requested_tf, input_tf, false, false);
103 if (security_eval_states_.size() > before) {
104 pine_security_state(sec_id).lower_tf_array_requested = true;
105 }
106}
107
108
111 return false;
112 }
113 for (const auto& state : security_eval_states_) {
114 if (state.sec_id != sec_id) {
115 continue;
116 }
117 const auto pine = pine_security_states_.find(sec_id);
118 const bool lookahead_on =
119 pine != pine_security_states_.end() && pine->second.lookahead_on;
120 return !lookahead_on || state.current_sub_bar_count <= 1;
121 }
122 return true;
123}
124
125
127 for (const auto& state : security_eval_states_) {
128 if (state.sec_id == sec_id) {
129 return pine_security_state(sec_id).lower_tf_sub_bar_index;
130 }
131 }
132 return 0;
133}
134
135
136void source::PineStrategyHost::prune_pine_security_states() {
137 for (auto it = pine_security_states_.begin(); it != pine_security_states_.end();) {
138 bool registered = false;
139 for (const auto& state : security_eval_states_) {
140 if (state.sec_id == it->first) {
141 registered = true;
142 break;
143 }
144 }
145 it = registered ? std::next(it) : pine_security_states_.erase(it);
146 }
147}
148
149
150// Safe wrapper around tf_to_seconds: returns <=0 on any parse failure
151// (including std::invalid_argument from stoi on garbage like "abc").
152// We use this instead of letting stoi escape so we can attach the
153// offending literal to the diagnostic.
154static int safe_tf_to_seconds(const std::string& tf) {
155 try {
156 return tf_to_seconds(tf);
157 } catch (...) {
158 return 0;
159 }
160}
161
163 if (input_tf.empty()) {
164 if (!security_eval_states_.empty()) {
165 throw std::runtime_error(
166 "request.security cannot infer input timeframe from available input bars; pass input_tf explicitly"
167 );
168 }
169 return;
170 }
171
172 // Note: script_tf >= input_tf is enforced separately in
173 // BacktestEngine::run() (see engine_run.cpp ~line 199), which throws
174 // "script timeframe must be coarser than or equal to input timeframe"
175 // when the script_tf/input_tf ratio is invalid. We do not re-check
176 // that invariant here.
177 int input_seconds = tf_to_seconds(input_tf);
178 int script_seconds = script_tf_seconds_;
179 for (auto& state : security_eval_states_) {
180 PineSecurityEvalState& pine = pine_security_state(state.sec_id);
181 pine.lower_tf_requested = false;
182 pine.lower_tf_emulation = false;
183 pine.lower_tf_ratio = 0;
184 pine.lower_tf_seconds = 0;
185 pine.lower_tf_use_input = false;
187 pine.lower_tf_input_buffer.clear();
190 pine.calling_open_latches_first = false;
191 pine.first_bucket_published = false;
192 pine.deferred_aux.clear();
193 if (state.tf.empty()) continue;
194
195 int lower_ratio = 0;
196 int lower_seconds = 0;
197 bool ltf_supported = supports_lower_tf_emulation(
198 input_tf, state.tf, &lower_ratio, &lower_seconds);
199 if (ltf_supported && pine.lower_tf_array_requested) {
200 // Only request.security_lower_tf may opt into LTF emulation.
201 // Scalar request.security remains a validate-time refusal even
202 // when registration recognized an integer-divisor lower TF.
203 pine.lower_tf_requested = true;
204 ensure_supported_lower_tf_emulation_flags(pine.lookahead_on, pine.gaps_on);
205 pine.lower_tf_emulation = true;
206 pine.lower_tf_ratio = lower_ratio;
207 pine.lower_tf_seconds = lower_seconds;
208 continue;
209 }
210
211 // Parse the requested TF defensively so a garbage literal like
212 // "abc" produces a precise diagnostic instead of an opaque
213 // std::invalid_argument from stoi.
214 int requested_seconds = safe_tf_to_seconds(state.tf);
215 // Calendar month ("M" / "NM") has no fixed second count; tf_to_seconds
216 // returns -1 as a calendar marker (a genuine parse failure returns 0).
217 // Month is always a COARSER HTF, so admit it for request.security and
218 // let the CALENDAR TimeframeAggregator (tf_ratio == -1) aggregate it.
219 // request.security_lower_tf("M") stays invalid — month is never an
220 // intrabar TF. (Weekly/daily already pass: they return positive seconds.)
221 bool is_calendar_month = (requested_seconds == -1 && !pine.lower_tf_array_requested);
222 if (requested_seconds <= 0 && !is_calendar_month) {
223 const char* api = pine.lower_tf_array_requested
224 ? "request.security_lower_tf" : "request.security";
225 throw std::runtime_error(
226 std::string(api) + ": invalid timeframe literal '" + state.tf + "'"
227 );
228 }
229
230 if (!is_calendar_month && requested_seconds < input_seconds) {
231 // Finer than input — only valid for security_lower_tf with
232 // an integer divisor ratio.
233 if (!pine.lower_tf_array_requested) {
234 throw std::runtime_error(
235 "request.security: requested timeframe '" + state.tf
236 + "' is finer than input '" + input_tf
237 + "'. Use request.security_lower_tf for sub-input timeframes."
238 );
239 }
240 // LTF case: must be an exact integer divisor.
241 if (input_seconds % requested_seconds != 0) {
242 throw std::runtime_error(
243 "request.security_lower_tf: requested timeframe '" + state.tf
244 + "' is not an integer divisor of input '" + input_tf
245 + "' (ratio " + std::to_string(
246 static_cast<double>(input_seconds) / requested_seconds)
247 + " is non-integer; chart bars cannot be evenly subdivided)"
248 );
249 }
250 // Defensive: integer-ratio finer LTF should already have been
251 // accepted by supports_lower_tf_emulation above. Reaching
252 // here implies a mismatch between the two checks (e.g. a
253 // non-fixed-intraday TF on one side).
254 throw std::runtime_error(
255 "request.security_lower_tf: internal error - passed integer-ratio check but emulation support returned false (requested '"
256 + state.tf + "', input '" + input_tf + "')"
257 );
258 }
259
260 // requested_seconds >= input_seconds: HTF or same TF. Valid for
261 // request.security; for request.security_lower_tf this is the
262 // input-passthrough path when the requested TF is also strictly
263 // finer than the script TF.
264 if (pine.lower_tf_array_requested) {
265 if (script_seconds <= 0) {
266 throw std::runtime_error(
267 "request.security_lower_tf: script timeframe is unknown — cannot validate '"
268 + state.tf + "' against script TF"
269 );
270 }
271 if (requested_seconds >= script_seconds) {
272 throw std::runtime_error(
273 "request.security_lower_tf: requested timeframe '" + state.tf
274 + "' must be finer than script timeframe '" + script_tf_
275 + "'. Lower-TF API requires a strictly finer timeframe."
276 );
277 }
278 if (script_seconds % requested_seconds != 0) {
279 throw std::runtime_error(
280 "request.security_lower_tf: requested timeframe '" + state.tf
281 + "' must evenly divide script timeframe '"
282 + script_tf_ + "' (script_tf must be an integer multiple of requested TF)"
283 );
284 }
285 if (requested_seconds % input_seconds != 0) {
286 throw std::runtime_error(
287 "request.security_lower_tf: requested timeframe '" + state.tf
288 + "' is not an integer multiple of input '" + input_tf
289 + "' (cannot aggregate raw input bars to requested TF)"
290 );
291 }
292 pine.lower_tf_requested = true;
293 pine.lower_tf_use_input = true;
295 requested_seconds / input_seconds;
296 pine.lower_tf_ratio = script_seconds / requested_seconds;
297 pine.lower_tf_seconds = requested_seconds;
298 } else if (!is_calendar_month && pine.lookahead_on
299 && script_seconds > 0
300 && requested_seconds < script_seconds
301 && script_seconds % requested_seconds == 0) {
302 // Plain request.security with a target TF finer than the
303 // script/chart TF (e.g. so2TF="5" on a 15m chart) and
304 // lookahead=barmerge.lookahead_ON: TradingView merges the
305 // FIRST intrabar of each calling bar, so a history-offset
306 // read (``expr[1]``) must expose the value confirmed as of
307 // the PREVIOUS calling bar's boundary, not whatever native
308 // sub-period last completed inside the CURRENT calling bar.
309 // Gate publication (see feed_security_eval_state) to only
310 // the completion whose bucket end aligns with a script_tf
311 // boundary.
312 //
313 // lookahead_OFF is deliberately EXCLUDED: TV's lookahead_off
314 // merge takes the LAST intrabar of the calling bar, so the
315 // exposed value (and any ``[k]`` offset off it) advances at
316 // the security's own finer cadence — one push per completed
317 // security period, exactly the ungated behavior. Gating it
318 // regressed masayanfx-multi-time-score-strategy
319 // (request.security(sym, "5", ta.highest(high, 20)[1],
320 // barmerge.gaps_off, barmerge.lookahead_off) on a 15m chart)
321 // from 100.0% to 93.7% trade parity vs TradingView, while
322 // the gated lookahead_on case (3commas triple-RSI DCA,
323 // so2Rsi = request.security(sym, "5", ta.rsi(close,7)[1],
324 // lookahead=barmerge.lookahead_on)) needs the latch to hold
325 // 100.0%.
326 pine.publish_gate_tf_seconds = requested_seconds;
327#ifdef PINEFORGE_HAS_AUX_SECURITY_FEED_V1
328 // Served by the auxiliary finer feed (the split-feed path every
329 // @1D lane runs): TradingView reads the calling bar's FIRST
330 // intrabar, so the evaluator publishes every completed bucket
331 // and the chart body runs right after the first one of its
332 // slice (feed_aux_security_for_chart_bar defers the rest) --
333 // see calling_open_latches_first. The gate served the legacy
334 // single-feed loop and stays there.
335 if (aux_security_feed_enabled()) {
337 pine.calling_open_latches_first = true;
338 }
339#endif
340 }
341#ifdef PINEFORGE_HAS_AUX_SECURITY_FEED_V1
342 else if (!is_calendar_month && !pine.lookahead_on
343 && aux_security_feed_enabled()
344 && script_seconds > 0
345 && requested_seconds < script_seconds) {
346 // Plain request.security, lookahead_OFF, target TF strictly
347 // finer than the script/chart TF, served by the auxiliary finer
348 // feed: TV's merge takes the LAST intrabar of the calling bar,
349 // whatever its sub-bar count -- see the field's note. The
350 // aggregator's own completions stay ungated (masayanfx above);
351 // only a bucket still partial on the calling bar's last
352 // auxiliary bar is finalized and published there.
354 }
355#endif
356 }
357}
358
359
360void source::PineStrategyHost::publish_security_eval_state_at_calling_boundary(
361 SecurityEvalState& state) {
362 if (pine_security_state(state.sec_id).publish_gate_tf_seconds <= 0
363 || state.feed_count <= 0) {
364 return;
365 }
366
367 struct ReplayScope {
368 bool& active;
369 bool previous;
370 explicit ReplayScope(bool& flag)
371 : active(flag), previous(flag) { active = true; }
372 ~ReplayScope() { active = previous; }
373 } replay_scope(security_history_publication_replay_);
374
375 // The boundary-triggering input belongs to the next calling bar. Publish
376 // the final requested value that was already evaluated for the completed
377 // caller, before the chart body runs and before that retained input is fed.
378 // security_series_slot_is_new() returns false in this scope, so generated
379 // TA sites recompute the current slot instead of advancing their cadence —
380 // under the same requested-context bar index as the evaluation replayed.
381 dispatch_security_eval(state, state.current_bar, true,
382 state.ta_bar_index >= 0 ? state.ta_bar_index
383 : state.eval_complete_count);
384}
385
386
387bool source::PineStrategyHost::security_input_precedes_range_start(
388 const SecurityEvalState& state, int64_t input_ts) const {
389 const PineSecurityEvalState& pine = pine_security_state(state.sec_id);
390 if (security_range_start_na_warmup_) {
391 // TradingView's deep-backtest request.security series are built from the
392 // HTF bars whose OPEN lies inside the loaded chart range: a bucket that
393 // opened before the range start is not a partial first bar, it is absent.
394 // Keying the cut on the bucket open (session_period_open_ms for D/W/M,
395 // the session-anchored intraday grid otherwise) reproduces that; keying
396 // on the input timestamp would let the pre-range remainder of that bucket
397 // pose as HTF bar 1 and shift every SMA-seeded EMA/RSI/ATR/Stoch by one
398 // bucket (OANDA:EURUSD 1700-1700: the week opening Sun 17:00 ET before
399 // the range start, the month opening Feb 28 17:00 ET before it). With a
400 // range start on the bucket grid — 24x7 UTC midnight for every intraday
401 // TF and D, Monday for W, the 1st for M — this is the timestamp cut.
402 // Lower-TF (passthrough) evaluators keep the timestamp cut exactly.
403#ifdef PINEFORGE_HAS_AUX_SECURITY_FEED_V1
404 // A strictly-coarser-than-chart calendar evaluator whose partition is
405 // the chart's own dailies (a "W" / "M" on a 1D chart: the implicit
406 // native feed of prepare_native_security_feeds, which begins at the
407 // range start) labels the bucket in progress at the epoch by its
408 // first IN-RANGE stamp -- Tue 2025-04-01 for the week that opened Mon
409 // 03-31 -- so the bucket-open cut above keeps it as bar 0 and every
410 // SMA-seeded weekly EMA runs one bar early (round 8, family P:
411 // hungpixi-macd-enhanced-mtf on BINANCE:BTCUSDT 1D under the ladder's
412 // range-start-na-warmup candidate, lab tv famp-sense-hp-btc1d
413 // 2026-09-05: TradingView's weekly EMA26 first reads on the 09-29
414 // week and its signal EMA9 on the 11-24 week -- weekly bar 0 is the
415 // 04-07 week -- while the engine's read one week earlier; on the
416 // 11-24 week TradingView's hist[1] is still na (score -6) where the
417 // engine's is numeric (score -10), and the 11-25 reversal waited for
418 // 11-28). TradingView's rule is the default path's: the bucket is
419 // absent iff its NOMINAL open precedes the epoch and the symbol
420 // traded between the two (the auxiliary 1m feed is the evidence, so
421 // the NSE week whose holiday Monday never traded stays kept). The
422 // chart's own timeframe keeps the chart series' first bar, and a
423 // native partition with pre-range history (the 15m lanes' explicit
424 // daily feed) already opened that bucket before the epoch, so the
425 // rule below agrees with the cut above wherever both apply.
426 if (aux_security_feed_enabled() && !pine.lower_tf_requested
427 && !pine.lower_tf_array_requested) {
428 const CalendarPeriod period = calendar_period_for(state.tf);
429 const CalendarPeriod chart_period = calendar_period_for(script_tf_);
430 const bool strictly_coarser = period != CalendarPeriod::NONE
431 && static_cast<int>(period) > static_cast<int>(chart_period);
432 if (strictly_coarser) {
433 const int64_t nominal_open = session_period_open_ms(
434 input_ts, syminfo_.timezone, syminfo_.session, period);
435 if (nominal_open < security_range_start_ms_
436 && aux_security_traded_between(nominal_open,
437 security_range_start_ms_)) {
438 return true;
439 }
440 }
441 }
442#endif
443 return state.aggregator.bucket_open_ms(input_ts) < security_range_start_ms_;
444 }
445#ifdef PINEFORGE_HAS_AUX_SECURITY_FEED_V1
446 // R19 OTC daily pins: a historical intraday chart beginning mid-session
447 // has no partial first D bar in request.security, even when this run has
448 // only the chart feed. XAUUSD from Apr1 00:00Z reads D time/close as na
449 // that day and ATR14[1] first becomes numeric Apr23; counting the partial
450 // Mar31 session seeds ATR a day early. Restrict the no-aux inference to
451 // the pinned daily OTC clock. W/M need trading-history evidence to retain
452 // a holiday-open period, and native/aux feeds keep their existing rules.
453 if (!aux_security_feed_enabled() && native_security_feeds_.empty()
454 && security_first_chart_bar_ms_ > 0
455 && script_tf_seconds_ > 0 && script_tf_seconds_ < 86400
456 && (state.tf == "D" || state.tf == "1D")
457 && !pine.lower_tf_requested && !pine.lower_tf_array_requested
458 && (syminfo_.type == "forex" || syminfo_.type == "cfd")
459 && !stream_warmup_mode_ && stream_phase_ == StreamPhase::IDLE) {
460 const int64_t stamp = session_period_open_ms(
461 input_ts, syminfo_.timezone, syminfo_.session, CalendarPeriod::DAY);
462 // Metals stamp the D bar at 17:00 ET but first trade at 18:00.
463 // Starting at that actual open keeps the complete first session.
464 const int64_t trading_open = session_covered_instant_ms(
465 stamp, syminfo_.timezone, syminfo_.session);
466 return trading_open < security_first_chart_bar_ms_;
467 }
468 // Default cut (round 8, family P), split-feed runs only: a coarser-than-
469 // chart or chart-timeframe series starts at the first bucket that OPENS at
470 // or after the run's first chart bar -- the deep-backtest range start --
471 // and the bucket in progress at that instant is absent, exactly as
472 // TradingView's is (security_first_chart_bar_ms_). "In progress" is a
473 // fact about trading, not the calendar: TradingView dates a bar by its
474 // first traded bar, so the NSE week whose Monday 2025-03-31 was a holiday
475 // opens on Tuesday 04-01 -- the range start -- and is kept (famp-sense-
476 // nifty15full: "W" first reads 08-22), while the NYSE week that traded
477 // Monday 03-31 (F: 08-29), the CME trade date that opened Mon 22:00Z
478 // (ES/NQ "D": 05-01) and the OANDA week that opened Sun 21:00Z (XAUUSD
479 // 1D "W": 08-28) are absent. The auxiliary 1m feed holds the pre-range
480 // bars (the lanes' finer feeds begin in 2021), so the bucket whose
481 // nominal open precedes the first chart bar is dropped iff the feed
482 // traded between that open and the first chart bar. A finer-than-chart
483 // evaluator is never cut (its slice begins at the first chart bar), and
484 // a single-feed run has no evidence and keeps its feed-start series.
485 if (security_first_chart_bar_ms_ <= 0 || !aux_security_feed_enabled()
486 || pine.lower_tf_requested || pine.lower_tf_array_requested
487 || script_tf_seconds_ <= 0) {
488 return false;
489 }
490 const int requested_seconds = safe_tf_to_seconds(state.tf);
491 const bool coarser_or_chart = requested_seconds == -1
492 || requested_seconds >= script_tf_seconds_;
493 if (!coarser_or_chart) {
494 return false;
495 }
496 const CalendarPeriod period = calendar_period_for(state.tf);
497 const int64_t nominal_open = period != CalendarPeriod::NONE
498 ? session_period_open_ms(input_ts, syminfo_.timezone, syminfo_.session,
499 period)
500 : session_intraday_bucket_open_ms(input_ts, requested_seconds,
501 syminfo_.timezone, syminfo_.session);
502 if (nominal_open >= security_first_chart_bar_ms_) {
503 return false;
504 }
505 return aux_security_traded_between(nominal_open, security_first_chart_bar_ms_);
506#else
507 return false;
508#endif
509}
510
511
512#ifdef PINEFORGE_HAS_AUX_SECURITY_FEED_V1
513bool source::PineStrategyHost::aux_security_traded_between(int64_t from_ms,
514 int64_t to_ms) const {
515 const Bar* bars = nullptr;
516 int n = 0;
517 source_aux_security_input_view(bars, n);
518 if (bars == nullptr || n <= 0) return false;
519 auto it = std::lower_bound(
520 bars, bars + n, from_ms,
521 [](const Bar& bar, int64_t ts) { return bar.timestamp < ts; });
522 return it != bars + n && it->timestamp < to_ms;
523}
524#endif
525
526
527void source::PineStrategyHost::pine_feed_security_eval_state(
528 SecurityEvalState& state, const Bar& input_bar,
529 bool calling_bar_complete) {
530 // Opt-in KI-55 HTF warmup parity (security_range_start_na_warmup run flag):
531 // (a) start every request.security aggregation at range_start_ms, not the
532 // feed start — drop every input bar whose HTF bucket opened before
533 // the range start (security_input_precedes_range_start) so the
534 // aggregator, its TA members, and the exposed history all begin at
535 // the first WHOLE bucket opening at/after the range start;
536 // (b) its embedded lookback ta.ema na-warms per TV built-in semantics —
537 // scoped by raising ta::ema_na_warmup_flag() for the duration of this
538 // call, which covers every evaluate_security() dispatch below (each of
539 // which is the only place the security's EMA members compute());
540 // (c) plain security expressions (e.g. `close`) read na until the first
541 // COMPLETED HTF bar from the range start — a consequence of (a): under
542 // lookahead_off no evaluate_security() fires until that first whole
543 // bucket completes; a bucket straddling the range start never counts.
544 // All three collapse to no-ops when the flag is unset (byte-identical).
545 if (security_input_precedes_range_start(state, input_bar.timestamp)) {
546 return;
547 }
548 PineSecurityEvalState& pine = pine_security_state(state.sec_id);
549 struct SecurityNaWarmupScope {
550 bool prev_;
551 explicit SecurityNaWarmupScope(bool on)
552 : prev_(ta::ema_na_warmup_flag()) { ta::ema_na_warmup_flag() = on; }
553 ~SecurityNaWarmupScope() { ta::ema_na_warmup_flag() = prev_; }
554 } _na_warmup_scope(security_range_start_na_warmup_);
555
556 // Heikin-Ashi same-symbol read: replace an aggregated bar's OHLC with its
557 // HA candle before evaluating the security expression. The completed
558 // state advances once per committed HTF bucket; a partial/projection peek
559 // derives from the prior state without committing it.
560 auto apply_ha = [&pine](Bar& b, bool commit) {
561 double ha_close = (b.open + b.high + b.low + b.close) / 4.0;
562 double ha_open = pine.ha_seeded
563 ? (pine.ha_prev_open + pine.ha_prev_close) / 2.0
564 : (b.open + b.close) / 2.0;
565 double ha_high = std::max(b.high, std::max(ha_open, ha_close));
566 double ha_low = std::min(b.low, std::min(ha_open, ha_close));
567 b.open = ha_open;
568 b.high = ha_high;
569 b.low = ha_low;
570 b.close = ha_close;
571 if (commit) {
572 pine.ha_prev_open = ha_open;
573 pine.ha_prev_close = ha_close;
574 pine.ha_seeded = true;
575 }
576 };
577
578 if (pine.lower_tf_use_input) {
579 // Buffer raw input bars until we accumulate one full script-TF
580 // chunk, then aggregate (if req > input) and dispatch each
581 // resulting LTF bar to the codegen via evaluate_security. The
582 // codegen detects ``lower_tf_sub_bar_index == 0`` to clear its
583 // accumulator vector and pushes once per dispatch.
584 //
585 // Bucket-aware dispatch (mirrors feed_ratio_mode in
586 // src/timeframe.cpp:270): when the incoming bar belongs to a
587 // different wall-clock script-TF bucket than the buffered
588 // window, we MUST flush the buffer (even if partial) BEFORE
589 // pushing — otherwise a feed gap, warmup misalignment, or
590 // sparse-data run leaks bars across the chart-bar boundary
591 // (e.g. a 16-bar window dispatched per 15m chart bar instead
592 // of 15). Pure count-based dispatch is preserved as a
593 // secondary trigger so dense gap-free feeds still flush
594 // promptly when the chunk fills.
595 int input_seconds = tf_to_seconds(security_input_tf_);
596 int script_seconds = script_tf_seconds_;
597 if (input_seconds <= 0 || script_seconds <= 0) {
598 // Cannot compute bucket math — fall back to original
599 // count-only behaviour.
600 pine.lower_tf_input_buffer.push_back(input_bar);
601 return;
602 }
603 int chunk_size = script_seconds / input_seconds;
604 if (chunk_size <= 0) {
605 pine.lower_tf_input_buffer.push_back(input_bar);
606 return;
607 }
608 // The buffer fills to at most chunk_size input bars before it is
609 // dispatched and cleared. Reserve once (no-op when capacity already
610 // suffices) so the repeated fill/clear cycle reuses one allocation.
611 pine.lower_tf_input_buffer.reserve(static_cast<std::size_t>(chunk_size));
612 int64_t bucket_ms = static_cast<int64_t>(script_seconds) * 1000;
613 int64_t this_bucket = input_bar.timestamp / bucket_ms;
614 bool boundary_crossed = false;
615 if (!pine.lower_tf_input_buffer.empty()) {
616 int64_t buffer_bucket =
617 pine.lower_tf_input_buffer.front().timestamp / bucket_ms;
618 if (this_bucket != buffer_bucket) {
619 boundary_crossed = true;
620 }
621 }
622
623 // Lambda: aggregate + dispatch the current buffer (length may
624 // be < chunk_size on a boundary-triggered partial flush) and
625 // clear it. Uses the same agg_ratio rollup as before but is
626 // length-driven by the actual buffer size rather than
627 // chunk_size, so partial windows don't index out of bounds.
628 auto dispatch_and_clear = [&]() {
629 int agg_ratio = pine.lower_tf_input_aggregation_ratio;
630 if (agg_ratio < 1) agg_ratio = 1;
631 int buf_len = static_cast<int>(pine.lower_tf_input_buffer.size());
632 std::vector<Bar> ltf_bars;
633 ltf_bars.reserve(static_cast<std::size_t>(buf_len / agg_ratio + 1));
634 if (agg_ratio == 1) {
635 for (const Bar& b : pine.lower_tf_input_buffer) {
636 ltf_bars.push_back(b);
637 }
638 } else {
639 for (int i = 0; i + agg_ratio <= buf_len; i += agg_ratio) {
640 Bar acc = pine.lower_tf_input_buffer[
641 static_cast<std::size_t>(i)];
642 double vol = acc.volume;
643 for (int j = 1; j < agg_ratio; ++j) {
644 const Bar& nxt = pine.lower_tf_input_buffer[
645 static_cast<std::size_t>(i + j)];
646 if (nxt.high > acc.high) acc.high = nxt.high;
647 if (nxt.low < acc.low) acc.low = nxt.low;
648 acc.close = nxt.close;
649 vol += nxt.volume;
650 }
651 acc.volume = vol;
652 ltf_bars.push_back(acc);
653 }
654 }
655 pine.lower_tf_sub_bar_index = 0;
656 for (const Bar& b : ltf_bars) {
657 state.feed_count++;
658 state.current_bar = b;
659 state.current_sub_bar_count = 1;
660 state.eval_complete_count++;
661 dispatch_security_eval(state, b, true,
662 state.eval_complete_count - 1);
663 pine.lower_tf_sub_bar_index++;
664 }
665 pine.lower_tf_input_buffer.clear();
666 };
667
668 if (boundary_crossed) {
669 // Flush stale bucket BEFORE pushing the new bar so the
670 // new bar starts a fresh window aligned to its own bucket.
671 dispatch_and_clear();
672 }
673
674 pine.lower_tf_input_buffer.push_back(input_bar);
675
676 // Secondary trigger: if the buffer happens to fill to
677 // chunk_size mid-bucket (the dense gap-free case), flush
678 // immediately. This preserves the original count-based
679 // behaviour for the common path.
680 if (static_cast<int>(pine.lower_tf_input_buffer.size()) >= chunk_size) {
681 dispatch_and_clear();
682 }
683 return;
684 }
685 if (pine.lower_tf_emulation) {
686 std::vector<Bar> synthetic_bars =
687 synthesize_lower_tf_bars(input_bar, pine.lower_tf_ratio, pine.lower_tf_seconds);
688 if (synthetic_bars.empty()) {
689 throw std::runtime_error(
690 "request.security lower TF emulation could not synthesize bars for requested "
691 + state.tf + " from input timeframe " + security_input_tf_
692 );
693 }
694 // Reset the sub-bar counter at the start of every chart bar's
695 // synthesis so a ``request.security_lower_tf`` codegen path can
696 // detect index 0 and clear its accumulator vector before pushing
697 // each per-sub-bar value. The counter is incremented after every
698 // dispatch so callers see 0, 1, ..., ratio-1 in sequence.
699 pine.lower_tf_sub_bar_index = 0;
700 for (const auto& synthetic_bar : synthetic_bars) {
701 state.feed_count++;
702 state.current_bar = synthetic_bar;
703 state.current_sub_bar_count = 1;
704 state.eval_complete_count++;
705 dispatch_security_eval(state, synthetic_bar, true,
706 state.eval_complete_count - 1);
707 pine.lower_tf_sub_bar_index++;
708 }
709 return;
710 }
711
712 if (historical_security_lookahead_projection_active_
713 && !pine.historical_projections.empty()) {
714 // Keyed by the input's instant, not by a feed-call index: on the
715 // split-feed path this evaluator is fed the finer auxiliary slice
716 // (hundreds of inputs per chart bar), and the projection of a bucket
717 // is dispatched on the first input at or after its first retained
718 // child (that child's own first auxiliary bar) -- exactly the chart
719 // bar TradingView's lookahead_on leaks the bucket's FINAL values
720 // from. On the single-feed path the first input at or after the
721 // child's timestamp is that child itself, as the index cut was.
722 const int64_t input_ms = input_bar.timestamp;
723 while (pine.historical_projection_cursor + 1
724 < pine.historical_projections.size()
725 && pine.historical_projections[
726 pine.historical_projection_cursor + 1]
727 .first_child_ms <= input_ms) {
728 ++pine.historical_projection_cursor;
729 pine.historical_projection_dispatched = false;
730 }
731 const auto& projection = pine.historical_projections[
732 pine.historical_projection_cursor];
733 state.feed_count++;
734 if (pine.historical_projection_dispatched
735 || input_ms < projection.first_child_ms) {
736 // gaps_off holds the first-child projection unchanged until the
737 // next HTF bucket. No evaluator call means TA/security histories
738 // also advance exactly once per projected bucket.
739 return;
740 }
741 pine.historical_projection_dispatched = true;
742
743 Bar projected_bar = projection.bar;
744 // A projected bucket is the exchange's bar wherever a native feed
745 // serves this timeframe -- complete or not. TradingView's
746 // lookahead_on leaks the period's FINAL values from its first chart
747 // bar, so the trailing period still in progress at the range end
748 // carries the whole native period whenever the feed holds it (lab tv
749 // wm-m-f15-jul, 2026-09-05: August's final o/h/l/c 10.92/11.99/
750 // 10.68/11.77 from 08-01 09:30 on a chart ending 08-08). A partial
751 // with no native bar keeps the available aggregate, uncounted as a
752 // miss.
753 substitute_native_security_bar(state, projected_bar,
754 /*count_miss=*/projection.is_complete);
755 if (pine.heikinashi) {
756 apply_ha(projected_bar, projection.is_complete);
757 }
758 state.current_bar = projected_bar;
759 // The projected HTF bucket is introduced on its first chart child, so
760 // generated security series must allocate a fresh history/TA slot even
761 // though projected_bar itself already contains every available child.
762 state.current_sub_bar_count = 1;
763 if (projection.is_complete) {
764 state.eval_complete_count++;
765 } else {
766 state.eval_partial_count++;
767 }
768 dispatch_security_eval(state, projected_bar, projection.is_complete,
769 projection.is_complete
770 ? state.eval_complete_count - 1
771 : state.eval_complete_count);
772 return;
773 }
774
775 // The next input bar's timestamp (0 when unknown) lets a calendar
776 // bucket complete on the period's actual last chart bar -- see
777 // security_next_input_ms_ -- and the calling chart bar's nominal close
778 // (split-feed path, else 0) lets an OTC bucket do so exactly when that
779 // close reaches the period's -- see security_calling_close_ms_.
780 AggregatedBar ab = state.aggregator.feed(input_bar, security_next_input_ms_,
781 security_calling_close_ms_);
782 state.feed_count++;
783 state.current_sub_bar_count = ab.sub_bar_count;
784 if (ab.is_complete) {
785 // The aggregator decided WHEN the bucket completes; a native feed for
786 // this timeframe decides WHAT it closed at (the settlement / official
787 // print), before any Heikin-Ashi derivation. Partial (lookahead_on)
788 // peeks keep the running aggregate.
789 substitute_native_security_bar(state, ab.bar);
790 if (pine.heikinashi) apply_ha(ab.bar, /*commit=*/true);
791 state.current_bar = ab.bar;
792 state.eval_complete_count++;
793 // For a plain request.security whose target TF is strictly finer
794 // than script_tf (publish_gate_tf_seconds > 0), the security's own
795 // aggregator completes multiple times per calling/script bar.
796 // Only the completion whose bucket END lands on a script_tf
797 // boundary is "the last completion of THIS calling bar" — that's
798 // the one a history-offset read (``expr[1]``) should latch as
799 // "confirmed as of the previous calling bar" the NEXT time the
800 // calling script reads it. Suppress ``is_complete`` (so codegen's
801 // gated hist.push() does not fire) for every other, intermediate
802 // completion; the underlying TA state keeps advancing regardless
803 // (compute()/recompute() dispatch is driven by
804 // current_sub_bar_count, not by this flag) — only the exposed
805 // history buffer's advance is gated. eval_complete_count/current_bar
806 // bookkeeping above stays driven by the real completion.
807 bool publish = true;
808 if (pine.publish_gate_tf_seconds > 0 && script_tf_seconds_ > 0) {
809 // Merge finer-context history on the event that the calling chart
810 // aggregator actually completes. Unlike a fixed seconds modulus,
811 // this includes session-clipped calling bars.
812 // The requested-context evaluator still runs once
813 // per input update; only its is_complete publication signal is
814 // replaced.
815 publish = calling_bar_complete;
816 }
817 // A boundary emission: the input bar opened a NEW bucket and the
818 // aggregator emitted the previous one, still partial (a singleton the
819 // count / real-end / session-close rules never reached: OANDA:XAUUSD
820 // Thanksgiving's 21:54 bucket holding the 21:56 minute, emitted when
821 // 21:59 opens the 21:57 bucket). Under lookahead_on that bucket's
822 // history slot was already opened by its first sub-bar's partial
823 // peek (compute), so the completion must REWRITE it (recompute) --
824 // a fresh compute here committed a phantom copy of the bucket and
825 // shifted every later requested value (the RSI diverged from the
826 // lookahead_off twin's on the same buckets). And the input bar that
827 // opened the new bucket got no peek of its own in this feed, so open
828 // its slot now, exactly as the merge branch below does for a first
829 // sub-bar. Count, real-end and session-close completions carry the
830 // input bar's own bucket (the label matches) and are untouched, as
831 // is lookahead_off (no peeks: every completion is a new slot).
832 // The emitted bucket is a boundary emission exactly when it is not
833 // the aggregator's CURRENT bucket: a boundary completion hands back
834 // the previous bucket and re-seats the aggregator on the one the
835 // input opened (feed_calendar_mode / feed_ratio_mode), while every
836 // eager completion (count, real end, session close, the calendar
837 // period's last input) leaves the completed bucket current. The
838 // former test compared the label against bar_label_ms(input), which
839 // is the intraday grid open for a fixed-TF bucket but the DAY stamp
840 // for a calendar W / M bucket -- so every weekly completion on its
841 // last daily bar looked like a boundary and committed a phantom copy
842 // of the week as one more requested bar (round 7, family I: the
843 // hungpixi weekly f_count carry decayed twice per week, hist ties
844 // compared the week with its own copy).
845 const bool boundary_emission = pine.lookahead_on
846 && state.aggregator.is_active()
847 && ab.bar.timestamp != state.aggregator.current().timestamp;
848 if (boundary_emission && state.current_sub_bar_count < 2) {
849 state.current_sub_bar_count = 2;
850 }
851 pine.last_published_label = ab.bar.timestamp;
852 dispatch_security_eval(state, ab.bar, publish,
853 state.eval_complete_count - 1);
854 if (boundary_emission) {
855 Bar fresh = state.aggregator.current();
856 if (pine.heikinashi) apply_ha(fresh, /*commit=*/false);
857 state.current_bar = fresh;
858 state.current_sub_bar_count = 1;
859 state.eval_partial_count++;
860 const bool peek_publish = pine.publish_gate_tf_seconds > 0
861 && calling_bar_complete;
862 dispatch_security_eval(state, fresh, peek_publish,
863 state.eval_complete_count);
864 }
865 } else if (pine.lookahead_on) {
866 if (pine.heikinashi) apply_ha(ab.bar, /*commit=*/false);
867 state.current_bar = ab.bar;
868 state.eval_partial_count++;
869 // A shortened calling bar can complete while the finer requested
870 // bucket is partial. Publish that current requested-context value to
871 // merged history without adding a second evaluator/TA dispatch.
872 const bool publish = pine.publish_gate_tf_seconds > 0
873 && calling_bar_complete;
874 // Partial (in-progress) bucket: the index the completion will carry.
875 dispatch_security_eval(state, ab.bar, publish,
876 state.eval_complete_count);
877 } else {
878 state.current_bar = ab.bar;
879 if (pine.gaps_on) {
880 clear_security(state.sec_id);
881 }
882 }
883
884 // The calling chart bar's last auxiliary bar left the finer bucket it
885 // opened (or merged into) partial: its count, real end and session
886 // close all lie beyond the chart bar's last sub-bar (the Thanksgiving
887 // 21:57 3m bucket holding the 21:59 minute alone; the 2-minute 20:57
888 // bucket of a day whose 20:59 minute did not trade). TradingView reads
889 // that bucket at the chart bar's close (lab tv dca-ltf-last-intrabar,
890 // 2026-09-05: 72.64, the 21:59 minute's RSI, where the previous bucket
891 // reads 38.87), so finalize it now, exactly as a count / real-end /
892 // session-close completion would have, and publish it as one more
893 // completed requested-context bar. The aggregator marks it emitted:
894 // the next chart bar's first sub-bar starts a fresh bucket without
895 // re-emitting this one, and a later sub-bar of the same bucket (not a
896 // completed chart bar's, but guarded) merges without completing it
897 // again. A dense feed whose final bucket completed on its count has no
898 // pending partial and is untouched (calling_close_completes_partial).
899 if (calling_bar_complete && pine.calling_close_completes_partial
900 && state.aggregator.has_pending_partial()) {
901 AggregatedBar tail = state.aggregator.complete_pending_partial();
902 if (tail.is_complete) {
903 state.current_sub_bar_count = tail.sub_bar_count;
904 substitute_native_security_bar(state, tail.bar);
905 if (pine.heikinashi) apply_ha(tail.bar, /*commit=*/true);
906 state.current_bar = tail.bar;
907 state.eval_complete_count++;
908 pine.last_published_label = tail.bar.timestamp;
909 dispatch_security_eval(state, tail.bar, true,
910 state.eval_complete_count - 1);
911 }
912 }
913}
914
915} // namespace pineforge
void validate_security_timeframes(const std::string &input_tf)
void register_security_eval(int sec_id, const std::string &requested_tf, const std::string &input_tf, bool lookahead_on, bool gaps_on=false, bool heikinashi=false)
int security_lower_tf_sub_bar_index(int sec_id) const
void register_security_lower_tf_eval(int sec_id, const std::string &requested_tf, const std::string &input_tf)
bool security_series_slot_is_new(int) const noexcept
std::map< int, PineSecurityEvalState > pine_security_states_
int b(int64_t c)
Definition color.hpp:30
bool & ema_na_warmup_flag()
int64_t session_covered_instant_ms(int64_t ms, const std::string &tz, const std::string &session)
The session instant a native CALENDAR chart stamp covers.
int tf_to_seconds(const std::string &tf)
Convert a TradingView timeframe string to seconds.
CalendarPeriod calendar_period_for(const std::string &tf)
Determine the calendar period for a target TF string.
int64_t session_intraday_bucket_open_ms(int64_t ms, int64_t bucket_sec, const std::string &tz, const std::string &session)
Open (Unix ms) of the bucket_sec-wide intraday bucket that contains ms on the symbol's day-stamp-anch...
static int safe_tf_to_seconds(const std::string &tf)
int64_t session_period_open_ms(int64_t ms, const std::string &tz, const std::string &session, CalendarPeriod period)
Open (Unix ms) of the symbol's D/W/M bar that contains ms: the day stamp of the period's first sessio...