PineForge unreleased
Deterministic PineScript v6 backtest runtime — C ABI reference
Loading...
Searching...
No Matches
native_calendar.hpp
Go to the documentation of this file.
1#pragma once
2
3#include <cstdint>
4#include <optional>
5#include <string>
6#include <string_view>
7#include <utility>
8#include <vector>
9
10// Isolated calendar / interval values for a later native host. This component
11// does not match orders, emit strategy callbacks, aggregate OHLCV, or run a
12// NativeRunSpec. Legacy helpers in session_time.cpp / timeframe.cpp stay
13// unchanged and are not native authority.
15inline namespace native_calendar_v2 {
16
17// ---------------------------------------------------------------------------
18// Timeframe
19// ---------------------------------------------------------------------------
20//
21// Grammar: optional count (>= 1, no leading zeros) then a unit suffix.
22// nS seconds "15S"
23// n minutes "1", "5", "60", "240"
24// D / nD "D", "1D", "2D"
25// W / nW "W", "1W", "2W"
26// M / nM "M", "1M", "3M"
27// Literal is identity: "1D" is not rewritten to "D".
28//
29// Rejected before any conversion: empty, whitespace, leading zeros ("01",
30// "01D"), bare "S", unknown suffix including "1H"/"4H" (legacy stoi("1H")==1
31// minute is not copied), overflowed digit strings, count whose unit duration
32// is not representable as int64 milliseconds.
33
35
36// Parsed timeframe. Default, moved-from, and any object that is not the
37// result of a successful parse is invalid. Fields are immutable; there is
38// no public constructor that can inject a zero count or a mismatched
39// literal. Copy preserves validity; move leaves the source invalid.
40class Timeframe {
41public:
42 Timeframe() = default;
43 Timeframe(const Timeframe&) = default;
44 Timeframe& operator=(const Timeframe&) = default;
45 Timeframe(Timeframe&& other) noexcept { *this = std::move(other); }
46 Timeframe& operator=(Timeframe&& other) noexcept {
47 if (this == &other) return *this;
48 unit_ = other.unit_;
49 count_ = other.count_;
50 literal_ = std::move(other.literal_);
51 valid_ = other.valid_;
52 other.valid_ = false;
53 other.count_ = 0;
54 other.literal_.clear();
55 return *this;
56 }
57
58 bool valid() const noexcept { return valid_ && count_ >= 1 && unit_ok(unit_); }
59 TimeframeUnit unit() const noexcept { return unit_; }
60 int count() const noexcept { return count_; }
61 const std::string& literal() const noexcept { return literal_; }
62 bool is_fixed() const noexcept {
63 return unit_ == TimeframeUnit::Second || unit_ == TimeframeUnit::Minute;
64 }
65 bool is_calendar() const noexcept { return valid() && !is_fixed(); }
66
67private:
68 friend std::optional<Timeframe> parse_timeframe(std::string_view);
69 friend bool operator==(const Timeframe&, const Timeframe&) noexcept;
70 static bool unit_ok(TimeframeUnit u) noexcept {
71 switch (u) {
77 return true;
78 }
79 return false;
80 }
81 Timeframe(TimeframeUnit unit, int count, std::string literal)
82 : unit_(unit), count_(count), literal_(std::move(literal)), valid_(true) {}
84 int count_ = 0;
85 std::string literal_;
86 bool valid_ = false;
87};
88
89inline bool operator==(const Timeframe& a, const Timeframe& b) noexcept {
90 return a.valid() == b.valid() && a.unit_ == b.unit_ && a.count_ == b.count_
91 && a.literal_ == b.literal_;
92}
93
94std::optional<Timeframe> parse_timeframe(std::string_view text);
95
96// Pairing is a classification only. Equal-TF passthrough versus grouping is
97// the host's choice; this component does not drop 2D/2W/M/3M and does not
98// collapse count the way calendar_period_for(last letter) does.
99enum class TimeframePairing {
100 Passthrough, // byte-identical literals
101 SameUnitMultiple, // same unit, script.count % input.count == 0, script >= input
102 FixedDivisible, // both fixed, script ms % input ms == 0 (e.g. 15S -> 1)
103 FixedToCalendar, // second/minute -> D/W/M
104 CalendarToCalendar, // D/nD -> W/M, W/nW -> M
105 ScriptFiner, // script strictly finer than input
106 IndivisibleFixed, // fixed remainder != 0 (5 -> 7) or same-unit non-multiple
108 Invalid, // one or both timeframes are not valid parsed values
109};
110
113 int64_t group_factor = 0; // script/input count or ms ratio; 0 if not a ratio pairing
114};
115
117
118// Whether the pairing's script bars are buckets the kernel gathers from the
119// input (a same-unit or fixed multiple, a fixed or calendar input under a
120// calendar script bar -- a multiple of one included), rather than the input
121// bars themselves (byte-identical literals, Passthrough). False for a finer or
122// indivisible script and an invalid pair, which a run cannot hold.
123bool pairing_aggregates(const TimeframeCompatibility& pairing) noexcept;
124
125// Stream input M/nM is the existing refusal (engine_stream requires a fixed
126// positive duration). Batch monthly remains accepted via compatibility().
128
129// ---------------------------------------------------------------------------
130// Session / calendar definition
131// ---------------------------------------------------------------------------
132//
133// Currently valid session syntax, parsed once into an immutable value:
134// empty or "24x7" all-day every day, origin 00:00
135// HHMM-HHMM one window; 2400 is a legal end
136// HHMM-HHMM,HHMM-HHMM comma-separated union
137// start == end 24h wrap from that clock (1700-1700)
138// end < start overnight wrap (1800-1700, 2200-0200)
139// :1234567 day mask, 1=Sun .. 7=Sat, after the windows
140//
141// Overnight / day-mask interpretation (generic; no stock/forex recognizer):
142// A wrapped or 24h-from-nonzero window is one session-day. The day mask
143// applies to that session-day's trading date — the local civil date of
144// last_traded_close_ms - 1ms — not to each timestamp's weekday. Sunday
145// 18:00 of an 1800-1700 session with mask :23456 (Mon-Fri) is therefore
146// Monday's session, not a Sunday reject.
147//
148// Normalized schedule: the first declared start O fixes civil cycles
149// [O(date), O(date+1)). Recurring instances of every window (including wrap
150// and equal-start full-day) are built on that date and its neighbors,
151// intersected with the cycle, clipped at both cycle ends, then unioned.
152// A start earlier than O can contribute coverage on BOTH sides of the cycle
153// boundary (overlapping 1300-1600,0900-1500); merely shifting every earlier
154// start one day is not sufficient. That split is schedule normalization,
155// not OHLC splitting. Origin stays the first declared start; permuting later
156// windows cannot change the union, while changing the first window can
157// change origin and trading-date assignment.
158//
159// Trading date is the civil date of the final nominal UNMASKED recurring
160// coverage endpoint minus one instant, computed in civil minutes before
161// weekday masks and before UTC DST-gap collapse. A rejected mask or empty
162// resolved window never moves that date onto the next live cycle; there is
163// no cycle-end-next-day fallback. Inverse lookup matches this date only, so
164// an empty Sunday cannot alias Monday. Empty resolved/allowed coverage has
165// no membership or synthesis; D/W/M bounds still contain the query
166// (open <= query < next_period_open). A DST gap that maps both ends of one
167// instance to the same epoch omits that instance only.
168//
169// Native origin is that first-window start. Pine's 17:00 day stamp on an
170// 1800-1700 session is a compatibility label, not this component's default.
171//
172// Fold tie-break (civil -> epoch): a repeated local civil time maps to the
173// earlier UTC occurrence (first time through). Adjacent intervals that share
174// a civil boundary reuse that same epoch, so conversion cannot invent a gap
175// or overlap. A missing local time (DST gap) maps to the first representable
176// instant at or after the declared civil. Conversion does not copy tm_isdst
177// from a query instant and does not use a UTC-day offset cache as authority.
178
179class SessionCalendar;
180std::optional<SessionCalendar> parse_session(std::string_view session,
181 std::string_view timezone);
182
183// Single calendar-owned timezone acceptance boundary. Empty is accepted and
184// means UTC for this component (native full-spec nonempty scheduling TZ is
185// separate). Also used by public resolve_civil. Not for hot interval loops.
186//
187// Accepted:
188// IANA/TZif names and aliases installed under libc's effective zoneinfo
189// root (not a search of trees libc will not read):
190// charset [A-Za-z0-9/_+-], no absolute path, no `.` / `..` components;
191// realpath stays in that root and the file magic is TZif. Leading `:`
192// is a tzfile reference only.
193// macOS: /var/db/timezone/zoneinfo (TZDIR ignored; tzset(3) does not
194// use it). glibc: nonempty TZDIR is exclusive (relative allowed;
195// missing/non-directory fails closed). Unset/empty TZDIR uses
196// /usr/share/zoneinfo.
197// UTC, GMT, Etc/UTC, Etc/GMT
198// UTC/GMT conventional offsets (TV sign):
199// (UTC|GMT)[+-](H|HH|HMM|HHMM|H:MM|HH:MM) hours 0–23, minutes 0–59;
200// digit length is bounded before conversion so overflow cannot escape.
201// POSIX TZ, including DST rule forms:
202// std offset [dst[offset][,start[/time],end[/time]]]
203// A slash before any comma is a tzfile path; slash after a comma is
204// POSIX rule time (M3.2.0/2), not an IANA name.
205// Rejected before libc setenv/mktime: unknown names, UTC+24:00 / UTC+01:99,
206// overflowed digit strings, whitespace/control/NUL, absolute paths, `..`
207// traversal, and non-TZif zoneinfo files (zone.tab, iso3166.tab, +VERSION).
208bool timezone_accepted(std::string_view timezone);
209
210// Observational identity for the native runner (setup/identity time only).
211// Shares the acceptance/normalization/effective-root resolver. Does not hash.
212// Semantics version 1. nullopt = backing facts could not be established;
213// the runner must refuse. Does not relax timezone_accepted.
214enum class TimezoneSourceKind : std::uint32_t {
215 Utc = 1,
220};
221
223 static constexpr std::uint32_t kSemanticsVersion = 1;
224 std::uint32_t semantics_version = 0;
226 std::string input;
228 // Where this machine keeps the zone data, and which files under it were
229 // read. DIAGNOSTICS: both are absolute paths of the host that resolved
230 // the zone, so no digest folds either member (R5 lane E23).
231 std::string zoneinfo_root;
232 std::vector<std::string> resource_paths;
233 // The CONTENT of `resource_paths`, as one value: FNV-1a 64 over the
234 // resource count, then each file's byte length and its bytes, in the
235 // order listed. This is the zone fact a run's identity folds, so two
236 // machines carrying the same tzdata release agree on it whatever their
237 // zoneinfo root is called, while a tzdata update that rewrites the zone's
238 // rules moves it. Set by `timezone_identity_descriptor`, which returns
239 // nullopt when a named resource cannot be read back.
240 std::uint64_t resource_digest = 0;
241 bool valid() const noexcept;
242};
243
245timezone_identity_descriptor(std::string_view timezone);
246
248public:
249 SessionWindow() = default;
250 bool valid() const noexcept {
251 if (start_minutes_ < 0 || start_minutes_ >= 1440) return false;
252 if (end_minutes_ < 0 || end_minutes_ > 1440) return false;
253 const bool expect_full = (end_minutes_ != 1440 && start_minutes_ == end_minutes_);
254 const bool expect_wrap = (!expect_full && end_minutes_ < start_minutes_);
255 return full_day_ == expect_full && wraps_ == expect_wrap;
256 }
257 int start_minutes() const noexcept { return start_minutes_; }
258 int end_minutes() const noexcept { return end_minutes_; }
259 bool wraps() const noexcept { return wraps_; }
260 bool full_day() const noexcept { return full_day_; }
261
262private:
263 friend std::optional<SessionCalendar> parse_session(std::string_view, std::string_view);
264 SessionWindow(int start, int end, bool wraps, bool full_day)
265 : start_minutes_(start), end_minutes_(end), wraps_(wraps), full_day_(full_day) {}
266 int start_minutes_ = 0; // [0, 1440)
267 int end_minutes_ = 0; // (0, 1440]; 1440 = 24:00
268 bool wraps_ = false;
269 bool full_day_ = false;
270};
271
272// Parsed session. Default and moved-from objects are invalid. Windows, mask,
273// origin and flags are immutable after a successful parse. 2400 is stored as
274// end_minutes 1440; cyclic coverage is computed internally and is not a
275// public field that can be set to 25:00.
277public:
278 SessionCalendar() = default;
281 SessionCalendar(SessionCalendar&& other) noexcept { *this = std::move(other); }
283 if (this == &other) return *this;
284 timezone_ = std::move(other.timezone_);
285 literal_ = std::move(other.literal_);
286 windows_ = std::move(other.windows_);
287 day_mask_ = other.day_mask_;
288 origin_minutes_ = other.origin_minutes_;
289 all_day_ = other.all_day_;
290 valid_ = other.valid_;
291 other.valid_ = false;
292 other.day_mask_ = 0;
293 other.origin_minutes_ = 0;
294 other.all_day_ = false;
295 other.timezone_.clear();
296 other.literal_.clear();
297 other.windows_.clear();
298 return *this;
299 }
300
301 bool valid() const noexcept;
302 const std::string& timezone() const noexcept { return timezone_; }
303 const std::string& literal() const noexcept { return literal_; }
304 const std::vector<SessionWindow>& windows() const noexcept { return windows_; }
305 std::uint8_t day_mask() const noexcept { return day_mask_; }
306 int origin_minutes() const noexcept { return origin_minutes_; }
307 bool all_day() const noexcept { return all_day_; }
308
309private:
310 friend std::optional<SessionCalendar> parse_session(std::string_view, std::string_view);
311 SessionCalendar(std::string timezone,
312 std::string literal,
313 std::vector<SessionWindow> windows,
314 std::uint8_t day_mask,
315 int origin_minutes,
316 bool all_day)
317 : timezone_(std::move(timezone)),
318 literal_(std::move(literal)),
319 windows_(std::move(windows)),
320 day_mask_(day_mask),
321 origin_minutes_(origin_minutes),
322 all_day_(all_day),
323 valid_(true) {}
324 std::string timezone_;
325 std::string literal_;
326 std::vector<SessionWindow> windows_;
327 std::uint8_t day_mask_ = 0;
328 int origin_minutes_ = 0;
329 bool all_day_ = false;
330 bool valid_ = false;
331};
332
333std::optional<SessionCalendar> parse_session(std::string_view session,
334 std::string_view timezone);
335
336bool in_session(const SessionCalendar& calendar, int64_t ms);
337
338// ---------------------------------------------------------------------------
339// Civil conversion
340// ---------------------------------------------------------------------------
341// A proleptic-Gregorian civil date: month 1-12, day 1-31.
343 int64_t year = 1970;
344 int month = 1;
345 int day = 1;
346};
347
348// The civil date `days` days after 1970-01-01 (before it when negative):
349// Howard Hinnant's civil_from_days, integer arithmetic exact for every day
350// count whose year fits int64_t. The one copy of this arithmetic: the
351// calendar, the time-of-day helpers, the report's month keys and a host's
352// own day keys all read it.
353constexpr NativeCivilDate native_civil_date(int64_t days) noexcept {
354 const int64_t z = days + 719468;
355 const int64_t era = (z >= 0 ? z : z - 146096) / 146097;
356 const int64_t doe = z - era * 146097; // [0, 146096]
357 const int64_t yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399]
358 const int64_t doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // from March 1
359 const int64_t mp = (5 * doy + 2) / 153; // 0 = March
360 const int64_t month = mp < 10 ? mp + 3 : mp - 9;
361 return NativeCivilDate{yoe + era * 400 + (month <= 2 ? 1 : 0),
362 static_cast<int>(month),
363 static_cast<int>(doy - (153 * mp + 2) / 5 + 1)};
364}
365
366// Its inverse: the days from 1970-01-01 to (year, month 1-12, day)
367// (Hinnant's days_from_civil). Out-of-range months and days are not refused;
368// the arithmetic continues through them.
369constexpr int64_t native_civil_days(int64_t year, int month, int day) noexcept {
370 year -= month <= 2;
371 const int64_t era = (year >= 0 ? year : year - 399) / 400;
372 const int64_t yoe = year - era * 400; // [0, 399]
373 const int64_t doy = (153 * (month + (month > 2 ? -3 : 9)) + 2) / 5 + day - 1; // [0, 365]
374 const int64_t doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
375 return era * 146097 + doe - 719468;
376}
377
378enum class CivilKind { Unique, Gap, Fold };
379
384
385// Resolve (timezone, civil) to a Unix-ms epoch. Invalid calendar dates and
386// out-of-range fields return nullopt. Fold -> earlier UTC; gap -> first
387// representable instant >= declared. Independent of prior queries.
388std::optional<CivilResolution> resolve_civil(std::string_view timezone,
389 int year,
390 int month,
391 int day,
392 int hour,
393 int minute,
394 int second = 0);
395
396// ---------------------------------------------------------------------------
397// Period keys (stable, independent of the first supplied sample)
398// ---------------------------------------------------------------------------
399//
400// Session-day ordinal: Howard Hinnant days_from_civil of the session-day's
401// trading date. Day 0 is 1970-01-01. Dates before that are negative; nD uses
402// floor division toward -inf: key = floor(ordinal / n) * n.
403//
404// Session-week origin: Monday 1969-12-29, the Monday of the week containing
405// 1970-01-01. Week index 0 is that week. nW key = floor(week_index / n) * n.
406// An overnight session whose trading date is Monday opens at the previous
407// civil day's first-window start (Sunday 18:00 for 1800-1700) — derived from
408// the windows, not from a forex special case.
409//
410// Session-month ordinal: year * 12 + (month - 1) of the trading date.
411// 3M groups Jan-Mar, Apr-Jun, Jul-Sep, Oct-Dec (January remainder 0).
412// Dates before year 0 continue with floor division toward -inf.
413//
414// Incomplete leading input does not move these anchors.
415
416std::optional<int64_t> session_day_ordinal(const SessionCalendar& calendar, int64_t ms);
417std::optional<int64_t> session_week_ordinal(const SessionCalendar& calendar, int64_t ms);
418std::optional<int64_t> session_month_ordinal(const SessionCalendar& calendar, int64_t ms);
419std::optional<int64_t> period_key(const SessionCalendar& calendar, const Timeframe& tf, int64_t ms);
420
421// ---------------------------------------------------------------------------
422// Session day as a value
423// ---------------------------------------------------------------------------
424//
425// The session day an instant falls in, resolved once: the cycle
426// [origin_ms, next_origin_ms) that holds it, the session-day ordinal every
427// instant of that cycle keys to, and the cycle's in-session spans in epoch ms
428// ([first, second), ascending and disjoint; none on a masked or empty day).
429// Where the session cycles tile the timeline, for every instant t the day
430// holds(), in_session(calendar, t) is in_session_at(t) and
431// session_day_ordinal(calendar, t) is `ordinal`, so a caller asking about many
432// instants of one day resolves the day once rather than once per instant.
433// A clock change that moves the local date breaks the tiling: America/Sitka
434// and America/Juneau in October 1867 under "2330-2300" hold instants (30 and
435// 45, all out of session) whose own session_day_at(t) carries another
436// ordinal, so a caller that needs exact ordinals there re-resolves each
437// instant. For the queried instant itself both answers hold even when
438// holds() is false (a clock-change fold the cycles do not tile).
439
441 int64_t origin_ms = 0;
442 int64_t next_origin_ms = 0;
443 int64_t ordinal = 0;
444 std::vector<std::pair<int64_t, int64_t>> spans;
445
446 bool holds(int64_t ms) const noexcept { return origin_ms <= ms && ms < next_origin_ms; }
447 bool in_session_at(int64_t ms) const noexcept {
448 for (const auto& span : spans) {
449 if (span.first <= ms && ms < span.second) return true;
450 }
451 return false;
452 }
453};
454
455std::optional<NativeSessionDay> session_day_at(const SessionCalendar& calendar, int64_t ms);
456
457// ---------------------------------------------------------------------------
458// Interval
459// ---------------------------------------------------------------------------
460//
461// open_ms nominal origin of this interval (session origin or
462// fixed-grid anchor from the declared first-window
463// origin). A split-session reopen can fall after this
464// anchor (hourly grid 12:30 vs 13:00 lunch reopen).
465// eligible_open_ms first in-session instant of this interval; equal to
466// open_ms when the nominal anchor is itself eligible.
467// last_traded_close_ms exclusive end of trading in this interval
468// next_period_open_ms nominal open of the next interval of this (unit,
469// count), including a closed grid slot
470// next_input_open_ms next actual eligible input opening at or after
471// last_traded_close_ms; skips declared closed time
472// (13:00, not a closed 12:30). Not a resolution-
473// stepped cursor; it jumps to the next normalized
474// span. Off-session observed ticks are a later host
475// concern and are not forced through this field.
476//
477// RTH Friday 16:00 last-traded close is not Monday 09:30. Those are separate
478// fields. Fixed buckets add elapsed UTC seconds/minutes from the session-day
479// origin and clip to the union of windows. Calendar periods use civil
480// session-day / week / month boundaries and every session window.
481//
482// A timestamp in a declared closed gap still belongs to the period whose
483// [open, next period open) covers it; last_traded_close may then precede it.
484
486 int64_t open_ms = 0;
487 int64_t eligible_open_ms = 0;
491};
492
493// Uses tf as both the period unit and the input unit.
494std::optional<NativeInterval> interval_containing(const SessionCalendar& calendar,
495 const Timeframe& tf,
496 int64_t ms);
497
498std::optional<NativeInterval> interval_containing(const SessionCalendar& calendar,
499 const Timeframe& script_tf,
500 const Timeframe& input_tf,
501 int64_t ms);
502
503} // inline namespace native_calendar_v2
504} // namespace pineforge::native_calendar
SessionCalendar & operator=(const SessionCalendar &)=default
SessionCalendar & operator=(SessionCalendar &&other) noexcept
friend std::optional< SessionCalendar > parse_session(std::string_view, std::string_view)
const std::vector< SessionWindow > & windows() const noexcept
friend std::optional< SessionCalendar > parse_session(std::string_view, std::string_view)
Timeframe & operator=(const Timeframe &)=default
friend bool operator==(const Timeframe &, const Timeframe &) noexcept
friend std::optional< Timeframe > parse_timeframe(std::string_view)
Timeframe & operator=(Timeframe &&other) noexcept
bool operator==(const Timeframe &a, const Timeframe &b) noexcept
std::optional< TimezoneIdentityDescriptor > timezone_identity_descriptor(std::string_view timezone)
std::optional< Timeframe > parse_timeframe(std::string_view text)
constexpr int64_t native_civil_days(int64_t year, int month, int day) noexcept
bool pairing_aggregates(const TimeframeCompatibility &pairing) noexcept
std::optional< int64_t > session_week_ordinal(const SessionCalendar &calendar, int64_t ms)
std::optional< CivilResolution > resolve_civil(std::string_view timezone, int year, int month, int day, int hour, int minute, int second=0)
bool in_session(const SessionCalendar &calendar, int64_t ms)
std::optional< int64_t > session_day_ordinal(const SessionCalendar &calendar, int64_t ms)
constexpr NativeCivilDate native_civil_date(int64_t days) noexcept
bool timezone_accepted(std::string_view timezone)
std::optional< int64_t > session_month_ordinal(const SessionCalendar &calendar, int64_t ms)
TimeframeCompatibility stream_compatibility(const Timeframe &input, const Timeframe &script)
std::optional< NativeSessionDay > session_day_at(const SessionCalendar &calendar, int64_t ms)
std::optional< int64_t > period_key(const SessionCalendar &calendar, const Timeframe &tf, int64_t ms)
std::optional< NativeInterval > interval_containing(const SessionCalendar &calendar, const Timeframe &tf, int64_t ms)
std::optional< SessionCalendar > parse_session(std::string_view session, std::string_view timezone)
TimeframeCompatibility compatibility(const Timeframe &input, const Timeframe &script)