PineForge unreleased
Deterministic PineScript v6 backtest runtime — C ABI reference
Loading...
Searching...
No Matches
series.hpp
Go to the documentation of this file.
1#pragma once
6#include <array>
7#include <atomic>
8#include <deque>
9#include <vector>
10#include <algorithm>
11#include <cstddef>
12#include <cstdint>
13#include <exception>
14#include <limits>
15#include <memory>
16#include <mutex>
17#include <new>
18#include <optional>
19#include <stdexcept>
20#include <string>
21#include <tuple>
22#include <type_traits>
23#include <unordered_map>
24#include <utility>
25#include "na.hpp"
26
27namespace pineforge {
28
29// Optional numeric-window residency, in 1024-value pages. The default keeps
30// today's owning in-memory window. A finite smaller cache uses scratch backing;
31// zero bypasses the cache. Neither setting changes logical history capacity.
33 static constexpr std::size_t kKeepResident = std::numeric_limits<std::size_t>::max();
34 std::size_t pages = kKeepResident;
35 // Captured with the existing evaluation context at owner construction.
36 // Later scope changes never retune an existing window or its COW clone.
38};
39
41 static thread_local WindowCacheCapacity capacity;
42 return capacity;
43}
44
45// Constructor default for call sites built inside an evaluation context.
46// Objects latch it at construction; restoring the scope cannot resize them.
48 WindowCacheCapacity previous_;
49public:
51 : previous_(window_cache_capacity()) { window_cache_capacity() = capacity; }
55};
56
58 std::uint64_t hits = 0, misses = 0, evictions = 0, bypass_reads = 0;
59 std::uint64_t backing_reads = 0, backing_read_bytes = 0;
60 std::uint64_t backing_writes = 0, backing_write_bytes = 0;
62 std::uint64_t allocation_bypasses = 0, io_errors = 0;
64 std::size_t workspace_bytes = 0;
65 std::uint64_t backing_bytes = 0, logical_payload_bytes = 0;
66 bool storage_complete = true;
67 bool scratch_backed = false;
68};
69
70namespace detail {
71
72// Typed window backing and copy-out access through the shared page cache.
73// Non-arithmetic values keep owning typed pages: an object handle cannot be
74// serialized as native bytes without its own codec/lifetime contract.
75template<class T>
77public:
78 static constexpr std::size_t kChunk = 1024;
79
81 : capacity_(capacity) {}
82 WindowValues(std::size_t size, T initial,
84 : capacity_(capacity) { reset(size, std::move(initial)); }
86 : capacity_(other.capacity_), size_(other.size_), initial_(other.initial_),
87 spilled_(other.spilled_), pages_(other.pages_), backing_(other.backing_) {}
89 if (this != &other) {
90 WindowValues copy(other);
91 *this = std::move(copy);
92 }
93 return *this;
94 }
95 WindowValues(WindowValues&& other) noexcept(std::is_nothrow_move_constructible_v<T>)
96 : capacity_(other.capacity_), size_(other.size_), initial_(std::move(other.initial_)),
97 spilled_(other.spilled_), pages_(std::move(other.pages_)),
98 backing_(std::move(other.backing_)), stats_(other.stats_) {
99 absorb_cache_stats(other.read_cache_stats());
100 other.clear_moved_from();
101 }
103 noexcept(std::is_nothrow_move_assignable_v<T>) {
104 if (this != &other) {
105 // ReadThroughCache is immovable and its callback names this owner.
106 // Reconstruct lazily instead of retaining the old owner's context.
107 cache_.reset();
108 cache_failure_ = nullptr;
109 capacity_ = other.capacity_;
110 size_ = other.size_;
111 initial_ = std::move(other.initial_);
112 spilled_ = other.spilled_;
113 pages_ = std::move(other.pages_);
114 backing_ = std::move(other.backing_);
115 stats_ = other.stats_;
116 absorb_cache_stats(other.read_cache_stats());
117 other.clear_moved_from();
118 }
119 return *this;
120 }
121
122 void reset(std::size_t size, T initial) {
123 // This is representable storage geometry, never a data/cache limit.
124 if (size > std::numeric_limits<std::size_t>::max() / sizeof(T))
126 {{"resource", "layout"}}, "window byte extent is not representable");
127 size_ = size;
128 initial_ = std::move(initial);
129 spilled_ = std::is_arithmetic_v<T> && capacity_.pages < page_count();
130 pages_.clear();
131 backing_.reset();
132 cache_.reset();
133 cache_failure_ = nullptr;
134 stats_ = {};
135 if (!spilled_) pages_.resize(page_count());
136 }
137
138 T read(std::size_t index) const {
139 const std::size_t page = index / kChunk, offset = index % kChunk;
140 if (!spilled_) return pages_[page] ? pages_[page]->values[offset] : initial_;
141 if constexpr (std::is_arithmetic_v<T>) {
142 // An unopened backing is the authoritative uniform initial value.
143 if (!backing_) return initial_;
144 ensure_cache();
145 std::array<T, kChunk> transfer;
146 cache_failure_ = nullptr;
147 const auto status = cache_->get(cache_key(page),
148 {reinterpret_cast<std::byte*>(transfer.data()), page_size(page) * sizeof(T)});
149 if (cache_failure_)
150 std::rethrow_exception(std::exchange(cache_failure_, {}));
151 require_cache_status(status);
152 return transfer[offset];
153 }
154 return initial_; // Non-arithmetic storage always takes the owning path.
155 }
156
157 void write(std::size_t index, T value) {
158 const std::size_t page = index / kChunk, offset = index % kChunk;
159 if (!spilled_) {
160 auto& stored = pages_[page];
161 if (!stored) {
162 OwnedAllocator<Page> allocation;
163 stored = std::allocate_shared<Page>(allocation, allocation, page_size(page), initial_);
164 } else if (!stored.unique()) {
165 OwnedAllocator<Page> allocation;
166 stored = std::allocate_shared<Page>(allocation, allocation, *stored);
167 }
168 stored->values[offset] = std::move(value);
169 return;
170 }
171 if constexpr (std::is_arithmetic_v<T>) {
172 // A failed write cannot leave an old cache tag answering success.
173 // Scalar writes are not page prefixes: invalidate, then retain the
174 // exact existing write without a speculative read/modify/write.
175 if (cache_) require_cache_status(cache_->invalidate(cache_key(page)));
176 make_writable_backing();
177 write_backing(*backing_, index, &value, 1);
178 }
179 }
180
181 // The owner has semantically retired this entire page. Copies retain
182 // their own ownership; ordinary cache eviction never calls this method.
183 void retire_page(std::size_t page) {
184 if (!spilled_) pages_[page].reset();
185 if (cache_) require_cache_status(cache_->invalidate(cache_key(page)));
186 }
187
188 std::size_t size() const { return size_; }
189 WindowCacheCapacity cache_capacity() const { return capacity_; }
191 const auto bytes = sizeof(T) * kChunk;
192 const auto line = ReadThroughCache::line_bytes(bytes);
193 const auto requested = spilled_ ? std::min(capacity_.pages, page_count()) : 0;
194 // An unrepresentable optional budget reduces residency, never data.
195 const auto slots = line ? std::min(requested,
196 std::numeric_limits<std::size_t>::max() / line) : 0;
197 return {slots * line, bytes, ReadThroughCacheReplacement::direct_mapped,
199 }
201 return cache_ ? cache_->stats() : ReadThroughCacheStats{};
202 }
203 void observe_owned_storage(OwnedStorage& out) const noexcept {
204 if (!out.visit(*this)) return;
205 out.logical(out.product(size_, sizeof(T)));
207 // The pointer directory has its own capacity, including after clear().
208 if (out.claim(pages_.data()))
209 out.metadata(out.product(pages_.capacity(), sizeof(std::shared_ptr<Page>)));
210 for (const auto& page : pages_) {
211 if (!page || !out.claim(page.get())) continue;
212 page->allocation.observe_owned_storage(out); // object + shared control allocation
213 ::pineforge::observe_owned_storage(out, page->values);
214 }
215 if (cache_ && out.claim(&*cache_)) {
216 // This immovable cache has one authoritative owner. Its public
217 // counters contain the actual allocated metadata/payload bytes.
218 const auto shared = read_cache_stats();
219 out.metadata(shared.current_metadata_bytes);
220 out.payload(shared.current_payload_bytes);
221 }
222 if (spilled_) out.workspace(sizeof(T) * kChunk);
223 if (backing_ && out.claim(backing_.get())) {
224 backing_->allocation.observe_owned_storage(out);
225 backing_->file.observe_owned_storage(out);
226 }
227 if (cache_failure_) out.incomplete(OwnedStorageUnsupported::exception_state);
228 }
230 auto result = stats_;
231 const auto shared = read_cache_stats();
232 add_counter(result.hits, shared.hits);
233 add_counter(result.misses, shared.misses);
234 add_counter(result.evictions, shared.evictions);
235 add_counter(result.bypass_reads, shared.bypass_reads);
236 add_counter(result.allocation_bypasses, shared.allocation_bypass_count);
237 result.scratch_backed = spilled_;
238 OwnedStorage observation;
239 observation.metadata(sizeof(*this));
240 observe_owned_storage(observation);
241 const auto owned = observation.usage();
242 const auto bounded_size = [](std::uint64_t bytes) {
243 return static_cast<std::size_t>(std::min<std::uint64_t>(
244 bytes, std::numeric_limits<std::size_t>::max()));
245 };
246 result.workspace_bytes = bounded_size(owned.workspace_bytes);
247 result.resident_metadata_bytes = bounded_size(owned.resident_metadata_bytes);
248 result.resident_payload_bytes = bounded_size(owned.resident_payload_bytes);
249 result.backing_bytes = owned.backing_bytes;
250 result.logical_payload_bytes = owned.logical_payload_bytes;
251 result.storage_complete = owned.complete();
252 return result;
253 }
254
255private:
256 struct Page {
257 OwnedAllocator<Page> allocation;
258 std::vector<T> values;
259 Page(const OwnedAllocator<Page>& owner, std::size_t size, const T& initial)
260 : allocation(owner), values(size, initial) {}
261 Page(const OwnedAllocator<Page>& owner, const Page& other)
262 : allocation(owner), values(other.values) {}
263 };
264 struct Backing {
265 OwnedAllocator<Backing> allocation;
266 ScratchFile file{ScratchFile::IoRaiser::auxiliary};
267 // Checkpoint copies can read one backing from distinct owners.
268 std::mutex io_mutex;
269 Backing(const OwnedAllocator<Backing>& owner, const ScratchIoPolicy& policy)
270 : allocation(owner) {
271 file.bound_cache();
272 file.set_scratch_io_policy(policy);
273 file.open("pineforge-window-");
274 }
275 };
276 std::size_t page_count() const noexcept { return size_ / kChunk + (size_ % kChunk != 0); }
277 std::size_t page_size(std::size_t page) const {
278 return std::min(kChunk, size_ - page * kChunk);
279 }
280 void ensure_cache() const {
281 if (cache_) return;
282 cache_.emplace(read_cache_policy(),
283 ReadThroughCacheBacking{const_cast<WindowValues*>(this), &cache_read, nullptr});
284 require_cache_status(cache_->status());
285 }
286 static ReadThroughCacheKey cache_key(std::size_t page) noexcept {
287 // A cache belongs to one owner/backing snapshot. Reset/copy/move
288 // discard it; cloning preserves all bytes except the invalidated page.
289 return {1, 0, static_cast<std::uint64_t>(page)};
290 }
291 static ReadThroughCacheIoResult cache_read(void* context, ReadThroughCacheKey key,
292 ScratchBuffer destination) noexcept {
293 auto& owner = *static_cast<WindowValues*>(context);
294 if (key.backing_identity != 1 || key.generation != 0
295 || key.page_number >= owner.page_count() || !destination.data
296 || !destination.size || destination.size % sizeof(T) != 0
297 || destination.size > owner.page_size(static_cast<std::size_t>(key.page_number)) * sizeof(T))
299 try {
300 owner.read_backing(static_cast<std::size_t>(key.page_number) * kChunk,
301 destination.data, destination.size / sizeof(T));
302 return {ScratchStatus::ok, destination.size};
303 } catch (...) {
304 // The status port is noexcept. Rethrow the original dynamic type,
305 // errno and arena/resource fields immediately after get returns.
306 owner.cache_failure_ = std::current_exception();
308 }
309 }
310 static void require_cache_status(ScratchStatus status) {
312 throw coded<std::logic_error>(RunFailureCode::engine_invariant, {},
313 "window read cache contract failed");
314 }
315 static void add_counter(std::uint64_t& value, std::uint64_t count) noexcept {
316 value += std::min(count, std::numeric_limits<std::uint64_t>::max() - value);
317 }
318 void absorb_cache_stats(const ReadThroughCacheStats& shared) noexcept {
319 add_counter(stats_.hits, shared.hits);
320 add_counter(stats_.misses, shared.misses);
321 add_counter(stats_.evictions, shared.evictions);
322 add_counter(stats_.bypass_reads, shared.bypass_reads);
323 add_counter(stats_.allocation_bypasses, shared.allocation_bypass_count);
324 }
325 void clear_moved_from() noexcept {
326 cache_.reset();
327 cache_failure_ = nullptr;
328 pages_.clear();
329 size_ = 0;
330 spilled_ = false;
331 stats_ = {};
332 }
333 void read_backing(std::size_t index, void* values, std::size_t count) const {
334 std::lock_guard<std::mutex> lock(backing_->io_mutex);
335 add_counter(stats_.backing_read_attempts, 1);
336 try { backing_->file.read(index * sizeof(T), values, count * sizeof(T)); }
337 catch (const std::system_error&) { add_counter(stats_.io_errors, 1); throw; }
338 add_counter(stats_.backing_reads, 1);
339 add_counter(stats_.backing_read_bytes, count * sizeof(T));
340 }
341 void write_backing(Backing& backing, std::size_t index, const T* values,
342 std::size_t count) {
343 std::lock_guard<std::mutex> lock(backing.io_mutex);
344 add_counter(stats_.backing_write_attempts, 1);
345 try { backing.file.write(index * sizeof(T), values, count * sizeof(T)); }
346 catch (const std::system_error&) { add_counter(stats_.io_errors, 1); throw; }
347 add_counter(stats_.backing_writes, 1);
348 add_counter(stats_.backing_write_bytes, count * sizeof(T));
349 }
350 void make_writable_backing() {
351 if (backing_ && backing_.unique()) return;
352 OwnedAllocator<Backing> allocation;
353 auto replacement = std::allocate_shared<Backing>(
354 allocation, allocation, capacity_.scratch_io_policy);
355 std::array<T, kChunk> transfer;
356 if (!backing_) transfer.fill(initial_);
357 for (std::size_t page = 0; page != page_count(); ++page) {
358 const auto count = page_size(page);
359 if (backing_) read_backing(page * kChunk, transfer.data(), count);
360 write_backing(*replacement, page * kChunk, transfer.data(), count);
361 }
362 // Copies/checkpoints share immutable bytes until a successful clone.
363 backing_ = std::move(replacement);
364 }
365
366 WindowCacheCapacity capacity_;
367 std::size_t size_ = 0;
368 T initial_ = na<T>();
369 bool spilled_ = false;
370 std::vector<std::shared_ptr<Page>> pages_;
371 std::shared_ptr<Backing> backing_;
372 mutable std::optional<ReadThroughCache> cache_;
373 mutable std::exception_ptr cache_failure_;
374 mutable WindowCacheStats stats_;
375};
376
377} // namespace detail
378
379template<typename T>
382 std::size_t head_ = 0;
383 std::size_t size_ = 0;
384 std::size_t capacity_ = 0;
385
386public:
387 explicit DynamicRingBuffer(std::size_t capacity,
389 : buffer_(cache), capacity_(capacity) {}
390
391 void push_front(T val) {
392 if (capacity_ == 0) return;
393 // Many declared Series never execute. Materialize on first write.
394 if (buffer_.size() == 0) buffer_.reset(capacity_, na<T>());
395 if (size_ == 0) {
396 buffer_.write(0, val);
397 size_ = 1;
398 head_ = 0;
399 } else {
400 const auto next = (head_ == 0) ? capacity_ - 1 : head_ - 1;
401 buffer_.write(next, val);
402 head_ = next;
403 if (size_ < capacity_) {
404 size_++;
405 }
406 }
407 }
408
409 void update_front(T val) {
410 if (size_ == 0) {
411 push_front(val);
412 } else {
413 buffer_.write(head_, val);
414 }
415 }
416
417 T operator[](std::size_t offset) const {
418 if (offset >= size_ || capacity_ == 0) {
419 return na<T>();
420 }
421 // offset < size_ <= capacity_ and head_ < capacity_, so
422 // head_ + offset < 2 * capacity_: one conditional subtract
423 // replaces the hardware divide of `% capacity_`.
424 std::size_t idx = head_ + offset;
425 if (idx >= capacity_) idx -= capacity_;
426 return buffer_.read(idx);
427 }
428
429 std::size_t size() const { return size_; }
430 std::size_t capacity() const { return capacity_; }
431 WindowCacheCapacity cache_capacity() const { return buffer_.cache_capacity(); }
432 WindowCacheStats cache_stats() const { return buffer_.cache_stats(); }
433 void observe_owned_storage(OwnedStorage& out) const noexcept {
434 buffer_.observe_owned_storage(out);
435 }
436 // Optional read lines only; cache_stats() also counts fixed owner metadata.
438 return buffer_.read_cache_stats();
439 }
440
441 void clear() {
442 head_ = 0;
443 size_ = 0;
444 buffer_.reset(0, na<T>());
445 }
446
447 void resize(std::size_t new_capacity) {
448 if (new_capacity == capacity_) return;
449 detail::WindowValues<T> new_buffer(buffer_.cache_capacity());
450 std::size_t new_size = std::min(size_, new_capacity);
451 if (new_size != 0) new_buffer.reset(new_capacity, na<T>());
452 for (std::size_t i = 0; i < new_size; ++i) {
453 new_buffer.write(i, (*this)[i]);
454 }
455 buffer_ = std::move(new_buffer);
456 capacity_ = new_capacity;
457 head_ = 0;
458 size_ = new_size;
459 }
460};
461
462/*
463 * PineScript history indexing for transpiled series (close, open, user vars, etc.)
464 *
465 * Pine uses expr[k] with k = 0 on the current bar, k >= 1 meaning k bars into the past
466 * (close[1] = previous bar). Newest values live at the front of the ring; operator[](k)
467 * reads k steps into the past. Codegen emits the same k for _s_close[k] and Series vars.
468 *
469 * push() — new bar; update() — same bar (magnifier intrabar). Negative or out-of-range
470 * offsets return na (insufficient history).
471 */
472
473template<typename T>
474class Series {
476
477public:
478 explicit Series(int max_len = 500, WindowCacheCapacity cache = window_cache_capacity())
479 : buf(max_len, cache) {}
480
481 void push(T value) {
482 buf.push_front(value);
483 }
484
485 void update(T value) {
486 buf.update_front(value);
487 }
488
489 T operator[](int offset) const {
490 if (offset < 0) {
491 return na<T>();
492 }
493 return buf[static_cast<std::size_t>(offset)];
494 }
495
496 T current() const {
497 return (*this)[0];
498 }
499
500 int size() const {
501 return static_cast<int>(buf.size());
502 }
503 WindowCacheCapacity cache_capacity() const { return buf.cache_capacity(); }
504 WindowCacheStats cache_stats() const { return buf.cache_stats(); }
505 void observe_owned_storage(OwnedStorage& out) const noexcept {
506 buf.observe_owned_storage(out);
507 }
508
509 void clear() {
510 buf.clear();
511 }
512};
513
514} // namespace pineforge
void observe_owned_storage(OwnedStorage &out) const noexcept
Definition series.hpp:433
WindowCacheStats cache_stats() const
Definition series.hpp:432
detail::ReadThroughCacheStats read_cache_stats() const noexcept
Definition series.hpp:437
T operator[](std::size_t offset) const
Definition series.hpp:417
DynamicRingBuffer(std::size_t capacity, WindowCacheCapacity cache=window_cache_capacity())
Definition series.hpp:387
std::size_t size() const
Definition series.hpp:429
std::size_t capacity() const
Definition series.hpp:430
void resize(std::size_t new_capacity)
Definition series.hpp:447
WindowCacheCapacity cache_capacity() const
Definition series.hpp:431
OwnedStorageUsage usage() const noexcept
void metadata(std::uint64_t bytes) noexcept
T operator[](int offset) const
Definition series.hpp:489
void push(T value)
Definition series.hpp:481
int size() const
Definition series.hpp:500
T current() const
Definition series.hpp:496
WindowCacheCapacity cache_capacity() const
Definition series.hpp:503
WindowCacheStats cache_stats() const
Definition series.hpp:504
void observe_owned_storage(OwnedStorage &out) const noexcept
Definition series.hpp:505
void update(T value)
Definition series.hpp:485
Series(int max_len=500, WindowCacheCapacity cache=window_cache_capacity())
Definition series.hpp:478
WindowCacheScope & operator=(const WindowCacheScope &)=delete
WindowCacheScope(const WindowCacheScope &)=delete
WindowCacheScope(WindowCacheCapacity capacity)
Definition series.hpp:50
Base with a code: throw coded<std::invalid_argument>(code, {args}, text).
static std::size_t line_bytes(std::size_t page_size) noexcept
void retire_page(std::size_t page)
Definition series.hpp:183
WindowValues(const WindowValues &other)
Definition series.hpp:85
ReadThroughCacheStats read_cache_stats() const noexcept
Definition series.hpp:200
void reset(std::size_t size, T initial)
Definition series.hpp:122
WindowValues & operator=(WindowValues &&other) noexcept(std::is_nothrow_move_assignable_v< T >)
Definition series.hpp:102
std::size_t size() const
Definition series.hpp:188
WindowValues(WindowCacheCapacity capacity=window_cache_capacity())
Definition series.hpp:80
WindowValues(WindowValues &&other) noexcept(std::is_nothrow_move_constructible_v< T >)
Definition series.hpp:95
WindowValues & operator=(const WindowValues &other)
Definition series.hpp:88
WindowCacheStats cache_stats() const
Definition series.hpp:229
WindowCacheCapacity cache_capacity() const
Definition series.hpp:189
void observe_owned_storage(OwnedStorage &out) const noexcept
Definition series.hpp:203
static constexpr std::size_t kChunk
Definition series.hpp:78
T read(std::size_t index) const
Definition series.hpp:138
WindowValues(std::size_t size, T initial, WindowCacheCapacity capacity=window_cache_capacity())
Definition series.hpp:82
void write(std::size_t index, T value)
Definition series.hpp:157
ReadThroughCachePolicy read_cache_policy() const noexcept
Definition series.hpp:190
std::uint64_t page(std::uint64_t bytes)
WindowCacheCapacity & window_cache_capacity()
Definition series.hpp:40
T na()
Definition na.hpp:54
void observe_owned_storage(OwnedStorage &out, const T &value) noexcept
static constexpr std::size_t kKeepResident
Definition series.hpp:33
ScratchIoPolicy scratch_io_policy
Definition series.hpp:37
std::uint64_t bypass_reads
Definition series.hpp:58
std::uint64_t backing_write_bytes
Definition series.hpp:60
std::size_t resident_metadata_bytes
Definition series.hpp:63
std::size_t resident_payload_bytes
Definition series.hpp:63
std::size_t workspace_bytes
Definition series.hpp:64
std::uint64_t backing_bytes
Definition series.hpp:65
std::uint64_t logical_payload_bytes
Definition series.hpp:65
std::uint64_t backing_write_attempts
Definition series.hpp:61
std::uint64_t io_errors
Definition series.hpp:62
std::uint64_t backing_read_bytes
Definition series.hpp:59
std::uint64_t evictions
Definition series.hpp:58
std::uint64_t backing_writes
Definition series.hpp:60
std::uint64_t backing_read_attempts
Definition series.hpp:61
std::uint64_t backing_reads
Definition series.hpp:59
std::uint64_t allocation_bypasses
Definition series.hpp:62