PineForge unreleased
Deterministic PineScript v6 backtest runtime — C ABI reference
Loading...
Searching...
No Matches
pine_strategy_host.cpp
Go to the documentation of this file.
6#include <pineforge/ta.hpp>
9#include "../engine_internal.hpp"
10#include "../report_replay.hpp"
11#include "../timezone.hpp"
12#include "../native_execution_consumer.hpp"
13#include "../scratch_table_binders.hpp"
14#include "pine_host_reads.hpp"
15#include "pine_intent_state.hpp"
16#include "pine_quiet_bar.hpp"
18#include "pine_run_failure.hpp"
23
24#include <algorithm>
25#include <array>
26#include <cerrno>
27#include <cmath>
28#include <cstring>
29#include <cstdlib>
30#include <ctime>
31#include <iterator>
32#include <limits>
33#include <memory>
34#include <numeric>
35#include <optional>
36#include <stdexcept>
37#include <string>
38#include <system_error>
39#include <utility>
40#include <variant>
41#include <unistd.h>
42
43namespace pineforge {
44using namespace source;
45
46namespace {
47
48::pineforge::detail::ScratchStoreConfig command_scratch_config(
49 std::size_t page_size, ::pineforge::detail::ScratchKeyOrder order) noexcept {
50 return {2, page_size, 1, 1, order,
52}
53
54std::size_t command_scratch_page_size() {
55 errno = 0;
56 const long size = ::sysconf(_SC_PAGESIZE);
57 if (size <= 0)
59 std::system_error(errno ? errno : EINVAL, std::generic_category(),
60 "command scratch platform page size"));
61 return static_cast<std::size_t>(size);
62}
63
64struct SourceExitScratchRun {
65 // Backings precede their stores/context, so borrowers die first.
66 // The native consumer retains this run through source-adapter destruction.
67 // Geometry is fixed independently of the bar and order populations.
68 const std::size_t page_size = command_scratch_page_size();
69 std::unique_ptr<::pineforge::detail::ScratchBacking> exit_backing;
70 ::pineforge::detail::CommandScratchStore exit{
71 command_scratch_config(page_size, source::detail::exit_scratch_key_order()),
72 exit_backing.get()};
73 source::detail::ExitScratchContext exit_context{exit, {}};
74 source::detail::ExitScratchVersionsProduction exit_versions{exit};
75
76 explicit SourceExitScratchRun(CacheCapacity capacity)
77 : exit_backing(source::detail::make_command_scratch_backing(page_size, capacity)) {
78 if (exit.status() != ::pineforge::detail::ScratchStatus::ok) {
79 if (exit.status() == ::pineforge::detail::ScratchStatus::io_error) // the store carries a refused workspace allocation
80 ::pineforge::detail::rethrow_carried_scratch_failure(exit.carried_failure() ? exit.carried_failure() : exit_backing->carried_failure());
81 const auto code = exit.status() == ::pineforge::detail::ScratchStatus::io_error
82 ? RunFailureCode::scratch_io_failed
83 : RunFailureCode::engine_invariant;
84 throw coded<std::logic_error>(code, {},
85 "run command scratch store failed to open");
86 }
87 // This head exposes owning NativeOpenLot rows and snapshot readers,
88 // rather than the reference tree's NativeOpenLotView visitor API.
89 exit_context.versions = &exit_versions;
90 exit_context.lots = [](const NativeStrategyHost& host, double mark, void* context,
91 bool (*visit)(void*, const NativeOpenLot&)) {
92 for (const auto& lot : host.native_open_lots(mark))
93 if (!visit(context, lot)) return ::pineforge::detail::ScratchStatus::cancelled;
94 return ::pineforge::detail::ScratchStatus::ok;
95 };
96 exit_context.working = [](const NativeStrategyHost& host, void* context,
97 bool (*visit)(void*, const NativeWorkingRequest&)) {
98 for (const auto& request : host.native_working_requests())
99 if (!visit(context, request)) return ::pineforge::detail::ScratchStatus::cancelled;
100 return ::pineforge::detail::ScratchStatus::ok;
101 };
102 }
103};
104
105using TradeScratchKey = std::array<std::uint64_t, 4>;
106using TradeScratchHeader = std::array<std::uint64_t, 23>;
107enum : std::uint64_t {
108 scratch_incarnation, scratch_row, scratch_sort, scratch_permutation
109};
110
111::pineforge::detail::ScratchBytes scratch_bytes(const void* data, std::size_t size) noexcept {
112 return {static_cast<const std::byte*>(data), size};
113}
114
115::pineforge::detail::ScratchBuffer scratch_buffer(void* data, std::size_t size) noexcept {
116 return {static_cast<std::byte*>(data), size};
117}
118
119::pineforge::detail::ScratchStatus compare_trade_scratch(
120 void*, const ::pineforge::detail::ScratchByteSource& a, const ::pineforge::detail::ScratchByteSource& b,
121 int& order) noexcept {
122 TradeScratchKey ka{}, kb{};
123 if (a.size() != sizeof ka || b.size() != sizeof kb)
124 return ::pineforge::detail::ScratchStatus::programming_error;
125 auto status = a.read(0, scratch_buffer(ka.data(), sizeof ka));
127 status = b.read(0, scratch_buffer(kb.data(), sizeof kb));
129 order = ka < kb ? -1 : kb < ka ? 1 : 0;
130 return ::pineforge::detail::ScratchStatus::ok;
131}
132
133void require_trade_scratch(::pineforge::detail::ScratchStatus status,
134 const ::pineforge::detail::ScratchReadback& store) {
135 if (status == ::pineforge::detail::ScratchStatus::ok) return;
141 throw coded<std::logic_error>(code, {},
142 "trade scratch contract operation failed");
143}
144
145// These records are temporary, including on success. Rollback restores the
146// empty store and its handle counter; no scratch state survives a callback.
147class TradeScratchTransaction {
148public:
149 explicit TradeScratchTransaction(::pineforge::detail::CommandScratchStore& store) : store_(store) {
150 require_trade_scratch(store_.begin(), store_);
151 }
152 ~TradeScratchTransaction() {
153 if (store_.transaction_active()) (void)store_.rollback();
154 }
155 void finish() { require_trade_scratch(store_.rollback(), store_); }
156private:
157 ::pineforge::detail::CommandScratchStore& store_;
158};
159
160::pineforge::detail::ScratchHandle scratch_seek(const ::pineforge::detail::CommandScratchStore& store,
161 const TradeScratchKey& key) {
162 ::pineforge::detail::ScratchSpanSource probe(scratch_bytes(key.data(), sizeof key));
164 const auto status = store.lower_bound(probe, handle);
165 if (status != ::pineforge::detail::ScratchStatus::end) require_trade_scratch(status, store);
166 return handle;
167}
168
169TradeScratchKey scratch_key(const ::pineforge::detail::ScratchReadback& store,
171 TradeScratchKey key{};
172 require_trade_scratch(store.read(handle, ::pineforge::detail::ScratchField::key, 0,
173 scratch_buffer(key.data(), sizeof key)), store);
174 return key;
175}
176
177::pineforge::detail::ScratchHandle scratch_find(const ::pineforge::detail::CommandScratchStore& store,
178 const TradeScratchKey& key) {
179 const auto handle = scratch_seek(store, key);
180 return handle != ::pineforge::detail::scratch_no_handle && scratch_key(store, handle) == key
182}
183
184void scratch_put(::pineforge::detail::CommandScratchStore& store, const TradeScratchKey& key,
185 const ::pineforge::detail::ScratchByteSource& value) {
186 ::pineforge::detail::ScratchSpanSource source(scratch_bytes(key.data(), sizeof key));
188 require_trade_scratch(store.put(source, value, handle), store);
189}
190
191void scratch_put(::pineforge::detail::CommandScratchStore& store, const TradeScratchKey& key,
192 ::pineforge::detail::ScratchBytes value = {}) {
193 ::pineforge::detail::ScratchSpanSource source(value);
194 scratch_put(store, key, source);
195}
196
197template<class Visit>
198void scratch_walk(const ::pineforge::detail::CommandScratchStore& store, std::uint64_t kind, Visit&& visit) {
199 auto handle = scratch_seek(store, {kind, 0, 0, 0});
201 const auto key = scratch_key(store, handle);
202 if (key[0] != kind) break;
203 visit(handle, key);
205 const auto status = store.next(handle, next);
206 if (status != ::pineforge::detail::ScratchStatus::end) require_trade_scratch(status, store);
207 handle = next;
208 }
209}
210
211template<class T>
212std::uint64_t scratch_word(const T& value) noexcept {
213 static_assert(sizeof(T) <= sizeof(std::uint64_t), "trade scratch scalar");
214 std::uint64_t word = 0;
215 std::memcpy(&word, &value, sizeof value);
216 return word;
217}
218
219template<class T>
220void scratch_scalar(std::uint64_t word, T& value) noexcept {
221 static_assert(sizeof(T) <= sizeof word, "trade scratch scalar");
222 std::memcpy(&value, &word, sizeof value);
223}
224
225TradeScratchHeader scratch_trade_header(const Trade& row, std::size_t source,
226 std::uint8_t phase) noexcept {
227 return {source, phase, scratch_word(row.entry_time), scratch_word(row.exit_time),
228 scratch_word(row.entry_price), scratch_word(row.exit_price), scratch_word(row.qty),
229 scratch_word(row.pnl), scratch_word(row.pnl_pct), scratch_word(row.is_long),
230 scratch_word(row.entry_bar_index), scratch_word(row.exit_bar_index),
231 scratch_word(row.exit_from_bracket), scratch_word(row.max_runup),
232 scratch_word(row.max_drawdown), scratch_word(row.commission),
233 row.entry_incarnation, scratch_word(row.open_at_end), scratch_word(row.close_cause),
234 row.entry_id.size(), row.entry_comment.size(), row.exit_comment.size(),
235 row.exit_id.size()};
236}
237
238void scratch_trade_scalars(const TradeScratchHeader& header, Trade& row) noexcept {
239 scratch_scalar(header[2], row.entry_time);
240 scratch_scalar(header[3], row.exit_time);
241 scratch_scalar(header[4], row.entry_price);
242 scratch_scalar(header[5], row.exit_price);
243 scratch_scalar(header[6], row.qty);
244 scratch_scalar(header[7], row.pnl);
245 scratch_scalar(header[8], row.pnl_pct);
246 scratch_scalar(header[9], row.is_long);
247 scratch_scalar(header[10], row.entry_bar_index);
248 scratch_scalar(header[11], row.exit_bar_index);
249 scratch_scalar(header[12], row.exit_from_bracket);
250 scratch_scalar(header[13], row.max_runup);
251 scratch_scalar(header[14], row.max_drawdown);
252 scratch_scalar(header[15], row.commission);
253 row.entry_incarnation = header[16];
254 scratch_scalar(header[17], row.open_at_end);
255 scratch_scalar(header[18], row.close_cause);
256}
257
258// Scalar amendments plus text borrowed from one ledger value. put streams
259// all five spans without retaining the replacement population in RAM.
260class TradeScratchSource final : public ::pineforge::detail::ScratchByteSource {
261public:
262 TradeScratchSource(TradeScratchHeader header, const Trade& text) noexcept
263 : header_(header), parts_{scratch_bytes(header_.data(), sizeof header_),
264 scratch_bytes(text.entry_id.data(), text.entry_id.size()),
265 scratch_bytes(text.entry_comment.data(), text.entry_comment.size()),
266 scratch_bytes(text.exit_comment.data(), text.exit_comment.size()),
267 scratch_bytes(text.exit_id.data(), text.exit_id.size())} {}
268 std::uint64_t size() const noexcept override {
269 std::uint64_t result = 0;
270 for (const auto part : parts_) result += part.size;
271 return result;
272 }
273 ::pineforge::detail::ScratchStatus read(std::uint64_t offset,
274 ::pineforge::detail::ScratchBuffer destination) const noexcept override {
275 if (offset > size() || destination.size > size() - offset
276 || (destination.size && !destination.data))
277 return ::pineforge::detail::ScratchStatus::programming_error;
278 if (!destination.size) return ::pineforge::detail::ScratchStatus::ok;
279 for (const auto part : parts_) {
280 if (offset >= part.size) { offset -= part.size; continue; }
281 const auto count = std::min(destination.size,
282 part.size - static_cast<std::size_t>(offset));
283 if (count) std::memcpy(destination.data, part.data + offset, count);
284 destination.data += count;
285 destination.size -= count;
286 offset = 0;
287 if (!destination.size) break;
288 }
289 return ::pineforge::detail::ScratchStatus::ok;
290 }
291private:
292 const TradeScratchHeader header_;
293 const std::array<::pineforge::detail::ScratchBytes, 5> parts_;
294};
295
296TradeScratchHeader scratch_read_trade(const ::pineforge::detail::ScratchReadback& store,
298 TradeScratchHeader header{};
299 require_trade_scratch(store.read(handle, ::pineforge::detail::ScratchField::value, 0,
300 scratch_buffer(header.data(), sizeof header)), store);
301 return header;
302}
303
304void scratch_trade_text(const ::pineforge::detail::ScratchReadback& store, ::pineforge::detail::ScratchHandle handle,
305 const TradeScratchHeader& header, Trade& row) {
306 const std::array<std::string*, 4> text{
307 &row.entry_id, &row.entry_comment, &row.exit_comment, &row.exit_id};
308 std::uint64_t offset = sizeof header;
309 for (std::size_t index = 0; index < text.size(); ++index) {
310 text[index]->resize(static_cast<std::size_t>(header[19 + index]));
311 require_trade_scratch(store.read(handle, ::pineforge::detail::ScratchField::value, offset,
312 scratch_buffer(text[index]->data(), text[index]->size())), store);
313 offset += text[index]->size();
314 }
315}
316
317[[noreturn]] void reject_begin_bar(int index, const char* field, const char* detail,
318 const char* reason) {
321 {{"index", index}, {"field", field}, {"reason", reason}},
322 "bar[" + std::to_string(index) + "]." + field + (detail ? detail : ""));
323}
324
325// Validate the borrowed public begin array before the source provider stages
326// syminfo, inputs, adapter state, or a native run spec. This mirrors the
327// legacy chart/stream shape checks and deliberately does not impose native
328// calendar or slot-label policy; those remain the generic preflight's job.
329void validate_source_begin_bars(const NativeBeginArgs& args) {
330 if (args.n < 0) {
332 "bar count must be non-negative");
333 }
334 if (args.n > 0 && args.bars == nullptr) {
336 "bars must be non-null for a nonempty array");
337 }
338 for (int i = 0; i < args.n; ++i) {
339 const Bar& bar = args.bars[i];
340 if (!std::isfinite(bar.open)) reject_begin_bar(i, "open", " must be finite", "not_finite");
341 if (!std::isfinite(bar.high)) reject_begin_bar(i, "high", " must be finite", "not_finite");
342 if (!std::isfinite(bar.low)) reject_begin_bar(i, "low", " must be finite", "not_finite");
343 if (!std::isfinite(bar.close)) reject_begin_bar(i, "close", " must be finite", "not_finite");
344 if (args.is_stream) {
345 if (bar.timestamp < 0)
346 reject_begin_bar(i, "timestamp", " must be non-negative", "negative");
347 if (bar.open < 0.0) reject_begin_bar(i, "open", " must be non-negative", "negative");
348 if (bar.high < 0.0) reject_begin_bar(i, "high", " must be non-negative", "negative");
349 if (bar.low < 0.0) reject_begin_bar(i, "low", " must be non-negative", "negative");
350 if (bar.close < 0.0) reject_begin_bar(i, "close", " must be non-negative", "negative");
351 if (!std::isfinite(bar.volume) || bar.volume < 0.0)
352 reject_begin_bar(i, "volume", " must be non-negative finite", "volume_invalid");
353 } else if (!std::isnan(bar.volume)
354 && (!std::isfinite(bar.volume) || bar.volume < 0.0)) {
355 reject_begin_bar(i, "volume", " must be non-negative finite or NaN (unavailable)",
356 "volume_invalid");
357 }
358 if (bar.low > std::min(bar.open, bar.close))
359 reject_begin_bar(i, "low", " must not exceed open or close", "low_above_body");
360 if (bar.high < std::max(bar.open, bar.close))
361 reject_begin_bar(i, "high", " must not be below open or close", "high_below_body");
362 if (i > 0) {
363 const std::int64_t previous = args.bars[i - 1].timestamp;
364 if (bar.timestamp <= previous)
365 reject_begin_bar(i, "timestamp", " must be strictly increasing", "not_increasing");
366 if (previous < 0
367 && bar.timestamp > std::numeric_limits<std::int64_t>::max() + previous) {
368 reject_begin_bar(i, "timestamp", " delta exceeds int64 range", "delta_overflow");
369 }
370 }
371 }
372 if (args.is_stream && args.n > 0
373 && (!std::isfinite(args.bars[args.n - 1].close)
374 || args.bars[args.n - 1].close <= 0.0)) {
376 "stream warmup final close must be finite and positive");
377 }
378}
379
380// AR-R4: each installed feed lent to a projected spec. The lent entry holds
381// the bars, closes and column values; the store keeps the instrument, tf and
382// column names. Every allocation happens before the first byte moves, and the
383// moves are swaps, so a throw leaves the store whole.
384std::vector<NativeInstrumentFeed> lend_feed_bytes(std::vector<NativeInstrumentFeed>& store) {
385 std::vector<NativeInstrumentFeed> lent(store.size());
386 for (std::size_t i = 0; i < store.size(); ++i) {
387 lent[i].instrument = store[i].instrument;
388 lent[i].tf = store[i].tf;
389 lent[i].columns.resize(store[i].columns.size());
390 for (std::size_t c = 0; c < store[i].columns.size(); ++c)
391 lent[i].columns[c].name = store[i].columns[c].name;
392 }
393 for (std::size_t i = 0; i < store.size(); ++i) {
394 lent[i].bars.swap(store[i].bars);
395 lent[i].close_ms.swap(store[i].close_ms);
396 for (std::size_t c = 0; c < store[i].columns.size(); ++c)
397 lent[i].columns[c].values.swap(store[i].columns[c].values);
398 }
399 return lent;
400}
401
402// The bytes of a configure that did not apply go back where they came from.
403void return_feed_bytes(std::vector<NativeInstrumentFeed>& store,
404 std::vector<NativeInstrumentFeed>& lent) noexcept {
405 for (std::size_t i = 0; i < store.size() && i < lent.size(); ++i) {
406 store[i].bars.swap(lent[i].bars);
407 store[i].close_ms.swap(lent[i].close_ms);
408 for (std::size_t c = 0; c < store[i].columns.size() && c < lent[i].columns.size(); ++c)
409 store[i].columns[c].values.swap(lent[i].columns[c].values);
410 }
411}
412
413// A legacy setter the generated strategy latched (a request's input or
414// override it could not take) surfaces at the run's begin: the same type and
415// text, coded setting_rejected unless it already carries a code.
416[[noreturn]] void rethrow_latched_setting(const checked_settings::LatchedSettingsFailure& error) {
417 if (dynamic_cast<const RunFailureInfo*>(&error) != nullptr) throw;
419 error.what());
420}
421
422} // namespace
423
425 return {0x5830365830370001ULL, nullptr, compare_trade_scratch};
426}
427
429 require_trade_scratch(store.status(), store);
430 if (store.transaction_active() || store.record_count() != 0
431 || (trade_scratch_ && trade_scratch_->transaction_active()))
433 "trade scratch must be exclusively owned and empty");
434 trade_scratch_ = &store;
435}
436
437::pineforge::detail::CommandScratchStore& source::PineStrategyHost::trade_scratch() {
438 if (!trade_scratch_)
440 "run owner did not provision trade scratch");
441 return *trade_scratch_;
442}
443
446 adapter_(*this, cap),
448 _src_open_(scheduler_.language()._src_open_),
449 _src_high_(scheduler_.language()._src_high_),
450 _src_low_(scheduler_.language()._src_low_),
453 _src_hl2_(scheduler_.language()._src_hl2_),
454 _src_hlc3_(scheduler_.language()._src_hlc3_),
458 // ab9714be LegacyCompatibilityConsumer::refuse was a no-op on this handle.
459 host_mutation_guard_inert_ = true;
460 NativeExecutionConsumer::bound(*this).set_batch_input_release(
461 this, [](void* owner) noexcept {
462 static_cast<PineStrategyHost*>(owner)->scheduler_.release_batch_input();
463 });
464#ifdef PINEFORGE_HAS_AUX_SECURITY_FEED_V1
465 // A fresh host folds its empty feed as one cleared at a run's end does.
467#endif
468}
469
471 // Fold current source/generic state with the last script-point
472 // continuation. Recording only controls whether the per-bar array is
473 // retained; the continuation snapshot keeps the scalar independent of
474 // that switch and of NativeCompleted teardown.
475 if (last_script_continuation_valid_) {
476 return broker_state_hash_from_execution_hash(last_script_continuation_hash_);
477 }
478 const std::uint64_t execution = execution_consumer().continuation_hash();
479 // The fold cannot throw: a read of the spilled book that failed inside it
480 // was carried by the core and is thrown here, never folded as a hash it
481 // cut short.
482 as_native_consumer(execution_consumer()).request_core().rethrow_book_read_failure();
483 return broker_state_hash_from_execution_hash(execution);
484}
485
487 // The level is the kernel's (native_liquidation_price, solved from closed
488 // money alone: project() declares NativeLiquidationLevelBase::RealizedOnly),
489 // and na where it has none -- a flat book, a run without a margin model.
490 // TradingView's spelling is the one thing kept here (design row MG8): the
491 // level rounded onto the tick grid away from the position, down for a
492 // long, up for a short.
493 const std::optional<double> level = native_liquidation_price();
494 if (!level) return na<double>();
495 double liquidation = *level;
496 if (syminfo_mintick_ > 0.0) {
497 liquidation = position_side_ == PositionSide::SHORT
498 ? std::ceil(liquidation / syminfo_mintick_) * syminfo_mintick_
499 : std::floor(liquidation / syminfo_mintick_) * syminfo_mintick_;
500 }
501 return liquidation;
502}
503
504PineStrategyConfig source::PineStrategyHost::apply_overrides(
505 PineStrategyConfig config, const StrategyOverrides& overrides) {
506 if (std::isinf(overrides.default_qty_value)) {
509 {{"entrypoint", "strategy_set_override"},
510 {"reason", "number_out_of_finite_range"}},
511 "default_qty_value: number out of finite range");
512 }
513 if (!std::isnan(overrides.initial_capital)) config.initial_capital = overrides.initial_capital;
514 if (!std::isnan(overrides.commission_value)) config.commission_value = overrides.commission_value;
515 if (!std::isnan(overrides.default_qty_value)) config.default_qty_value = overrides.default_qty_value;
516 if (overrides.pyramiding >= 0) config.pyramiding = overrides.pyramiding;
517 if (overrides.slippage >= 0) config.slippage = overrides.slippage;
518 if (overrides.commission_type >= 0) config.commission_type = overrides.commission_type;
519 if (overrides.default_qty_type >= 0) config.default_qty_type = overrides.default_qty_type;
520 if (overrides.process_orders_on_close >= 0)
521 config.process_orders_on_close = overrides.process_orders_on_close != 0;
522 if (overrides.calc_on_order_fills >= 0)
523 config.calc_on_order_fills = overrides.calc_on_order_fills != 0;
524 if (overrides.close_entries_rule >= 0)
525 config.close_entries_rule_any = overrides.close_entries_rule != 0;
526 return config;
527}
528
529StagedConfiguration source::PineStrategyHost::staged_configuration() const {
530 StagedConfiguration staged;
531 staged.syminfo = syminfo_;
532 staged.syminfo.mintick = syminfo_mintick_;
533 staged.inputs = inputs_;
534 staged.chart_timezone = chart_timezone_;
535 staged.account_fx = account_currency_fx_;
536 staged.account_fx_effective_from_ms = account_currency_fx_timestamps_;
537 staged.account_fx_per_quote = account_currency_fx_rates_;
538 if (std::isfinite(qty_step_) && qty_step_ > 0.0) staged.quantity_grid = qty_step_;
539 staged.instrument_feeds.reserve(symbol_feeds_.size());
540 for (std::size_t i = 0; i < symbol_feeds_.size(); ++i) {
541 const NativeInstrumentFeed& feed = installed_symbol_feed(i);
542 const auto views = NativeExecutionConsumer::bound(*this).bar_views();
543 const auto* slot = views.instrument_feed_count == 0
544 ? nullptr : views.instrument_feeds[i];
545 const auto data = native_instrument_feed_view(feed, slot);
546 const std::size_t count = i < symbol_feed_views_.size() && symbol_feed_views_[i].present
547 ? symbol_feed_views_[i].size : data.bars.size;
548 staged.instrument_feeds.push_back(
549 {feed.instrument, feed.tf, count, feed.columns.size()});
550 }
551 return staged;
552}
553
555 // Idle abort requests are consumed by the public begin entry even when
556 // input validation refuses before a run starts. Crucially, validation
557 // runs before any provider-owned state is changed.
558 abort_requested_.store(false, std::memory_order_relaxed);
559 validate_source_begin_bars(args);
560 // Admission owns a reentrant begin's refusal. Keep the running caller
561 // span and source state intact until the outer public call unwinds.
562 if (native_state().kind == NativeLifecycleKind::Running) return;
563 if (args.syminfo) {
564 syminfo_ = *args.syminfo;
565 syminfo_mintick_ = syminfo_.mintick;
566 if (std::isfinite(syminfo_.qty_step) && syminfo_.qty_step > 0.0)
567 qty_step_ = syminfo_.qty_step;
568 }
569 if (args.inputs) inputs_ = *args.inputs;
570
571 if (args.is_stream && native_security_feed_enabled()) {
573 RunFailureCode::run_mode_unsupported, {{"feature", "stream_native_security_feed"}},
574 "native request.security feed supports historical runs only");
575 }
576 // Lane XSYM-D: another symbol's feeds and the recorded request series are
577 // installed whole before a run and have no realtime ingress, exactly as
578 // the native feed above.
579 if (args.is_stream && (!symbol_feeds_.empty() || !recorded_series_.empty())) {
581 RunFailureCode::run_mode_unsupported, {{"feature", "stream_symbol_feeds"}},
582 "request.security symbol feeds and recorded request series support historical "
583 "runs only");
584 }
585
586 if (!(args.n < 2 && !args.is_stream)) {
587 std::string effective_input = args.input_tf;
588 if (effective_input.empty() && args.n >= 2 && args.bars != nullptr)
589 effective_input = detect_timeframe(args.bars, args.n);
590 const std::string effective_script = args.script_tf.empty()
591 ? effective_input : args.script_tf;
592 try {
593 if (!effective_input.empty() && !effective_script.empty()
594 && tf_ratio(effective_input, effective_script) == -2) {
597 {{"option", "script_timeframe"}, {"script_tf", effective_script},
598 {"input_tf", effective_input}},
599 "script timeframe must be coarser than or equal to input timeframe: requested script_tf "
600 + effective_script + " from input timeframe " + effective_input);
601 }
602 } catch (const std::runtime_error&) {
603 throw;
604 } catch (...) {
605 // The native specification validator owns malformed literals.
606 }
607 }
608
609 if (args.is_stream && config_.calc_on_order_fills) {
611 RunFailureCode::run_mode_unsupported, {{"feature", "stream_calc_on_order_fills"}},
612 "native stream requires close-only calculation; calc_on_order_fills is unsupported");
613 }
616 RunFailureCode::run_mode_unsupported, {{"feature", "stream_probe_overrides"}},
617 "native stream cannot use historical probe/tail overrides");
618 }
619 PineStrategyConfig effective = config_;
620 if (args.overrides_opaque) {
621 const auto* overrides = static_cast<const StrategyOverrides*>(args.overrides_opaque);
622 effective = apply_overrides(effective, *overrides);
623 }
624 const StagedConfiguration staged = staged_configuration();
625 if (!staged.account_fx_effective_from_ms.empty() && effective.calc_on_order_fills)
627 RunFailureCode::run_mode_unsupported, {{"feature", "fx_series_calc_on_order_fills"}},
628 "timestamped account-currency FX does not support calc_on_order_fills");
629 if (!staged.account_fx_effective_from_ms.empty() && args.bar_magnifier)
631 RunFailureCode::run_mode_unsupported, {{"feature", "fx_series_bar_magnifier"}},
632 "timestamped account-currency FX is not supported with bar magnifier");
633
634 adapter_.reset_for_run();
635 adapter_.set_cache_policy_for_run(cache_policy_for_run());
636 for (auto& tape : recorded_series_)
637 tape.second.rows.set_capacity_bytes(tape.second.cache_capacity_override.value_or(
638 cache_policy_for_run().recorded_series_pages.capacity_bytes));
639 const auto& scratch = scratch_tables_for_run();
642 auto command_scratch = std::make_shared<SourceExitScratchRun>(cache_policy_for_run().exit_pages);
643 auto& consumer = NativeExecutionConsumer::bound(*this);
644 using ScratchBinding = NativeExecutionConsumer::CommandScratchBinding;
645 // Reserve placement evidence by incarnation once for the disclosed batch:
646 // two legs per bar, including re-issued brackets' historical projections.
647 if (args.n > 0 && static_cast<std::size_t>(args.n)
648 <= adapter_.placement_.max_size() / 2U) {
649 adapter_.placement_.reserve(static_cast<std::size_t>(args.n) * 2U);
650 }
651 adapter_.set_configuration(effective);
652 adapter_.set_staged_configuration(staged);
653 adapter_.set_begin_mode(args.is_stream, args.bar_magnifier);
654 adapter_.set_margin_call_enabled(margin_call_enabled_);
655 scheduler_.capture_begin(args);
656 scheduler_.set_source_series_active(effective.src_series_active);
657 const NativePathOrder path_order = path_order_mode_ == 1
658 ? NativePathOrder::HighFirst
659 : (path_order_mode_ == 2 ? NativePathOrder::LowFirst
660 : NativePathOrder::Auto);
661 // The installed feeds' bytes move into the projected spec (project()
662 // moves them in, and back out if it throws), which the kernel keeps as its
663 // own when it applies it: one copy, read through installed_symbol_feed()
664 // while it is lent (AR-R4). A refusal, or an exception, hands them back.
665 // Nothing of this host or of the script runs inside configure.
666 restore_symbol_feeds();
667 std::vector<NativeInstrumentFeed> lent = lend_feed_bytes(symbol_feeds_);
668 NativeRunSpec spec = [&] {
669 try {
670 const bool has_borrowed_feed = std::any_of(
672 [](const BorrowedFeedSlot& slot) { return slot.present; });
673 if (has_borrowed_feed) {
674 stage_symbol_feed_views();
675 } else {
676 NativeExecutionConsumer::bound(*this).stage_instrument_feed_views(nullptr, 0);
677 }
678 return adapter_.project(effective, staged, args, path_order, &lent);
679 } catch (...) {
680 return_feed_bytes(symbol_feeds_, lent);
681 throw;
682 }
683 }();
684 const bool failed_before = native_state().kind == NativeLifecycleKind::Failed;
685 NativeSetupResult setup;
686 try {
687 // project() establishes the actual run identity and installs a
688 // standalone store. Replace it with the host's service afterwards,
689 // before configure or any run/bar callback can read the adapter.
690 // Keeping this in the feed-return guard also covers binding failure.
691 command_scratch->exit_context.run = spec.identity;
692 adapter_.bind_exit_scratch(command_scratch->exit_context);
693 consumer.retain_command_scratch_owner(ScratchBinding::Exit, command_scratch);
694 setup = NativeExecutionConsumer::bound(*this).configure(*this, std::move(spec));
695 } catch (...) {
696 return_feed_bytes(symbol_feeds_, spec.instrument_feeds);
697 throw;
698 }
699 if (setup.status == NativeSetupStatus::Applied) {
701 } else {
702 return_feed_bytes(symbol_feeds_, spec.instrument_feeds);
703 }
704 if (setup.status != NativeSetupStatus::Applied) {
705 // configure() recorded the cause (its own text and code): the refusal
706 // below keeps its text and carries that code.
708 if (cause.code == RunFailureCode::none)
710 if (failed_before) {
711 try { prepare_script_run(nullptr, 0, false); }
712 catch (const checked_settings::LatchedSettingsFailure& error) {
713 rethrow_latched_setting(error);
714 }
715 catch (...) {}
716 }
717 throw coded<std::logic_error>(std::move(cause),
718 "Pine native adapter failed to configure projected run spec");
719 }
720 config_ = effective;
722}
723
725 NativeExecutionConsumer::bound(*this).provision_run_command_scratch(
726 NativeExecutionConsumer::CommandScratchBinding::Trade,
727 trade_scratch_order(), trade_scratch_);
728 // The adapter reads the command journal with its receipt cursor and
729 // acknowledges it as it moves (observe_terminal_receipts); acknowledging
730 // "nothing read yet" here marks this host as one that polls, so the
731 // kernel keeps every event above the cursor until the adapter has seen it.
732 native_acknowledge_events(0);
733 // The spec facts the per-bar callbacks read, once for the run (the
734 // per-pump state cache, pine_host_reads.hpp).
736 // A selected window's pre-roll capture state is parked for this run alone
737 // (pine_intent_state.hpp). It sits outside the try below on purpose: an
738 // allocation failure fails the begin instead of becoming a script preparation
739 // failure, and nothing is dropped silently.
744 session_ismarket_ = false;
745 session_isfirstbar_ = false;
746 session_islastbar_ = false;
750 // Generated configure_security_evaluators() registers this run's foreign
751 // sites again inside run_begin; a run that configures none has none.
755 try {
757 scheduler_.run_begin(*this);
758 } catch (const checked_settings::LatchedSettingsFailure& error) {
759 rethrow_latched_setting(error);
760 } catch (const std::exception& error) {
761 if (adapter_.stream_mode_) throw;
763 note_run_failure(*this, std::string(error.what()), classify_run_failure(error));
764 } catch (...) {
765 if (adapter_.stream_mode_)
767 "unknown error during Pine script preparation");
769 note_run_failure(*this, "unknown error during Pine script preparation",
771 }
772}
773
774std::int64_t source::PineStrategyHost::symbol_calendar_open_before(std::int64_t before) const {
775 if (symbol_calendar_.empty() || before <= symbol_calendar_.front().first
776 || before > symbol_calendar_.back().second) {
777 return na<std::int64_t>();
778 }
779 const bool daily = calendar_period_for(script_tf_) != CalendarPeriod::NONE;
780 const std::int64_t step = daily ? 0 : static_cast<std::int64_t>(script_tf_seconds_) * 1000;
781 // The last day opening before `before`, and its last instant before it.
782 const auto it = std::lower_bound(
783 symbol_calendar_.begin(), symbol_calendar_.end(), before,
784 [](const std::pair<std::int64_t, std::int64_t>& day, std::int64_t value) {
785 return day.first < value;
786 });
787 const auto& day = *std::prev(it);
788 const std::int64_t last = std::min(before, day.second) - 1;
789 if (step <= 0) return day.first;
790 return day.first + (last - day.first) / step * step;
791}
792
793// The symbol calendar the C ABI named day by day (set_syminfo_metadata): every
794// day of the count it announced, or the run fails naming what is missing.
796 if (symbol_calendar_days_ < 0) return;
797 // The announced days are this run's: taken whole, so a later run's
798 // announcement starts from none (a day it leaves out is missing, never
799 // an earlier run's).
800 const long long count = symbol_calendar_days_;
801 const std::map<long long, double> opens = std::move(symbol_calendar_opens_);
802 const std::map<long long, double> closes = std::move(symbol_calendar_closes_);
806 // The announcement replaces the calendar: one it cannot install leaves
807 // none behind for a later run to read.
808 symbol_calendar_.clear();
809 std::vector<std::pair<std::int64_t, std::int64_t>> sessions;
810 for (long long i = 0; i < count; ++i) {
811 const auto open = opens.find(i);
812 const auto close = closes.find(i);
813 if (open == opens.end() || close == closes.end()
814 || !std::isfinite(open->second) || !std::isfinite(close->second)) {
816 RunFailureCode::symbol_metadata_rejected, {{"field", "session_calendar"}},
817 "symbol calendar: session day " + std::to_string(i) + " of "
818 + std::to_string(count) + " is missing");
819 }
820 sessions.emplace_back(std::llround(open->second), std::llround(close->second));
821 }
822 if (!set_symbol_calendar(std::move(sessions))) {
824 {{"field", "session_calendar"}},
825 "symbol calendar: session days out of order");
826 }
827}
828
830 std::vector<std::pair<std::int64_t, std::int64_t>> sessions) {
831 for (std::size_t i = 0; i < sessions.size(); ++i) {
832 if (sessions[i].first >= sessions[i].second) return false;
833 if (i + 1 < sessions.size() && sessions[i].second > sessions[i + 1].first) return false;
834 }
835 symbol_calendar_ = std::move(sessions);
836 return true;
837}
838
839const std::pair<std::int64_t, std::int64_t>* source::PineStrategyHost::symbol_calendar_day(
840 std::int64_t ms) const {
841 const auto it = std::upper_bound(
842 symbol_calendar_.begin(), symbol_calendar_.end(), ms,
843 [](std::int64_t value, const std::pair<std::int64_t, std::int64_t>& day) {
844 return value < day.first;
845 });
846 if (it == symbol_calendar_.begin()) return nullptr;
847 const auto& day = *std::prev(it);
848 return ms < day.second ? &day : nullptr;
849}
850
851std::int64_t source::PineStrategyHost::symbol_calendar_open_from(std::int64_t at) const {
852 if (symbol_calendar_.empty() || at < symbol_calendar_.front().first)
853 return na<std::int64_t>();
854 const bool daily = calendar_period_for(script_tf_) != CalendarPeriod::NONE;
855 const std::int64_t step = daily ? 0 : static_cast<std::int64_t>(script_tf_seconds_) * 1000;
856 auto it = std::upper_bound(
857 symbol_calendar_.begin(), symbol_calendar_.end(), at,
858 [](std::int64_t value, const std::pair<std::int64_t, std::int64_t>& day) {
859 return value < day.second;
860 });
861 for (; it != symbol_calendar_.end(); ++it) {
862 if (at <= it->first) return it->first;
863 if (step <= 0) continue;
864 const std::int64_t slot = it->first + (at - it->first + step - 1) / step * step;
865 if (slot < it->second) return slot;
866 }
867 return na<std::int64_t>();
868}
869
870void source::PineStrategyHost::capture_script_continuation_hash() {
871 // Taken at once: since v19 the fold is the live state alone, which is what
872 // a deferred view would have recorded word for word (the v19 live-state hash change).
873 const std::uint64_t execution = execution_consumer().continuation_hash();
874 // A read of the spilled book that failed inside the noexcept fold is
875 // thrown here, before the hash it cut short is latched as valid; the
876 // latch keeps what it held.
877 as_native_consumer(execution_consumer()).request_core().rethrow_book_read_failure();
878 last_script_continuation_hash_ = execution;
879 last_script_continuation_valid_ = true;
880 if (broker_state_hash_recording_ && !broker_state_hashes_.empty()) {
881 broker_state_hashes_.back() = broker_state_hash();
882 }
883}
884
886 const Bar& bar, const NativeInputContext& context) {
887 if (source_prepare_failed_) return;
888 if (detail::run_phase(*this) == NativeRunPhase::Realtime)
889 stream_warmup_mode_ = false;
891 scheduler_.input(bar, context, *this);
892 // Aggregation can deliver leftover input after the last script callback.
893 // Refresh the last recorded row (and the continuation snapshot) so the
894 // scalar stays the same fold with or without recording.
895 if (scheduler_.terminal_source_bar()) capture_script_continuation_hash();
896}
897
899 const Bar& tick, const NativeTickContext& context) {
900 if (source_prepare_failed_) return;
901 if (detail::run_phase(*this) == NativeRunPhase::Realtime)
902 stream_warmup_mode_ = false;
903 scheduler_.tick(tick, context, *this);
904 adapter_.on_tick(tick, context);
905}
906
908 const Bar& bar, const NativeDecisionContext& context) {
909 if (source_prepare_failed_) return;
910 // Foreign sites, after the input's deliveries and before the bar's
911 // calc_on_order_fills checkpoint (PineScheduler::bar_open), so every
912 // calculation of the bar reads the same values: on a calendar chart the
913 // bars TradingView's merge rule makes visible at this bar that the kernel
914 // has not handed over yet run first; then a gaps_on site that ran on no
915 // new bar reads na.
916 if (!foreign_security_sites_.empty()) {
917 read_ahead_foreign_security_sites(bar);
918 clear_gapped_foreign_security_sites();
919 }
920 bar_magnifier_enabled_ = scheduler_.bar_magnifier_enabled();
921 diag_magnifier_sub_bars_processed_ = bar_magnifier_enabled_
922 ? static_cast<std::int64_t>(context.driver_statistics.sub_bars_processed) : 0;
923 diag_magnifier_sample_ticks_processed_ = bar_magnifier_enabled_
924 ? static_cast<std::int64_t>(context.driver_statistics.sample_ticks_processed) : 0;
925 // A selected window's pre-roll rows are placed here, once, before the adapter
926 // reads the bar's open: it must see them as the prior bar's orders
927 // (selected_replay_at_open). Nothing happens for any other run.
928 selected_replay_at_open(bar, context);
929 adapter_.on_bar_open(bar, context);
930 scheduler_.bar_open(bar, context, *this);
931}
932
934 const Bar&, const NativeDecisionContext&) {
935 if (source_prepare_failed_) return;
936 scheduler_.sub_bar_complete();
937}
938
940 const Bar& bar, const NativeDecisionContext& context) {
941 if (source_prepare_failed_) return;
942 bar_magnifier_enabled_ = scheduler_.bar_magnifier_enabled();
943 diag_magnifier_sub_bars_processed_ = bar_magnifier_enabled_
944 ? static_cast<std::int64_t>(context.driver_statistics.sub_bars_processed) : 0;
945 diag_magnifier_sample_ticks_processed_ = bar_magnifier_enabled_
946 ? static_cast<std::int64_t>(context.driver_statistics.sample_ticks_processed) : 0;
947 adapter_.observe_terminal_receipts();
948 adapter_.on_bar_close_before_script(bar, context);
949 scheduler_.bar(bar, context, *this);
950 adapter_.on_bar_close(bar, context);
951 if (context.is_terminal_sub_bar
953 scheduler_record_range_end(bar);
954 }
955 const bool recording = broker_state_hash_recording_ && !broker_state_hashes_.empty();
956 const bool last_batch = context.is_terminal_sub_bar
958 const bool stream_script = context.is_terminal_sub_bar
959 && stream_phase_ == StreamPhase::REALTIME;
960 if (recording || last_batch || stream_script) {
961 // ab9714be pine_scheduler.cpp:1753/:1875 records after dispatch_bar,
962 // including the terminal source policy updates. The native hook
963 // returns through adapter_.on_bar_close after the scheduler callback,
964 // so refresh the continuation snapshot (and the just-appended row)
965 // at that boundary.
966 capture_script_continuation_hash();
967 }
968}
969
972 const NativeDecisionContext& context) { adapter_.invalidate_exit_group(event.first_trade_index, event.closed_trade_count); // Covers later close_cause, exit_time and exit_id writes; no exit-group sort intervenes.
973 // The host excursion and bracket-cause integration: the kernel records CloseCause::Bracket for a close its
974 // owner's fill armed (WaitForApplied). A Pine row keeps the cause this host
975 // has always given it -- a strategy.exit leg by its order family
976 // (exit_from_bracket), a strategy.close armed on a reversal parent as a
977 // script close -- so the kernel's reading is not taken on these rows.
978 for (std::size_t i = 0; i < event.closed_trade_count; ++i) {
979 const std::size_t index = event.first_trade_index + i;
980 if (index < trades_.size()
981 && trades_[index].close_cause == execution::CloseCause::Bracket) {
982 trades_.modify(index, [](Trade& row) {
984 });
985 }
986 }
987 if (source_prepare_failed_) return;
988 const bool aggregated = detail::run_aggregates_input_bars(
989 detail::run_consumer(*this), &adapter_, adapter_.run_counter_);
990 // TradingView dates every fill of a chart bar at the bar's open, a
991 // process_orders_on_close fill included (the e25-f-islastbar tape, lane
992 // E27). The chart timeframe's zero-width interval gives exactly that:
993 // every point of a confirmed bar's modeled path (its open, the segments,
994 // the calculation, the close after it) sits at the bar's label. On an
995 // aggregated chart the kernel dates a close point at the bucket's close,
996 // and books a fill the host executes at such a point (a
997 // calc_on_order_fills first-open chain, a same-bar cover) at its decision
998 // floor, which the bucket's inputs have already carried to the close on
999 // the no-path route. The adapter dates the rows it presents at the chart
1000 // bar's open: every modeled point of an aggregated chart, as ab9714be's
1001 // aggregation loop did, and under the magnifier too -- each sub-bar's
1002 // open, path and close (ModeledOHLCClose, which only a magnified run
1003 // presents). TradingView dates every magnified fill at its chart bar's
1004 // open, 218 of 218 on H-MEASURE's sixteen tapes; ab9714be kept a sub-bar
1005 // fill's own instant, and so did this adapter for all but the close
1006 // points until the order-schedule correction. A realtime print keeps its own
1007 // instant, as it does on the chart timeframe, and so does a stream's
1008 // quiet carried interval, whose context still names the bar before it.
1009 // The kernel's own instant, which its FX lookup, decision floor and
1010 // ordering read, is untouched (lane F1).
1011 const auto provenance = context.coordinate.provenance;
1012 const bool modeled_point = provenance == NativePriceProvenance::Confirmed
1013 || provenance == NativePriceProvenance::ModeledOHLCOpen
1014 || provenance == NativePriceProvenance::ModeledOHLCClose
1015 || provenance == NativePriceProvenance::Calculation
1016 || provenance == NativePriceProvenance::AfterCalculationClose;
1017 if (aggregated && modeled_point) {
1018 const std::int64_t script_open = context.script_bar_open_ms;
1019 if (event.opened_units != 0.0) {
1020 for (auto& lot : pyramid_entries_) {
1021 if (lot.entry_incarnation == event.handle().incarnation)
1022 lot.time = script_open;
1023 }
1024 // A later execution of the same pass can close this fill's lot
1025 // before this notification arrives (the reversing second call
1026 // of a process_orders_on_close pair): the rows it booked after
1027 // this event's own still carry the kernel's instant (lane
1028 // FIX-E1E2, tests/fixtures/aggregated_admission_receipt).
1029 for (std::size_t index = event.first_trade_index + event.closed_trade_count;
1030 index < trades_.size(); ++index) {
1031 if (trades_[index].entry_incarnation == event.handle().incarnation)
1032 { adapter_.invalidate_exit_group(index); trades_.modify(index, [&](Trade& row) { row.entry_time = script_open; }); }
1033 }
1034 }
1035 for (std::size_t i = 0; i < event.closed_trade_count; ++i) {
1036 const std::size_t index = event.first_trade_index + i;
1037 if (index >= trades_.size()) continue;
1038 trades_.modify(index, [&](Trade& row) { row.exit_time = script_open; });
1039 }
1040 }
1041 const auto p = adapter_.placement_.find(event.handle().incarnation);
1042 const bool pine_priced = p != adapter_.placement_.end()
1043 && ((std::isfinite(p->second.exit_levels.stop) && p->second.exit_levels.stop > 0.0)
1044 || (std::isfinite(p->second.exit_levels.limit) && p->second.exit_levels.limit > 0.0));
1045 if (event.opened_units != 0.0) {
1046 // ab9714be pine_orders.cpp:750-753 and pine_fills.cpp:7189-7193
1047 // A MARKET entry that adds to a live same-side position is
1048 // flagged so a same-bar from_entry bracket exit covers it.
1049 const bool market_add = !pine_priced && event.closed_units == 0.0
1050 && p != adapter_.placement_.end()
1051 && (p->second.family == PineOrderFamily::Entry
1052 || p->second.family == PineOrderFamily::Order)
1053 && std::any_of(pyramid_entries_.begin(), pyramid_entries_.end(),
1054 [&](const PyramidEntry& lot) {
1055 return lot.entry_incarnation != event.handle().incarnation;
1056 });
1057 if (market_add) adapter_.mark_market_pyramid_add(event.handle().incarnation);
1058 }
1059 // The legacy source observer counted one broker fill for every committed
1060 // execution event. The native consumer owns those events now; mirror the
1061 // count at its notification boundary so restored source tests and public
1062 // source-side policy reads see the same monotone value.
1063 // ab9714be pine_fills.cpp:5954/:6300 and the margin/FX sites: one source
1064 // broker fill sequence is consumed per applied broker instruction, not
1065 // per closed trade row. Native ordinals remain the execution authority;
1066 // this is the generated/source-visible diagnostic projection.
1067 if (broker_fill_event_seq_ == std::numeric_limits<std::uint64_t>::max()) {
1069 "source broker fill sequence exhausted");
1070 }
1071 ++broker_fill_event_seq_;
1072 if (position_side_ != PositionSide::FLAT) {
1073 // ab9714be engine_orders.cpp:531-541: settle_position_after_partial_exit resets to flat when position_qty_ <= kQtyEpsilon or empty
1074 bool changed = false;
1075 for (auto it = pyramid_entries_.begin(); it != pyramid_entries_.end(); ) {
1076 if (it->qty <= internal::kQtyEpsilon) {
1077 it = pyramid_entries_.erase(it);
1078 changed = true;
1079 } else {
1080 ++it;
1081 }
1082 }
1083 if (pyramid_entries_.empty() || position_qty_ <= internal::kQtyEpsilon) {
1084 reset_position_state_to_flat();
1085 } else if (changed) {
1086 double total_qty = 0.0;
1087 double weighted_price = 0.0;
1088 for (const auto& pe : pyramid_entries_) {
1089 total_qty += pe.qty;
1090 weighted_price += pe.price * pe.qty;
1091 }
1092 position_qty_ = total_qty;
1093 position_entry_price_ = weighted_price / total_qty;
1094 position_entry_count_ = static_cast<int>(pyramid_entries_.size());
1095 }
1096 }
1097 // Read before the adapter retires the legs this fill leaves without a lot.
1098 bool other_exit_here = false;
1099 if (event.closed_trade_count >= 2 && event.first_trade_index < trades_.size()) {
1100 const auto own = adapter_.placement_.find(event.handle().incarnation);
1101 other_exit_here = own == adapter_.placement_.end()
1102 || adapter_.other_exit_reaches(trades_[event.first_trade_index].exit_price,
1103 trades_[event.first_trade_index].is_long,
1104 own->second.source_id, event.handle());
1105 }
1106 adapter_.on_applied(event, context);
1107 precommit_held_units_ = std::numeric_limits<double>::quiet_NaN();
1108 if (adapter_.take_intraday_loss_relabel(event.ordinal)) {
1109 for (std::size_t i = 0; i < event.closed_trade_count; ++i) {
1110 const std::size_t index = event.first_trade_index + i;
1111 if (index >= trades_.size()) continue;
1112 trades_.modify(index, [](Trade& row) {
1113 row.exit_id.clear();
1115 });
1116 }
1117 }
1118 // The kernel/source boundary extraction (2.ii l): the kernel used to read these strings back out of
1119 // the row to answer strategy_closed_trade_close_cause. They are the
1120 // adapter's, so the adapter's host states the cause instead, on the rows
1121 // this event produced and after the relabel above has settled them. The
1122 // predicates are the retired kernel branches verbatim, including the
1123 // empty-exit-id gate and the filled-orders PREFIX match.
1124 for (std::size_t i = 0; i < event.closed_trade_count; ++i) {
1125 const std::size_t index = event.first_trade_index + i;
1126 if (index >= trades_.size()) continue;
1127 Trade row = trades_[index]; const auto previous_cause = row.close_cause;
1128 if (row.exit_id == source::kMarginCallLabel) {
1130 } else if (row.exit_id.empty()) {
1131 if (row.exit_comment.rfind(source::kFillCapCommentPrefix, 0) == 0)
1133 else if (row.exit_comment.rfind(source::kIntradayLossComment, 0) == 0)
1135 }
1136 adapter_.invalidate_exit_group(index); if (row.close_cause != previous_cause || index < trades_.base()) trades_.replace(index, row);
1137 }
1138 project_short_seed_report_rows(event);
1139 if (!other_exit_here) order_global_exit_children(event);
1140 scheduler_.applied(event);
1141 // R6: with calc_on_order_fills the kernel drives this event's
1142 // recalculation next, in the same drain iteration and at the same cursor
1143 // (NativeCalculationTrigger::BarCloseAndFills). The range-end row stays
1144 // after it, exactly where the adapter's own cascade used to put it.
1145 // Finish the same range-end mark, then any post-calculation journal tail.
1146 finish_applied_admission(event, context);
1147 // The observer seam: once per applied event, after every row this event
1148 // produced has settled. Empty unless a host overrides it.
1149 on_native_applied_event(event, context);
1150}
1151
1152void source::PineStrategyHost::record_applied_range_end() {
1153 if (scheduler_.terminal_source_bar() || barstate_islast_) {
1154 const Bar terminal = scheduler_.current_script_bar()
1155 ? *scheduler_.current_script_bar() : current_bar_;
1156 scheduler_record_range_end(terminal);
1157 }
1158}
1159
1161 const Bar& bar, const NativeDecisionContext& context,
1164 if (reason != NativeCalculationReason::OrderFill) {
1165 // BarClose is the script bar's own calculation; Tick and SubBar are
1166 // cadences the Pine spec never selects.
1167 on_native_bar(bar, context);
1168 return;
1169 }
1170 if (source_prepare_failed_ || cause == nullptr) return;
1171 // A fill Pine does not recalculate on (POOC's terminal close fill, a
1172 // grouped-stop sibling, a replayed ordinal) still spends a kernel
1173 // recalculation slot; it publishes nothing and books nothing.
1174 if (!scheduler_.coof_recalculation_due(*cause, context, *this)) return;
1175 scheduler_.recalculate(*cause, context, *this);
1176 record_applied_range_end();
1177}
1178
1183
1185 const NativeMarginCheckPoint& point) const {
1186 return adapter_.margin_check_allowed(point);
1187}
1188
1190 const NativeMarginRequirementView& view) const {
1191 return adapter_.resolve_margin_requirement(view);
1192}
1193
1195 const NativeMarginCallView& view) const {
1196 return adapter_.resolve_margin_call_units(view);
1197}
1198
1200 const NativeAnchoredLevelView& view) const {
1201 return adapter_.resolve_anchored_level(view);
1202}
1203
1205 const NativePrecommitView& view) const {
1206 const auto verdict = adapter_.validate_precommit(view);
1207 precommit_held_units_ = std::abs(detail::run_position(*this).signed_units);
1208 return verdict;
1209}
1210
1212 guard_native_mutation("configure_pine_strategy");
1214 config_ = config;
1215 adapter_.set_configuration(config_);
1216 scheduler_.set_source_series_active(config_.src_series_active);
1218}
1219
1221 guard_native_mutation("set_strategy_override");
1222 override_ = overrides;
1223 config_ = apply_overrides(config_, override_);
1224 adapter_.set_configuration(config_);
1225 scheduler_.set_source_series_active(config_.src_series_active);
1227}
1228
1229void source::PineStrategyHost::set_syminfo_session(const std::string& session) {
1230 BacktestEngine::set_syminfo_session(session);
1231}
1232
1233// The six risk setters apply live as they always did. While a selected window's
1234// pre-roll evaluation captures they also append an ordered row, so the replay
1235// can set them again in the script's own order against the saved configuration.
1236// The three int arguments cannot represent NaN/infinity; their existing
1237// direction/count/cap behavior needs no numeric admission.
1240 adapter_.set_risk_direction(direction);
1241}
1242
1245 adapter_.set_risk_max_cons_loss_days(value);
1246}
1247
1249 admit_pine_risk_max_drawdown(value, percent);
1251 adapter_.set_risk_max_drawdown(value, percent);
1252}
1253
1255 admit_pine_risk_max_intraday_loss(value, percent);
1257 adapter_.set_risk_max_intraday_loss(value, percent);
1258}
1259
1262 if (limit > 0) adapter_.cap.adopt_script_count();
1263 adapter_.cap = limit;
1264}
1265
1271
1273 // Inside a foreign site's payload, bar_index is the requested context's.
1274 if (foreign_context_ != nullptr) return static_cast<int>(foreign_context_->delivered - 1);
1275 return source_bar_index_ + scheduler_.bar_index_offset();
1276}
1277
1279 return source_last_bar_index_ + scheduler_.bar_index_offset();
1280}
1281
1283 return scheduler_.is_first_tick();
1284}
1285
1287 return scheduler_.is_last_tick();
1288}
1289
1291 NativeDecisionContext context;
1292 if (const auto point = current_execution_point()) {
1293 context = point->decision;
1294 } else {
1295 context.coordinate.interval_index = bar_index_;
1296 context.sub_bar_open_ms = current_bar_.timestamp;
1297 context.script_bar_open_ms = current_bar_.timestamp;
1298 }
1299 const BarTime time = fixture_chart_time(context.sub_bar_open_ms);
1300 return {context.sub_bar_open_ms,
1301 syminfo_.session.empty() ? "24x7" : syminfo_.session,
1302 syminfo_.timezone.empty() ? "UTC" : syminfo_.timezone,
1303 time.dayofmonth, time.month};
1304}
1305
1307 NativeDecisionContext context;
1308 if (const auto point = current_execution_point()) {
1309 context = point->decision;
1310 } else {
1311 context.coordinate.interval_index = bar_index_;
1312 context.sub_bar_open_ms = current_bar_.timestamp;
1313 context.script_bar_open_ms = current_bar_.timestamp;
1314 }
1315 return adapter_.cap_calculation(context);
1316}
1317
1322
1323source::PineStrategyHost::BarTime source::PineStrategyHost::fixture_chart_time(
1324 std::int64_t timestamp_ms) const {
1325 const std::time_t seconds = static_cast<std::time_t>(timestamp_ms / 1000);
1326 std::tm tm{};
1327 const auto utc = [&]() {
1328 return ::gmtime_r(&seconds, &tm) != nullptr;
1329 };
1330 if (chart_timezone_.empty() || chart_timezone_ == "UTC"
1331 || chart_timezone_ == "Etc/UTC") {
1332 (void)utc();
1333 } else {
1334 try {
1335 tz_util::ScopedTimezone guard(chart_timezone_);
1336 if (::localtime_r(&seconds, &tm) == nullptr) (void)utc();
1337 } catch (...) {
1338 (void)utc();
1339 }
1340 }
1341 BarTime result;
1342 result.year = tm.tm_year + 1900;
1343 result.month = tm.tm_mon + 1;
1344 result.dayofmonth = tm.tm_mday;
1345 result.hour = tm.tm_hour;
1346 result.minute = tm.tm_min;
1347 result.second = tm.tm_sec;
1348 result.dayofweek = tm.tm_wday + 1;
1349 result.weekofyear = (tm.tm_yday + 7 - ((tm.tm_wday + 6) % 7)) / 7;
1350 return result;
1351}
1352
1354 as_native_consumer(execution_consumer()).set_retention_override(NativeEventRetention::Full);
1355}
1356
1358 // Every ExecutionAppliedEvent the run committed. The adapter acknowledges
1359 // the journal as it reads it, so the window no longer holds the whole
1360 // run; the kernel's own count of the events it committed is the same
1361 // number the journal scan made when it did.
1362 return as_native_consumer(execution_consumer()).applied_event_count();
1363}
1364
1366 return scheduler_.history_advances_new_bar();
1367}
1368
1369
1371 return scheduler_.previous_chart_close();
1372}
1373
1375 return adapter_.pending_intent_view().last_bar_dual_entry_path();
1376}
1377
1379 if (position_side_ == PositionSide::FLAT) return 0.0;
1380 double paid = 0.0;
1381 for (const auto& lot : pyramid_entries_) {
1382 if (lot.qty > internal::kQtyEpsilon) paid += open_entry_commission(lot);
1383 }
1384 return paid;
1385}
1386
1388 return scheduler_.script_position_view(bar_index_, position_side_, position_qty_);
1389}
1390
1392 const double tick = syminfo_mintick_;
1393 if (!source::detail::script_rule_switches().equity_tick_mark || !std::isfinite(price)
1394 || !std::isfinite(tick) || !(tick > 0.0)) {
1395 return price;
1396 }
1397 const double ticks = price / tick;
1398 const double nearest = std::floor(ticks + 0.5);
1399 // On the grid up to the quotient's own rounding: the close itself.
1400 if (std::abs(ticks - nearest) <= 1e-9 * std::max(1.0, std::abs(ticks))) return price;
1401 return nearest * tick;
1402}
1403
1405 scheduler_.freeze_script_position_view(
1406 bar_index_, position_side_, position_qty_, pyramid_entries_);
1407}
1408
1410 scheduler_.clear_script_position_view();
1411}
1412
1414 // Only a book the call left flat: any open lot keeps its own average.
1415 if (position_side_ != PositionSide::FLAT || !std::isfinite(average) || !(average > 0.0))
1416 return;
1417 position_entry_price_ = average;
1418 script_average_held_ = true;
1419}
1420
1422 if (!script_average_held_) return;
1423 script_average_held_ = false;
1424 // reset_position_state_to_flat's value; a fill since the hold that
1425 // opened a position set its own average.
1426 if (position_side_ == PositionSide::FLAT) position_entry_price_ = 0.0;
1427}
1428
1429const Series<double>& source::PineStrategyHost::source_series(const std::string& key) const {
1430 return scheduler_.source_series(key);
1431}
1432
1434 const std::string& key, const Series<double>& fallback) const {
1435 const auto found = inputs_.find(key);
1436 if (found == inputs_.end() || found->second.empty()) return fallback;
1437 try {
1438 return scheduler_.source_series(found->second);
1439 } catch (const std::invalid_argument&) {
1440 return fallback;
1441 }
1442}
1443
1447
1451
1455
1457 return adapter_.admission_journal;
1458}
1459
1460std::vector<admission::Field> source::PineStrategyHost::market_admission_fields() const {
1461 std::vector<admission::Field> fields;
1462 adapter_.admission_journal.reflect("journal", [&](const admission::Field& field) {
1463 fields.push_back(field);
1464 });
1465 return fields;
1466}
1467
1469 int index, double fill_price, double* qty, int* close_only, int* partition) const {
1470 return pending_intent_view().probe_fill_qty(index, fill_price, qty, close_only, partition);
1471}
1472
1474 return pending_intent_view().level_resolved(index);
1475}
1476
1478 int index, double* stop, double* limit, double* trail_activation) const {
1479 return pending_intent_view().effective_levels(index, stop, limit, trail_activation);
1480}
1481
1483 return adapter_.pending_intent_view();
1484}
1485
1487 native_order::RequestHandle handle) const noexcept {
1488 return adapter_.short_seed_collision_role_v1(std::move(handle));
1489}
1490
1492 adapter_.enable_intraday_cap();
1493}
1494
1496 adapter_.attach_execution_adapter();
1497 // Generated constructors attach the source execution bridge before their
1498 // risk statements and metadata arrive. The intraday-cap configuration is
1499 // part of that same source-policy attachment; leaving it detached makes a
1500 // later max_intraday_filled_orders declaration silently inert.
1501 adapter_.enable_intraday_cap();
1502}
1503
1505 const std::string& key, double value) {
1506 if (key == "report_terminal_quote_time_ms") {
1507 if (NativeExecutionConsumer::selected_query_closed(*this)) {
1509 }
1510 report_quote_time_ms_ = std::isfinite(value) && value >= 0.0
1511 && value <= 9007199254740991.0 && std::floor(value) == value
1512 ? static_cast<std::int64_t>(value) : -1;
1513 return;
1514 }
1515 if (key == "report_terminal_quote_close") {
1516 if (NativeExecutionConsumer::selected_query_closed(*this)) {
1518 }
1519 report_quote_close_ = std::isfinite(value) && value > 0.0
1520 ? value : std::numeric_limits<double>::quiet_NaN();
1521 return;
1522 }
1523 // Malformed origins are a no-op even for the generic metadata store.
1524 if (key == "series_origin_ms" && (!std::isfinite(value) || value <= 0.0))
1525 return;
1526 BacktestEngine::set_syminfo_metadata(key, value);
1527 // TradingView's symbol calendar (set_symbol_calendar) over the C ABI:
1528 // "symbol_calendar_days" = N, then "symbol_calendar_open:<i>" and
1529 // "symbol_calendar_close:<i>" for i < N, installed (and the days consumed)
1530 // when the run begins. A count that is not a number >= 0 removes the
1531 // calendar; a day key whose index is not a decimal number is no day.
1532 if (key.compare(0, 16, "symbol_calendar_") == 0) {
1533 if (key == "symbol_calendar_days") {
1534 if (std::isfinite(value) && value >= 0.0 && value <= 1e9) {
1535 symbol_calendar_days_ = std::llround(value);
1536 } else {
1538 symbol_calendar_opens_.clear();
1540 symbol_calendar_.clear();
1541 }
1542 }
1543 for (const auto& [prefix, days] : {std::pair<const char*, std::map<long long, double>*>{
1544 "symbol_calendar_open:", &symbol_calendar_opens_},
1545 {"symbol_calendar_close:", &symbol_calendar_closes_}}) {
1546 const std::size_t n = std::char_traits<char>::length(prefix);
1547 if (key.size() <= n || key.size() - n > 9 || key.compare(0, n, prefix) != 0) continue;
1548 long long index = 0;
1549 bool digits = true;
1550 for (std::size_t i = n; i < key.size(); ++i) {
1551 if (key[i] < '0' || key[i] > '9') {
1552 digits = false;
1553 break;
1554 }
1555 index = index * 10 + (key[i] - '0');
1556 }
1557 if (digits) (*days)[index] = value;
1558 }
1559 return;
1560 }
1561 if (key == "bar_index_offset") {
1562 scheduler_.set_bar_index_offset(std::isfinite(value)
1563 ? static_cast<int>(std::llround(value)) : 0);
1564 }
1565 // One declared reference-series origin enables both warmup contexts.
1566 // Raw keys written afterwards win for their own context (last writer).
1567 if (key == "series_origin_ms" && std::isfinite(value) && value > 0.0) {
1568 chart_ema_na_warmup_ = true;
1570 security_range_start_ms_ = static_cast<int64_t>(std::llround(value));
1571 }
1572 if (key == "security_range_start_na_warmup") {
1573 if (std::isfinite(value) && value > 0.0) {
1575 security_range_start_ms_ = static_cast<int64_t>(std::llround(value));
1576 } else {
1579 }
1580 }
1581 if (key == "chart_ema_na_warmup")
1582 chart_ema_na_warmup_ = std::isfinite(value) && value > 0.0;
1583 if (key == "historical_security_lookahead_projection")
1584 historical_security_lookahead_projection_ = std::isfinite(value) && value > 0.0;
1585 if (key == "margin_long" && config_.margin_long == 100.0)
1586 config_.margin_long = (std::isfinite(value) && value > 0.0) ? value : 100.0;
1587 if (key == "margin_short" && config_.margin_short == 100.0)
1588 config_.margin_short = (std::isfinite(value) && value > 0.0) ? value : 100.0;
1589 adapter_.set_configuration(config_);
1590 adapter_.priority.metadata(key, value);
1591 adapter_.cap.metadata(key, value);
1592}
1593
1594bool source::PineStrategyHost::set_report_terminal_quote(std::int64_t time_ms, double close) {
1595 if (NativeExecutionConsumer::selected_query_closed(*this) || time_ms < 0
1596 || time_ms > 9007199254740991LL || !std::isfinite(close) || close <= 0.0) return false;
1597 report_quote_time_ms_ = time_ms;
1598 report_quote_close_ = close;
1599 return true;
1600}
1601
1603 if (NativeExecutionConsumer::selected_query_closed(*this)) {
1605 }
1606 report_quote_time_ms_ = -1;
1607 report_quote_close_ = std::numeric_limits<double>::quiet_NaN();
1608}
1609
1611 return pending_intent_view().last_bar_dual_entry_path();
1612}
1613
1617
1619 int index, pf_pending_order_v1_t* out) const {
1620 return pending_intent_view().copy_v1(index, out);
1621}
1622
1624 int index, double fill_price, double* qty, int* close_only, int* partition) const {
1625 return pending_intent_view().probe_fill_qty(index, fill_price, qty, close_only, partition);
1626}
1627
1629 return pending_intent_view().level_resolved(index);
1630}
1631
1633 int index, double* stop, double* limit, double* trail_activation) const {
1634 return pending_intent_view().effective_levels(index, stop, limit, trail_activation);
1635}
1636
1638 return adapter_.pending_intent_view().trail_best_price();
1639}
1640
1641void source::PineStrategyHost::adapter_label_bracket_trades(
1642 const native_order::ExecutionAppliedEvent& event, bool from_bracket) {
1643 // ab9714be pine_fills.cpp:6232-6252: every trade row emitted by a real
1644 // strategy.exit leg carries the bracket cause; strategy.close and
1645 // close_all requests remain script closes.
1646 for (std::size_t offset = 0; offset < event.closed_trade_count; ++offset) {
1647 const std::size_t index = event.first_trade_index + offset;
1648 if (index >= trades_.size()) continue;
1649 adapter_.invalidate_exit_group(index); trades_.modify(index, [&](Trade& trade) { trade.exit_from_bracket = from_bracket; });
1650 }
1651}
1652
1653bool source::PineStrategyHost::adapter_has_open_entry_id(
1654 const std::string& id) const {
1655 return std::any_of(pyramid_entries_.begin(), pyramid_entries_.end(),
1656 [&](const PyramidEntry& row) { return row.entry_id == id && row.qty > 0.0; });
1657}
1658
1659const std::vector<source::PineStrategyHost::FixtureIntentRow>&
1662 source_pending_view_cache_.reserve(adapter_.pending_same_bar_commands_.size()
1663 + adapter_.pending_entries_.size() + adapter_.pending_bracket_legs_.size()
1664 + adapter_.pending_coof_requests_.size()
1665 + adapter_.delayed_market_orders_.size()
1666 + adapter_.source_shadow_pending_.size() + adapter_.live_handles_.size());
1667 const auto append = [&](const PlacementSnapshot& snapshot, const std::string& label) {
1668 FixtureIntentKind type = FixtureIntentKind::MARKET;
1669 switch (snapshot.family) {
1677 type = FixtureIntentKind::EXIT;
1678 break;
1680 type = FixtureIntentKind::RAW_ORDER;
1681 break;
1683 break;
1684 }
1685 FixtureIntentRow row;
1686 row.id = snapshot.family == PineOrderFamily::Close
1687 ? "__close__" + snapshot.source_id
1688 : (snapshot.frozen_market_targeted_close ? label : snapshot.source_id);
1689 row.type = type;
1690 const bool default_stop = snapshot.family == PineOrderFamily::Entry
1691 && !std::isfinite(snapshot.exit_levels.limit)
1692 && std::isfinite(snapshot.exit_levels.stop)
1693 && std::isnan(snapshot.requested_qty)
1694 && config_.default_qty_type == static_cast<int>(QtyType::PERCENT_OF_EQUITY)
1695 && config_.default_qty_value <= 100.0;
1696 const double absent = std::numeric_limits<double>::quiet_NaN();
1697 row.default_stop_placement_qty = default_stop ? snapshot.sizing.frozen_units : absent;
1698 row.default_stop_sizing_price = snapshot.sizing.price;
1699 row.frozen_market_own_units = snapshot.frozen_market_own_units;
1700 row.frozen_market_transaction_units = snapshot.frozen_market_transaction_units;
1701 row.from_entry = snapshot.from_entry;
1702 row.is_long = snapshot.is_long;
1703 row.qty = snapshot.family == PineOrderFamily::Close
1704 ? snapshot.requested_qty
1705 : (std::isfinite(snapshot.projection_remaining_qty)
1706 ? snapshot.projection_remaining_qty : snapshot.requested_qty);
1707 row.qty_percent = snapshot.qty_percent;
1708 row.created_bar = snapshot.projection_created_bar;
1709 row.created_seq = static_cast<std::int64_t>(snapshot.source_sequence);
1710 row.incarnation = snapshot.source_sequence;
1711 row.over_pyramiding_cap_at_placement = snapshot.projection_over_pyramiding;
1713 row.paired_flat_market_transaction_qty = std::numeric_limits<double>::quiet_NaN();
1714 row.frozen_default_qty = default_stop ? absent : snapshot.sizing.frozen_units;
1715 row.default_stop_placement_equity = default_stop
1716 ? snapshot.projection_default_stop_equity : absent;
1717 row.default_stop_placement_signal_close = default_stop
1718 ? snapshot.projection_default_stop_signal_close : absent;
1719 row.affordability_placement_equity = snapshot.projection_affordability_equity;
1720 row.market_admission = snapshot.market_admission;
1721 if (!row.market_admission.observation()
1722 && snapshot.family == PineOrderFamily::Entry
1723 && snapshot.frozen_market_instruction
1724 && std::isfinite(snapshot.requested_qty)) {
1725 auto observation = std::make_shared<admission::CommandObservation>();
1726 observation->command = snapshot.command_ordinal;
1727 observation->kind = admission::CommandKind::Entry;
1728 observation->birth = snapshot.birth.cause() == OrderBirthCause::Unattributed
1729 ? OrderBirth::chart_evaluation(source_bar_index_, current_bar_.timestamp)
1730 : snapshot.birth;
1731 observation->id = snapshot.source_id;
1732 observation->requested_quantity = snapshot.requested_qty;
1733 observation->quantity_type = snapshot.qty_type;
1734 observation->buy = snapshot.is_long;
1735 observation->prices = {snapshot.exit_levels.limit, snapshot.exit_levels.stop};
1736 observation->oca_name = snapshot.oca_name;
1737 observation->oca_type = snapshot.oca_type;
1738 auto& configuration = observation->configuration;
1739 configuration.process_on_close = config_.process_orders_on_close;
1740 configuration.calc_on_fills = config_.calc_on_order_fills;
1741 configuration.slippage = config_.slippage;
1742 configuration.pyramiding = config_.pyramiding;
1743 configuration.default_quantity_type = config_.default_qty_type;
1744 configuration.default_quantity_value = config_.default_qty_value;
1745 configuration.long_margin = config_.margin_long;
1746 configuration.short_margin = config_.margin_short;
1747 configuration.commission_value = config_.commission_value;
1748 configuration.commission_type = config_.commission_type;
1749 configuration.pointvalue = staged_configuration().syminfo.pointvalue;
1750 configuration.fx = snapshot.sizing.fx;
1751 configuration.quantity_step = staged_configuration().quantity_grid
1752 ? *staged_configuration().quantity_grid : 0.0;
1753 configuration.mintick = staged_configuration().syminfo.mintick;
1754 observation->bar = source_bar_index_;
1755 observation->placement_side = static_cast<int>(PositionSide::FLAT);
1756 observation->placement_cycle = snapshot.placement_cycle;
1757 observation->held_quantity = 0.0;
1758 observation->held_entries = 0;
1759 observation->realized_equity = snapshot.sizing.equity;
1760 observation->placement_equity = snapshot.sizing.equity;
1761 observation->signal_close = snapshot.sizing.price;
1762 observation->quantized_fixed_quantity =
1763 snapshot.frozen_market_own_units;
1764 observation->original_sizing = admission::SizingObservation{
1765 snapshot.requested_qty, snapshot.sizing.equity,
1766 snapshot.sizing.price, snapshot.sizing.mark, snapshot.sizing.fx};
1767 row.market_admission.bind(std::move(observation));
1768 }
1769 source_pending_view_cache_.push_back(std::move(row));
1770 };
1771 for (const auto& command : adapter_.pending_same_bar_commands_) {
1772 // ab9714be strategy.close under process_orders_on_close is held in the
1773 // same-bar close accumulator until the callback returns; the legacy
1774 // pending_orders_ observer therefore sees the two entry commands but
1775 // not that staged close during the source body.
1776 if (config_.process_orders_on_close
1777 && command.snapshot.family == PineOrderFamily::Close
1778 && !command.snapshot.birth.at_terminal_fill()) {
1779 continue;
1780 }
1781 append(command.snapshot, command.request.label);
1782 }
1783 for (const auto& pending : adapter_.pending_entries_)
1784 append(pending.snapshot, pending.request.label);
1785 for (const auto& delayed : adapter_.delayed_market_orders_)
1786 append(delayed.snapshot, delayed.request.label);
1787 for (const auto& leg : adapter_.pending_bracket_legs_)
1788 append(leg.snapshot, leg.request.label);
1789 for (const auto& pending : adapter_.pending_coof_requests_)
1790 append(pending.snapshot, pending.request.label);
1791 for (const auto& shadow : adapter_.source_shadow_pending_)
1792 append(shadow.snapshot, shadow.label);
1793 for (const auto& handle : adapter_.live_handles_) {
1794 const auto found = adapter_.placement_.find(handle.incarnation);
1795 if (found == adapter_.placement_.end()) continue;
1796 if (config_.process_orders_on_close
1797 && found->second.family == PineOrderFamily::Close
1798 && found->second.projection_created_bar == source_bar_index_
1799 && !found->second.birth.at_terminal_fill()) {
1800 continue;
1801 }
1802 append(found->second, found->second.source_id);
1803 }
1805}
1806
1809
1810void source::PineStrategyHost::project_short_seed_report_rows(
1812 const ShortSeedPlan plan = adapter_.short_seed_;
1813 if (!plan.report_swap_pending || event.closed_trade_count == 0
1814 || event.handle() == plan.final_short) {
1815 return;
1816 }
1817 std::optional<PlacementSnapshot> placement_snapshot;
1818 if (const auto placement = adapter_.placement_.find(event.handle().incarnation);
1819 placement != adapter_.placement_.end()) {
1820 placement_snapshot = placement->second;
1821 }
1822 if (!placement_snapshot || placement_snapshot->family != PineOrderFamily::Close
1823 || (placement_snapshot->from_entry != plan.seed_id
1824 && placement_snapshot->source_id != plan.seed_id)) {
1825 return;
1826 }
1827 for (std::size_t index = trades_.base(); index < trades_.size(); ++index) {
1828 Trade trade = trades_[index];
1829 if (trade.entry_incarnation == plan.materialize_long.incarnation
1830 && trade.entry_id == plan.materialize_label) {
1831 trade.entry_incarnation = plan.final_short.incarnation;
1832 adapter_.invalidate_exit_group(index); trades_.replace(index, trade);
1833 }
1834 }
1835 const std::size_t begin = event.first_trade_index;
1836 const std::size_t end = begin + event.closed_trade_count;
1837 for (std::size_t index = begin; index < end && index < trades_.size(); ++index) {
1838 if (trades_[index].entry_incarnation == plan.final_short.incarnation
1839 && trades_[index].entry_id == plan.final_short_id) {
1840 adapter_.invalidate_exit_group(index); trades_.modify(index, [&](Trade& row) {
1841 row.entry_incarnation = plan.materialize_long.incarnation;
1842 });
1843 }
1844 }
1845 adapter_.short_seed_.report_swap_pending = false;
1846}
1847
1848void source::PineStrategyHost::scheduler_prepare_script_run(
1849 PineScheduler::InputView bars, bool static_eligible,
1850 int expected_script_bars, bool script_bar_geometry) {
1851 if (const auto state = native_state(); state.spec) {
1852 input_tf_ = state.spec->timeframe_undetected ? "" : state.spec->input_tf;
1853 script_tf_ = state.spec->timeframe_undetected ? "" : state.spec->script_tf;
1854 script_tf_seconds_ = tf_to_seconds(script_tf_);
1855 }
1856 prepare_script_run(bars.empty() ? nullptr : bars.data(), static_cast<int>(bars.size()),
1857 static_eligible);
1858 last_bar_index_ = expected_script_bars - 1;
1859 last_bar_time_ = bars.empty() ? 0 : bars.back().timestamp;
1860 apply_realtime_tail_horizon(
1861 bars.empty() ? nullptr : bars.data(), static_cast<int>(bars.size()),
1862 script_bar_geometry);
1863 source_last_bar_index_ = last_bar_index_;
1864}
1865
1866thread_local const source::PineStrategyHost* source::PineRuntimeSecurityScope::owner_ = nullptr;
1867thread_local source::PineRuntimeSecurityCursor* source::PineRuntimeSecurityScope::cursor_ = nullptr;
1868
1870 const PineStrategyHost& owner, PineRuntimeSecurityCursor& cursor) noexcept
1871 : previous_owner_(owner_), previous_cursor_(cursor_) {
1872 owner_ = &owner; cursor_ = &cursor;
1873}
1875 owner_ = previous_owner_; cursor_ = previous_cursor_;
1876}
1878 const PineStrategyHost& owner) noexcept {
1879 return owner_ == &owner ? cursor_ : nullptr;
1880}
1882 const std::string& selected) const {
1883 if (!selected.empty()) return selected;
1884 const auto* parent = PineRuntimeSecurityScope::current(*this);
1885 return parent ? parent->timeframe : script_tf_;
1886}
1887
1889 const std::string& selected, bool lookahead, bool gaps, bool lower_array,
1890 bool heikinashi) const {
1891 if (input_tf_.empty())
1893 RunFailureCode::run_options_rejected, {{"option", "input_timeframe"}},
1894 "request.security cannot infer input timeframe; pass input_tf explicitly");
1895 const std::string timeframe = runtime_security_timeframe(selected);
1897 const char* api = lower_array ? "request.security_lower_tf" : "request.security";
1900 std::string(api) + ": invalid timeframe literal '" + timeframe + "'");
1901 }
1903 cursor.timeframe = timeframe;
1904 const auto* parent = PineRuntimeSecurityScope::current(*this);
1905 cursor.chart_timeframe = parent ? parent->timeframe : script_tf_;
1906 cursor.timezone = syminfo_.timezone;
1907 cursor.session = syminfo_.session;
1908 cursor.lookahead_on = lookahead;
1909 cursor.gaps_on = gaps;
1910 cursor.lower_array = lower_array;
1911 cursor.heikinashi = heikinashi;
1912 const int requested_seconds = tf_to_seconds(timeframe);
1913 const int input_seconds = tf_to_seconds(input_tf_);
1914 const int chart_seconds = tf_to_seconds(cursor.chart_timeframe);
1915 if (lower_array && chart_seconds <= 0)
1918 {{"reason", "lower_tf_script_unknown"}},
1919 "request.security_lower_tf: script timeframe is unknown");
1920 if (lower_array && (requested_seconds <= 0 || requested_seconds >= chart_seconds))
1923 {{"reason", "lower_tf_not_finer_than_script"}, {"script_tf", script_tf_}},
1924 "request.security_lower_tf: requested timeframe must be finer than the chart timeframe");
1925 if (lower_array && chart_seconds % requested_seconds != 0)
1928 {{"reason", "lower_tf_not_divisor_of_script"}, {"script_tf", script_tf_}},
1929 "request.security_lower_tf: requested timeframe must evenly divide the chart timeframe");
1930 if (requested_seconds > 0 && input_seconds > 0 && requested_seconds < input_seconds) {
1931 if (lower_array) {
1932 cursor.subdivision = runtime_subdivision(input_tf_, timeframe);
1933 if (!cursor.subdivision)
1936 {{"reason", "lower_tf_not_divisor_of_input"}, {"input_tf", input_tf_}},
1937 "request.security_lower_tf: requested timeframe is not an integer divisor of input");
1938 } else if (chart_seconds == -1 || input_seconds < chart_seconds) {
1939 cursor.no_loaded_bars = true;
1940 } else {
1943 {{"reason", "finer_than_input"}, {"input_tf", input_tf_}},
1944 "request.security: requested timeframe is finer than input; supply finer bars");
1945 }
1946 }
1947 cursor.aggregator = TimeframeAggregator(timeframe, input_tf_,
1948 cursor.timezone, cursor.session);
1949 cursor.aggregator.set_early_close_completes(runtime_context_early_close_completes());
1950 return cursor;
1951}
1952
1954 const auto& service = runtime_contexts();
1955 if (service.active_context().serial) return service.callback_frontier();
1956 // A lazy seal may calculate the preceding chart bar after the next input
1957 // was admitted. Its timestamp is not part of that chart bar's frontier.
1958 return service.frontier_before(chart_time_close());
1959}
1960
1962 const RuntimeContextEvent& event, const Delivery& deliver,
1963 const std::function<void()>& begin_chart) {
1964 if (prior_input == std::numeric_limits<std::int64_t>::min()
1966 chart_open = event.bar.timestamp;
1967 sub_bar_index = 0;
1968 begin_chart();
1969 }
1970 prior_input = event.bar.timestamp;
1971 if (no_loaded_bars) return;
1972 if (subdivision) {
1973 for (const auto& sub_bar : runtime_subdivide(event.bar, *subdivision)) {
1974 slot_new = true;
1977 deliver(sub_bar, true);
1978 ++sub_bar_index;
1979 }
1980 return;
1981 }
1982 auto aggregate = aggregator.feed(event.bar);
1983 if (!aggregate.is_complete && (lower_array || !lookahead_on)) return;
1984 slot_new = aggregate.bar.timestamp != slot_open;
1985 slot_open = aggregate.bar.timestamp;
1987 if (heikinashi) {
1988 const double close = (aggregate.bar.open + aggregate.bar.high
1989 + aggregate.bar.low + aggregate.bar.close) / 4.0;
1990 const double open = ha_seeded ? (ha_open + ha_close) / 2.0
1991 : (aggregate.bar.open + aggregate.bar.close) / 2.0;
1992 aggregate.bar.high = std::max(aggregate.bar.high, std::max(open, close));
1993 aggregate.bar.low = std::min(aggregate.bar.low, std::min(open, close));
1994 aggregate.bar.open = open; aggregate.bar.close = close;
1995 if (aggregate.is_complete) { ha_open = open; ha_close = close; ha_seeded = true; }
1996 }
1998 deliver(aggregate.bar, aggregate.is_complete);
2000}
2001
2002// Live-runtime tail (spec §3.1): the horizon freeze of last_bar_index_ /
2003// last_bar_time_. Moved verbatim from src/engine_run.cpp (the source-runtime extraction); the
2004// geometry rules are documented on the declaration.
2006 bool script_bar_geometry) {
2007 if (!realtime_tail_ || realtime_tail_horizon_bars_ <= 0 || n <= 0 || bars == nullptr) return;
2008 const int horizon = realtime_tail_horizon_bars_;
2009 last_bar_index_ = horizon - 1;
2010 const int64_t script_tf_ms =
2011 static_cast<int64_t>(script_tf_seconds_ > 0 ? script_tf_seconds_ : 0) * 1000;
2012 if (script_bar_geometry) {
2013 if (horizon <= n) {
2014 last_bar_time_ = bars[horizon - 1].timestamp;
2015 } else {
2016 last_bar_time_ = bars[n - 1].timestamp
2017 + static_cast<int64_t>(horizon - n) * script_tf_ms;
2018 }
2019 } else {
2020 last_bar_time_ = bars[0].timestamp
2021 + static_cast<int64_t>(horizon - 1) * script_tf_ms;
2022 }
2023}
2024
2025void source::PineStrategyHost::scheduler_configure_security_evaluators() {
2026 configure_security_evaluators();
2027 prune_pine_security_states();
2028}
2029
2030bool source::PineStrategyHost::scheduler_uses_aux_security_feed() const noexcept {
2031#ifdef PINEFORGE_HAS_AUX_SECURITY_FEED_V1
2032 return aux_security_feed_enabled();
2033#else
2034 return false;
2035#endif
2036}
2037
2038namespace {
2039
2040// N of an N-day timeframe ("2D", "8D"), 0 for any other.
2041int multi_day_count(const std::string& tf) {
2042 const auto parsed = native_calendar::parse_timeframe(tf);
2043 if (!parsed || parsed->unit() != native_calendar::TimeframeUnit::Day) return 0;
2044 return parsed->count() >= 2 ? parsed->count() : 0;
2045}
2046
2047} // namespace
2048
2049void source::PineStrategyHost::scheduler_prepare_security_sequence(
2051 security_input_tf_ = input_tf_;
2052#ifdef PINEFORGE_HAS_AUX_SECURITY_FEED_V1
2053 if (aux_security_feed_enabled()) security_input_tf_ = aux_security_input_tf_;
2054#endif
2055 validate_security_timeframes(security_input_tf_);
2056 security_first_chart_bar_ms_ = bars.empty() ? 0 : bars.front().timestamp;
2057 if (declare_security_sites_to_kernel()) {
2058 // The kernel registers the declared sites after this callback returns
2059 // and prepares their feeds itself (begin_timeframe_subscriptions).
2060 // The chart's day partition is the chart's, not a site's, and stays.
2061 security_next_input_ms_ = 0;
2062 security_calling_close_ms_ = 0;
2063 clear_historical_security_lookahead_projections();
2064 prepare_chart_day_partition(
2065 bars.empty() ? nullptr : bars.data(), static_cast<int>(bars.size()));
2066 return;
2067 }
2068 init_security_eval_states_for_run(security_input_tf_);
2069 prepare_native_security_feeds(
2070 bars.empty() ? nullptr : bars.data(), static_cast<int>(bars.size()));
2071 // An N-day request's bars are TradingView's (make_multi_day_period_source),
2072 // its aggregator's period partition: the calendar aggregator split them
2073 // into single days. The partition is generated from the chart's and the
2074 // auxiliary feed's bars as the run asks for it, never stored: it reads
2075 // them until the next prepare rebuilds this aggregator.
2076 for (auto& state : security_eval_states_) {
2077 const int days = multi_day_count(state.tf);
2078 if (days == 0 || state.aggregator.calendar_period() != CalendarPeriod::DAY
2079 || state.aggregator.has_native_periods()) {
2080 continue;
2081 }
2082 MultiDayFeeds feeds;
2083 feeds.days = days;
2084 feeds.chart = bars.data();
2085 feeds.n_chart = static_cast<int>(bars.size());
2086 feeds.daily_chart = tf_to_seconds(input_tf_) == 86400;
2087#ifdef PINEFORGE_HAS_AUX_SECURITY_FEED_V1
2088 if (aux_security_feed_enabled()) {
2089 feeds.aux = aux_security_bars_.data();
2090 feeds.n_aux = aux_security_bars_.size();
2091 }
2092#endif
2093 feeds.tz = syminfo_.timezone;
2094 feeds.session = syminfo_.session;
2095 feeds.exchange_calendar = session_template_knows_early_close();
2096 if (auto partition = make_multi_day_period_source(feeds))
2097 state.aggregator.set_native_period_source(std::move(partition));
2098 }
2099#ifdef PINEFORGE_HAS_AUX_SECURITY_FEED_V1
2100 if (aux_security_feed_enabled()) {
2101 prepare_aux_security_chart_ranges(
2102 bars.empty() ? nullptr : bars.data(), static_cast<int>(bars.size()), script_tf_);
2103 }
2104#endif
2105 prepare_historical_security_lookahead_projections(
2106 bars.empty() ? nullptr : bars.data(), static_cast<int>(bars.size()), input_tf_);
2107 prepare_chart_day_partition(
2108 bars.empty() ? nullptr : bars.data(), static_cast<int>(bars.size()));
2109}
2110
2111bool source::PineStrategyHost::security_sites_kernel_routed() const noexcept {
2112 // A foreign site's InstrumentFeed series is always the kernel's; the
2113 // same-symbol sites are routed exactly when the running spec names one of
2114 // theirs.
2115 const NativeRunSpec* spec = detail::run_spec(*this);
2116 if (spec == nullptr) return false;
2117 for (const auto& series : spec->subscriptions) {
2118 if (series.source != NativeSeriesSource::InstrumentFeed) return true;
2119 }
2120 return false;
2121}
2122
2123bool source::PineStrategyHost::declare_security_sites_to_kernel() {
2124 std::vector<NativeTimeframeSubscription> declared;
2125 bool routed = same_symbol_sites_routable(declared);
2126 const std::size_t same_symbol = declared.size();
2127 // Lane XSYM-D: every foreign site that reads a feed is the kernel's,
2128 // declared after the same-symbol ones so theirs keep sec_id = index.
2129 prepare_foreign_security_sites(declared);
2130 if (declared.empty()) return false;
2131 if (declared.size() == same_symbol) {
2132 if (!declare_timeframe_subscriptions(std::move(declared))) return false;
2133 } else {
2134 auto result = declare_timeframe_subscriptions_result(declared);
2135 if (result.status != NativeSetupStatus::Applied && routed) {
2136 // The same-symbol sites stay on this host's own drive; the
2137 // foreign series have no other drive, so only their refusal is
2138 // final.
2139 declared.erase(declared.begin(),
2140 declared.begin() + static_cast<std::ptrdiff_t>(same_symbol));
2141 foreign_security_series_.erase(
2142 foreign_security_series_.begin(),
2143 foreign_security_series_.begin() + static_cast<std::ptrdiff_t>(same_symbol));
2144 for (auto& entry : foreign_security_sites_) {
2145 if (entry.second.subscription >= 0)
2146 entry.second.subscription -= static_cast<std::int64_t>(same_symbol);
2147 }
2148 routed = false;
2149 result = declare_timeframe_subscriptions_result(declared);
2150 }
2151 if (result.status != NativeSetupStatus::Applied) {
2152 RunFailureArgs refused{{"reason", "kernel_refused_series"}};
2153 if (const char* field = instrument_feed_field_name(result.validation.field))
2154 refused.emplace_back("field", field);
2155 throw coded<std::runtime_error>(
2157 "request.security: the kernel refused the symbol feed series (NativeRunSpecError "
2158 + std::to_string(static_cast<int>(result.validation.error)) + ", field "
2159 + std::to_string(static_cast<int>(result.validation.field)) + ")");
2160 }
2161 }
2162 // The kernel registers the routed sites, sec_id by index, after this
2163 // callback returns; the per-site table keeps their Pine semantics.
2164 if (routed) security_eval_states_.clear();
2165 return routed;
2166}
2167
2168bool source::PineStrategyHost::same_symbol_sites_routable(
2169 std::vector<NativeTimeframeSubscription>& out) {
2170 if (security_eval_states_.empty()) return false;
2171 // Run shapes with a Pine-only rule around the step: a stream feeds its
2172 // sites from realtime prints (the kernel takes confirmed bars only); an
2173 // aggregated chart or the bar magnifier can hold an input back until the
2174 // calling bar's callback has run (PineScheduler::input's deferrals); the
2175 // auxiliary slice is fed per chart bar; the range-start cut, the
2176 // historical lookahead projection and the OTC-daily pins veto or
2177 // re-shape inputs before the aggregator sees them.
2178 if (stream_warmup_mode_ || scheduler_.bar_magnifier_enabled()) return false;
2179 if (security_range_start_na_warmup_ || historical_security_lookahead_projection_)
2180 return false;
2181#ifdef PINEFORGE_HAS_AUX_SECURITY_FEED_V1
2182 if (aux_security_feed_enabled()) return false;
2183#endif
2184 if (input_tf_.empty() || input_tf_ != script_tf_ || security_input_tf_ != input_tf_)
2185 return false;
2186 const auto view = native_state();
2187 if (view.spec == nullptr || view.spec->timeframe_undetected
2188 || view.spec->input_tf != input_tf_) {
2189 return false;
2190 }
2191 const bool otc_daily_pins = (syminfo_.type == "forex" || syminfo_.type == "cfd")
2192 && native_security_feeds_.empty() && script_tf_seconds_ > 0
2193 && script_tf_seconds_ < 86400;
2194 std::vector<NativeTimeframeSubscription> declared;
2195 declared.reserve(security_eval_states_.size());
2196 for (std::size_t i = 0; i < security_eval_states_.size(); ++i) {
2197 const SecurityEvalState& state = security_eval_states_[i];
2198 const bool minute_day = calendar_period_for(state.tf) == CalendarPeriod::NONE
2199 && tf_to_seconds(state.tf) == 86400;
2200 if (minute_day && !pine_security_state(state.sec_id).lookahead_on
2201 && script_tf_seconds_ > 0 && script_tf_seconds_ < 86400) {
2202 return false;
2203 }
2204 // The kernel registers sec_id = index, which is what generated code
2205 // dispatches on.
2206 if (state.sec_id != static_cast<int>(i) || state.tf.empty()) return false;
2207 const PineSecurityEvalState& pine = pine_security_state(state.sec_id);
2208 // Site rules inside the step: the lookahead_on peeks and merge latch
2209 // (and the publication gate they arm), ticker.heikinashi, both
2210 // lower-timeframe paths, the auxiliary-slice completions.
2211 if (pine.lookahead_on || pine.heikinashi || pine.lower_tf_requested
2212 || pine.lower_tf_emulation || pine.lower_tf_use_input
2213 || pine.lower_tf_array_requested || pine.publish_gate_tf_seconds > 0
2214 || pine.calling_close_completes_partial || pine.calling_open_latches_first) {
2215 return false;
2216 }
2217 if (otc_daily_pins && (state.tf == "D" || state.tf == "1D")) return false;
2218 // TradingView's N-day bars restart every year and count the symbol's
2219 // trading dates (make_multi_day_period_source); the kernel's are a
2220 // fixed grid of calendar days.
2221 if (multi_day_count(state.tf) != 0) return false;
2222 NativeTimeframeSubscription subscription;
2223 subscription.tf = state.tf;
2224 subscription.gaps = pine.gaps_on;
2225 declared.push_back(std::move(subscription));
2226 }
2227 out = std::move(declared);
2228 return true;
2229}
2230
2231bool source::PineStrategyHost::scheduler_feed_security_input(
2232 const Bar& bar, std::int64_t next_input_ms, bool calling_bar_complete,
2233 bool defer_boundary_gate) {
2234 security_next_input_ms_ = next_input_ms;
2235 security_calling_close_ms_ = 0;
2236 bool deferred = false;
2237 for (auto& state : security_eval_states_) {
2238 if (defer_boundary_gate
2239 && pine_security_state(state.sec_id).publish_gate_tf_seconds > 0) {
2240 deferred = true;
2241 continue;
2242 }
2243 pine_feed_security_eval_state(state, bar, calling_bar_complete);
2244 }
2245 return deferred;
2246}
2247
2248void source::PineStrategyHost::scheduler_publish_security_boundary() {
2249 for (auto& state : security_eval_states_) {
2250 if (pine_security_state(state.sec_id).publish_gate_tf_seconds > 0)
2251 publish_security_eval_state_at_calling_boundary(state);
2252 }
2253}
2254
2255void source::PineStrategyHost::scheduler_feed_deferred_security_input(
2256 const Bar& bar, std::int64_t next_input_ms) {
2257 security_next_input_ms_ = next_input_ms;
2258 security_calling_close_ms_ = 0;
2259 for (auto& state : security_eval_states_) {
2260 if (pine_security_state(state.sec_id).publish_gate_tf_seconds > 0)
2261 pine_feed_security_eval_state(state, bar, false);
2262 }
2263}
2264
2265void source::PineStrategyHost::scheduler_feed_aux_security(int chart_index) {
2266#ifdef PINEFORGE_HAS_AUX_SECURITY_FEED_V1
2267 if (aux_security_feed_enabled() && scheduler_coof_enabled()) {
2268 // ab9714be pine_scheduler.cpp:1705-1708 feeds the auxiliary slice
2269 // before dispatch_bar, whose COOF bar takes its script checkpoint
2270 // only afterwards (pine_scheduler.cpp:467). The native scheduler
2271 // checkpoints at the script-bar open and restores that checkpoint
2272 // after this feed, which would discard the fed request.security
2273 // values every bar. Rebase the checkpoint on the open state plus
2274 // this feed so the restore keeps them.
2275 restore_script_state();
2276 feed_aux_security_for_chart_bar(chart_index);
2277 snapshot_script_state();
2278 return;
2279 }
2280 if (aux_security_feed_enabled()) feed_aux_security_for_chart_bar(chart_index);
2281#else
2282 (void)chart_index;
2283#endif
2284}
2285
2286void source::PineStrategyHost::scheduler_feed_deferred_aux_security(int chart_index) {
2287#ifdef PINEFORGE_HAS_AUX_SECURITY_FEED_V1
2288 if (aux_security_feed_enabled()) feed_deferred_aux_security_for_chart_bar(chart_index);
2289#else
2290 (void)chart_index;
2291#endif
2292}
2293
2294void source::PineStrategyHost::scheduler_finish_security_sequence() {
2295 clear_historical_security_lookahead_projections();
2296#ifdef PINEFORGE_HAS_AUX_SECURITY_FEED_V1
2297 release_aux_security_chart_ranges();
2298#endif
2299}
2300
2301// sort_same_bar_exit_trades' first test, asked before the call (the UTC chart-day memo):
2302// its group grows past the last row only when the row before it is a bracket
2303// exit of the same entry at the same exit time, as the last row is. Otherwise
2304// the group is one row, the sort moves nothing and the call answers nullopt.
2305// A quiet-bar gate (pine_quiet_bar.hpp): the ungated reference calls the sort
2306// at every publication.
2307static bool same_bar_exit_group_possible(const TradeLedger& trades) {
2308#if PINEFORGE_PINE_QUIET_BAR_GATES
2309 if (trades.size() < 2) return false;
2310 const auto last_index = trades.size() - 1;
2311 const auto previous_index = last_index - 1;
2312 const Trade last = trades.numeric_at(last_index);
2313 const Trade previous = trades.numeric_at(previous_index);
2314 if (!previous.exit_from_bracket || !last.exit_from_bracket
2315 || previous.exit_time != last.exit_time || previous.entry_time != last.entry_time)
2316 return false;
2317 // Entry identity is still exact, but comments and exit IDs are irrelevant
2318 // here. Do not decode even the entry IDs unless the numeric test matches.
2319 const auto last_entry_id = trades.entry_id_at(last_index);
2320 return trades.entry_id_at(previous_index) == last_entry_id;
2321#else
2322 (void)trades;
2323 return true;
2324#endif
2325}
2326
2327bool source::PineStrategyHost::report_terminal_quote_eligible() const {
2328 if (!last_equity_point() || range_end_trades_.empty() || !native_bound()) return false;
2329 const auto state = as_native_consumer(execution_consumer()).view();
2330 const auto interval = static_cast<std::int64_t>(script_tf_seconds_) * 1000;
2331 const auto last_time = last_equity_point()->time_ms;
2332 return state.spec && state.spec->report_policy == NativeReportPolicy::KernelRecordedAtHostMarks
2333 && state.kind == NativeLifecycleKind::Completed && bar_magnifier_enabled_
2334 && script_tf_seconds_ > 60 && script_tf_seconds_ < 86400
2335 && report_quote_time_ms_ >= last_time && report_quote_time_ms_ - last_time <= interval
2336 && (report_quote_time_ms_ - last_time) % interval == 0
2337 && std::isfinite(report_quote_close_)
2338 && range_end_trades_.size() == pyramid_entries_.size();
2339}
2340
2342 if (!report_terminal_quote_eligible() || report.equity_curve_len == 0
2343 || report.equity_curve[report.equity_curve_len - 1].time_ms != report_quote_time_ms_) return false;
2344 std::size_t rows = 0;
2345 for (int index = 0; index < report.trades_len; ++index) {
2346 const auto& row = report.trades[index];
2347 if (!row.open_at_end) continue;
2348 if (row.exit_time != report_quote_time_ms_ || row.exit_price != bar_fill_price(report_quote_close_))
2349 return false;
2350 ++rows;
2351 }
2352 return rows == pyramid_entries_.size();
2353}
2354
2355void source::PineStrategyHost::present_report(ReportC* out) const {
2356 if (out->equity_curve_len == 0 || !native_bound()) return;
2357 const auto state = as_native_consumer(execution_consumer()).view();
2358 if (!state.spec
2359 || state.spec->report_policy != NativeReportPolicy::KernelRecordedAtHostMarks) return;
2360 const auto position = as_native_consumer(execution_consumer()).position(*this);
2361 auto& last = out->equity_curve[out->equity_curve_len - 1];
2362 if ((stream_phase_ == StreamPhase::REALTIME
2363 || state.completion == NativeCompletion::StreamEnded)
2364 && (!range_end_trades_.empty()
2365 || (position.signed_units == 0.0 && position.lot_count == 0
2366 && last.open_profit != 0.0))) {
2367 last.open_profit = 0.0;
2368 if (!range_end_trades_.empty()) {
2369 double range_end_pnl = 0.0;
2370 for (const auto& row : range_end_trades_) range_end_pnl += row.pnl;
2371 last.equity = initial_capital_ + net_profit_sum_ + range_end_pnl;
2372 amend_report_replay_point(last.time_ms, report::EquityBasis::range_end, range_end_pnl);
2373 }
2374 }
2375 if (!report_terminal_quote_eligible()) return;
2376 const double price = bar_fill_price(report_quote_close_);
2377 const double fx = account_currency_fx_at(report_quote_time_ms_);
2378 std::size_t lot_index = 0;
2379 for (int index = 0; index < out->trades_len; ++index) {
2380 auto& row = out->trades[index];
2381 if (!row.open_at_end) continue;
2382 const auto& lot = pyramid_entries_[lot_index++];
2383 execution::PhysicalExecutionContext context;
2384 context.effective_time_ms = report_quote_time_ms_;
2385 context.interval_index = row.exit_bar_index;
2386 context.account_fx = fx;
2387 const auto mark = build_close_trade_with_costs(lot, row.qty, price, row.is_long != 0,
2388 allocated_entry_commission(lot, row.qty), calc_commission_at(price, row.qty, fx), context);
2389 // settlement safety guard (an invariant), not Pine parity. The price
2390 // test is implied by the predicate (the mark carries it as exit_price);
2391 // no mutant can kill it alone.
2392 if (!internal::closed_row_all_finite(mark) || !std::isfinite(price))
2393 throw std::logic_error("non-finite terminal-quote report row");
2394 row.exit_time = report_quote_time_ms_;
2395 row.exit_price = price;
2396 row.pnl = mark.pnl;
2397 row.pnl_pct = mark.pnl_pct;
2398 row.commission = mark.commission;
2399 }
2400 out->net_profit = out->total_trades == out->trades_len ? 0.0 : report_closed_net_profit();
2401 for (int index = 0; index < out->trades_len; ++index) out->net_profit += out->trades[index].pnl;
2402 last.time_ms = report_quote_time_ms_;
2403 last.equity = initial_capital_ + out->net_profit;
2404 amend_report_replay_point(last.time_ms, report::EquityBasis::terminal_quote, out->net_profit);
2405}
2406
2407void source::PineStrategyHost::scheduler_record_range_end(const Bar& terminal_bar) {
2408 range_end_trades_.clear();
2409 if (stream_warmup_mode_ || realtime_tail_
2410 || position_side_ == PositionSide::FLAT || !last_equity_point()
2411 || !std::isfinite(terminal_bar.close)) return;
2412 const double fill_price = bar_fill_price(terminal_bar.close);
2413 // R5 audit lane Q6 (duplicate D5): the row loop itself is the kernel's
2414 // generic producer, called here at TradingView's own mark instead of
2415 // being restated. What stays below is the part that is report SHAPE and
2416 // not a mark-to-market row — the reason the kernel's run-end producer is
2417 // gated out of every Pine run (pine_adapter.cpp project()). The rows are
2418 // dated on the curve's last point and convert at that instant's rate,
2419 // handed to the producer: the presented clock is never rewritten (R5
2420 // lane B-ADAPTER; E21's rule -- thread the rate, never the clock).
2421 const std::int64_t mark_time = last_equity_point()->time_ms;
2422 const double range_end_pnl = as_native_consumer(execution_consumer())
2423 .append_open_position_report_rows(*this, fill_price, mark_time, bar_index_,
2424 account_currency_fx_at(mark_time));
2425 if (stream_phase_ != StreamPhase::REALTIME) {
2426 auto& last = *last_equity_point();
2427 last.open_profit = 0.0;
2428 last.equity = initial_capital_ + net_profit_sum_ + range_end_pnl;
2429 amend_report_replay_point(last.time_ms, report::EquityBasis::range_end, range_end_pnl);
2430 // Restore the fold before the held point, then fold its replacement.
2431 // Debug checks all four resulting doubles against a complete walk.
2432 // The sink-off helper keeps the existing walk and initial seed.
2433 // Presentation later receives its own copy of this editable tail.
2434 refold_equity_tail();
2435 }
2436 if (same_bar_exit_group_possible(trades_)) {
2437 if (const auto moved = sort_same_bar_exit_trades(trades_, adapter_))
2438 native_closed_rows_amended(*moved);
2439 }
2440}
2441
2442// TradingView keeps one child of a strategy.exit that names no entry for every
2443// open entry. When one path point triggers several, it fills them one at a
2444// time: the children of the entry id that comes first in its entry-id table
2445// first (PineExecutionAdapter::global_exit_child_ranks), and an older entry's
2446// child before a newer one's of the same id. Each child is a transaction of
2447// its entry's quantity, settled against its own entry under
2448// close_entries_rule = "ANY" and against the oldest lots first otherwise. Each
2449// settled slice is a row of its own, and its fee is its share of its lot's
2450// entry fee plus its share of its transaction's exit fee, each on
2451// TradingView's ten-significant-digit money (127 TradingView tapes,
2452// tests/fixtures/global_exit_children).
2453// The kernel fills such a leg once for the whole position, oldest lots first,
2454// one row per lot. Its rows are still the last ones and not final until this
2455// notification returns, so they are re-cut here into the children's rows.
2456// Every shape the pin does not reach keeps the kernel's rows:
2457// - a fill that leaves part of the position open;
2458// - a trailing leg;
2459// - an exit filled at the same point before it, or a leg of any other
2460// exit live at the fill that its price also reaches (the order of a
2461// named child, or another exit's, against these; on_native_applied reads
2462// it before the adapter retires that leg);
2463// - a table the pin does not size (global_exit_child_ranks);
2464// - calc_on_order_fills, process_orders_on_close or an intrabar feed;
2465// - a non-unit account rate, a cash-per-order fee or a negative fee;
2466// - a lot quantity off TradingView's ten significant digits.
2467void source::PineStrategyHost::order_global_exit_children(
2468 const native_order::ExecutionAppliedEvent& event) {
2469 const std::size_t first = event.first_trade_index;
2470 const std::size_t count = event.closed_trade_count;
2471 if (count < 2 || event.opened_units != 0.0 || first + count != trades_.size()
2472 || !pyramid_entries_.empty()) {
2473 return;
2474 }
2475 const auto& run = adapter_.config_;
2476 if (run.calc_on_order_fills || run.process_orders_on_close || bar_magnifier_enabled_
2477 || active_account_currency_fx() != 1.0 || commission_value_ < 0.0
2478 || (commission_value_ != 0.0 && commission_type_ == CommissionType::CASH_PER_ORDER)) {
2479 return;
2480 }
2481 const auto placement = adapter_.placement_.find(event.handle().incarnation);
2482 if (placement == adapter_.placement_.end()) return;
2483 const auto& cause = placement->second;
2484 if ((cause.family != PineOrderFamily::ExitLimit && cause.family != PineOrderFamily::ExitStop)
2485 || !cause.from_entry.empty()) {
2486 return;
2487 }
2488 // Ledger reads own their strings. Retain only the group keys so a wide
2489 // comment does not stay resident while another row is being read.
2490 Trade head{};
2491 {
2492 Trade row = trades_[first];
2493 head.exit_time = row.exit_time;
2494 head.exit_price = row.exit_price;
2495 head.exit_id = std::move(row.exit_id);
2496 head.is_long = row.is_long;
2497 head.entry_id = std::move(row.entry_id);
2498 }
2499 auto& scratch = trade_scratch();
2500 TradeScratchTransaction transaction(scratch);
2501 bool different_ids = false;
2502 for (std::size_t index = first; index < first + count; ++index) {
2503 const Trade& row = trades_[index];
2504 if (row.exit_time != head.exit_time || row.exit_price != head.exit_price
2505 || row.exit_id != head.exit_id || row.is_long != head.is_long
2506 || !(row.qty > 0.0) || row.open_at_end) {
2507 return;
2508 }
2509 const TradeScratchKey incarnation{scratch_incarnation, row.entry_incarnation, 0, 0};
2510 if (scratch_find(scratch, incarnation) != ::pineforge::detail::scratch_no_handle) return;
2511 scratch_put(scratch, incarnation);
2512 different_ids = different_ids || row.entry_id != head.entry_id;
2513 }
2514 if (!different_ids) return;
2515 if (first > 0 && trades_[first - 1].exit_time == head.exit_time
2516 && trades_[first - 1].exit_price == head.exit_price) {
2517 return;
2518 }
2519 // Validate one id and each distinct pair through the existing table rule:
2520 // a grown table rejects shared buckets, while the original table does
2521 // not. Each query owns at most two ids, irrespective of the lot count.
2522 // note_exit_rank_key retains at most 27 keys (the existing parity pin),
2523 // so distinct-id work is fixed even when one id owns arbitrarily many lots.
2524 std::array<std::size_t, 27> ages{};
2525 std::array<std::uint32_t, 27> ranks{};
2526 std::size_t age_count = 0;
2527 const auto age_of = [&](std::size_t lot) {
2528 const auto& keys = adapter_.exit_rank_keys_;
2529 const auto found = keys.find(trades_[first + lot].entry_id);
2530 return found ? static_cast<std::size_t>(keys.get(*found))
2531 : static_cast<std::size_t>(keys.size());
2532 };
2533 const auto key_of = [&](std::size_t age) {
2534 SourceId id;
2535 adapter_.exit_rank_keys_.for_each_inserted([&](SourceId key, std::uint32_t key_age) {
2536 if (key_age == age) id = std::move(key);
2537 });
2538 return id;
2539 };
2540 std::vector<SourceId> ids;
2541 for (std::size_t lot = 0; lot < count; ++lot) {
2542 const auto age = age_of(lot);
2543 if (age == adapter_.exit_rank_keys_.size()) return;
2544 if (std::find(ages.begin(), ages.begin() + age_count, age) != ages.begin() + age_count)
2545 continue;
2546 ids.assign(1, trades_[first + lot].entry_id);
2547 const auto rank = adapter_.global_exit_child_ranks(ids);
2548 if (!rank) return;
2549 ranks[age] = rank->front();
2550 for (std::size_t earlier = 0; earlier < age_count; ++earlier) {
2551 ids.resize(1);
2552 ids.push_back(key_of(ages[earlier]));
2553 if (!adapter_.global_exit_child_ranks(ids)) return;
2554 }
2555 ages[age_count++] = age;
2556 }
2557 const auto rank_of = [&](std::size_t lot) {
2558 return ranks[age_of(lot)];
2559 };
2560 // Children in lot order settle exactly as the kernel's one fill did.
2561 bool ordered = true;
2562 auto previous_rank = rank_of(0);
2563 for (std::size_t lot = 1; lot < count; ++lot) {
2564 const auto rank = rank_of(lot);
2565 if (rank < previous_rank) ordered = false;
2566 previous_rank = rank;
2567 }
2568 if (ordered) return;
2569 auto* const replay = report_replay_state();
2570 std::unique_ptr<::pineforge::detail::ScratchSequence<report::ReplayTrade>> replay_rows;
2571 if (replay) {
2572 replay_rows = std::make_unique<::pineforge::detail::ScratchSequence<report::ReplayTrade>>();
2573 replay_rows->set_directory(replay->scratch_directory());
2574 }
2575 const auto visit_children = [&](const auto& visit) {
2576 for (std::size_t rank = 0; rank < adapter_.exit_rank_keys_.size(); ++rank)
2577 for (std::size_t child = 0; child < count; ++child)
2578 if (rank_of(child) == rank && !visit(child)) return false;
2579 return true;
2580 };
2581 std::size_t row_count = 0;
2582 const auto emit_row = [&](TradeScratchHeader header, const Trade& text) {
2583 const TradeScratchSource row(header, text);
2584 scratch_put(scratch, {scratch_row, row_count++, 0, 0}, row);
2585 };
2586 const auto header_for = [&](std::size_t source, const Trade& row) {
2587 const auto phase = adapter_.exit_phase_value(source);
2588 return scratch_trade_header(row, source, phase);
2589 };
2590 const auto annotate = [&](std::size_t lot, std::size_t child, double transaction_qty,
2591 bool fifo, bool fees, double exit_fee, double transaction_fee) {
2592 if (!replay) return;
2593 const Trade whole = trades_.numeric_at(first + lot);
2594 auto row = replay->trade(first + lot);
2595 row.parent = event.ordinal;
2596 row.slice = row_count - 1;
2597 row.child = child;
2598 row.transaction_qty = transaction_qty;
2599 row.whole_qty = whole.qty;
2600 row.whole_pnl = whole.pnl;
2601 row.whole_commission = whole.commission;
2602 row.whole_runup = whole.max_runup;
2603 row.whole_drawdown = whole.max_drawdown;
2604 row.whole_exit_fee = exit_fee;
2605 row.transaction_fee = transaction_fee;
2606 row.marked = true;
2607 row.fifo = fifo;
2608 row.fees = fees;
2609 replay_rows->push_back(row);
2610 };
2611 // The rule the adapter runs under, not the host's live copy a body writes.
2612 if (run.close_entries_rule_any) {
2613 visit_children([&](std::size_t lot) {
2614 const Trade row = trades_[first + lot];
2615 emit_row(header_for(first + lot, row), row);
2616 if (replay) annotate(lot, lot, row.qty, false, false, 0.0, 0.0);
2617 return true;
2618 });
2619 } else {
2620 const bool fees = commission_value_ != 0.0;
2621 for (std::size_t lot = 0; lot < count; ++lot) {
2622 const double qty = trades_[first + lot].qty;
2623 if (tv_money_round(qty) != qty) return;
2624 }
2625 const auto visit_slices = [&](const auto& visit) {
2626 std::size_t lot = 0;
2627 double left = trades_[first].qty;
2628 if (!visit_children([&](std::size_t child) {
2629 const double transaction = trades_[first + child].qty;
2630 double wanted = transaction;
2631 while (wanted > internal::kQtyEpsilon && lot < count) {
2632 const double taken = std::min(left, wanted);
2633 visit(lot, child, taken, transaction);
2634 left = tv_money_round(left - taken);
2635 wanted = tv_money_round(wanted - taken);
2636 if (left <= internal::kQtyEpsilon) {
2637 ++lot;
2638 if (lot < count) left = trades_[first + lot].qty;
2639 }
2640 }
2641 return !(wanted > internal::kQtyEpsilon);
2642 })) return false;
2643 return lot == count;
2644 };
2645 // Complete the exact quantity pass before calculating any replacement
2646 // money, as the old slice-vector pass did.
2647 if (!visit_slices([](std::size_t, std::size_t, double, double) {})) return;
2648 visit_slices([&](std::size_t lot, std::size_t child, double qty, double transaction) {
2649 const Trade& whole = trades_[first + lot];
2650 const double share = qty / whole.qty;
2651 double commission = 0.0;
2652 double replay_exit_fee = 0.0, replay_transaction_fee = 0.0;
2653 if (fees) {
2654 const double exit_fee = calc_commission_at(whole.exit_price, whole.qty, 1.0);
2655 const double entry_part =
2656 tv_money_round(std::max(0.0, whole.commission - exit_fee) * share);
2657 const double transaction_fee =
2658 tv_money_round(calc_commission_at(whole.exit_price, transaction, 1.0));
2659 const double exit_part = tv_money_round(transaction_fee * qty / transaction);
2660 commission = tv_money_round(entry_part + exit_part);
2661 if (replay) {
2662 replay_exit_fee = exit_fee;
2663 replay_transaction_fee = transaction_fee;
2664 }
2665 }
2666 auto header = header_for(first + lot, whole);
2667 Trade row{};
2668 scratch_trade_scalars(header, row);
2669 row.qty = qty;
2670 row.commission = commission;
2671 row.pnl = tv_money_round((whole.pnl + whole.commission) * share) - commission;
2672 const double cost = whole.entry_price * qty * syminfo_.pointvalue;
2673 row.pnl_pct = cost > 0.0 ? row.pnl / cost * 100.0 : 0.0;
2674 // The lot's excursions taken by share: an approximation where the
2675 // slice's lot is not its child's own (excursions are not graded).
2676 row.max_runup = whole.max_runup * share;
2677 row.max_drawdown = whole.max_drawdown * share;
2678 header[6] = scratch_word(row.qty);
2679 header[7] = scratch_word(row.pnl);
2680 header[8] = scratch_word(row.pnl_pct);
2681 header[13] = scratch_word(row.max_runup);
2682 header[14] = scratch_word(row.max_drawdown);
2683 header[15] = scratch_word(row.commission);
2684 emit_row(header, whole);
2685 if (replay) annotate(lot, child, transaction, true, fees,
2686 replay_exit_fee, replay_transaction_fee);
2687 });
2688 }
2689 // settlement safety guard (an invariant), not Pine parity. Validate all
2690 // replacements before changing the committed rows or their tallies.
2691 scratch_walk(scratch, scratch_row, [&](::pineforge::detail::ScratchHandle handle, const TradeScratchKey&) {
2692 Trade row{};
2693 scratch_trade_scalars(scratch_read_trade(scratch, handle), row);
2694 if (!internal::closed_row_all_finite(row))
2695 throw std::logic_error("non-finite amended trade row");
2696 });
2697 // strategy.wintrades / losstrades / eventrades and the gross sums count
2698 // rows: count the children's instead of the kernel's. net_profit_sum_ (the
2699 // equity) stays the kernel's whole-lot sum: the slices' PnLs can differ
2700 // from it by at most half a unit in the tenth significant digit of each
2701 // slice's gross and fee, which no tape has shown in strategy.netprofit or
2702 // in an equity-sized quantity.
2703 const auto tally = [this](double pnl, int sign) {
2704 if (pnl > 0.0) { gross_profit_sum_ += sign * pnl; win_trades_count_ += sign; }
2705 else if (pnl < 0.0) { gross_loss_sum_ += sign * pnl; loss_trades_count_ += sign; }
2706 else { eventrades_count_ += sign; }
2707 };
2708 for (std::size_t index = first; index < first + count; ++index) tally(trades_[index].pnl, -1);
2709 scratch_walk(scratch, scratch_row, [&](::pineforge::detail::ScratchHandle handle, const TradeScratchKey&) {
2710 Trade row{};
2711 scratch_trade_scalars(scratch_read_trade(scratch, handle), row);
2712 tally(row.pnl, 1);
2713 });
2714 if (replay) {
2715 if (replay_rows->size() != row_count)
2716 throw coded<std::logic_error>(RunFailureCode::engine_invariant, {},
2717 "recut replay rows differ from replacement rows");
2718 replay->resize_trades(first + row_count);
2719 for (std::size_t i = 0; i < row_count; ++i) {
2720 auto annotation = replay_rows->at(i);
2721 annotation.slices = row_count;
2722 replay->replace(first + i, annotation);
2723 }
2724 }
2725 adapter_.invalidate_exit_group(first, trades_.size() - first); trades_.resize(first);
2726 scratch_walk(scratch, scratch_row, [&](::pineforge::detail::ScratchHandle handle, const TradeScratchKey&) {
2727 const auto header = scratch_read_trade(scratch, handle);
2728 Trade row{};
2729 scratch_trade_scalars(header, row);
2730 scratch_trade_text(scratch, handle, header, row);
2731 trades_.push_back(std::move(row));
2732 });
2733 const auto none = static_cast<std::uint8_t>(NativePathPhase::None);
2734 scratch_walk(scratch, scratch_row, [&](::pineforge::detail::ScratchHandle handle, const TradeScratchKey& key) {
2735 const auto header = scratch_read_trade(scratch, handle);
2736 const auto target = first + static_cast<std::size_t>(key[1]);
2737 const auto phase = static_cast<std::uint8_t>(header[1]);
2738 if (phase == none && adapter_.exit_phase_value(target) == none) return;
2739 adapter_.set_exit_phase(target, phase);
2740 });
2741 transaction.finish();
2742 native_closed_rows_amended(first);
2743}
2744
2745// ab9714be pine_fills.cpp:664-670: same-bar bracket exit trades sort by script command sequence created_seq
2746// The rows are final kernel rows by now (their applied notifications have
2747// returned), so a sort that moves one answers the first row it moved, which
2748// the caller hands to the kernel before the next hash read
2749// (NativeStrategyHost::native_closed_rows_amended).
2750std::optional<std::size_t> source::PineStrategyHost::sort_same_bar_exit_trades(
2751 TradeLedger& trades, source::PineExecutionAdapter& adapter) {
2752 ++adapter.exit_group_work_.publications; if (adapter.exit_group_certified(trades) || trades.size() < 2) return std::nullopt;
2753 const std::size_t end = trades.size();
2754 std::size_t start = end - 1;
2755 while (start > 0
2756 && trades[start - 1].exit_time == trades[end - 1].exit_time
2757 && trades[start - 1].entry_time == trades[end - 1].entry_time
2758 && trades[start - 1].entry_id == trades[end - 1].entry_id
2759 && trades[start - 1].exit_from_bracket
2760 && trades[end - 1].exit_from_bracket) {
2761 --start; ++adapter.exit_group_work_.tail_rows;
2762 }
2763 if (end - start > 1) {
2764 // ab9714be pine_scheduler.cpp:262-283 and pine_fills.cpp:3710-3730:
2765 // fills on the bar OPEN precede intrabar fills chronologically. Sibling
2766 // bracket exits that fill within the same phase tie-break by command
2767 // sequence (test_l10m_corpus_parity.cpp).
2768 const auto less = [&](std::size_t ia, std::size_t ib) {
2769 ++adapter.exit_group_work_.comparisons; const bool open_a = adapter.is_open_phase_exit(ia);
2770 const bool open_b = adapter.is_open_phase_exit(ib);
2771 if (open_a != open_b) return open_a;
2772 const auto sa = adapter.command_sequence_for_exit(trades[ia].exit_id, trades[ia].entry_id);
2773 const auto sb = adapter.command_sequence_for_exit(trades[ib].exit_id, trades[ib].entry_id);
2774 return sa < sb;
2775 };
2776 const auto none = static_cast<std::uint8_t>(NativePathPhase::None);
2777 bool ordered = true;
2778 for (std::size_t index = start + 1; index < end; ++index)
2779 if (less(index, index - 1)) { ordered = false; break; }
2780 if (ordered) { adapter.certify_exit_group(trades, start); return std::nullopt; }
2781 auto& scratch = trade_scratch(); adapter.certify_exit_group(trades, start, false);
2782 const auto scratch_before = scratch.stats(); TradeScratchTransaction transaction(scratch);
2783 for (std::size_t source = start; source < end; ++source) {
2784 const auto phase = adapter.exit_phase_value(source);
2785 const auto sequence = adapter.command_sequence_for_exit(
2786 trades[source].exit_id, trades[source].entry_id);
2787 scratch_put(scratch,
2788 {scratch_sort, adapter.is_open_phase_exit(source) ? 0U : 1U, sequence, source},
2789 scratch_bytes(&phase, sizeof phase));
2790 }
2791 std::size_t target = start;
2792 // source is the last key word, so ties retain their original order.
2793 // This is a seek walk, not a store callback: the fixed mapping value
2794 // is read out before put, and its source never reads the same store.
2795 scratch_walk(scratch, scratch_sort,
2796 [&](::pineforge::detail::ScratchHandle handle, const TradeScratchKey& key) {
2797 std::uint8_t phase = none;
2798 require_trade_scratch(scratch.read(handle, ::pineforge::detail::ScratchField::value, 0,
2799 scratch_buffer(&phase, sizeof phase)), scratch);
2800 const std::array<std::uint64_t, 2> mapping{key[3], phase};
2801 scratch_put(scratch, {scratch_permutation, target++, 0, 0},
2802 scratch_bytes(mapping.data(), sizeof mapping));
2803 });
2804 // Apply disjoint permutation cycles with one saved Trade. Erasing a
2805 // mapping marks that target visited in backing, without a RAM bitmap.
2806 for (std::size_t cycle = start; cycle < end; ++cycle) {
2807 auto handle = scratch_find(scratch, {scratch_permutation, cycle, 0, 0});
2808 if (handle == ::pineforge::detail::scratch_no_handle) continue;
2809 const Trade row = trades[cycle];
2810 auto* const replay = report_replay_state();
2811 const auto saved_replay = replay ? replay->trade(cycle) : report::ReplayTrade{};
2812 std::size_t current = cycle;
2813 for (;;) {
2814 std::array<std::uint64_t, 2> mapping{};
2815 require_trade_scratch(scratch.read(handle, ::pineforge::detail::ScratchField::value, 0,
2816 scratch_buffer(mapping.data(), sizeof mapping)), scratch);
2817 const auto source = static_cast<std::size_t>(mapping[0]);
2818 const auto phase = static_cast<std::uint8_t>(mapping[1]);
2819 // Both indices are global; only the adapter translates the
2820 // sealed base. Clear an existing phase even when None moves
2821 // here, without extending an absent all-None tail.
2822 if (phase != none || adapter.exit_phase_value(current) != none)
2823 adapter.set_exit_phase(current, phase);
2824 ++adapter.exit_group_work_.replacements; if (source == cycle) trades.replace(current, row);
2825 else trades.replace(current, trades[source]);
2826 if (replay)
2827 replay->replace(current, source == cycle ? saved_replay : replay->trade(source));
2828 require_trade_scratch(scratch.erase(handle), scratch);
2829 if (source == cycle) break;
2830 current = source;
2831 handle = scratch_find(scratch, {scratch_permutation, current, 0, 0});
2833 throw coded<std::logic_error>(RunFailureCode::engine_invariant, {},
2834 "incomplete trade scratch permutation");
2835 }
2836 }
2837 transaction.finish(); adapter.finish_exit_group_sort(scratch, scratch_before);
2838 return start;
2839 }
2840 return std::nullopt;
2841}
2842
2843void source::PineStrategyHost::scheduler_update_session_state() {
2844 // session_ismarket_ and the _regular pair are the kernel's session-day
2845 // facts of this script bar, selected before the source callback reads
2846 // them (the kernel session-day integration). The rule the session-day rule established here -- TradingView
2847 // ends a session at the session DAY, the flag belonging to the last chart
2848 // bar whose successor is out of session or on another session day,
2849 // isfirstbar its dual; the `lab tv` tapes in tests/fixtures/session_islastbar
2850 // give it bar for bar -- is the kernel's generic reading of the run's
2851 // calendar and input (NativeDecisionContext, market_driver.hpp), so every
2852 // driving path reads the same regular day by construction. The plain pair
2853 // starts from it; the chart's day an extended-hours chart widens it to is
2854 // Pine's own, below (update_extended_session_day).
2855 //
2856 // Pine's live-probe protocol (set_realtime_tail) stays too: its batch's
2857 // final bar is the still-forming realtime bar, so that bar closes its
2858 // session day where the calendar does, not by the batch's run-end
2859 // convention -- the kernel's open-ended reading, which equals the ordinary
2860 // one on every other bar.
2861 //
2862 // The scheduler's old lookahead indexed its retained input by the count
2863 // of source bars published, which under calc_on_order_fills read two bars
2864 // ahead on a bar a fill recalculation had already published
2865 // (tests/test_session_day_facts_adapter.cpp); the chart day's lookahead
2866 // starts from the point's own input and its script bar's end instead.
2867 const NativeCurrentPointView* point = detail::callback_point(*this);
2868 if (!point) return;
2869 const NativeDecisionContext& facts = point->decision;
2870 session_ismarket_ = facts.in_session;
2871 session_isfirstbar_regular_ = facts.opens_session_day;
2872 session_islastbar_regular_ = realtime_tail_ ? facts.closes_session_day_open_ended
2873 : facts.closes_session_day;
2874 session_isfirstbar_ = session_isfirstbar_regular_;
2875 session_islastbar_ = session_islastbar_regular_;
2876 // TradingView's symbol calendar, where the run holds one: a regular
2877 // intraday bar ends its session day when the calendar's next slot does not
2878 // lie in it -- OANDA:XAUUSD's 16:45 ET bar (the next slot, 17:00, opens the
2879 // next day), not its 12:45 ET bar of 2025-07-04, where the feed stops and
2880 // the calendar goes on (te_session_calendar_xau15-jul4). The first bar of a
2881 // day stays the one the run holds (isfirstbar is the bar after a bar of
2882 // another day, on the calendar and off it).
2883 if (facts.in_session && calendar_period_for(script_tf_) == CalendarPeriod::NONE
2884 && script_tf_seconds_ > 0) {
2885 if (const auto* day = symbol_calendar_day(facts.script_bar_open_ms)) {
2886 const bool last = facts.script_bar_open_ms
2887 + static_cast<std::int64_t>(script_tf_seconds_) * 1000 >= day->second;
2888 session_islastbar_regular_ = last;
2889 session_islastbar_ = last;
2890 }
2891 }
2892 update_extended_session_day(facts);
2893}
2894
2895// session.isfirstbar / session.islastbar mark the CHART's session day (R5
2896// lane K-SESSION-WINDOWS F2). With extended hours TradingView's chart holds
2897// the regular day's pre- and post-market bars too, and the plain pair spans
2898// them while the _regular pair keeps the regular day: on NASDAQ:AAPL 60 with
2899// extended hours isfirstbar is the 04:00 bar and islastbar the 19:00 one,
2900// isfirstbar_regular 10:00 and islastbar_regular 15:00
2901// (tests/fixtures/session_windows). A chart without extended hours holds no
2902// pre- or post-market bar, so there the pairs are one. The regular day is the
2903// kernel's session-day facts; pre- and post-market are session.ispremarket /
2904// ispostmarket's windows (session_time.hpp), which exist only on a session
2905// day that opens and closes on one date, the date the chart's day is keyed
2906// to. A bar neither in session nor pre- or post-market is in no chart day.
2907//
2908// The bar before is the script bar published last. The bar after is the
2909// first input the run holds -- a batch's, or a stream's warmup
2910// (PineScheduler::retained_input_from) -- past this script bar's interval,
2911// so the chart, magnified and aggregated paths read one answer for the same
2912// bars; a batch's final bar closes its day as the kernel's does unless the
2913// live probe tail is still forming it. Without such a bar a pre- or
2914// post-market bar reads the chart timeframe's next slot, while a regular bar
2915// keeps the kernel's close: nothing says whether the post-market bars that
2916// slot names will come.
2917void source::PineStrategyHost::update_extended_session_day(const NativeDecisionContext& facts) {
2918 const std::string& session = syminfo_.session;
2919 const std::string& tz = syminfo_.timezone;
2920 const std::int64_t at = facts.script_bar_open_ms;
2921 const bool regular = facts.in_session;
2922 // A regular bar that neither opens nor closes the regular day is inside
2923 // it, and so inside the chart's: the kernel's answer stands.
2924 if (regular && !facts.opens_session_day && !session_islastbar_regular_) return;
2925 const auto pre = [&](std::int64_t ms) {
2926 return pineforge::session_in_premarket(session, tz, ms, script_tf_);
2927 };
2928 const auto post = [&](std::int64_t ms) {
2929 return pineforge::session_in_postmarket(session, tz, ms, script_tf_);
2930 };
2931 const bool premarket = !regular && pre(at);
2932 const bool postmarket = !regular && !premarket && post(at);
2933 if (!regular && !premarket && !postmarket) return;
2934
2935 const std::int64_t day = pineforge::calendar_day_open_local_ms(at, tz);
2936 const auto same_day = [&](std::int64_t ms) {
2937 return pineforge::calendar_day_open_local_ms(ms, tz) == day;
2938 };
2939 const auto in_chart_day = [&](std::int64_t ms) {
2940 return (pineforge::session_in_market(session, tz, ms) || pre(ms) || post(ms))
2941 && same_day(ms);
2942 };
2943 const std::optional<std::int64_t> before = scheduler_.last_published_script_open();
2944 // The next script bar opens at or after this one's interval end: an
2945 // aggregated bucket's, or -- a FeedTolerant chart bar's interval being its
2946 // label alone -- the instant after the label.
2947 const std::int64_t next_open = std::max(at + 1, facts.script_interval.next_period_open_ms);
2948 std::optional<std::int64_t> after;
2949 bool known_after = false;
2950 if (const auto bar = scheduler_.retained_input_from(facts.coordinate.input_interval_index,
2951 next_open)) {
2952 after = bar->timestamp;
2953 known_after = true;
2954 }
2955 const bool run_end = !known_after && !scheduler_.retains_stream() && !realtime_tail_;
2956 if (!known_after && !run_end && !regular && script_tf_seconds_ > 0)
2957 after = at + static_cast<std::int64_t>(script_tf_seconds_) * 1000;
2958
2959 if (regular) {
2960 // The regular day's first bar opens the chart's unless pre-market
2961 // bars of its date precede it; its last closes the chart's unless
2962 // post-market bars of its date follow.
2963 if (facts.opens_session_day)
2964 session_isfirstbar_ = !(before && pre(*before) && same_day(*before));
2965 if (session_islastbar_regular_)
2966 session_islastbar_ = !(known_after && post(*after) && same_day(*after));
2967 return;
2968 }
2969 if (premarket) {
2970 session_isfirstbar_ = !(before && pre(*before) && same_day(*before));
2971 session_islastbar_ = after ? !in_chart_day(*after) : run_end;
2972 return;
2973 }
2974 session_isfirstbar_ = !(before && in_chart_day(*before));
2975 session_islastbar_ = after ? !(post(*after) && same_day(*after)) : run_end;
2976}
2977
2978void source::PineStrategyHost::scheduler_publish_source_bar(
2979 const Bar& bar, bool, bool advance_source_index) {
2980 current_bar_ = bar;
2981 const bool temporary_index = !advance_source_index
2982 && (!scheduler_.current_script_bar()
2983 || scheduler_.current_script_bar()->timestamp != bar.timestamp);
2984 const int previous_bar_index = bar_index_;
2985 const bool previous_barstate_islast = barstate_islast_;
2986 if (advance_source_index || temporary_index) ++source_bar_index_;
2987 ++source_callback_count_;
2988 bar_index_ = source_bar_index_;
2989 const NativeLifecycleKind lifecycle = detail::run_kind(*this);
2990 const NativeRunPhase phase = detail::run_phase(*this);
2991 if (lifecycle == NativeLifecycleKind::Running && phase == NativeRunPhase::Warmup) {
2992 barstate_islast_ = false;
2993 } else if (lifecycle == NativeLifecycleKind::Running
2994 && phase == NativeRunPhase::Realtime) {
2995 barstate_islast_ = true;
2996 } else {
2997 barstate_islast_ = source_bar_index_ == source_last_bar_index_;
2998 }
2999 // The chart day partition, the EMA seeding default and the TA bar context
3000 // this publication runs under, written on the pump's runtime block (R5
3001 // lane D2-C: no thread-local WRITE per bar; runtime_ambient.hpp). The
3002 // readers still reach the block through the thread's pointer, one
3003 // thread-local access per call: every extremum ring behind ta.highest /
3004 // lowest / highestbars / lowestbars / stoch / wpr / range, and the day
3005 // partition behind the default-anchored ta.vwap, crosses_boundary(DAY) and
3006 // the session-period helpers (docs/native-refactor-progress.md, "Deferred
3007 // performance work").
3008 internal::RuntimeAmbient* const ambient = NativeExecutionConsumer::bound(*this).pump_ambient();
3009 internal::AmbientDayPartitionScope chart_day_partition(
3010 ambient, chart_day_partition_.empty() ? nullptr : &chart_day_partition_);
3011 // A named-entry cancellation token has source-evaluation scope. Clear a
3012 // prior callback before publishing receipts and entering this body.
3013 adapter_.begin_source_evaluation();
3014 // A selected window's pre-roll evaluation records its commands and risk
3015 // statements instead of placing them: the buffer is this evaluation's alone,
3016 // reset here even if the body calls nothing, and the scope closes the bracket
3017 // on every exit (pine_intent_state.hpp).
3018 const bool capturing = selected_begin_evaluation(bar.timestamp);
3019 const detail::IntentCaptureScope capture_scope(*this, capturing);
3020 // Publish terminal and group-adjustment receipts before the source body
3021 // reads its public pending projection at this decision boundary.
3022 if (same_bar_exit_group_possible(trades_)) {
3023 if (const auto moved = sort_same_bar_exit_trades(trades_, adapter_))
3024 native_closed_rows_amended(*moved);
3025 }
3026 // Publish only the immutable prefix, once at this script-bar
3027 // boundary. Keep the last exit-time block AND its predecessor: sorting
3028 // and global-child re-cutting both inspect that predecessor.
3029 auto& consumer = detail::run_consumer(*this);
3030 if (consumer.streams_closed_rows(*this) && consumer.begin_closed_row_seal(bar.timestamp)) {
3031 const std::size_t base = trades_.base();
3032 auto prefix = consumer.closed_trade_prefix();
3033 if (prefix.base != base)
3034 throw coded<std::logic_error>(RunFailureCode::engine_invariant, {},
3035 "source trade seal has a mismatched base");
3036 const bool can_swap = adapter_.short_seed_.final_short.incarnation != 0
3037 && adapter_.config_.default_qty_type != static_cast<int>(QtyType::FIXED);
3038 const std::size_t next = consumer.closed_rows_sealable(trades_,
3039 adapter_.exit_phase_final_, adapter_.source_money_folded_, bar.timestamp,
3040 can_swap ? std::optional<std::uint64_t>(adapter_.short_seed_.materialize_long.incarnation)
3041 : std::nullopt);
3042 if (next - base > adapter_.trade_exit_phase_.size())
3043 throw coded<std::logic_error>(RunFailureCode::engine_invariant, {},
3044 "source trade seal exceeds resident exit phases");
3045 for (std::size_t index = base; index < next; ++index) {
3046 const Trade& row = trades_[index];
3047 prefix.exit_phase_digest = PineExecutionAdapter::fold_exit_phase(
3048 prefix.exit_phase_digest, adapter_.trade_exit_phase_[index - base]);
3049 if (row.pnl > 0.0) prefix.source_gains += row.pnl;
3050 else prefix.source_losses += row.pnl;
3051 // L18's position-tail spill consumes this row here, before the
3052 // seal below retires storage, including older-time barriers.
3053 }
3054 if (next > base) {
3055 // L18: spill the position tail of the whole prefix, in forward row
3056 // order, before the seal retires its storage; a throw seals nothing.
3057 adapter_.preserve_position_tail_before_seal(
3058 next, consumer.report_scratch_directory(*this));
3059 consumer.seal_closed_rows(*this, next, prefix.exit_phase_digest,
3060 prefix.source_gains, prefix.source_losses);
3061 for (std::size_t offset = 0; offset < next - base; ++offset)
3062 consumer.fold_sealed_exit_phase(adapter_.trade_exit_phase_[offset]);
3063 adapter_.trade_exit_phase_.erase(adapter_.trade_exit_phase_.begin(),
3064 adapter_.trade_exit_phase_.begin() + (next - base));
3065 }
3066 }
3067 adapter_.observe_terminal_receipts();
3068 // ab9714be src/source/pine_scheduler.cpp:242,258: under
3069 // process_orders_on_close the orders that were already resting fill at step 1
3070 // (process_pending_orders(before_pooc_script=true)) BEFORE the strategy body
3071 // runs at step 3, so a bracket leg this route parked while its parent entry
3072 // was pending settles on the touch bar ahead of this bar's source
3073 // evaluation. Draining it here keeps the body's position state, and the
3074 // levels it re-prices, on the same side of the fill as the owner; a leg that
3075 // carries a predecessor receipt stays staged for the flush below the body.
3076 // Quiet-bar gates (pine_quiet_bar.hpp): a drain with nothing staged or
3077 // queued is not called.
3078 using detail::QuietHook;
3079 using detail::skip_quiet;
3080 if (!skip_quiet(QuietHook::BracketLegs, adapter_.pending_bracket_legs_.empty())) {
3081 adapter_.flush_pending_bracket_legs({}, /*post_calculation=*/false,
3082 /*pre_script_drain=*/true);
3083 }
3084 internal::AmbientEmaSeedingScope ema_scope(ambient, chart_ema_na_warmup_);
3085 internal::AmbientBarContextScope bar_scope(ambient, pine_bar_index(),
3086 scheduler_.bar_index_offset());
3087 position_entry_count_ = detail::run_position(*this).signed_units == 0.0
3088 ? 0 : adapter_.source_entry_slot_count();
3089 on_source_bar(bar);
3090 // The pass is over: a pre-fill average it held for the script goes
3091 // before the orders it placed settle (hold_script_average_price).
3092 release_script_average_price();
3093 // Handwritten/source-generated callbacks historically read and could
3094 // update the live Pine configuration fields directly. Keep the adapter's
3095 // source policy view synchronized at the callback boundary; the generic
3096 // NativeRunSpec remains immutable for the run.
3097 adapter_.set_configuration(config_);
3098 if (capturing) selected_finish_evaluation();
3099 if (temporary_index) {
3100 --source_bar_index_;
3101 bar_index_ = previous_bar_index;
3102 barstate_islast_ = previous_barstate_islast;
3103 }
3104 finish_adapter_evaluation();
3105 if (advance_source_index) {
3106 scheduler_mark_report_point(bar.timestamp);
3107 prev_bar_timestamp_ = bar.timestamp;
3108 }
3109}
3110
3111// The drains that close one source evaluation once its body has run, in the
3112// order they always ran in. The replay of a selected window's pre-roll rows ends
3113// its evaluation with this same list (selected_replay_at_open).
3114void source::PineStrategyHost::finish_adapter_evaluation() {
3115 // Quiet-bar gates (pine_quiet_bar.hpp), as at the publication.
3116 using detail::QuietHook;
3117 using detail::skip_quiet;
3118 if (!skip_quiet(QuietHook::PendingCloses, adapter_.close_batch_callsites_.empty()))
3119 adapter_.flush_pending_closes();
3120#if PINEFORGE_PINE_QUIET_BAR_GATES
3121 // flush_pending_entries' own quiet gate, asked here instead of behind the
3122 // call (the UTC chart-day memo): with nothing delayed, batched or held, the flush
3123 // only resets the batch's close total. The gate is asked once either way,
3124 // so the probe counts what it always counted.
3125 if (adapter_.host_ != nullptr && adapter_.delayed_market_orders_.empty()
3126 && adapter_.pending_same_bar_commands_.empty() && adapter_.pending_entries_.empty()) {
3127 (void)skip_quiet(QuietHook::PendingEntries, true);
3128 adapter_.pending_same_bar_close_qty_ = 0.0;
3129 } else {
3130 adapter_.flush_pending_entries();
3131 }
3132#else
3133 adapter_.flush_pending_entries();
3134#endif
3135 if (!skip_quiet(QuietHook::BracketLegs, adapter_.pending_bracket_legs_.empty()))
3136 adapter_.flush_pending_bracket_legs();
3137 // After every command of this evaluation is in the kernel: a queued
3138 // relative strategy.exit whose parent entry is now a live request becomes
3139 // that parent's anchored bracket child.
3140 if (!skip_quiet(QuietHook::RelativeExits, adapter_.anchored_relative_legs_.empty()
3141 && adapter_.pending_relative_exits_.empty())) {
3142 adapter_.anchor_relative_exits();
3143 }
3144 adapter_.order_same_point_entries();
3145}
3146
3147void source::PineStrategyHost::scheduler_publish_suppressed_tail(const Bar& bar) {
3148 // ab9714be pine_scheduler.cpp:222-231: the forming probe tail advances
3149 // source history and settles the already-matched broker book, but does
3150 // not invoke generated code or synthesize a range-end close.
3151 current_bar_ = bar;
3152 ++source_bar_index_;
3153 bar_index_ = source_bar_index_;
3154 barstate_islast_ = false;
3155 internal::AmbientDayPartitionScope chart_day_partition(
3156 NativeExecutionConsumer::bound(*this).pump_ambient(),
3157 chart_day_partition_.empty() ? nullptr : &chart_day_partition_);
3158 adapter_.begin_source_evaluation();
3159 // A selected pre-roll tail runs no body: it leaves an empty last-present
3160 // evaluation, so no earlier bar's signal outlives it.
3161 selected_clear_suppressed_evaluation(bar.timestamp);
3162 adapter_.observe_terminal_receipts();
3163 scheduler_mark_report_point(bar.timestamp);
3164 prev_bar_timestamp_ = bar.timestamp;
3165}
3166
3167// The selected window's pre-roll capture hooks (pine_intent_state.hpp).
3168bool source::PineStrategyHost::selected_begin_evaluation(std::int64_t label_ms) {
3169 const detail::PreRollEvaluation pre_roll = detail::selected_pre_roll_evaluation(*this, label_ms);
3170 if (pre_roll.state == nullptr) return false;
3171 detail::reset_intent_evaluation(*pre_roll.state, label_ms, config_,
3172 adapter_.capture_replay_risk_config(), *pre_roll.point);
3173 pre_roll.state->capturing = true;
3174 return true;
3175}
3176
3177void source::PineStrategyHost::selected_finish_evaluation() noexcept {
3179 if (state == nullptr || !state->capturing) return;
3180 state->end_config = config_;
3181 state->end_risk = adapter_.capture_replay_risk_config();
3182 if (const NativeCurrentPointView* const point = detail::intent_live_point(*this))
3183 state->terminal_point = *point;
3184 state->capturing = false;
3185}
3186
3187void source::PineStrategyHost::selected_clear_suppressed_evaluation(std::int64_t label_ms) {
3188 const detail::PreRollEvaluation pre_roll = detail::selected_pre_roll_evaluation(*this, label_ms);
3189 if (pre_roll.state == nullptr) return;
3190 detail::reset_intent_evaluation(*pre_roll.state, label_ms, config_,
3191 adapter_.capture_replay_risk_config(), *pre_roll.point);
3192}
3193
3194namespace {
3195
3196// The selected window's replay (selected_replay_at_open) hands each retained
3197// row to the adapter's own door with exactly the arguments the script passed.
3198// The host's wrappers are never used: they would take the trading-window gate
3199// again and copy the live configuration over the row's. The nine command doors
3200// first, then the six risk setter statements.
3201void replay_intent(source::PineExecutionAdapter& adapter,
3202 const source::detail::IntentEntry& row) {
3203 source::admit_strategy_entry(row.id, row.is_long, row.limit_price, row.stop_price,
3204 row.qty, row.comment, row.oca_name, row.oca_type, row.qty_type);
3205 adapter.entry(row.id, row.is_long, row.limit_price, row.stop_price, row.qty, row.comment,
3206 row.oca_name, row.oca_type, row.qty_type);
3207}
3208void replay_intent(source::PineExecutionAdapter& adapter,
3209 const source::detail::IntentClose& row) {
3210 source::admit_strategy_close(row.id, row.comment, row.qty, row.qty_percent, row.immediately);
3211 adapter.close(row.id, row.comment, row.qty, row.qty_percent, row.immediately);
3212}
3213void replay_intent(source::PineExecutionAdapter& adapter,
3214 const source::detail::IntentCloseToken& row) {
3215 source::admit_strategy_close_token(row.id, row.comment, row.qty, row.qty_percent,
3216 row.immediately, row.callsite_token);
3217 adapter.close(row.id, row.comment, row.qty, row.qty_percent, row.immediately,
3218 row.callsite_token);
3219}
3220void replay_intent(source::PineExecutionAdapter& adapter, const source::detail::IntentCloseAll&) {
3222 adapter.close_all();
3223}
3224void replay_intent(source::PineExecutionAdapter& adapter,
3225 const source::detail::IntentExit& row) {
3226 source::admit_strategy_exit(row.id, row.from_entry, row.limit_price, row.stop_price,
3227 row.trail_points, row.trail_offset, row.trail_price,
3228 row.qty_percent, row.comment, row.qty, row.oca_name,
3229 row.profit_ticks, row.loss_ticks);
3230 adapter.exit(row.id, row.from_entry, row.limit_price, row.stop_price, row.trail_points,
3231 row.trail_offset, row.trail_price, row.qty_percent, row.comment, row.qty,
3232 row.oca_name, row.profit_ticks, row.loss_ticks);
3233}
3234void replay_intent(source::PineExecutionAdapter& adapter,
3235 const source::detail::IntentExitCancelBracket& row) {
3236 source::admit_strategy_exit_cancel_bracket(row.exit_id, row.from_entry, row.comment);
3237 adapter.exit_cancel_bracket(row.exit_id, row.from_entry, row.comment);
3238}
3239void replay_intent(source::PineExecutionAdapter& adapter,
3240 const source::detail::IntentCancel& row) {
3242 adapter.cancel(row.id);
3243}
3244void replay_intent(source::PineExecutionAdapter& adapter,
3245 const source::detail::IntentCancelAll&) {
3247 adapter.cancel_all();
3248}
3249void replay_intent(source::PineExecutionAdapter& adapter,
3250 const source::detail::IntentOrder& row) {
3251 source::admit_strategy_order(row.id, row.is_long, row.qty, row.limit_price, row.stop_price,
3252 row.oca_name, row.oca_type);
3253 adapter.order(row.id, row.is_long, row.qty, row.limit_price, row.stop_price, row.oca_name,
3254 row.oca_type);
3255}
3256void replay_intent(source::PineExecutionAdapter& adapter,
3257 const source::detail::IntentRiskDirection& row) {
3258 // The retained int direction/count/cap rows cannot carry NaN/infinity.
3259 adapter.set_risk_direction(row.direction);
3260}
3261void replay_intent(source::PineExecutionAdapter& adapter,
3262 const source::detail::IntentRiskMaxConsLossDays& row) {
3263 adapter.set_risk_max_cons_loss_days(row.value);
3264}
3265void replay_intent(source::PineExecutionAdapter& adapter,
3266 const source::detail::IntentRiskMaxDrawdown& row) {
3267 admit_pine_risk_max_drawdown(row.value, row.percent);
3268 adapter.set_risk_max_drawdown(row.value, row.percent);
3269}
3270void replay_intent(source::PineExecutionAdapter& adapter,
3271 const source::detail::IntentRiskMaxIntradayLoss& row) {
3272 admit_pine_risk_max_intraday_loss(row.value, row.percent);
3273 adapter.set_risk_max_intraday_loss(row.value, row.percent);
3274}
3275// As the host setter: the script's own statement adopts the count switches first.
3276void replay_intent(source::PineExecutionAdapter& adapter,
3277 const source::detail::IntentRiskMaxIntradayFilledOrders& row) {
3278 if (row.limit > 0) adapter.cap.adopt_script_count();
3279 adapter.cap = row.limit;
3280}
3281void replay_intent(source::PineExecutionAdapter& adapter,
3282 const source::detail::IntentRiskMaxPositionSize& row) {
3284 adapter.set_risk_max_position_size(row.value);
3285}
3286
3287// A selected replay the engine cannot take, or that a native failure cut short,
3288// is the engine's fault and fails the run: it is never skipped quietly. After a
3289// native failure the consumer already holds the first cause and keeps it (its
3290// bar-open catch records only when nothing failed before).
3291[[noreturn]] void replay_invariant(const char* what) {
3292 throw coded<std::logic_error>(RunFailureCode::engine_invariant, {}, what);
3293}
3294
3295// Ends a replay bracket on every exit, normal or by exception: the retained
3296// point pointer cleared, the open activation scope closed (the grants it
3297// recorded stay for the Open match that follows) and the adapter's
3298// configuration back to the host's. Two stores, a noexcept end and a plain
3299// copy: nothing here executes a command or allocates.
3300class ReplayBracket {
3301public:
3302 ReplayBracket(source::detail::PineIntentState& state, NativeExecutionConsumer& consumer,
3303 source::PineExecutionAdapter& adapter,
3304 const source::PineStrategyConfig& config) noexcept
3305 : state_(&state), consumer_(&consumer), adapter_(&adapter), config_(&config) {}
3306 ReplayBracket(const ReplayBracket&) = delete;
3307 ReplayBracket& operator=(const ReplayBracket&) = delete;
3308 ~ReplayBracket() noexcept {
3309 state_->replay_point = nullptr;
3310 consumer_->end_open_activation();
3311 adapter_->set_configuration(*config_);
3312 }
3313
3314private:
3315 source::detail::PineIntentState* state_;
3316 NativeExecutionConsumer* consumer_;
3317 source::PineExecutionAdapter* adapter_;
3318 const source::PineStrategyConfig* config_;
3319};
3320
3321} // namespace
3322
3323// The selected window's replay (pine_intent_state.hpp): the last pre-roll
3324// evaluation's rows, placed once at the window's first Open, before the adapter
3325// reads that bar's open. They are placed as the evaluation placed them, in its
3326// own order, each under the configuration and the retained point it was called
3327// with, so the Open matcher that follows sees the prior bar's orders. Nothing of
3328// the script runs again; nothing is published, matched or executed here.
3329void source::PineStrategyHost::selected_replay_at_open(const Bar& bar,
3330 const NativeDecisionContext&) {
3331 pf_selected_window_config_v1 window{};
3332 if (!NativeExecutionConsumer::selected_window_admitted(*this, &window)
3333 || bar.timestamp < window.start_ms) {
3334 return;
3335 }
3337 if (state == nullptr) replay_invariant("selected replay without its intent state");
3338 if (state->replayed) return;
3339 if (!state->has_evaluation) {
3340 state->replayed = true;
3341 return;
3342 }
3343 // The bar before the window left the book as it was: nothing held, nothing
3344 // working or queued in the source layer, no recalculation open.
3345 if (state->capturing) replay_invariant("selected replay inside a pre-roll capture bracket");
3346 if (state->label_ms >= window.start_ms)
3347 replay_invariant("selected replay of an evaluation labelled inside the window");
3348 if (detail::run_position(*this).signed_units != 0.0)
3349 replay_invariant("selected replay over a held position");
3350 if (adapter_.coof_recalc_active_)
3351 replay_invariant("selected replay inside a calc_on_order_fills recalculation");
3352 if (!adapter_.live_handles_.empty() || !adapter_.delayed_market_orders_.empty()
3353 || !adapter_.pending_same_bar_commands_.empty() || !adapter_.pending_entries_.empty()
3354 || !adapter_.close_batch_callsites_.empty() || !adapter_.pending_bracket_legs_.empty()
3355 || !adapter_.anchored_relative_legs_.empty()
3356 || !adapter_.pending_relative_exits_.empty()) {
3357 replay_invariant("selected replay over live requests or queued source commands");
3358 }
3359 // Marked before the bracket opens: a failure inside it never retries.
3360 state->replayed = true;
3361 NativeExecutionConsumer& consumer = NativeExecutionConsumer::bound(*this);
3362 if (!consumer.begin_open_activation(*this))
3363 replay_invariant("selected replay without an open activation scope");
3364 const ReplayBracket bracket(*state, consumer, adapter_, config_);
3365 const auto stop_if_failed = [this] {
3366 if (detail::run_kind(*this) == NativeLifecycleKind::Failed)
3367 replay_invariant("selected replay stopped by a native failure");
3368 };
3369 using detail::QuietHook;
3370 using detail::skip_quiet;
3371
3372 // The evaluation again, from its start: the configuration it began under,
3373 // then its receipts and the legs staged before its body, as
3374 // scheduler_publish_source_bar takes them.
3375 state->replay_point = &state->terminal_point;
3376 assert_admitted_pine_replay_risk_config(state->start_risk);
3377 adapter_.restore_replay_risk_config(state->start_risk);
3378 adapter_.set_configuration(state->start_config);
3379 adapter_.begin_source_evaluation();
3380 adapter_.observe_terminal_receipts();
3381 stop_if_failed();
3382 if (!skip_quiet(QuietHook::BracketLegs, adapter_.pending_bracket_legs_.empty())) {
3383 adapter_.flush_pending_bracket_legs({}, /*post_calculation=*/false,
3384 /*pre_script_drain=*/true);
3385 }
3386 stop_if_failed();
3387
3388 // Its statements, in the order the script made them.
3389 for (detail::PineIntentRow& row : state->rows) {
3390 state->replay_point = &row.point;
3391 adapter_.set_configuration(row.config);
3392 std::visit([this](const auto& payload) { replay_intent(adapter_, payload); },
3393 row.payload);
3394 stop_if_failed();
3395 }
3396
3397 // Its end: the configuration it ended under (the risk configuration alone,
3398 // never a counter), then the drains every source evaluation ends with.
3399 state->replay_point = &state->terminal_point;
3400 adapter_.set_configuration(state->end_config);
3402 adapter_.restore_replay_risk_config(state->end_risk);
3403 finish_adapter_evaluation();
3404 stop_if_failed();
3405
3406 // The terminal explicit-market policy of a process_orders_on_close pass,
3407 // which the close of that bar would have run: submit-only here, so the
3408 // reconciled entries stay working for the open matcher. The caller's own
3409 // quiet and count gate, as PineExecutionAdapter::on_bar_close asks it; the
3410 // policy keeps its own filters.
3411 const NativeDecisionContext& terminal = state->terminal_point.decision;
3412 const bool bound = adapter_.host_ != nullptr;
3413 const int bar_index = adapter_.projection_bar_index(terminal);
3414 std::size_t explicit_entries = 0;
3415 if (adapter_.config_.process_orders_on_close && adapter_.config_.pyramiding == 0
3416 && adapter_.modeled_input()) {
3417 for (const auto& handle : adapter_.live_handles_) {
3418 const auto found = adapter_.placement_.find(handle.incarnation);
3419 if (found != adapter_.placement_.end() && found->second.opening
3420 && found->second.family == PineOrderFamily::Entry
3421 && found->second.projection_created_bar == bar_index
3422 && std::isfinite(found->second.requested_qty)
3423 && found->second.requested_qty > 0.0) {
3424 ++explicit_entries;
3425 }
3426 }
3427 }
3428 if (!skip_quiet(QuietHook::TerminalExplicitMarket, bound && explicit_entries < 2))
3429 adapter_.apply_terminal_explicit_market_policy(terminal, false);
3430 stop_if_failed();
3431
3432 // Spent only once it all succeeded. The retained configurations, the terminal
3433 // point and the replayed flag stay as the hash reads them.
3434 state->rows.clear();
3435}
3436
3437// The Pine report series has one point per SOURCE slot this host published,
3438// which is not the kernel's per-calculation cadence: a calc_on_order_fills
3439// re-entry marks the slot it opened at the fill, the ordinary close
3440// calculation then marks nothing, and the probe's suppressed tail marks a
3441// slot generated code never calculated. The point also has to land inside
3442// this callback, before scheduler_record_broker_hash() folds the extremes it
3443// just moved and before scheduler_record_range_end() re-marks the curve's
3444// last point. So the cadence stays here and the recording does not: the
3445// kernel owns what a report point is — the extremes fold and the curve
3446// append in engine.hpp — reached through the run spec's report policy.
3447void source::PineStrategyHost::scheduler_mark_report_point(std::int64_t script_bar_ts) {
3448 detail::run_consumer(*this).mark_script_report_point(*this, script_bar_ts);
3449}
3450
3451void source::PineStrategyHost::scheduler_record_broker_hash() {
3452 if (!broker_state_hash_recording_) return;
3453 const std::uint64_t execution = execution_consumer().continuation_hash();
3454 // As capture_script_continuation_hash: a carried read failure is thrown
3455 // before the cut hash is latched or recorded as a row.
3456 as_native_consumer(execution_consumer()).request_core().rethrow_book_read_failure();
3457 last_script_continuation_hash_ = execution;
3458 last_script_continuation_valid_ = true;
3459 broker_state_hashes_.push_back(broker_state_hash());
3460}
3461
3463 return adapter_.core_sizes_default_opening(is_long);
3464}
3465
3466// The chart's time_close. A D/W/M chart bar closes at its period's last
3467// traded close, to the millisecond, whatever time of day its stamp reads, as
3468// time_close(timeframe.period) reads it on that chart (chart_period_close_ms,
3469// src/session_time.cpp, where the rule and its tapes are stated): OANDA
3470// stamps its XAUUSD daily bars at 17:00 ET, inside the 1800-1700 session's
3471// break, and the time-of-day session filter the intraday path applies read
3472// that stamp as out of session, so the D chart answered na.
3474 return chart_bar_close_ms(current_bar_.timestamp);
3475}
3476
3478 if (calendar_period_for(script_tf_) != CalendarPeriod::NONE) {
3479 return pine_time_close(stamp, script_tf_, "", "", script_tf_,
3480 syminfo_.timezone, syminfo_.session);
3481 }
3482 return pineforge::detail::symbol_session_time_close(stamp, script_tf_, syminfo_.session,
3483 syminfo_.timezone, script_tf_);
3484}
3485
3486// TradingView's time(tf, session, tz, bars_back, timeframe_bars_back) (lab tv
3487// tapes te_time_bb_chart and te_time_bb_tf, tests/fixtures/time_bars_back):
3488// the chart bar bars_back names -- a past one from the chart's history, na
3489// before its first -- then the tf bar holding it, stepped timeframe_bars_back
3490// of its own bars. TradingView steps forward, and steps tf bars, on its
3491// symbol calendar, closed days included: NASDAQ:AAPL's Thursday 2025-04-17
3492// 15:45 bar reads its next bar at Monday 04-21 09:30 over Good Friday, its
3493// daily bar of 01-08 the next at 01-10 over the 01-09 closure, time("D", 0,
3494// -1) on 04-17 reads 04-21 and time("60", 0, 1) on Monday's 09:30 bar
3495// Thursday's 15:30. The host holds no calendar of closed days; the chart's
3496// history holds exactly the calendar's bars on every tape but one, whose
3497// history lacks bars TradingView's calendar holds (OANDA:XAUUSD's 17:00-18:00
3498// ET slot and Good Friday, the test's no-bar slots). So the host reads the
3499// bars from the input it retains (a batch's, or a stream's warmup); past its
3500// edges it walks the symbol's session calendar (session_trades_at): a batch's
3501// final bar asks the calendar for the bar after it, as a live bar does.
3502namespace {
3503
3504constexpr std::int64_t kTimeOffsetMinuteMs = 60'000;
3505// The farthest the calendar walk looks for a traded minute: past any
3506// weekend and any run of closed days.
3507constexpr int kTimeOffsetCalendarMinutes = 16 * 24 * 60;
3508// The most chart bars a step inside a tf bar may pass before it leaves it.
3509constexpr int kTimeOffsetBarsPerStep = 100'000;
3510
3511} // namespace
3512
3513int64_t source::PineStrategyHost::pine_time_offset(int64_t bar_open_ms, int bars_back,
3514 const std::string& tf,
3515 const std::string& session,
3516 const std::string& tz,
3517 int timeframe_bars_back,
3518 bool close) const {
3519 if (is_na(bar_open_ms) || is_na(bars_back) || is_na(timeframe_bars_back))
3520 return na<int64_t>();
3521 const std::string& sym_tz = syminfo_.timezone;
3522 const std::string& sym_session = syminfo_.session;
3523 const auto input = scheduler_.retained_input();
3524 const bool input_is_chart = scheduler_.retained_input_is_chart();
3525 const bool daily_chart = calendar_period_for(script_tf_) != CalendarPeriod::NONE;
3526 // A chart bar's own open (the input's stamp) and close, as the chart's
3527 // time and time_close read them (chart_time_close).
3528 const auto chart_open_of = [&](std::int64_t input_ms) -> std::int64_t {
3529 return input_is_chart ? input_ms
3530 : pine_time(input_ms, script_tf_, "", "", script_tf_, sym_tz,
3531 sym_session);
3532 };
3533 // Where the run holds TradingView's symbol calendar (set_symbol_calendar),
3534 // the chart bars ahead are its slots: the chart's next bar, the first bar
3535 // from an instant, a chart bar's close and a day's open and close are the
3536 // calendar's inside its days, whatever bars the history holds there
3537 // (OANDA:XAUUSD's 17:00 ET slot, Good Friday's session). Bars behind stay
3538 // the history's. The calendar's slots are an intraday chart's bars and a
3539 // 1D chart's days; a W, M or multi-day chart's bars are no calendar day,
3540 // so it reads its history.
3541 const std::int64_t chart_step_ms = static_cast<std::int64_t>(script_tf_seconds_) * 1000;
3542 const bool calendar_chart = !symbol_calendar_.empty()
3543 && (!daily_chart || (calendar_period_for(script_tf_) == CalendarPeriod::DAY
3544 && script_tf_seconds_ == 86400));
3545 const auto chart_close_of = [&](std::int64_t open_ms) -> std::int64_t {
3546 if (const auto* day = calendar_chart ? symbol_calendar_day(open_ms) : nullptr) {
3547 return daily_chart || chart_step_ms <= 0 ? day->second
3548 : std::min(open_ms + chart_step_ms, day->second);
3549 }
3550 return daily_chart ? pine_time_close(open_ms, script_tf_, "", "", script_tf_, sym_tz,
3551 sym_session)
3553 open_ms, script_tf_, sym_session, sym_tz, script_tf_);
3554 };
3555 const auto chart_slot_of = [&](std::int64_t ms) -> std::int64_t {
3556 return pine_time(ms, script_tf_, "", "", script_tf_, sym_tz, sym_session);
3557 };
3558 // The first chart bar opening at or after `at`: the retained input's, else
3559 // the calendar's first traded chart slot.
3560 const auto chart_open_from = [&](std::int64_t at) -> std::int64_t {
3561 if (calendar_chart) {
3562 if (const std::int64_t slot = symbol_calendar_open_from(at); !is_na(slot)) return slot;
3563 }
3564 auto it = std::lower_bound(input.begin(), input.end(), at,
3565 [](const Bar& bar, std::int64_t value) {
3566 return bar.timestamp < value;
3567 });
3568 while (!input_is_chart && it != input.end() && chart_open_of(it->timestamp) < at)
3569 ++it;
3570 if (it != input.end()) return chart_open_of(it->timestamp);
3571 std::int64_t t = at;
3572 for (int k = 0; k < kTimeOffsetCalendarMinutes; ++k, t += kTimeOffsetMinuteMs) {
3573 if (session_trades_at(sym_session, sym_tz, t)) {
3574 const std::int64_t slot = chart_slot_of(t);
3575 return is_na(slot) || slot < at ? t : slot;
3576 }
3577 }
3578 return na<int64_t>();
3579 };
3580 // The last chart bar opening before `before`: the retained input's while
3581 // it holds the instant (a stream's warmup ends where its live bars
3582 // begin), else the calendar's last traded chart slot.
3583 const auto chart_open_before = [&](std::int64_t before) -> std::int64_t {
3584 if (calendar_chart) {
3585 if (const std::int64_t slot = symbol_calendar_open_before(before); !is_na(slot))
3586 return slot;
3587 }
3588 auto it = std::lower_bound(input.begin(), input.end(), before,
3589 [](const Bar& bar, std::int64_t value) {
3590 return bar.timestamp < value;
3591 });
3592 const bool held = !scheduler_.retains_stream() || it != input.end();
3593 if (held && it != input.begin()) return chart_open_of(std::prev(it)->timestamp);
3594 std::int64_t t = before - kTimeOffsetMinuteMs;
3595 for (int k = 0; k < kTimeOffsetCalendarMinutes; ++k, t -= kTimeOffsetMinuteMs) {
3596 if (session_trades_at(sym_session, sym_tz, t)) {
3597 const std::int64_t slot = chart_slot_of(t);
3598 return is_na(slot) || slot >= before ? t : slot;
3599 }
3600 }
3601 return na<int64_t>();
3602 };
3603 // The chart bar after the one opening at `open_ms`: the retained input's
3604 // next bar, else the calendar's first traded slot past its close.
3605 const auto next_chart_open = [&](std::int64_t open_ms) -> std::int64_t {
3606 if (calendar_chart && symbol_calendar_day(open_ms) != nullptr) {
3607 if (const std::int64_t slot = symbol_calendar_open_from(open_ms + 1); !is_na(slot))
3608 return slot;
3609 }
3610 auto it = std::upper_bound(input.begin(), input.end(), open_ms,
3611 [](std::int64_t value, const Bar& bar) {
3612 return value < bar.timestamp;
3613 });
3614 while (!input_is_chart && it != input.end() && chart_open_of(it->timestamp) <= open_ms)
3615 ++it;
3616 if (it != input.end()) return chart_open_of(it->timestamp);
3617 const std::int64_t end = chart_close_of(open_ms);
3618 return chart_open_from(is_na(end) || end <= open_ms ? open_ms + 1 : end);
3619 };
3620
3621 // A chart bar before the current one is one the run has had: the
3622 // retained input's, na before its first (time[bars_back]). A stream's
3623 // live bars are past its warmup: the calendar's.
3624 const auto prior_chart_open = [&](std::int64_t open_ms) -> std::int64_t {
3625 auto it = std::lower_bound(input.begin(), input.end(), open_ms,
3626 [](const Bar& bar, std::int64_t value) {
3627 return bar.timestamp < value;
3628 });
3629 if (scheduler_.retains_stream() && it == input.end())
3630 return chart_open_before(open_ms);
3631 while (it != input.begin()) {
3632 --it;
3633 const std::int64_t prior = chart_open_of(it->timestamp);
3634 if (prior < open_ms) return prior;
3635 }
3636 return na<int64_t>();
3637 };
3638
3639 std::int64_t chart_open = bar_open_ms;
3640 for (int k = 0; k < -bars_back && !is_na(chart_open); ++k)
3641 chart_open = next_chart_open(chart_open);
3642 for (int k = 0; k < bars_back && !is_na(chart_open); ++k)
3643 chart_open = prior_chart_open(chart_open);
3644 if (is_na(chart_open)) return na<int64_t>();
3645
3646 // The chart's own timeframe without a session: the chart bars themselves.
3647 if ((tf.empty() || tf == script_tf_) && session.empty()) {
3648 for (int k = 0; k < -timeframe_bars_back && !is_na(chart_open); ++k)
3649 chart_open = next_chart_open(chart_open);
3650 for (int k = 0; k < timeframe_bars_back && !is_na(chart_open); ++k)
3651 chart_open = chart_open_before(chart_open);
3652 if (is_na(chart_open)) return na<int64_t>();
3653 return close ? chart_close_of(chart_open) : chart_open;
3654 }
3655
3656 // A week or a month opens at its first traded day: the week of Martin
3657 // Luther King Day 2025 at Tuesday 01-21 on NASDAQ:AAPL, where the
3658 // calendar reads Monday (te-time-bb-tf-aapl1d). The day the chart's
3659 // history first trades in the period, when it trades later than the
3660 // calendar's first session day.
3661 const CalendarPeriod tf_period = calendar_period_for(tf.empty() ? script_tf_ : tf);
3662 // A day of the symbol calendar is its "D" bar.
3663 const bool calendar_days = session.empty() && tf_period == CalendarPeriod::DAY
3664 && tf_to_seconds(tf.empty() ? script_tf_ : tf) == 86400;
3665 const auto calendar_open_of = [&](std::int64_t ms) -> std::int64_t {
3666 if (calendar_days) {
3667 if (const auto* day = symbol_calendar_day(ms)) return day->first;
3668 }
3669 return pine_time(ms, tf, session, tz, script_tf_, sym_tz, sym_session);
3670 };
3671 const auto tf_close_of = [&](std::int64_t open_ms) -> std::int64_t {
3672 if (calendar_days) {
3673 if (const auto* day = symbol_calendar_day(open_ms)) return day->second;
3674 }
3675 return pine_time_close(open_ms, tf, session, tz, script_tf_, sym_tz, sym_session);
3676 };
3677 const auto tf_open_of = [&](std::int64_t ms) -> std::int64_t {
3678 const std::int64_t open = calendar_open_of(ms);
3679 if (is_na(open) || !session.empty()
3680 || (tf_period != CalendarPeriod::WEEK && tf_period != CalendarPeriod::MONTH)) {
3681 return open;
3682 }
3683 const std::int64_t first = chart_open_from(open);
3684 if (is_na(first) || calendar_open_of(first) != open) return open;
3685 const std::int64_t day = pine_time(first, "D", "", "", script_tf_, sym_tz, sym_session);
3686 return !is_na(day) && day > open ? day : open;
3687 };
3688 std::int64_t open = tf_open_of(chart_open);
3689 if (is_na(open)) return na<int64_t>();
3690 // A timeframe finer than the chart's steps by its own length and reads
3691 // the chart bar holding the instant it reaches: time("60", 0, -1) on a
3692 // daily bar is that bar, time("60", 0, 1) the bar before
3693 // (te-time-bb-tf-aapl1d / -btc1d / -eurusd1d).
3694 const int tf_seconds = tf_to_seconds(tf.empty() ? script_tf_ : tf);
3695 const int chart_seconds = tf_to_seconds(script_tf_);
3696 if (timeframe_bars_back != 0 && tf_seconds > 0 && chart_seconds > 0
3697 && tf_seconds < chart_seconds) {
3698 const std::int64_t at = open - static_cast<std::int64_t>(timeframe_bars_back)
3699 * tf_seconds * 1000;
3700 const std::int64_t held = chart_open_before(at + 1);
3701 if (is_na(held)) return na<int64_t>();
3702 return close ? chart_close_of(held) : held;
3703 }
3704 for (int k = 0; k < -timeframe_bars_back && !is_na(open); ++k) {
3705 // The first chart bar past this tf bar's end that a tf bar holds.
3706 const std::int64_t end = tf_close_of(open);
3707 std::int64_t at = chart_open_from(is_na(end) || end <= open ? open + 1 : end);
3708 std::int64_t next = na<int64_t>();
3709 for (int guard = 0; guard < kTimeOffsetBarsPerStep && !is_na(at); ++guard) {
3710 next = tf_open_of(at);
3711 if (!is_na(next) && next > open) break;
3712 next = na<int64_t>();
3713 at = next_chart_open(at);
3714 }
3715 open = next;
3716 }
3717 for (int k = 0; k < timeframe_bars_back && !is_na(open); ++k) {
3718 // The last chart bar before this tf bar's open that a tf bar holds.
3719 std::int64_t at = chart_open_before(open);
3720 std::int64_t prev = na<int64_t>();
3721 for (int guard = 0; guard < kTimeOffsetBarsPerStep && !is_na(at); ++guard) {
3722 prev = tf_open_of(at);
3723 if (!is_na(prev) && prev < open) break;
3724 prev = na<int64_t>();
3725 at = chart_open_before(at);
3726 }
3727 open = prev;
3728 }
3729 if (is_na(open)) return na<int64_t>();
3730 return close ? tf_close_of(open) : open;
3731}
3732
3735 const NativeDecisionContext& context) {
3736 const bool recalculation_due = scheduler_.coof_recalculation_due(event, context, *this);
3737 if (!recalculation_due) record_applied_range_end();
3738 // A process-on-close fill can append admission events after on_native_bar.
3739 // Retire that completed tail without recapturing the broker-state row:
3740 // F0a preserves the existing recording and continuation boundaries.
3741 // A synchronous fill inside an unfinished command is folded by the
3742 // enclosing on_bar_close instead.
3743 if (!recalculation_due && context.is_terminal_sub_bar
3744 && context.coordinate.provenance == NativePriceProvenance::AfterCalculationClose
3745 && !adapter_.admission_journal.has_unfinished_events()) {
3746 adapter_.finish_admission_bar();
3747 }
3748}
3749
3750} // namespace pineforge
3751
3752namespace pineforge {
3753
3754void source::PineStrategyHost::stage_symbol_feed_views() {
3755 if (symbol_feed_views_.size() != symbol_feeds_.size())
3757 "request data: symbol feed view table is out of sync");
3758 std::vector<NativeInstrumentFeedView> descriptors(symbol_feed_views_.size());
3759 std::vector<const NativeInstrumentFeedView*> slots(symbol_feed_views_.size(), nullptr);
3760 for (std::size_t i = 0; i < symbol_feed_views_.size(); ++i) {
3761 const BorrowedFeedSlot& slot = symbol_feed_views_[i];
3762 if (!slot.present) continue;
3763 descriptors[i] = {{slot.bars, slot.size}, slot.close_ms};
3764 slots[i] = &descriptors[i];
3765 }
3766 NativeExecutionConsumer::bound(*this).stage_instrument_feed_views(
3767 slots.empty() ? nullptr : slots.data(), slots.size());
3768}
3769
3770} // namespace pineforge
static OrderBirth chart_evaluation(int bar, int64_t timestamp)
The code carried beside a thrown exception.
The refusal of a read-only state query, or of a report-only write, that may no longer be answered.
void set_early_close_completes(bool on)
Whether a session ending BEFORE its nominal close completes the running D/W/M bucket on that session'...
const std::shared_ptr< const CommandObservation > & observation() const
void bind(std::shared_ptr< const CommandObservation > observation)
A thrown legacy setter's original message, propagated to native begin.
Base with a code: throw coded<std::invalid_argument>(code, {args}, text).
std::uint64_t record_count() const noexcept override
ScratchStatus put(const ScratchByteSource &key, const ScratchByteSource &value, ScratchHandle &result) noexcept
The public native host: an abstract subclass of BacktestEngine with no PineScript on it.
std::string entry_id_at(size_type global) const
Trade numeric_at(size_type global) const
static std::uint64_t fold_exit_phase(std::uint64_t digest, std::uint8_t phase) noexcept
PineRuntimeSecurityScope(const PineStrategyHost &, PineRuntimeSecurityCursor &) noexcept
static const PineRuntimeSecurityCursor * current(const PineStrategyHost &) noexcept
compat::pine::Calculation fixture_cap_calculation() const
std::map< long long, double > symbol_calendar_opens_
std::map< std::string, RecordedSeries > recorded_series_
void set_syminfo_session(const std::string &)
std::optional< NativeMarginDecision > resolve_margin_requirement(const NativeMarginRequirementView &) const final
const std::pair< std::int64_t, std::int64_t > * symbol_calendar_day(std::int64_t ms) const
std::optional< double > resolve_margin_call_units(const NativeMarginCallView &) const final
void prepare_native_begin(const NativeBeginArgs &) final
BarTime fixture_chart_time(std::int64_t timestamp_ms) const
compat::pine::CapClock fixture_cap_clock() const
const PendingIntentView & pending_intent_view() const noexcept
const std::vector< FixtureIntentRow > & source_pending_view() const
void finish_applied_admission(const native_order::ExecutionAppliedEvent &, const NativeDecisionContext &)
void on_native_tick(const Bar &, const NativeTickContext &) final
const Series< double > & source_input_series(const std::string &key, const Series< double > &fallback) const
int pending_order_effective_levels(int index, double *stop, double *limit, double *trail_activation) const
int observe_pending_effective_levels(int, double *, double *, double *) const override
double observe_trail_best_price_v1() const override
void configure_pine_strategy(const PineStrategyConfig &)
int observe_pending_level_resolved(int) const override
bool set_symbol_calendar(std::vector< std::pair< std::int64_t, std::int64_t > > sessions)
virtual void on_native_applied_event(const native_order::ExecutionAppliedEvent &, const NativeDecisionContext &)
std::vector< BorrowedFeedSlot > symbol_feed_views_
const Series< double > & source_series(const std::string &) const
void apply_realtime_tail_horizon(const Bar *bars, int n, bool script_bar_geometry)
int64_t chart_bar_close_ms(int64_t stamp) const
std::vector< std::pair< std::int64_t, std::int64_t > > symbol_calendar_
PineRuntimeSecurityCursor make_runtime_security_cursor(const std::string &timeframe, bool lookahead, bool gaps, bool lower_array, bool heikinashi=false) const
void on_native_applied(const native_order::ExecutionAppliedEvent &, const NativeDecisionContext &) final
std::vector< admission::Field > market_admission_fields() const
PineStrategyHost(compat::pine::CapAttachment cap=compat::pine::CapAttachment::None)
int observe_last_bar_dual_entry_path_v1() const override
void set_trade_scratch(detail::CommandScratchStore &)
std::uint64_t broker_state_hash_projection() const override
std::vector< FixtureIntentRow > source_pending_view_cache_
bool margin_check_allowed(const NativeMarginCheckPoint &) const final
std::optional< double > resolve_anchored_level(const NativeAnchoredLevelView &) const final
std::string runtime_security_timeframe(const std::string &selected) const
std::uint64_t fixture_applied_receipt_count() const
bool report_terminal_quote_applied(const ReportC &) const
std::vector< NativeInstrumentFeed > symbol_feeds_
std::int64_t symbol_calendar_open_before(std::int64_t before) const
void on_native_input(const Bar &, const NativeInputContext &) final
std::map< long long, double > symbol_calendar_closes_
int short_seed_collision_role_v1(native_order::RequestHandle) const noexcept
void on_native_bar_open(const Bar &, const NativeDecisionContext &) final
bool set_report_terminal_quote(std::int64_t time_ms, double close)
MarketAdmissionJournal & market_admission_journal()
static detail::ScratchKeyOrder trade_scratch_order() noexcept
NativePrecommitVerdict validate_execution_precommit(const NativePrecommitView &) const final
int64_t pine_time_offset(int64_t bar_open_ms, int bars_back, const std::string &tf, const std::string &session, const std::string &tz, int timeframe_bars_back, bool close) const
double script_mark_price(double price) const
void on_native_bar(const Bar &, const NativeDecisionContext &) final
int observe_probe_fill_qty(int, double, double *, int *, int *) const override
void set_syminfo_metadata(const std::string &, double) override
void set_strategy_override(const StrategyOverrides &)
int observe_pending_copy_v1(int, pf_pending_order_v1_t *) const override
bool adapter_core_sizes_default_opening(bool is_long) const
native_order::ExecutionTerms resolve_execution_terms(const NativeExecutionTermsFacts &) const final
void on_native_sub_bar(const Bar &, const NativeDecisionContext &) final
int probe_fill_qty(int index, double fill_price, double *qty, int *close_only, int *partition) const
void on_native_recalculate(const Bar &, const NativeDecisionContext &, NativeCalculationReason, const native_order::ExecutionAppliedEvent *) final
std::int64_t symbol_calendar_open_from(std::int64_t at) const
const ForeignSecuritySite * foreign_context_
std::map< int, ForeignSecuritySite > foreign_security_sites_
void source_stream_entry_comment(const PyramidEntry &, std::string &) const override
ScratchStatus finish(CommandScratchStore &store, ScratchStatus status) noexcept
std::uint64_t slots(std::uint64_t keys)
void bind_id_pair_scratch_tables(source::PineExecutionAdapter &, const std::shared_ptr< ScratchSlab > &)
std::uint64_t ScratchHandle
constexpr ScratchHandle scratch_no_handle
int64_t symbol_session_time_close(int64_t bar_ms, const std::string &tf, const std::string &session, const std::string &tz, const std::string &chart_tf)
void bind_tree_scratch_tables(source::PineExecutionAdapter &, const std::shared_ptr< ScratchSlab > &)
void rethrow_carried_scratch_failure(const ReportScratchIoError *failure)
NativeCalculationReason
Why the kernel is asking the host to calculate.
NativeLifecycleKind
Where a host stands.
NativeRunPhase
Which driving is Running: a batch run(), a stream's internal warmup, or its realtime leg.
NativePrecommitVerdict
The host is consulted before generic opening-margin admission.
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)
NativePathOrder
Generic ordering for a modeled OHLC path.
std::optional< Plan > plan(double signed_position, Reduce request) noexcept
int b(int64_t c)
Definition color.hpp:44
NativePhysicalPosition run_position(const NativeStrategyHost &host)
void take_run_facts(NativeExecutionConsumer &consumer, const PineExecutionAdapter &adapter, std::uint64_t run)
const NativeCurrentPointView * callback_point(const NativeStrategyHost &host)
ScriptRuleSwitches & script_rule_switches() noexcept
PineIntentState * selected_intents(const NativeStrategyHost &host) noexcept
ScratchKeyOrder exit_scratch_key_order(ExitScratchOrderStats *stats=nullptr) noexcept
void reset_intent_evaluation(PineIntentState &state, std::int64_t label_ms, const PineStrategyConfig &config, const PineReplayRiskConfig &risk, const NativeCurrentPointView &point) noexcept
bool run_aggregates_input_bars(const NativeExecutionConsumer &consumer, const void *owner, std::uint64_t run)
const NativeRunSpec * run_spec(const NativeStrategyHost &host)
NativeExecutionConsumer & run_consumer(const NativeStrategyHost &host)
bool skip_quiet(QuietHook hook, bool quiet) noexcept
NativeRunPhase run_phase(const NativeStrategyHost &host)
PreRollEvaluation selected_pre_roll_evaluation(const NativeStrategyHost &host, std::int64_t label_ms)
void adopt_selected_intents(NativeStrategyHost &host)
const NativeCurrentPointView * intent_live_point(const NativeStrategyHost &host) noexcept
void capture_risk_row(const NativeStrategyHost &host, const PineStrategyConfig &config, Payload &&payload)
NativeLifecycleKind run_kind(const NativeStrategyHost &host)
void admit_strategy_declaration(const PineStrategyConfig &config)
void admit_strategy_exit(const std::string &id, const std::string &from_entry, double limit_price, double, double, double trail_offset, double trail_price, double, const std::string &comment, double, const std::string &oca_name, double profit_ticks, double loss_ticks)
void admit_strategy_close_token(const std::string &id, const std::string &comment, double qty, double qty_percent, bool immediately, std::uint64_t)
constexpr char kMarginCallLabel[]
double tv_money_round(double value)
constexpr char kIntradayLossComment[]
void admit_strategy_cancel(const std::string &id)
void admit_pine_risk_max_drawdown(double value, bool)
void assert_admitted_pine_replay_risk_config(const PineReplayRiskConfig &snapshot)
void admit_strategy_close_all() noexcept
void admit_pine_risk_max_intraday_loss(double value, bool)
std::string SourceId
const char * instrument_feed_field_name(NativeRunSpecField field) noexcept
constexpr char kFillCapCommentPrefix[]
std::shared_ptr< TimeframeAggregator::PeriodSource > make_multi_day_period_source(const MultiDayFeeds &feeds)
The N-day partition of feeds, or null where TradingView's rule yields none: fewer than two days,...
void admit_strategy_entry(const std::string &id, bool, double limit_price, double stop_price, double, const std::string &comment, const std::string &oca_name, int, int)
void admit_pine_risk_max_position_size(double value)
void admit_strategy_exit_cancel_bracket(const std::string &exit_id, const std::string &from_entry, const std::string &comment)
void admit_strategy_close(const std::string &id, const std::string &comment, double, double, bool)
void admit_strategy_cancel_all() noexcept
void admit_strategy_order(const std::string &id, bool, double, double limit_price, double stop_price, const std::string &oca_name, int)
int64_t pine_time(int64_t bar_ms, const std::string &tf, const std::string &session, const std::string &tz, const std::string &chart_tf, const std::string &syminfo_tz=std::string())
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 na< int64_t >()
Definition na.hpp:66
CalendarPeriod calendar_period_for(const std::string &tf)
Determine the calendar period for a target TF string.
std::optional< RuntimeSubdivision > runtime_subdivision(const std::string &input_timeframe, const std::string &requested_timeframe)
void note_run_failure(BacktestEngine &engine, std::string text, RunFailureCode code, const RunFailureArgs &args={})
bool tf_change(int64_t prev_ms, int64_t curr_ms, const std::string &tf)
Check if prev/curr timestamps cross a timeframe boundary.
double na< double >()
Definition na.hpp:64
bool session_in_market(const std::string &session, const std::string &tz, int64_t bar_ms)
std::vector< Bar > runtime_subdivide(const Bar &, RuntimeSubdivision)
RunFailureValue run_failure_value_of(const BacktestEngine &engine) noexcept
The recorded value under the same rule (none when there is no failure).
bool session_trades_at(const std::string &session, const std::string &tz, int64_t bar_ms)
bool is_na(const Line &h)
Definition drawing.hpp:58
RunFailureValue classify_run_failure(const std::exception &error) noexcept
The code an exception carries: a coded exception's own; else the safety net by type (std::bad_alloc -...
std::string detect_timeframe(const Bar *bars, int n, int max_samples=100)
Detect the timeframe string from an array of bars by computing the median timestamp delta and mapping...
int64_t calendar_day_open_local_ms(int64_t bar_ms, const std::string &tz)
T na()
Definition na.hpp:54
bool session_in_premarket(const std::string &session, const std::string &tz, int64_t bar_ms)
std::vector< RunFailureArg > RunFailureArgs
admission::Journal MarketAdmissionJournal
static bool same_bar_exit_group_possible(const TradeLedger &trades)
bool session_in_postmarket(const std::string &session, const std::string &tz, int64_t bar_ms)
int64_t pine_time_close(int64_t bar_ms, const std::string &tf, const std::string &session, const std::string &tz, const std::string &chart_tf, const std::string &syminfo_tz=std::string())
int64_t timestamp
Definition bar.hpp:8
int64_t equity_curve_len
Definition engine.hpp:310
pf_equity_point_t * equity_curve
Definition engine.hpp:309
A validated code and its canonical arguments: the JSON object with its keys sorted and no whitespace,...
int32_t exit_bar_index
Definition engine.hpp:252
bool exit_from_bracket
Definition engine.hpp:214
std::string exit_comment
Definition engine.hpp:201
execution::CloseCause close_cause
Definition engine.hpp:233
int64_t exit_time
Definition engine.hpp:190
std::string exit_id
Definition engine.hpp:202
int64_t entry_time
Definition engine.hpp:189
Ephemeral read-only facts of one anchored-leg materialization (L7b), offered to the host exactly once...
Borrowed begin-call facts.
const SymInfo * syminfo
The rich run overload's symbol metadata is borrowed only for this callback.
Read-only owning-value facts for one candidate.
Accepted input facts presented before the generic consumer aggregates the bar into its script interva...
Ephemeral factual view of one kernel-issued liquidation before its units are fixed.
Ephemeral factual view of one kernel check point, offered to the host before the check runs.
Ephemeral factual view of the numbers the kernel is about to compare, at one check point,...
Ephemeral factual view of one prepared execution before any physical effect.
What configure_native answers: the status and, on refusal, the first error field validate_native_run_...
One accepted realtime print before native matching at its current decision point.
One committed execution: the definition, the cursor, the resolved price and units,...
One complete setup value, staged/copied by NativeStrategyHost before it is applied at begin.
std::vector< NativeInstrumentFeed > instrument_feeds
Opt-in feeds of other instruments.
What a run holds of the symbol the N-day request reads.
int days
N of the request, at least 2.
std::optional< RuntimeSubdivision > subdivision
void feed(const RuntimeContextEvent &, const Delivery &, const std::function< void()> &begin_chart)
std::function< void(const Bar &, bool)> Delivery
std::vector< std::int64_t > account_fx_effective_from_ms