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