PineForge unreleased
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#include "../native_execution_consumer.hpp"
17#include "pine_run_failure.hpp"
18
19#include <pineforge/ta.hpp>
21
22#include <algorithm>
23#include <cmath>
24#include <iterator>
25#include <limits>
26#include <stdexcept>
27#include <string>
28#include <utility>
29
30namespace pineforge {
31
32using namespace internal;
33
34namespace {
35
36// TradingView's request.security_lower_tf accepts only the default merge
37// flags: lookahead_on and gaps_on are refused at validation. This is a rule
38// of the source language, not of the kernel's sub-bar synthesis, so it lives
39// here beside the evaluator that applies it (R5 lane N14: it used to be
40// internal::ensure_supported_lower_tf_emulation_flags in engine_lower_tf.cpp;
41// the message and the refusal are unchanged).
42void ensure_supported_lower_tf_emulation_flags(bool lookahead_on, bool gaps_on) {
43 if (lookahead_on || gaps_on) {
45 RunFailureCode::request_unsupported, {{"reason", "lower_tf_lookahead_or_gaps"}},
46 "request.security lower TF emulation only supports lookahead=barmerge.lookahead_off and gaps=barmerge.gaps_off"
47 );
48 }
49}
50
51} // namespace
52
53
54// --- register_security_eval ---
56 int sec_id, const std::string& requested_tf, const std::string& input_tf,
57 bool lookahead_on, bool gaps_on, bool heikinashi) {
58 const std::size_t before = security_eval_states_.size();
59 // Generated configure_security_evaluators() opens with
60 // security_eval_states_.clear(): the first site registered into an empty
61 // registry starts this host's per-site table over with it.
62 if (before == 0) pine_security_states_.clear();
63 BacktestEngine::register_security_eval(sec_id, requested_tf, input_tf);
64 if (security_eval_states_.size() <= before) return;
65 SecurityEvalState& state = security_eval_states_.back();
67 pine = PineSecurityEvalState{};
68 pine.gaps_on = gaps_on;
69 pine.lookahead_on = lookahead_on;
70 pine.heikinashi = heikinashi;
71
72 const std::string& evaluator_input_tf =
73 security_input_tf_.empty() ? input_tf : security_input_tf_;
74 if (evaluator_input_tf.empty()) return;
75 int lower_ratio = 0;
76 int lower_seconds = 0;
77 if (supports_lower_tf_emulation(evaluator_input_tf, requested_tf,
78 &lower_ratio, &lower_seconds)) {
79 // Registration precedes the final input-timeframe validation and
80 // cannot yet distinguish request.security_lower_tf from a plain
81 // request.security evaluator. The latter retains its own
82 // lookahead/gaps contract, so the lower-TF-array restriction is
83 // applied only after that identity is known below.
84 pine.lower_tf_requested = true;
85 pine.lower_tf_emulation = true;
86 pine.lower_tf_ratio = lower_ratio;
87 pine.lower_tf_seconds = lower_seconds;
88 // A lower-timeframe site synthesizes its sub-bars; it has no
89 // aggregator of its own.
90 state.aggregator = TimeframeAggregator();
91 }
92}
93
94
95// --- register_security_lower_tf_eval ---
96// ``request.security_lower_tf`` is a strict subset of ``request.security``:
97// the requested TF must be finer than the chart's input TF and lookahead /
98// gaps are pinned off (TV does not expose them on this builtin). We reuse
99// the existing eval-state plumbing and set ``lower_tf_array_requested``
100// so ``validate_security_timeframes`` can produce a precise diagnostic
101// when the chart's input TF makes lower-TF emulation impossible.
103 int sec_id,
104 const std::string& requested_tf,
105 const std::string& input_tf
106) {
107 auto before = security_eval_states_.size();
108 register_security_eval(sec_id, requested_tf, input_tf, false, false);
109 if (security_eval_states_.size() > before) {
110 pine_security_state(sec_id).lower_tf_array_requested = true;
111 }
112}
113
114
117 return false;
118 }
119 for (const auto& state : security_eval_states_) {
120 if (state.sec_id != sec_id) {
121 continue;
122 }
123 const auto pine = pine_security_states_.find(sec_id);
124 const bool lookahead_on =
125 pine != pine_security_states_.end() && pine->second.lookahead_on;
126 return !lookahead_on || state.current_sub_bar_count <= 1;
127 }
128 return true;
129}
130
131
133 for (const auto& state : security_eval_states_) {
134 if (state.sec_id == sec_id) {
135 return pine_security_state(sec_id).lower_tf_sub_bar_index;
136 }
137 }
138 return 0;
139}
140
141
142void source::PineStrategyHost::prune_pine_security_states() {
143 for (auto it = pine_security_states_.begin(); it != pine_security_states_.end();) {
144 bool registered = false;
145 for (const auto& state : security_eval_states_) {
146 if (state.sec_id == it->first) {
147 registered = true;
148 break;
149 }
150 }
151 it = registered ? std::next(it) : pine_security_states_.erase(it);
152 }
153}
154
155
156// Safe wrapper around tf_to_seconds: returns <=0 on any parse failure
157// (including std::invalid_argument from stoi on garbage like "abc").
158// We use this instead of letting stoi escape so we can attach the
159// offending literal to the diagnostic.
160static int safe_tf_to_seconds(const std::string& tf) {
161 try {
162 return tf_to_seconds(tf);
163 } catch (...) {
164 return 0;
165 }
166}
167
169 if (input_tf.empty()) {
170 if (!security_eval_states_.empty()) {
172 RunFailureCode::run_options_rejected, {{"option", "input_timeframe"}},
173 "request.security cannot infer input timeframe from available input bars; pass input_tf explicitly"
174 );
175 }
176 return;
177 }
178
179 // Note: script_tf >= input_tf is enforced separately in
180 // BacktestEngine::run() (see engine_run.cpp ~line 199), which throws
181 // "script timeframe must be coarser than or equal to input timeframe"
182 // when the script_tf/input_tf ratio is invalid. We do not re-check
183 // that invariant here.
184 int input_seconds = tf_to_seconds(input_tf);
185 int script_seconds = script_tf_seconds_;
186 for (auto& state : security_eval_states_) {
187 PineSecurityEvalState& pine = pine_security_state(state.sec_id);
188 pine.lower_tf_requested = false;
189 pine.lower_tf_emulation = false;
190 pine.lower_tf_ratio = 0;
191 pine.lower_tf_seconds = 0;
192 pine.lower_tf_use_input = false;
194 pine.lower_tf_input_buffer.clear();
197 pine.no_loaded_bars = false;
198 pine.calling_open_latches_first = false;
199 pine.first_bucket_published = false;
200 pine.deferred_aux.clear();
201 if (state.tf.empty()) continue;
202
203 int lower_ratio = 0;
204 int lower_seconds = 0;
205 bool ltf_supported = supports_lower_tf_emulation(
206 input_tf, state.tf, &lower_ratio, &lower_seconds);
207 if (ltf_supported && pine.lower_tf_array_requested) {
208 // Only request.security_lower_tf may opt into LTF emulation.
209 // Scalar request.security remains a validate-time refusal even
210 // when registration recognized an integer-divisor lower TF.
211 pine.lower_tf_requested = true;
212 ensure_supported_lower_tf_emulation_flags(pine.lookahead_on, pine.gaps_on);
213 pine.lower_tf_emulation = true;
214 pine.lower_tf_ratio = lower_ratio;
215 pine.lower_tf_seconds = lower_seconds;
216 continue;
217 }
218
219 // Parse the requested TF defensively so a garbage literal like
220 // "abc" produces a precise diagnostic instead of an opaque
221 // std::invalid_argument from stoi.
222 int requested_seconds = safe_tf_to_seconds(state.tf);
223 // Calendar month ("M" / "NM") has no fixed second count; tf_to_seconds
224 // returns -1 as a calendar marker (a genuine parse failure returns 0).
225 // Month is always a COARSER HTF, so admit it for request.security and
226 // let the CALENDAR TimeframeAggregator (tf_ratio == -1) aggregate it.
227 // request.security_lower_tf("M") stays invalid — month is never an
228 // intrabar TF. (Weekly/daily already pass: they return positive seconds.)
229 bool is_calendar_month = (requested_seconds == -1 && !pine.lower_tf_array_requested);
230 if (requested_seconds <= 0 && !is_calendar_month) {
231 const char* api = pine.lower_tf_array_requested
232 ? "request.security_lower_tf" : "request.security";
235 std::string(api) + ": invalid timeframe literal '" + state.tf + "'"
236 );
237 }
238
239 if (!is_calendar_month && requested_seconds < input_seconds) {
240 // Finer than input — only valid for security_lower_tf with
241 // an integer divisor ratio.
242 if (!pine.lower_tf_array_requested) {
243 // The host already supplied bars finer than the chart (the
244 // auxiliary slice, or an input finer than the script TF), so
245 // nothing finer is loaded: the site reads na on every chart
246 // bar, TradingView's reading of a chart bar holding no loaded
247 // bar of the requested timeframe (see no_loaded_bars). A chart
248 // fed without intrabar bars keeps the refusal below, which
249 // asks its host for finer bars.
250 const bool intrabar_bars_supplied = input_seconds > 0
251 && (script_seconds == -1 || input_seconds < script_seconds);
252 if (intrabar_bars_supplied) {
253 pine.no_loaded_bars = true;
254 continue;
255 }
258 {{"reason", "finer_than_input"}, {"input_tf", input_tf}},
259 "request.security: requested timeframe '" + state.tf
260 + "' is finer than input '" + input_tf
261 + "'. Use request.security_lower_tf for sub-input timeframes."
262 );
263 }
264 // LTF case: must be an exact integer divisor.
265 if (input_seconds % requested_seconds != 0) {
268 {{"reason", "lower_tf_not_divisor_of_input"}, {"input_tf", input_tf}},
269 "request.security_lower_tf: requested timeframe '" + state.tf
270 + "' is not an integer divisor of input '" + input_tf
271 + "' (ratio " + std::to_string(
272 static_cast<double>(input_seconds) / requested_seconds)
273 + " is non-integer; chart bars cannot be evenly subdivided)"
274 );
275 }
276 // Defensive: integer-ratio finer LTF should already have been
277 // accepted by supports_lower_tf_emulation above. Reaching
278 // here implies a mismatch between the two checks (e.g. a
279 // non-fixed-intraday TF on one side).
282 "request.security_lower_tf: internal error - passed integer-ratio check but emulation support returned false (requested '"
283 + state.tf + "', input '" + input_tf + "')"
284 );
285 }
286
287 // requested_seconds >= input_seconds: HTF or same TF. Valid for
288 // request.security; for request.security_lower_tf this is the
289 // input-passthrough path when the requested TF is also strictly
290 // finer than the script TF.
291 if (pine.lower_tf_array_requested) {
292 if (script_seconds <= 0) {
295 {{"reason", "lower_tf_script_unknown"}},
296 "request.security_lower_tf: script timeframe is unknown — cannot validate '"
297 + state.tf + "' against script TF"
298 );
299 }
300 if (requested_seconds >= script_seconds) {
303 {{"reason", "lower_tf_not_finer_than_script"}, {"script_tf", script_tf_}},
304 "request.security_lower_tf: requested timeframe '" + state.tf
305 + "' must be finer than script timeframe '" + script_tf_
306 + "'. Lower-TF API requires a strictly finer timeframe."
307 );
308 }
309 if (script_seconds % requested_seconds != 0) {
312 {{"reason", "lower_tf_not_divisor_of_script"}, {"script_tf", script_tf_}},
313 "request.security_lower_tf: requested timeframe '" + state.tf
314 + "' must evenly divide script timeframe '"
315 + script_tf_ + "' (script_tf must be an integer multiple of requested TF)"
316 );
317 }
318 if (requested_seconds % input_seconds != 0) {
321 {{"reason", "lower_tf_not_multiple_of_input"}, {"input_tf", input_tf}},
322 "request.security_lower_tf: requested timeframe '" + state.tf
323 + "' is not an integer multiple of input '" + input_tf
324 + "' (cannot aggregate raw input bars to requested TF)"
325 );
326 }
327 pine.lower_tf_requested = true;
328 pine.lower_tf_use_input = true;
330 requested_seconds / input_seconds;
331 pine.lower_tf_ratio = script_seconds / requested_seconds;
332 pine.lower_tf_seconds = requested_seconds;
333 } else if (!is_calendar_month && pine.lookahead_on
334 && script_seconds > 0
335 && requested_seconds < script_seconds
336 && script_seconds % requested_seconds == 0) {
337 // Plain request.security with a target TF finer than the
338 // script/chart TF (e.g. so2TF="5" on a 15m chart) and
339 // lookahead=barmerge.lookahead_ON: TradingView merges the
340 // FIRST intrabar of each calling bar, so a history-offset
341 // read (``expr[1]``) must expose the value confirmed as of
342 // the PREVIOUS calling bar's boundary, not whatever native
343 // sub-period last completed inside the CURRENT calling bar.
344 // Gate publication (see feed_security_eval_state) to only
345 // the completion whose bucket end aligns with a script_tf
346 // boundary.
347 //
348 // lookahead_OFF is deliberately EXCLUDED: TV's lookahead_off
349 // merge takes the LAST intrabar of the calling bar, so the
350 // exposed value (and any ``[k]`` offset off it) advances at
351 // the security's own finer cadence — one push per completed
352 // security period, exactly the ungated behavior. Gating it
353 // regressed masayanfx-multi-time-score-strategy
354 // (request.security(sym, "5", ta.highest(high, 20)[1],
355 // barmerge.gaps_off, barmerge.lookahead_off) on a 15m chart)
356 // from 100.0% to 93.7% trade parity vs TradingView, while
357 // the gated lookahead_on case (3commas triple-RSI DCA,
358 // so2Rsi = request.security(sym, "5", ta.rsi(close,7)[1],
359 // lookahead=barmerge.lookahead_on)) needs the latch to hold
360 // 100.0%.
361 pine.publish_gate_tf_seconds = requested_seconds;
362#ifdef PINEFORGE_HAS_AUX_SECURITY_FEED_V1
363 // Served by the auxiliary finer feed (the split-feed path every
364 // @1D lane runs): TradingView reads the calling bar's FIRST
365 // intrabar, so the evaluator publishes every completed bucket
366 // and the chart body runs right after the first one of its
367 // slice (feed_aux_security_for_chart_bar defers the rest) --
368 // see calling_open_latches_first. The gate served the legacy
369 // single-feed loop and stays there.
370 if (aux_security_feed_enabled()) {
372 pine.calling_open_latches_first = true;
373 }
374#endif
375 }
376#ifdef PINEFORGE_HAS_AUX_SECURITY_FEED_V1
377 else if (!is_calendar_month && !pine.lookahead_on
378 && aux_security_feed_enabled()
379 && script_seconds > 0
380 && requested_seconds < script_seconds) {
381 // Plain request.security, lookahead_OFF, target TF strictly
382 // finer than the script/chart TF, served by the auxiliary finer
383 // feed: TV's merge takes the LAST intrabar of the calling bar,
384 // whatever its sub-bar count -- see the field's note. The
385 // aggregator's own completions stay ungated (masayanfx above);
386 // only a bucket still partial on the calling bar's last
387 // auxiliary bar is finalized and published there.
389 }
390#endif
391 }
392}
393
394
395void source::PineStrategyHost::publish_security_eval_state_at_calling_boundary(
396 SecurityEvalState& state) {
397 if (pine_security_state(state.sec_id).publish_gate_tf_seconds <= 0
398 || state.feed_count <= 0) {
399 return;
400 }
401
402 struct ReplayScope {
403 bool& active;
404 bool previous;
405 explicit ReplayScope(bool& flag)
406 : active(flag), previous(flag) { active = true; }
407 ~ReplayScope() { active = previous; }
408 } replay_scope(security_history_publication_replay_);
409
410 // The boundary-triggering input belongs to the next calling bar. Publish
411 // the final requested value that was already evaluated for the completed
412 // caller, before the chart body runs and before that retained input is fed.
413 // security_series_slot_is_new() returns false in this scope, so generated
414 // TA sites recompute the current slot instead of advancing their cadence —
415 // under the same requested-context bar index as the evaluation replayed.
416 dispatch_security_eval(state, state.current_bar, true,
417 state.ta_bar_index >= 0 ? state.ta_bar_index
418 : state.eval_complete_count);
419}
420
421
422bool source::PineStrategyHost::security_input_precedes_range_start(
423 const SecurityEvalState& state, int64_t input_ts) const {
424 const PineSecurityEvalState& pine = pine_security_state(state.sec_id);
425 if (security_range_start_na_warmup_) {
426 // TradingView's deep-backtest request.security series are built from the
427 // HTF bars whose OPEN lies inside the loaded chart range: a bucket that
428 // opened before the range start is not a partial first bar, it is absent.
429 // Keying the cut on the bucket open (session_period_open_ms for D/W/M,
430 // the session-anchored intraday grid otherwise) reproduces that; keying
431 // on the input timestamp would let the pre-range remainder of that bucket
432 // pose as HTF bar 1 and shift every SMA-seeded EMA/RSI/ATR/Stoch by one
433 // bucket (OANDA:EURUSD 1700-1700: the week opening Sun 17:00 ET before
434 // the range start, the month opening Feb 28 17:00 ET before it). With a
435 // range start on the bucket grid — 24x7 UTC midnight for every intraday
436 // TF and D, Monday for W, the 1st for M — this is the timestamp cut.
437 // Lower-TF (passthrough) evaluators keep the timestamp cut exactly.
438#ifdef PINEFORGE_HAS_AUX_SECURITY_FEED_V1
439 // A strictly-coarser-than-chart calendar evaluator whose partition is
440 // the chart's own dailies (a "W" / "M" on a 1D chart: the implicit
441 // native feed of prepare_native_security_feeds, which begins at the
442 // range start) labels the bucket in progress at the epoch by its
443 // first IN-RANGE stamp -- Tue 2025-04-01 for the week that opened Mon
444 // 03-31 -- so the bucket-open cut above keeps it as bar 0 and every
445 // SMA-seeded weekly EMA runs one bar early (round 8, family P:
446 // hungpixi-macd-enhanced-mtf on BINANCE:BTCUSDT 1D under the ladder's
447 // range-start-na-warmup candidate, lab tv famp-sense-hp-btc1d
448 // 2026-09-05: TradingView's weekly EMA26 first reads on the 09-29
449 // week and its signal EMA9 on the 11-24 week -- weekly bar 0 is the
450 // 04-07 week -- while the engine's read one week earlier; on the
451 // 11-24 week TradingView's hist[1] is still na (score -6) where the
452 // engine's is numeric (score -10), and the 11-25 reversal waited for
453 // 11-28). TradingView's rule is the default path's: the bucket is
454 // absent iff its NOMINAL open precedes the epoch and the symbol
455 // traded between the two (the auxiliary 1m feed is the evidence, so
456 // the NSE week whose holiday Monday never traded stays kept). The
457 // chart's own timeframe keeps the chart series' first bar, and a
458 // native partition with pre-range history (the 15m lanes' explicit
459 // daily feed) already opened that bucket before the epoch, so the
460 // rule below agrees with the cut above wherever both apply.
461 if (aux_security_feed_enabled() && !pine.lower_tf_requested
462 && !pine.lower_tf_array_requested) {
463 const CalendarPeriod period = calendar_period_for(state.tf);
464 const CalendarPeriod chart_period = calendar_period_for(script_tf_);
465 const bool strictly_coarser = period != CalendarPeriod::NONE
466 && static_cast<int>(period) > static_cast<int>(chart_period);
467 if (strictly_coarser) {
468 const int64_t nominal_open = session_period_open_ms(
469 input_ts, syminfo_.timezone, syminfo_.session, period);
470 if (nominal_open < security_range_start_ms_
471 && aux_security_traded_between(nominal_open,
472 security_range_start_ms_)) {
473 return true;
474 }
475 }
476 }
477#endif
478 return state.aggregator.bucket_open_ms(input_ts) < security_range_start_ms_;
479 }
480#ifdef PINEFORGE_HAS_AUX_SECURITY_FEED_V1
481 // R19 OTC daily pins: a historical intraday chart beginning mid-session
482 // has no partial first D bar in request.security, even when this run has
483 // only the chart feed. XAUUSD from Apr1 00:00Z reads D time/close as na
484 // that day and ATR14[1] first becomes numeric Apr23; counting the partial
485 // Mar31 session seeds ATR a day early. Restrict the no-aux inference to
486 // the pinned daily OTC clock. W/M need trading-history evidence to retain
487 // a holiday-open period, and native/aux feeds keep their existing rules.
488 if (!aux_security_feed_enabled() && native_security_feeds_.empty()
489 && security_first_chart_bar_ms_ > 0
490 && script_tf_seconds_ > 0 && script_tf_seconds_ < 86400
491 && (state.tf == "D" || state.tf == "1D")
492 && !pine.lower_tf_requested && !pine.lower_tf_array_requested
493 && (syminfo_.type == "forex" || syminfo_.type == "cfd")) {
494 const int64_t stamp = session_period_open_ms(
495 input_ts, syminfo_.timezone, syminfo_.session, CalendarPeriod::DAY);
496 // Metals stamp the D bar at 17:00 ET but first trade at 18:00.
497 // Starting at that actual open keeps the complete first session.
498 const int64_t trading_open = session_covered_instant_ms(
499 stamp, syminfo_.timezone, syminfo_.session);
500 return trading_open < security_first_chart_bar_ms_;
501 }
502 // Default cut (round 8, family P), split-feed runs only: a coarser-than-
503 // chart or chart-timeframe series starts at the first bucket that OPENS at
504 // or after the run's first chart bar -- the deep-backtest range start --
505 // and the bucket in progress at that instant is absent, exactly as
506 // TradingView's is (security_first_chart_bar_ms_). "In progress" is a
507 // fact about trading, not the calendar: TradingView dates a bar by its
508 // first traded bar, so the NSE week whose Monday 2025-03-31 was a holiday
509 // opens on Tuesday 04-01 -- the range start -- and is kept (famp-sense-
510 // nifty15full: "W" first reads 08-22), while the NYSE week that traded
511 // Monday 03-31 (F: 08-29), the CME trade date that opened Mon 22:00Z
512 // (ES/NQ "D": 05-01) and the OANDA week that opened Sun 21:00Z (XAUUSD
513 // 1D "W": 08-28) are absent. The auxiliary 1m feed holds the pre-range
514 // bars (the lanes' finer feeds begin in 2021), so the bucket whose
515 // nominal open precedes the first chart bar is dropped iff the feed
516 // traded between that open and the first chart bar. A finer-than-chart
517 // evaluator is never cut (its slice begins at the first chart bar), and
518 // a single-feed run has no evidence and keeps its feed-start series.
519 if (security_first_chart_bar_ms_ <= 0 || !aux_security_feed_enabled()
520 || pine.lower_tf_requested || pine.lower_tf_array_requested
521 || script_tf_seconds_ <= 0) {
522 return false;
523 }
524 const int requested_seconds = safe_tf_to_seconds(state.tf);
525 const bool coarser_or_chart = requested_seconds == -1
526 || requested_seconds >= script_tf_seconds_;
527 if (!coarser_or_chart) {
528 return false;
529 }
530 const CalendarPeriod period = calendar_period_for(state.tf);
531 const int64_t nominal_open = period != CalendarPeriod::NONE
532 ? session_period_open_ms(input_ts, syminfo_.timezone, syminfo_.session,
533 period)
534 : session_intraday_bucket_open_ms(input_ts, requested_seconds,
535 syminfo_.timezone, syminfo_.session);
536 if (nominal_open >= security_first_chart_bar_ms_) {
537 return false;
538 }
539 return aux_security_traded_between(nominal_open, security_first_chart_bar_ms_);
540#else
541 return false;
542#endif
543}
544
545
546#ifdef PINEFORGE_HAS_AUX_SECURITY_FEED_V1
547bool source::PineStrategyHost::aux_security_traded_between(int64_t from_ms,
548 int64_t to_ms) const {
549 const Bar* bars = nullptr;
550 int n = 0;
551 source_aux_security_input_view(bars, n);
552 if (bars == nullptr || n <= 0) return false;
553 auto it = std::lower_bound(
554 bars, bars + n, from_ms,
555 [](const Bar& bar, int64_t ts) { return bar.timestamp < ts; });
556 return it != bars + n && it->timestamp < to_ms;
557}
558#endif
559
560
561void source::PineStrategyHost::pine_feed_security_eval_state(
562 SecurityEvalState& state, const Bar& input_bar,
563 bool calling_bar_complete) {
564 // Opt-in HTF warmup parity (security_range_start_na_warmup run flag):
565 // (a) start every request.security aggregation at range_start_ms, not the
566 // feed start — drop every input bar whose HTF bucket opened before
567 // the range start (security_input_precedes_range_start) so the
568 // aggregator, its TA members, and the exposed history all begin at
569 // the first WHOLE bucket opening at/after the range start;
570 // (b) its embedded lookback ta.ema na-warms per TV built-in semantics —
571 // scoped by raising ta::ema_na_warmup_flag() for the duration of this
572 // call, which covers every evaluate_security() dispatch below (each of
573 // which is the only place the security's EMA members compute());
574 // (c) plain security expressions (e.g. `close`) read na until the first
575 // COMPLETED HTF bar from the range start — a consequence of (a): under
576 // lookahead_off no evaluate_security() fires until that first whole
577 // bucket completes; a bucket straddling the range start never counts.
578 // All three collapse to no-ops when the flag is unset (byte-identical).
579 if (security_input_precedes_range_start(state, input_bar.timestamp)) {
580 return;
581 }
582 PineSecurityEvalState& pine = pine_security_state(state.sec_id);
583 // No bar of the requested timeframe is loaded (no_loaded_bars): the
584 // site is never evaluated, so it and its TA read na throughout.
585 if (pine.no_loaded_bars) return;
586 // On the pump's runtime block when it has one (R5 lane D2-C).
587 internal::AmbientEmaSeedingScope _na_warmup_scope(
588 NativeExecutionConsumer::bound(*this).pump_ambient(),
589 security_range_start_na_warmup_);
590
591 // Heikin-Ashi same-symbol read: replace an aggregated bar's OHLC with its
592 // HA candle before evaluating the security expression. The completed
593 // state advances once per committed HTF bucket; a partial/projection peek
594 // derives from the prior state without committing it.
595 auto apply_ha = [&pine](Bar& b, bool commit) {
596 double ha_close = (b.open + b.high + b.low + b.close) / 4.0;
597 double ha_open = pine.ha_seeded
598 ? (pine.ha_prev_open + pine.ha_prev_close) / 2.0
599 : (b.open + b.close) / 2.0;
600 double ha_high = std::max(b.high, std::max(ha_open, ha_close));
601 double ha_low = std::min(b.low, std::min(ha_open, ha_close));
602 b.open = ha_open;
603 b.high = ha_high;
604 b.low = ha_low;
605 b.close = ha_close;
606 if (commit) {
607 pine.ha_prev_open = ha_open;
608 pine.ha_prev_close = ha_close;
609 pine.ha_seeded = true;
610 }
611 };
612
613 if (pine.lower_tf_use_input) {
614 // Buffer raw input bars until we accumulate one full script-TF
615 // chunk, then aggregate (if req > input) and dispatch each
616 // resulting LTF bar to the codegen via evaluate_security. The
617 // codegen detects ``lower_tf_sub_bar_index == 0`` to clear its
618 // accumulator vector and pushes once per dispatch.
619 //
620 // Bucket-aware dispatch (mirrors feed_ratio_mode in
621 // src/timeframe.cpp:1100): when the incoming bar belongs to a
622 // different wall-clock script-TF bucket than the buffered
623 // window, we MUST flush the buffer (even if partial) BEFORE
624 // pushing — otherwise a feed gap, warmup misalignment, or
625 // sparse-data run leaks bars across the chart-bar boundary
626 // (e.g. a 16-bar window dispatched per 15m chart bar instead
627 // of 15). Pure count-based dispatch is preserved as a
628 // secondary trigger so dense gap-free feeds still flush
629 // promptly when the chunk fills.
630 int input_seconds = tf_to_seconds(security_input_tf_);
631 int script_seconds = script_tf_seconds_;
632 if (input_seconds <= 0 || script_seconds <= 0) {
633 // Cannot compute bucket math — fall back to original
634 // count-only behaviour.
635 pine.lower_tf_input_buffer.push_back(input_bar);
636 return;
637 }
638 int chunk_size = script_seconds / input_seconds;
639 if (chunk_size <= 0) {
640 pine.lower_tf_input_buffer.push_back(input_bar);
641 return;
642 }
643 // The buffer fills to at most chunk_size input bars before it is
644 // dispatched and cleared. Reserve once (no-op when capacity already
645 // suffices) so the repeated fill/clear cycle reuses one allocation.
646 pine.lower_tf_input_buffer.reserve(static_cast<std::size_t>(chunk_size));
647 int64_t bucket_ms = static_cast<int64_t>(script_seconds) * 1000;
648 int64_t this_bucket = input_bar.timestamp / bucket_ms;
649 bool boundary_crossed = false;
650 if (!pine.lower_tf_input_buffer.empty()) {
651 int64_t buffer_bucket =
652 pine.lower_tf_input_buffer.front().timestamp / bucket_ms;
653 if (this_bucket != buffer_bucket) {
654 boundary_crossed = true;
655 }
656 }
657
658 // Lambda: aggregate + dispatch the current buffer (length may
659 // be < chunk_size on a boundary-triggered partial flush) and
660 // clear it. Uses the same agg_ratio rollup as before but is
661 // length-driven by the actual buffer size rather than
662 // chunk_size, so partial windows don't index out of bounds.
663 auto dispatch_and_clear = [&]() {
664 int agg_ratio = pine.lower_tf_input_aggregation_ratio;
665 if (agg_ratio < 1) agg_ratio = 1;
666 int buf_len = static_cast<int>(pine.lower_tf_input_buffer.size());
667 std::vector<Bar> ltf_bars;
668 ltf_bars.reserve(static_cast<std::size_t>(buf_len / agg_ratio + 1));
669 if (agg_ratio == 1) {
670 for (const Bar& b : pine.lower_tf_input_buffer) {
671 ltf_bars.push_back(b);
672 }
673 } else {
674 for (int i = 0; i + agg_ratio <= buf_len; i += agg_ratio) {
675 Bar acc = pine.lower_tf_input_buffer[
676 static_cast<std::size_t>(i)];
677 double vol = acc.volume;
678 for (int j = 1; j < agg_ratio; ++j) {
679 const Bar& nxt = pine.lower_tf_input_buffer[
680 static_cast<std::size_t>(i + j)];
681 if (nxt.high > acc.high) acc.high = nxt.high;
682 if (nxt.low < acc.low) acc.low = nxt.low;
683 acc.close = nxt.close;
684 vol += nxt.volume;
685 }
686 acc.volume = vol;
687 ltf_bars.push_back(acc);
688 }
689 }
690 pine.lower_tf_sub_bar_index = 0;
691 for (const Bar& b : ltf_bars) {
692 state.feed_count++;
693 state.current_bar = b;
694 state.current_sub_bar_count = 1;
695 state.eval_complete_count++;
696 dispatch_security_eval(state, b, true,
697 state.eval_complete_count - 1);
698 pine.lower_tf_sub_bar_index++;
699 }
700 pine.lower_tf_input_buffer.clear();
701 };
702
703 if (boundary_crossed) {
704 // Flush stale bucket BEFORE pushing the new bar so the
705 // new bar starts a fresh window aligned to its own bucket.
706 dispatch_and_clear();
707 }
708
709 pine.lower_tf_input_buffer.push_back(input_bar);
710
711 // Secondary trigger: if the buffer happens to fill to
712 // chunk_size mid-bucket (the dense gap-free case), flush
713 // immediately. This preserves the original count-based
714 // behaviour for the common path.
715 if (static_cast<int>(pine.lower_tf_input_buffer.size()) >= chunk_size) {
716 dispatch_and_clear();
717 }
718 return;
719 }
720 if (pine.lower_tf_emulation) {
721 std::vector<Bar> synthetic_bars =
722 synthesize_lower_tf_bars(input_bar, pine.lower_tf_ratio, pine.lower_tf_seconds);
723 if (synthetic_bars.empty()) {
724 throw coded<std::runtime_error>(
726 {{"reason", "lower_tf_synthesis_failed"}, {"input_tf", security_input_tf_}},
727 "request.security lower TF emulation could not synthesize bars for requested "
728 + state.tf + " from input timeframe " + security_input_tf_
729 );
730 }
731 // Reset the sub-bar counter at the start of every chart bar's
732 // synthesis so a ``request.security_lower_tf`` codegen path can
733 // detect index 0 and clear its accumulator vector before pushing
734 // each per-sub-bar value. The counter is incremented after every
735 // dispatch so callers see 0, 1, ..., ratio-1 in sequence.
736 pine.lower_tf_sub_bar_index = 0;
737 for (const auto& synthetic_bar : synthetic_bars) {
738 state.feed_count++;
739 state.current_bar = synthetic_bar;
740 state.current_sub_bar_count = 1;
741 state.eval_complete_count++;
742 dispatch_security_eval(state, synthetic_bar, true,
743 state.eval_complete_count - 1);
744 pine.lower_tf_sub_bar_index++;
745 }
746 return;
747 }
748
749 if (historical_security_lookahead_projection_active_
750 && !pine.historical_projections.empty()) {
751 // Keyed by the input's instant, not by a feed-call index: on the
752 // split-feed path this evaluator is fed the finer auxiliary slice
753 // (hundreds of inputs per chart bar), and the projection of a bucket
754 // is dispatched on the first input at or after its first retained
755 // child (that child's own first auxiliary bar) -- exactly the chart
756 // bar TradingView's lookahead_on leaks the bucket's FINAL values
757 // from. On the single-feed path the first input at or after the
758 // child's timestamp is that child itself, as the index cut was.
759 const int64_t input_ms = input_bar.timestamp;
760 while (pine.historical_projection_cursor + 1
761 < pine.historical_projections.size()
762 && pine.historical_projections[
763 pine.historical_projection_cursor + 1]
764 .first_child_ms <= input_ms) {
765 ++pine.historical_projection_cursor;
766 pine.historical_projection_dispatched = false;
767 }
768 const auto& projection = pine.historical_projections[
769 pine.historical_projection_cursor];
770 state.feed_count++;
771 if (pine.historical_projection_dispatched
772 || input_ms < projection.first_child_ms) {
773 // gaps_off holds the first-child projection unchanged until the
774 // next HTF bucket. No evaluator call means TA/security histories
775 // also advance exactly once per projected bucket.
776 return;
777 }
778 pine.historical_projection_dispatched = true;
779
780 Bar projected_bar = projection.bar;
781 // A projected bucket is the exchange's bar wherever a native feed
782 // serves this timeframe -- complete or not. TradingView's
783 // lookahead_on leaks the period's FINAL values from its first chart
784 // bar, so the trailing period still in progress at the range end
785 // carries the whole native period whenever the feed holds it (lab tv
786 // wm-m-f15-jul, 2026-09-05: August's final o/h/l/c 10.92/11.99/
787 // 10.68/11.77 from 08-01 09:30 on a chart ending 08-08). A partial
788 // with no native bar keeps the available aggregate, uncounted as a
789 // miss.
790 substitute_native_security_bar(state, projected_bar,
791 /*count_miss=*/projection.is_complete);
792 if (pine.heikinashi) {
793 apply_ha(projected_bar, projection.is_complete);
794 }
795 state.current_bar = projected_bar;
796 // The projected HTF bucket is introduced on its first chart child, so
797 // generated security series must allocate a fresh history/TA slot even
798 // though projected_bar itself already contains every available child.
799 state.current_sub_bar_count = 1;
800 if (projection.is_complete) {
801 state.eval_complete_count++;
802 } else {
803 state.eval_partial_count++;
804 }
805 dispatch_security_eval(state, projected_bar, projection.is_complete,
806 projection.is_complete
807 ? state.eval_complete_count - 1
808 : state.eval_complete_count);
809 return;
810 }
811
812 // The next input bar's timestamp (0 when unknown) lets a calendar
813 // bucket complete on the period's actual last chart bar -- see
814 // security_next_input_ms_ -- and the calling chart bar's nominal close
815 // (split-feed path, else 0) lets an OTC bucket do so exactly when that
816 // close reaches the period's -- see security_calling_close_ms_.
817 AggregatedBar ab = state.aggregator.feed(input_bar, security_next_input_ms_,
818 security_calling_close_ms_);
819 // timeframe.cpp excludes target_seconds >= kSecPerDay from its sub-day
820 // session-close rule. A minute-spelled day closes at the day's close,
821 // deliberately not at an intermediate session window's close.
822 const bool minute_day = calendar_period_for(state.tf) == CalendarPeriod::NONE
823 && tf_to_seconds(state.tf) == 86400;
824 if (!ab.is_complete && minute_day && !pine.lookahead_on
825 && state.aggregator.has_pending_partial()) {
826 const int input_seconds = tf_to_seconds(security_input_tf_);
827 const auto session_open = session_period_open_ms(
828 input_bar.timestamp, syminfo_.timezone, syminfo_.session,
830 const bool next_session = state.aggregator.early_close_completes()
831 && security_next_input_ms_ > 0
832 && session_period_open_ms(security_next_input_ms_, syminfo_.timezone,
833 syminfo_.session, CalendarPeriod::DAY)
834 != session_open;
835 const auto session_close = session_period_close_ms(
836 input_bar.timestamp, syminfo_.timezone, syminfo_.session,
838 const bool reaches_close = input_seconds > 0
839 && input_bar.timestamp + static_cast<int64_t>(input_seconds) * 1000
840 >= session_close;
841 if (next_session || reaches_close) {
842 ab = state.aggregator.complete_pending_partial();
843 }
844 }
845 state.feed_count++;
846 state.current_sub_bar_count = ab.sub_bar_count;
847 if (ab.is_complete) {
848 // The aggregator decided WHEN the bucket completes; a native feed for
849 // this timeframe decides WHAT it closed at (the settlement / official
850 // print), before any Heikin-Ashi derivation. Partial (lookahead_on)
851 // peeks keep the running aggregate.
852 substitute_native_security_bar(state, ab.bar);
853 if (pine.heikinashi) apply_ha(ab.bar, /*commit=*/true);
854 state.current_bar = ab.bar;
855 state.eval_complete_count++;
856 // For a plain request.security whose target TF is strictly finer
857 // than script_tf (publish_gate_tf_seconds > 0), the security's own
858 // aggregator completes multiple times per calling/script bar.
859 // Only the completion whose bucket END lands on a script_tf
860 // boundary is "the last completion of THIS calling bar" — that's
861 // the one a history-offset read (``expr[1]``) should latch as
862 // "confirmed as of the previous calling bar" the NEXT time the
863 // calling script reads it. Suppress ``is_complete`` (so codegen's
864 // gated hist.push() does not fire) for every other, intermediate
865 // completion; the underlying TA state keeps advancing regardless
866 // (compute()/recompute() dispatch is driven by
867 // current_sub_bar_count, not by this flag) — only the exposed
868 // history buffer's advance is gated. eval_complete_count/current_bar
869 // bookkeeping above stays driven by the real completion.
870 bool publish = true;
871 if (pine.publish_gate_tf_seconds > 0 && script_tf_seconds_ > 0) {
872 // Merge finer-context history on the event that the calling chart
873 // aggregator actually completes. Unlike a fixed seconds modulus,
874 // this includes session-clipped calling bars.
875 // The requested-context evaluator still runs once
876 // per input update; only its is_complete publication signal is
877 // replaced.
878 publish = calling_bar_complete;
879 }
880 // A boundary emission: the input bar opened a NEW bucket and the
881 // aggregator emitted the previous one, still partial (a singleton the
882 // count / real-end / session-close rules never reached: OANDA:XAUUSD
883 // Thanksgiving's 21:54 bucket holding the 21:56 minute, emitted when
884 // 21:59 opens the 21:57 bucket). Under lookahead_on that bucket's
885 // history slot was already opened by its first sub-bar's partial
886 // peek (compute), so the completion must REWRITE it (recompute) --
887 // a fresh compute here committed a phantom copy of the bucket and
888 // shifted every later requested value (the RSI diverged from the
889 // lookahead_off twin's on the same buckets). And the input bar that
890 // opened the new bucket got no peek of its own in this feed, so open
891 // its slot now, exactly as the merge branch below does for a first
892 // sub-bar. Count, real-end and session-close completions carry the
893 // input bar's own bucket (the label matches) and are untouched, as
894 // is lookahead_off (no peeks: every completion is a new slot).
895 // The emitted bucket is a boundary emission exactly when it is not
896 // the aggregator's CURRENT bucket: a boundary completion hands back
897 // the previous bucket and re-seats the aggregator on the one the
898 // input opened (feed_calendar_mode / feed_ratio_mode), while every
899 // eager completion (count, real end, session close, the calendar
900 // period's last input) leaves the completed bucket current. The
901 // former test compared the label against bar_label_ms(input), which
902 // is the intraday grid open for a fixed-TF bucket but the DAY stamp
903 // for a calendar W / M bucket -- so every weekly completion on its
904 // last daily bar looked like a boundary and committed a phantom copy
905 // of the week as one more requested bar (round 7, family I: the
906 // hungpixi weekly f_count carry decayed twice per week, hist ties
907 // compared the week with its own copy).
908 const bool boundary_emission = pine.lookahead_on
909 && state.aggregator.is_active()
910 && ab.bar.timestamp != state.aggregator.current().timestamp;
911 if (boundary_emission && state.current_sub_bar_count < 2) {
912 state.current_sub_bar_count = 2;
913 }
914 pine.last_published_label = ab.bar.timestamp;
915 dispatch_security_eval(state, ab.bar, publish,
916 state.eval_complete_count - 1);
917 if (boundary_emission) {
918 Bar fresh = state.aggregator.current();
919 if (pine.heikinashi) apply_ha(fresh, /*commit=*/false);
920 state.current_bar = fresh;
921 state.current_sub_bar_count = 1;
922 state.eval_partial_count++;
923 const bool peek_publish = pine.publish_gate_tf_seconds > 0
924 && calling_bar_complete;
925 dispatch_security_eval(state, fresh, peek_publish,
926 state.eval_complete_count);
927 }
928 } else if (pine.lookahead_on) {
929 if (pine.heikinashi) apply_ha(ab.bar, /*commit=*/false);
930 state.current_bar = ab.bar;
931 state.eval_partial_count++;
932 // A shortened calling bar can complete while the finer requested
933 // bucket is partial. Publish that current requested-context value to
934 // merged history without adding a second evaluator/TA dispatch.
935 const bool publish = pine.publish_gate_tf_seconds > 0
936 && calling_bar_complete;
937 // Partial (in-progress) bucket: the index the completion will carry.
938 dispatch_security_eval(state, ab.bar, publish,
939 state.eval_complete_count);
940 } else {
941 state.current_bar = ab.bar;
942 if (pine.gaps_on) {
943 clear_security(state.sec_id);
944 }
945 }
946
947 // The calling chart bar's last auxiliary bar left the finer bucket it
948 // opened (or merged into) partial: its count, real end and session
949 // close all lie beyond the chart bar's last sub-bar (the Thanksgiving
950 // 21:57 3m bucket holding the 21:59 minute alone; the 2-minute 20:57
951 // bucket of a day whose 20:59 minute did not trade). TradingView reads
952 // that bucket at the chart bar's close (lab tv dca-ltf-last-intrabar,
953 // 2026-09-05: 72.64, the 21:59 minute's RSI, where the previous bucket
954 // reads 38.87), so finalize it now, exactly as a count / real-end /
955 // session-close completion would have, and publish it as one more
956 // completed requested-context bar. The aggregator marks it emitted:
957 // the next chart bar's first sub-bar starts a fresh bucket without
958 // re-emitting this one, and a later sub-bar of the same bucket (not a
959 // completed chart bar's, but guarded) merges without completing it
960 // again. A dense feed whose final bucket completed on its count has no
961 // pending partial and is untouched (calling_close_completes_partial).
962 if (calling_bar_complete && pine.calling_close_completes_partial
963 && state.aggregator.has_pending_partial()) {
964 AggregatedBar tail = state.aggregator.complete_pending_partial();
965 if (tail.is_complete) {
966 state.current_sub_bar_count = tail.sub_bar_count;
967 substitute_native_security_bar(state, tail.bar);
968 if (pine.heikinashi) apply_ha(tail.bar, /*commit=*/true);
969 state.current_bar = tail.bar;
970 state.eval_complete_count++;
971 pine.last_published_label = tail.bar.timestamp;
972 dispatch_security_eval(state, tail.bar, true,
973 state.eval_complete_count - 1);
974 }
975 }
976}
977
978
979// ---- request.security of another symbol (lane XSYM-D) ----------------------
980//
981// A site of ANOTHER symbol reads that symbol's own bars, installed before the
982// run (strategy_set_symbol_feed) and served by the kernel as the site's
983// NativeSeriesSource::InstrumentFeed series: the kernel owns the merge by
984// interval (NativeInstrumentFeed), this host the Pine semantics around it --
985// the barmerge flags mapped onto the series, the payload evaluated on every
986// bar handed over, in the requested context, ignore_invalid_symbol, and on a
987// calendar chart TradingView's merge against the chart bar's own time and
988// close (read_ahead_foreign_security_sites).
989
990namespace {
991
992// The one timeframe spelling of the symbol-data stores, the requests
993// manifest's: whole minutes as a bare integer ("15", "240"), days, weeks,
994// months and seconds as <n>D|W|M|S, Pine's bare "D"/"W"/"M"/"S" folded to a
995// count of one. Any other text is kept as it is, and no feed will match it.
996std::string canonical_symbol_timeframe(const std::string& tf) {
997 if (tf.size() == 1 && (tf[0] == 'D' || tf[0] == 'W' || tf[0] == 'M' || tf[0] == 'S'))
998 return "1" + tf;
999 return tf;
1000}
1001
1002// syminfo.ticker of a requested symbol: the key without its exchange prefix.
1003std::string symbol_ticker(const std::string& key) {
1004 const auto colon = key.rfind(':');
1005 return colon == std::string::npos ? key : key.substr(colon + 1);
1006}
1007
1008bool parse_fact_number(const std::string& text, double& value) {
1009 if (text.empty()) return false;
1010 std::size_t used = 0;
1011 try {
1012 value = std::stod(text, &used);
1013 } catch (...) {
1014 return false;
1015 }
1016 return used == text.size();
1017}
1018
1019// The doors install before a run: a run in progress (a stream's warmup or
1020// realtime leg included) has already staged and hashed what it reads.
1021constexpr const char* kRunInProgress =
1022 "a run is in progress; request data is installed before a run begins";
1023
1024bool run_in_progress(const NativeStrategyHost& host) {
1025 return host.native_state().kind == NativeLifecycleKind::Running;
1026}
1027
1028} // namespace
1029
1031 int sec_id, const std::string& symbol, const std::string& requested_tf,
1032 const std::string& input_tf, bool lookahead_on, bool gaps_on,
1033 bool ignore_invalid_symbol) {
1034 // The evaluator input is the symbol's own feed, not the chart's: the
1035 // chart's input timeframe plays no part in the site.
1036 (void)input_tf;
1037 ForeignSecuritySite site;
1038 site.symbol = symbol;
1039 site.requested_tf = requested_tf;
1040 site.lookahead = lookahead_on;
1041 site.gaps = gaps_on;
1042 site.ignore_invalid = ignore_invalid_symbol;
1043 foreign_security_sites_[sec_id] = std::move(site);
1044}
1045
1046std::int64_t source::PineStrategyHost::find_symbol_feed(const std::string& key,
1047 const std::string& tf) const {
1048 for (std::size_t i = 0; i < symbol_feeds_.size(); ++i) {
1049 if (symbol_feeds_[i].instrument == key && symbol_feeds_[i].tf == tf)
1050 return static_cast<std::int64_t>(i);
1051 }
1052 return -1;
1053}
1054
1055// AR-R4: an applied configure holds the feeds' bytes in the kernel's run spec
1056// (prepare_native_begin), which keeps them, in this host's order, from that
1057// configure to the next one: the spec is moved, never rewritten, from Ready to
1058// Running to Completed, and a failure carries it into Failed.
1059const NativeInstrumentFeed& source::PineStrategyHost::installed_symbol_feed(
1060 std::size_t i) const {
1061 if (!symbol_feeds_lent_) return symbol_feeds_[i];
1062 const NativeRunSpec* spec = native_state().spec;
1063 if (spec == nullptr || i >= spec->instrument_feeds.size())
1064 throw coded<std::logic_error>(
1066 "request data: the run spec no longer holds the symbol feeds");
1067 return spec->instrument_feeds[i];
1068}
1069
1070void source::PineStrategyHost::restore_symbol_feeds() {
1071 if (!symbol_feeds_lent_) return;
1072 const NativeRunSpec* spec = native_state().spec;
1073 if (spec == nullptr || spec->instrument_feeds.size() != symbol_feeds_.size())
1074 throw coded<std::logic_error>(
1076 "request data: the run spec no longer holds the symbol feeds");
1077 // Every entry is the one lent at that index -- its key and its column
1078 // names -- before a byte is copied back, so a mismatch changes nothing.
1079 for (std::size_t i = 0; i < symbol_feeds_.size(); ++i) {
1080 const NativeInstrumentFeed& held = spec->instrument_feeds[i];
1081 const NativeInstrumentFeed& feed = symbol_feeds_[i];
1082 bool same = held.instrument == feed.instrument && held.tf == feed.tf
1083 && held.columns.size() == feed.columns.size();
1084 for (std::size_t c = 0; same && c < feed.columns.size(); ++c)
1085 same = held.columns[c].name == feed.columns[c].name;
1086 if (!same)
1087 throw coded<std::logic_error>(
1089 "request data: the run spec holds other symbol feeds");
1090 }
1091 for (std::size_t i = 0; i < symbol_feeds_.size(); ++i) {
1092 const NativeInstrumentFeed& held = spec->instrument_feeds[i];
1093 NativeInstrumentFeed& feed = symbol_feeds_[i];
1094 const auto views = NativeExecutionConsumer::bound(*this).bar_views();
1095 const auto* slot = views.instrument_feed_count == 0
1096 ? nullptr : views.instrument_feeds[i];
1097 if (slot == nullptr) {
1098 feed.bars = held.bars;
1099 feed.close_ms = held.close_ms;
1100 }
1101 for (std::size_t c = 0; c < feed.columns.size(); ++c)
1102 feed.columns[c].values = held.columns[c].values;
1103 }
1104 symbol_feeds_lent_ = false;
1105}
1106
1107// A door's restore: the reason it failed, empty when the store holds its
1108// bytes, so the door refuses with that text rather than with none.
1109std::string source::PineStrategyHost::restore_symbol_feeds_reason() {
1110 try {
1111 restore_symbol_feeds();
1112 } catch (const std::exception& error) {
1113 return std::string("request data could not be restored: ") + error.what();
1114 }
1115 return {};
1116}
1117
1118void source::PineStrategyHost::prepare_foreign_security_sites(
1119 std::vector<NativeTimeframeSubscription>& declared) {
1120 foreign_security_series_.assign(declared.size(), -1);
1121 if (foreign_security_sites_.empty()) return;
1122 // The merge is judged per chart bar, and the kernel hands feed bars over
1123 // per accepted input: the two agree only while the input IS the chart
1124 // (a chart aggregated from finer input would publish, peek and clear on
1125 // the finer cadence).
1126 const int ratio = input_tf_.empty() || script_tf_.empty() ? 0
1127 : tf_ratio(input_tf_, script_tf_);
1128 if (input_tf_.empty() || ratio > 1 || ratio == -1) {
1129 RunFailureArgs aggregated{{"reason", "chart_input_aggregated"}};
1130 if (!input_tf_.empty()) aggregated.emplace_back("input_tf", input_tf_);
1131 if (!script_tf_.empty()) aggregated.emplace_back("script_tf", script_tf_);
1132 throw coded<std::runtime_error>(
1134 "request.security of another symbol needs the chart's own bars as input; input '"
1135 + input_tf_ + "' aggregated to chart '" + script_tf_ + "' is not supported");
1136 }
1137 for (auto& entry : foreign_security_sites_) {
1138 const int sec_id = entry.first;
1139 ForeignSecuritySite& site = entry.second;
1140 site.tf = canonical_symbol_timeframe(site.requested_tf.empty() ? script_tf_
1141 : site.requested_tf);
1142 site.invalid = false;
1143 site.subscription = -1;
1144 site.feed = 0;
1145 site.delivered = 0;
1146 site.close_ms = 0;
1147 site.last_input = -1;
1148 site.handed = 0;
1149 site.eval = SecurityEvalState{};
1150 site.eval.sec_id = sec_id;
1151 site.eval.tf = site.tf;
1152 const auto facts = symbol_facts_.find(site.symbol);
1153 const SymbolFacts* known = facts == symbol_facts_.end() ? nullptr : &facts->second;
1154 if (known != nullptr && known->valid && !*known->valid) {
1155 // TradingView reads na for an invalid symbol under
1156 // ignore_invalid_symbol and raises a runtime error without it.
1157 if (!site.ignore_invalid) {
1158 throw coded<std::runtime_error>(RunFailureCode::request_symbol_invalid, {},
1159 "request.security: symbol '" + site.symbol
1160 + "' is invalid");
1161 }
1162 site.invalid = true;
1163 continue;
1164 }
1165 const std::int64_t feed = find_symbol_feed(site.symbol, site.tf);
1166 if (feed < 0) {
1167 // The timeframe is an argument only when it is the run's own chart
1168 // timeframe (no requested timeframe), never the script's.
1169 RunFailureArgs missing;
1170 if (site.requested_tf.empty()) missing.emplace_back("timeframe", site.tf);
1171 throw coded<std::runtime_error>(RunFailureCode::other_symbol_feed_missing, missing,
1172 "request.security: no feed is installed for symbol '"
1173 + site.symbol + "' at timeframe '" + site.tf
1174 + "'");
1175 }
1176 site.feed = static_cast<std::size_t>(feed);
1177 // The requested context's syminfo: the symbol's own facts, and nothing
1178 // of the chart's. TradingView's syminfo.tickerid inside the request is
1179 // the string the script passed (a bare ticker stays bare). A fact the
1180 // host did not supply reads empty (NaN for a number).
1181 SymInfo info;
1182 info.ticker = symbol_ticker(site.symbol);
1183 info.tickerid = site.symbol;
1184 info.currency.clear();
1185 info.basecurrency.clear();
1186 info.type.clear();
1187 info.timezone.clear();
1188 info.session.clear();
1189 info.volumetype.clear();
1190 info.description.clear();
1191 info.mintick = std::numeric_limits<double>::quiet_NaN();
1192 info.pointvalue = std::numeric_limits<double>::quiet_NaN();
1193 info.qty_step = 0.0;
1194 if (known != nullptr) {
1195 info.type = known->type;
1196 info.timezone = known->timezone;
1197 info.session = known->session;
1198 info.currency = known->currency;
1199 info.mintick = known->mintick;
1200 }
1201 site.syminfo = std::move(info);
1202 NativeTimeframeSubscription series;
1203 series.tf = site.tf;
1204 series.source = NativeSeriesSource::InstrumentFeed;
1205 series.instrument = site.symbol;
1206 series.lookahead = site.lookahead;
1207 series.gaps = site.gaps;
1208 site.subscription = static_cast<std::int64_t>(declared.size());
1209 declared.push_back(std::move(series));
1210 foreign_security_series_.push_back(sec_id);
1211 }
1212}
1213
1215 const Bar& bar, const NativeTimeframeBarContext& context) {
1216 if (source_prepare_failed_) return;
1217 if (context.subscription >= foreign_security_series_.size()) return;
1218 const int sec_id = foreign_security_series_[context.subscription];
1219 if (sec_id < 0) return;
1220 const auto found = foreign_security_sites_.find(sec_id);
1221 if (found == foreign_security_sites_.end()) return;
1222 ForeignSecuritySite& site = found->second;
1223 // The kernel hands every bar over once, in order, so the count so far is
1224 // the bar's feed index. A bar the payload already ran on, made visible on
1225 // an earlier chart bar by the merge rule below, is not run again; any
1226 // other is the context's next bar, its close the feed's own.
1227 const std::int64_t index = site.handed;
1228 site.handed = index + 1;
1229 if (index < site.delivered) return;
1230 run_foreign_site_bar(site, bar, context.interval.last_traded_close_ms);
1231}
1232
1233void source::PineStrategyHost::run_foreign_site_bar(ForeignSecuritySite& site, const Bar& bar,
1234 std::int64_t close_ms) {
1235 const std::int64_t index = site.delivered;
1236 site.delivered = index + 1;
1237 site.close_ms = close_ms;
1238 site.last_input = foreign_input_index_;
1239 // The requested context answers pine_bar_index(), time_close() and
1240 // syminfo_ while its payload runs, and the chart's once it returns.
1241 struct ContextScope {
1242 PineStrategyHost& host;
1243 ForeignSecuritySite& site;
1244 const ForeignSecuritySite* previous_context;
1245 double previous_mintick;
1246 ContextScope(PineStrategyHost& h, ForeignSecuritySite& s)
1247 : host(h), site(s), previous_context(h.foreign_context_),
1248 previous_mintick(h.syminfo_mintick_) {
1249 std::swap(host.syminfo_, site.syminfo);
1250 host.syminfo_mintick_ = host.syminfo_.mintick;
1251 host.foreign_context_ = &site;
1252 }
1253 ~ContextScope() {
1254 host.foreign_context_ = previous_context;
1255 host.syminfo_mintick_ = previous_mintick;
1256 std::swap(host.syminfo_, site.syminfo);
1257 }
1258 } scope(*this, site);
1259 dispatch_security_eval(site.eval, bar, true, index);
1260}
1261
1262// TradingView merges another symbol's bars into a calendar (D/W/M) chart bar
1263// by the chart bar's own period, from its time to its time_close (tapes
1264// tg-xau1d-requests, w11-tclose-xau1d and w11-tclose-la-xau1d,
1265// tests/fixtures/daily_break_close):
1266// lookahead_off: the last requested bar closed by the chart bar's close;
1267// lookahead_on, an intraday requested timeframe: the last requested bar
1268// opened by the chart bar's time.
1269// The kernel hands a series the bars visible at its input's calendar
1270// interval, which is that period wherever the chart bar's stamp opens its own
1271// session, so nothing is left here to run. OANDA stamps its XAUUSD daily bars
1272// at 17:00 ET, inside the break of the 1800-1700 session, and the kernel reads
1273// such a label as the session that closes at it (test_daily_stamp_in_the_break
1274// in tests/test_native_instrument_feed.cpp): a lookahead-off request would
1275// read one bar late, a lookahead-on hourly one a session early. The bars the
1276// rule makes visible run here, in feed order, after the input's deliveries;
1277// the kernel hands them over again on a later input, below `delivered`.
1278// lookahead_on of a D/W/M timeframe, which TradingView aligns by trading date
1279// (the requested bar opening inside the chart bar there), keeps the kernel's
1280// reading.
1281void source::PineStrategyHost::read_ahead_foreign_security_sites(const Bar& chart_bar) {
1282 if (calendar_period_for(script_tf_) == CalendarPeriod::NONE) return;
1283 const std::int64_t chart_close = chart_bar_close_ms(chart_bar.timestamp);
1284 for (auto& entry : foreign_security_sites_) {
1285 ForeignSecuritySite& site = entry.second;
1286 if (site.invalid || site.subscription < 0 || site.feed >= symbol_feeds_.size()) continue;
1287 if (site.lookahead ? calendar_period_for(site.tf) != CalendarPeriod::NONE
1288 : is_na(chart_close)) {
1289 continue;
1290 }
1291 const auto views = NativeExecutionConsumer::bound(*this).bar_views();
1292 const auto* slot = views.instrument_feed_count == 0
1293 ? nullptr : views.instrument_feeds[site.feed];
1294 const auto feed = native_instrument_feed_view(installed_symbol_feed(site.feed), slot);
1295 while (static_cast<std::size_t>(site.delivered) < feed.bars.size) {
1296 const auto at = static_cast<std::size_t>(site.delivered);
1297 const bool visible = site.lookahead ? feed.bars.data[at].timestamp <= chart_bar.timestamp
1298 : feed.close_ms[at] <= chart_close;
1299 if (!visible) break;
1300 run_foreign_site_bar(site, feed.bars.data[at], feed.close_ms[at]);
1301 }
1302 }
1303}
1304
1305void source::PineStrategyHost::clear_gapped_foreign_security_sites() {
1306 for (const auto& entry : foreign_security_sites_) {
1307 const ForeignSecuritySite& site = entry.second;
1308 if (!site.gaps || site.subscription < 0) continue;
1309 if (site.last_input != foreign_input_index_) clear_security(entry.first);
1310 }
1311}
1312
1314 const std::string& name) const {
1315 const auto found = foreign_security_sites_.find(sec_id);
1316 if (found == foreign_security_sites_.end()) return std::numeric_limits<double>::quiet_NaN();
1317 const ForeignSecuritySite& site = found->second;
1318 if (site.subscription < 0 || site.delivered <= 0 || site.feed >= symbol_feeds_.size())
1319 return std::numeric_limits<double>::quiet_NaN();
1320 const NativeInstrumentFeed& feed = installed_symbol_feed(site.feed);
1321 const auto at = static_cast<std::size_t>(site.delivered - 1);
1322 for (const auto& column : feed.columns) {
1323 if (column.name != name) continue;
1324 return at < column.values.size() ? column.values[at]
1325 : std::numeric_limits<double>::quiet_NaN();
1326 }
1327 return std::numeric_limits<double>::quiet_NaN();
1328}
1329
1330double source::PineStrategyHost::recorded_series_value(const std::string& key) const {
1331 const auto found = recorded_series_.find(key);
1332 if (found == recorded_series_.end()) {
1334 "request data: no recorded series is installed for key '"
1335 + key + "'");
1336 }
1337 const RecordedSeries& series = found->second;
1338 std::size_t first = 0, last = series.rows.size();
1339 while (first < last) {
1340 const auto middle = first + (last - first) / 2;
1341 if (series.rows[middle].open_ms < current_bar_.timestamp) first = middle + 1;
1342 else last = middle;
1343 }
1344 if (first == series.rows.size())
1345 return std::numeric_limits<double>::quiet_NaN();
1346 const RecordedSeriesRow row = series.rows[first];
1347 return row.open_ms == current_bar_.timestamp
1348 ? row.value : std::numeric_limits<double>::quiet_NaN();
1349}
1350
1351// ---- the request-data doors ------------------------------------------------
1352
1354 const std::string& timeframe,
1355 const Bar* bars, const int64_t* close_ms,
1356 int n) {
1357 guard_native_mutation("set_symbol_feed");
1358 const auto refuse = [this](const char* reason, const std::string& why,
1359 const char* field = nullptr) {
1360 RunFailureArgs args{{"reason", reason}};
1361 if (field) args.emplace_back("field", field);
1362 note_run_failure(*this, "strategy_set_symbol_feed: " + why,
1364 return false;
1365 };
1366 if (run_in_progress(*this)) return refuse("run_in_progress", kRunInProgress);
1367 if (key.empty()) return refuse("empty_key", "empty symbol key");
1368 const std::string tf = canonical_symbol_timeframe(timeframe);
1369 if (tf.empty() || !native_calendar::parse_timeframe(tf)) {
1370 return refuse("timeframe_unparsable", "timeframe '" + timeframe + "' does not parse");
1371 }
1372 if (n < 0 || (n > 0 && (bars == nullptr || close_ms == nullptr)))
1373 return refuse("invalid_bars", "invalid bar array");
1374 const std::int64_t existing = find_symbol_feed(key, tf);
1375 // n == 0 is a symbol with no bars over the run (a feed file with a header
1376 // only): installed, so its sites read na rather than fail as unfed. The
1377 // feed is built inside the one-feed list it is judged in, then moved out.
1378 std::vector<NativeInstrumentFeed> judged_feeds(1);
1379 NativeInstrumentFeed& feed = judged_feeds.front();
1380 feed.instrument = key;
1381 feed.tf = tf;
1382 if (n > 0) {
1383 feed.bars.assign(bars, bars + n);
1384 feed.close_ms.assign(close_ms, close_ms + n);
1385 }
1386 // The kernel's own judgement of the feed, so a refusal names its field
1387 // here rather than at the next begin.
1388 const auto judged = validate_native_instrument_feeds(judged_feeds);
1389 if (!judged) {
1390 return refuse("kernel_refused", "feed refused (NativeRunSpecError "
1391 + std::to_string(static_cast<int>(judged.error)) + ", field "
1392 + std::to_string(static_cast<int>(judged.field)) + ")",
1393 instrument_feed_field_name(judged.field));
1394 }
1395 // Every refusal above reads keys only; a store whose bytes are lent
1396 // (restore_symbol_feeds) gets them back before one of its feeds changes.
1397 if (const std::string why = restore_symbol_feeds_reason(); !why.empty())
1398 return refuse("restore_failed", why);
1399 if (existing >= 0) {
1400 const std::size_t at = static_cast<std::size_t>(existing);
1401 symbol_feeds_[at] = std::move(feed);
1402 symbol_feed_views_[at] = {};
1403 } else {
1404 symbol_feeds_.reserve(symbol_feeds_.size() + 1);
1405 symbol_feed_views_.reserve(symbol_feed_views_.size() + 1);
1406 symbol_feeds_.push_back(std::move(feed));
1407 symbol_feed_views_.push_back({});
1408 }
1409 refresh_symbol_data_digest();
1410 clear_run_failure(*this);
1411 return true;
1412}
1413
1415 const std::string& timeframe,
1416 const std::string& name,
1417 const double* values, int n) {
1418 guard_native_mutation("set_symbol_feed_column");
1419 const auto refuse = [this](const char* reason, const std::string& why,
1420 const char* field = nullptr) {
1421 RunFailureArgs args{{"reason", reason}};
1422 if (field) args.emplace_back("field", field);
1423 note_run_failure(*this, "strategy_set_symbol_feed_column: " + why,
1425 return false;
1426 };
1427 if (run_in_progress(*this)) return refuse("run_in_progress", kRunInProgress);
1428 const std::int64_t found = find_symbol_feed(key, canonical_symbol_timeframe(timeframe));
1429 if (found < 0) {
1430 return refuse("no_feed", "no feed is installed for symbol '" + key + "' at timeframe '"
1431 + timeframe + "'");
1432 }
1433 if (name.empty()) return refuse("empty_column", "empty column name");
1434 // The refusals above read keys only; the count check below reads the
1435 // feed's bars, so a lent store gets its bytes back first.
1436 if (const std::string why = restore_symbol_feeds_reason(); !why.empty())
1437 return refuse("restore_failed", why);
1438 NativeInstrumentFeed& feed = symbol_feeds_[static_cast<std::size_t>(found)];
1439 const auto data = native_instrument_feed_view(feed, nullptr);
1440 const std::size_t feed_count = static_cast<std::size_t>(found) < symbol_feed_views_.size()
1441 && symbol_feed_views_[static_cast<std::size_t>(found)].present
1442 ? symbol_feed_views_[static_cast<std::size_t>(found)].size : data.bars.size;
1443 if (n < 0 || static_cast<std::size_t>(n) != feed_count || (n > 0 && values == nullptr))
1444 return refuse("column_length", "column '" + name + "' has " + std::to_string(n)
1445 + " values for " + std::to_string(feed_count) + " bars");
1446 // The kernel's judgement of the name, here rather than at the next begin:
1447 // a feed of the same key without bars carries it (the count is checked
1448 // above, and no column value is refused).
1450 named.instrument = feed.instrument;
1451 named.tf = feed.tf;
1452 named.columns.push_back(NativeInstrumentColumn{name, {}});
1453 if (const auto judged = validate_native_instrument_feeds({named}); !judged) {
1454 return refuse("column_refused", "column '" + name + "' refused (NativeRunSpecError "
1455 + std::to_string(static_cast<int>(judged.error)) + ", field "
1456 + std::to_string(static_cast<int>(judged.field)) + ")",
1457 instrument_feed_field_name(judged.field));
1458 }
1459 NativeInstrumentColumn column{name, n > 0 ? std::vector<double>(values, values + n)
1460 : std::vector<double>{}};
1461 bool replaced = false;
1462 for (auto& existing : feed.columns) {
1463 if (existing.name != name) continue;
1464 existing = std::move(column);
1465 replaced = true;
1466 break;
1467 }
1468 if (!replaced) feed.columns.push_back(std::move(column));
1469 refresh_symbol_data_digest();
1470 clear_run_failure(*this);
1471 return true;
1472}
1473
1475 const std::string& field,
1476 const std::string& value) {
1477 guard_native_mutation("set_symbol_facts");
1478 const auto refuse = [this](const char* reason, const std::string& why) {
1479 note_run_failure(*this, "strategy_set_symbol_facts: " + why,
1480 RunFailureCode::symbol_feeds_refused, {{"reason", reason}});
1481 return false;
1482 };
1483 if (run_in_progress(*this)) return refuse("run_in_progress", kRunInProgress);
1484 if (key.empty()) return refuse("empty_key", "empty symbol key");
1485 SymbolFacts candidate = symbol_facts_.count(key) ? symbol_facts_[key] : SymbolFacts{};
1486 if (field == "canonical") {
1487 candidate.canonical = value;
1488 } else if (field == "valid") {
1489 if (value == "true") candidate.valid = true;
1490 else if (value == "false") candidate.valid = false;
1491 else return refuse("valid_not_boolean",
1492 "valid must be \"true\" or \"false\", got '" + value + "'");
1493 } else if (field == "type") {
1494 candidate.type = value;
1495 } else if (field == "timezone") {
1496 candidate.timezone = value;
1497 } else if (field == "session") {
1498 candidate.session = value;
1499 } else if (field == "currency") {
1500 candidate.currency = value;
1501 } else if (field == "mintick") {
1502 double parsed = 0.0;
1503 if (!parse_fact_number(value, parsed) || !std::isfinite(parsed) || parsed <= 0.0)
1504 return refuse("mintick_invalid",
1505 "mintick must be a positive decimal, got '" + value + "'");
1506 candidate.mintick = parsed;
1507 } else {
1508 return refuse("unknown_field", "unknown field '" + field + "'");
1509 }
1510 // The digest below reads the feeds' sizes: a lent store gets its bytes
1511 // back first, after every refusal.
1512 if (const std::string why = restore_symbol_feeds_reason(); !why.empty())
1513 return refuse("restore_failed", why);
1514 symbol_facts_[key] = std::move(candidate);
1515 refresh_symbol_data_digest();
1516 clear_run_failure(*this);
1517 return true;
1518}
1519
1521 const int64_t* chart_open_ms,
1522 const double* values, int n) {
1523 return set_recorded_series_impl(key, chart_open_ms, values, n, std::nullopt);
1524}
1525
1527 const int64_t* chart_open_ms,
1528 const double* values, int n,
1529 std::size_t cache_capacity_bytes) {
1530 return set_recorded_series_impl(key, chart_open_ms, values, n, cache_capacity_bytes);
1531}
1532
1533bool source::PineStrategyHost::set_recorded_series_impl(
1534 const std::string& key, const int64_t* chart_open_ms, const double* values, int n,
1535 std::optional<std::size_t> cache_capacity_bytes) {
1536 guard_native_mutation("set_recorded_series");
1537 const auto refuse = [this](const char* reason, const std::string& why) {
1538 note_run_failure(*this, "strategy_set_recorded_series: " + why,
1539 RunFailureCode::recorded_request_refused, {{"reason", reason}});
1540 return false;
1541 };
1542 if (run_in_progress(*this)) return refuse("run_in_progress", kRunInProgress);
1543 if (key.empty()) return refuse("empty_key", "empty key");
1544 if (n < 0 || (n > 0 && (chart_open_ms == nullptr || values == nullptr)))
1545 return refuse("invalid_arrays", "invalid arrays");
1546 for (int i = 1; i < n; ++i) {
1547 if (chart_open_ms[i] <= chart_open_ms[i - 1])
1548 return refuse("times_not_increasing",
1549 "chart open times must be strictly increasing (row "
1550 + std::to_string(i) + ")");
1551 }
1552 // n == 0 is a request that returned na on every chart bar (a tape with a
1553 // header only): installed, so every read is na rather than a failure.
1554 pineforge::detail::FeedProjectionBuilder<RecordedSeriesRow> builder(report_scratch_directory(), true);
1555 for (int i = 0; i < n; ++i)
1556 builder.append(RecordedSeriesRow{chart_open_ms[i], values[i]});
1557 RecordedSeries series{builder.finish(cache_capacity_bytes.value_or(
1558 pineforge::detail::kFeedProjectionCacheBytes)), cache_capacity_bytes};
1559 // As in set_symbol_facts: the bytes back before the digest, after every
1560 // refusal.
1561 if (const std::string why = restore_symbol_feeds_reason(); !why.empty())
1562 return refuse("restore_failed", why);
1563 const auto digest = calculate_symbol_data_digest(&key, &series);
1564 recorded_series_[key] = std::move(series);
1565 symbol_data_digest_ = digest;
1566 clear_run_failure(*this);
1567 return true;
1568}
1569
1571 const std::string& key, std::shared_ptr<const FeedReader<RecordedSeriesRow>> reader) {
1572 return set_recorded_series_reader_impl(key, std::move(reader), std::nullopt);
1573}
1574
1576 const std::string& key, std::shared_ptr<const FeedReader<RecordedSeriesRow>> reader,
1577 std::size_t cache_capacity_bytes) {
1578 return set_recorded_series_reader_impl(key, std::move(reader), cache_capacity_bytes);
1579}
1580
1581bool source::PineStrategyHost::set_recorded_series_reader_impl(
1582 const std::string& key, std::shared_ptr<const FeedReader<RecordedSeriesRow>> reader,
1583 std::optional<std::size_t> cache_capacity_bytes) {
1584 guard_native_mutation("set_recorded_series_reader");
1585 const auto refuse = [this](const char* reason, const std::string& why) {
1586 note_run_failure(*this, "set_recorded_series_reader: " + why,
1587 RunFailureCode::recorded_request_refused, {{"reason", reason}});
1588 return false;
1589 };
1590 if (run_in_progress(*this)) return refuse("run_in_progress", kRunInProgress);
1591 if (key.empty()) return refuse("empty_key", "empty key");
1592 if (!reader) return refuse("invalid_arrays", "missing reader");
1593 RecordedSeries series{pineforge::detail::FeedProjection<RecordedSeriesRow>(
1594 std::move(reader), cache_capacity_bytes.value_or(
1595 pineforge::detail::kFeedProjectionCacheBytes)), cache_capacity_bytes};
1596 std::int64_t previous = 0;
1597 for (std::size_t i = 0; i < series.rows.size(); ++i) {
1598 const auto open = series.rows[i].open_ms;
1599 if (i && open <= previous)
1600 return refuse("times_not_increasing",
1601 "chart open times must be strictly increasing (row "
1602 + std::to_string(i) + ")");
1603 previous = open;
1604 }
1605 if (const std::string why = restore_symbol_feeds_reason(); !why.empty())
1606 return refuse("restore_failed", why);
1607 const auto digest = calculate_symbol_data_digest(&key, &series);
1608 recorded_series_[key] = std::move(series);
1609 symbol_data_digest_ = digest;
1610 clear_run_failure(*this);
1611 return true;
1612}
1613
1614// The facts and tapes as the extension folds them, and the feeds' keys and
1615// sizes (their bars are the projected spec's, which the continuation digest
1616// folds). Taken whenever the stores change, never during a run.
1617void source::PineStrategyHost::refresh_symbol_data_digest() {
1618 symbol_data_digest_ = calculate_symbol_data_digest();
1619}
1620
1621std::uint64_t source::PineStrategyHost::calculate_symbol_data_digest(
1622 const std::string* replacement_key, const RecordedSeries* replacement) const {
1623 BrokerStateHashSink f;
1624 f.u(symbol_feeds_.size());
1625 for (const auto& feed : symbol_feeds_) {
1626 f.s(feed.instrument);
1627 f.s(feed.tf);
1628 const auto data = native_instrument_feed_view(feed, nullptr);
1629 const std::size_t count = (&feed - symbol_feeds_.data()) < symbol_feed_views_.size()
1630 && symbol_feed_views_[static_cast<std::size_t>(&feed - symbol_feeds_.data())].present
1631 ? symbol_feed_views_[static_cast<std::size_t>(&feed - symbol_feeds_.data())].size
1632 : data.bars.size;
1633 f.u(count);
1634 f.u(feed.columns.size());
1635 for (const auto& column : feed.columns) f.s(column.name);
1636 }
1637 f.u(symbol_facts_.size());
1638 for (const auto& [key, facts] : symbol_facts_) {
1639 f.s(key);
1640 f.s(facts.canonical);
1641 f.b(facts.valid.has_value());
1642 if (facts.valid) f.b(*facts.valid);
1643 f.s(facts.type);
1644 f.s(facts.timezone);
1645 f.s(facts.session);
1646 f.s(facts.currency);
1647 f.d(facts.mintick);
1648 }
1649 const bool inserting = replacement_key && recorded_series_.find(*replacement_key)
1650 == recorded_series_.end();
1651 f.u(recorded_series_.size() + (inserting ? 1 : 0));
1652 const auto fold_series = [&f](const std::string& key, const RecordedSeries& series) {
1653 f.s(key);
1654 f.u(series.rows.size());
1655 for (std::size_t i = 0; i < series.rows.size(); ++i) {
1656 const RecordedSeriesRow row = series.rows[i];
1657 f.i(row.open_ms);
1658 f.d(row.value);
1659 }
1660 };
1661 // Stage a replacement in the same lexical key order as the original map,
1662 // without publishing it before every provider read and the fold succeed.
1663 bool emitted = false;
1664 for (const auto& [key, series] : recorded_series_) {
1665 if (replacement_key && !emitted && *replacement_key <= key) {
1666 fold_series(*replacement_key, *replacement);
1667 emitted = true;
1668 }
1669 if (!replacement_key || key != *replacement_key) fold_series(key, series);
1670 }
1671 if (replacement_key && !emitted) fold_series(*replacement_key, *replacement);
1672 return f.h;
1673}
1674
1675} // namespace pineforge
1676
1677namespace pineforge {
1678
1680 const std::string& key, const std::string& timeframe,
1681 const Bar* bars, const int64_t* close_ms, int n) {
1682 guard_native_mutation("set_symbol_feed_borrowed");
1683 const auto refuse = [this](const char* reason, const std::string& why,
1684 const char* field = nullptr) {
1685 RunFailureArgs args{{"reason", reason}};
1686 if (field) args.emplace_back("field", field);
1687 note_run_failure(*this, "strategy_set_symbol_feed_borrowed_v1: " + why,
1689 return false;
1690 };
1691 if (run_in_progress(*this)) return refuse("run_in_progress", kRunInProgress);
1692 if (key.empty()) return refuse("empty_key", "empty symbol key");
1693 const std::string tf = canonical_symbol_timeframe(timeframe);
1694 if (tf.empty() || !native_calendar::parse_timeframe(tf))
1695 return refuse("timeframe_unparsable", "timeframe '" + timeframe + "' does not parse");
1696 if (n < 0 || (n > 0 && (bars == nullptr || close_ms == nullptr)))
1697 return refuse("invalid_bars", "invalid bar array");
1698
1699 const std::size_t count = static_cast<std::size_t>(n);
1700 NativeInstrumentFeedView view{{bars, count}, close_ms};
1701 const NativeInstrumentFeedView* slots[] = {&view};
1702 NativeBarViews views{slots, 1};
1703 std::vector<NativeInstrumentFeed> judged_feeds(1);
1704 judged_feeds.front().instrument = key;
1705 judged_feeds.front().tf = tf;
1706 const auto judged = validate_native_instrument_feeds(judged_feeds, views);
1707 if (!judged) {
1708 return refuse("kernel_refused", "feed refused (NativeRunSpecError "
1709 + std::to_string(static_cast<int>(judged.error)) + ", field "
1710 + std::to_string(static_cast<int>(judged.field)) + ")",
1711 instrument_feed_field_name(judged.field));
1712 }
1713 if (const std::string why = restore_symbol_feeds_reason(); !why.empty())
1714 return refuse("restore_failed", why);
1715
1716 const std::int64_t existing = find_symbol_feed(key, tf);
1717 const BorrowedFeedSlot replacement{bars, close_ms, count, true};
1718 if (existing >= 0) {
1719 const std::size_t at = static_cast<std::size_t>(existing);
1721 feed.instrument = key;
1722 feed.tf = tf;
1723 feed.bars.clear();
1724 feed.close_ms.clear();
1725 feed.columns.clear();
1726 symbol_feed_views_[at] = replacement;
1727 } else {
1728 symbol_feeds_.reserve(symbol_feeds_.size() + 1);
1729 symbol_feed_views_.reserve(symbol_feed_views_.size() + 1);
1731 feed.instrument = key;
1732 feed.tf = tf;
1733 symbol_feeds_.push_back(std::move(feed));
1734 symbol_feed_views_.push_back(replacement);
1735 }
1736 refresh_symbol_data_digest();
1737 clear_run_failure(*this);
1738 return true;
1739}
1740
1741} // namespace pineforge
Base with a code: throw coded<std::invalid_argument>(code, {args}, text).
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)
std::map< std::string, RecordedSeries > recorded_series_
bool set_symbol_facts(const std::string &key, const std::string &field, const std::string &value) override
bool set_recorded_series_reader(const std::string &key, std::shared_ptr< const FeedReader< RecordedSeriesRow > > reader)
bool set_symbol_feed_borrowed(const std::string &key, const std::string &timeframe, const Bar *bars, const int64_t *close_ms, int n) override
std::vector< BorrowedFeedSlot > symbol_feed_views_
bool set_symbol_feed_column(const std::string &key, const std::string &timeframe, const std::string &name, const double *values, int n) override
int security_lower_tf_sub_bar_index(int sec_id) const
bool set_symbol_feed(const std::string &key, const std::string &timeframe, const Bar *bars, const int64_t *close_ms, int n) override
std::map< std::string, SymbolFacts > symbol_facts_
double security_column_value(int sec_id, const std::string &name) 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::vector< NativeInstrumentFeed > symbol_feeds_
bool set_recorded_series(const std::string &key, const int64_t *chart_open_ms, const double *values, int n) override
std::map< int, PineSecurityEvalState > pine_security_states_
double recorded_series_value(const std::string &key) const
void on_native_timeframe_bar(const Bar &, const NativeTimeframeBarContext &) final
std::map< int, ForeignSecuritySite > foreign_security_sites_
constexpr std::size_t kFeedProjectionCacheBytes
NativeRunSpecValidation validate_native_instrument_feeds(const std::vector< NativeInstrumentFeed > &feeds, const NativeBarViews &views) noexcept
The same feed-only validation over effective data, retaining all existing refusal ordering and typed ...
NativeInstrumentFeedReadView native_instrument_feed_view(const NativeInstrumentFeed &owned, const NativeInstrumentFeedView *borrowed) noexcept
Common accessor for effective feed data.
std::optional< Timeframe > parse_timeframe(std::string_view text)
int b(int64_t c)
Definition color.hpp:44
const char * instrument_feed_field_name(NativeRunSpecField field) noexcept
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.
int tf_ratio(const std::string &input_tf, const std::string &target_tf)
Compute how many input bars fit into one target bar.
int64_t session_period_close_ms(int64_t ms, const std::string &tz, const std::string &session, CalendarPeriod period)
Exclusive close (Unix ms) of the symbol's D/W/M bar that contains ms: DAY -> session-day open + sessi...
CalendarPeriod calendar_period_for(const std::string &tf)
Determine the calendar period for a target TF string.
void note_run_failure(BacktestEngine &engine, std::string text, RunFailureCode code, const RunFailureArgs &args={})
void clear_run_failure(BacktestEngine &engine) noexcept
bool is_na(const Line &h)
Definition drawing.hpp:58
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)
std::vector< RunFailureArg > RunFailureArgs
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...
One completed higher-timeframe bucket of a declared subscription (NativeRunSpec::subscriptions),...
Explicit read context for feeds and lower paths.
One named column an instrument feed carries beside its OHLCV: one value per bar, in bar order.
Another instrument's own bars of one timeframe, installed as data.
std::vector< NativeInstrumentColumn > columns
pineforge::detail::FeedProjection< RecordedSeriesRow > rows