PineForge v0.13.1-379-g9b50973
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
11inline namespace native_calendar_v2 {
12
13// Isolated calendar / interval values for a later native host. This component
14// does not match orders, emit strategy callbacks, aggregate OHLCV, or run a
15// NativeRunSpec. Legacy helpers in session_time.cpp / timeframe.cpp stay
16// unchanged and are not native authority.
17
18// ---------------------------------------------------------------------------
19// Timeframe
20// ---------------------------------------------------------------------------
21//
22// Grammar: optional count (>= 1, no leading zeros) then a unit suffix.
23// nS seconds "15S"
24// n minutes "1", "5", "60", "240"
25// D / nD "D", "1D", "2D"
26// W / nW "W", "1W", "2W"
27// M / nM "M", "1M", "3M"
28// Literal is identity: "1D" is not rewritten to "D".
29//
30// Rejected before any conversion: empty, whitespace, leading zeros ("01",
31// "01D"), bare "S", unknown suffix including "1H"/"4H" (legacy stoi("1H")==1
32// minute is not copied), overflowed digit strings, count whose unit duration
33// is not representable as int64 milliseconds.
34
36
37// Parsed timeframe. Default, moved-from, and any object that is not the
38// result of a successful parse is invalid. Fields are immutable; there is
39// no public constructor that can inject a zero count or a mismatched
40// literal. Copy preserves validity; move leaves the source invalid.
41class Timeframe {
42public:
43 Timeframe() = default;
44 Timeframe(const Timeframe&) = default;
45 Timeframe& operator=(const Timeframe&) = default;
46 Timeframe(Timeframe&& other) noexcept { *this = std::move(other); }
47 Timeframe& operator=(Timeframe&& other) noexcept {
48 if (this == &other) return *this;
49 unit_ = other.unit_;
50 count_ = other.count_;
51 literal_ = std::move(other.literal_);
52 valid_ = other.valid_;
53 other.valid_ = false;
54 other.count_ = 0;
55 other.literal_.clear();
56 return *this;
57 }
58
59 bool valid() const noexcept { return valid_ && count_ >= 1 && unit_ok(unit_); }
60 TimeframeUnit unit() const noexcept { return unit_; }
61 int count() const noexcept { return count_; }
62 const std::string& literal() const noexcept { return literal_; }
63 bool is_fixed() const noexcept {
64 return unit_ == TimeframeUnit::Second || unit_ == TimeframeUnit::Minute;
65 }
66 bool is_calendar() const noexcept { return valid() && !is_fixed(); }
67
68private:
69 friend std::optional<Timeframe> parse_timeframe(std::string_view);
70 friend bool operator==(const Timeframe&, const Timeframe&) noexcept;
71 static bool unit_ok(TimeframeUnit u) noexcept {
72 switch (u) {
78 return true;
79 }
80 return false;
81 }
82 Timeframe(TimeframeUnit unit, int count, std::string literal)
83 : unit_(unit), count_(count), literal_(std::move(literal)), valid_(true) {}
85 int count_ = 0;
86 std::string literal_;
87 bool valid_ = false;
88};
89
90inline bool operator==(const Timeframe& a, const Timeframe& b) noexcept {
91 return a.valid() == b.valid() && a.unit_ == b.unit_ && a.count_ == b.count_
92 && a.literal_ == b.literal_;
93}
94
95std::optional<Timeframe> parse_timeframe(std::string_view text);
96
97// Pairing is a classification only. Equal-TF passthrough versus grouping is
98// the host's choice; this component does not drop 2D/2W/M/3M and does not
99// collapse count the way calendar_period_for(last letter) does.
101 Passthrough, // byte-identical literals
102 SameUnitMultiple, // same unit, script.count % input.count == 0, script >= input
103 FixedDivisible, // both fixed, script ms % input ms == 0 (e.g. 15S -> 1)
104 FixedToCalendar, // second/minute -> D/W/M
105 CalendarToCalendar, // D/nD -> W/M, W/nW -> M
106 ScriptFiner, // script strictly finer than input
107 IndivisibleFixed, // fixed remainder != 0 (5 -> 7) or same-unit non-multiple
109 Invalid, // one or both timeframes are not valid parsed values
110};
111
114 int64_t group_factor = 0; // script/input count or ms ratio; 0 if not a ratio pairing
115};
116
118
119// Stream input M/nM is the existing refusal (engine_stream requires a fixed
120// positive duration). Batch monthly remains accepted via compatibility().
122
123// ---------------------------------------------------------------------------
124// Session / calendar definition
125// ---------------------------------------------------------------------------
126//
127// Currently valid session syntax, parsed once into an immutable value:
128// empty or "24x7" all-day every day, origin 00:00
129// HHMM-HHMM one window; 2400 is a legal end
130// HHMM-HHMM,HHMM-HHMM comma-separated union
131// start == end 24h wrap from that clock (1700-1700)
132// end < start overnight wrap (1800-1700, 2200-0200)
133// :1234567 day mask, 1=Sun .. 7=Sat, after the windows
134//
135// Overnight / day-mask interpretation (generic; no stock/forex recognizer):
136// A wrapped or 24h-from-nonzero window is one session-day. The day mask
137// applies to that session-day's trading date — the local civil date of
138// last_traded_close_ms - 1ms — not to each timestamp's weekday. Sunday
139// 18:00 of an 1800-1700 session with mask :23456 (Mon-Fri) is therefore
140// Monday's session, not a Sunday reject.
141//
142// Normalized schedule: the first declared start O fixes civil cycles
143// [O(date), O(date+1)). Recurring instances of every window (including wrap
144// and equal-start full-day) are built on that date and its neighbors,
145// intersected with the cycle, clipped at both cycle ends, then unioned.
146// A start earlier than O can contribute coverage on BOTH sides of the cycle
147// boundary (overlapping 1300-1600,0900-1500); merely shifting every earlier
148// start one day is not sufficient. That split is schedule normalization,
149// not OHLC splitting. Origin stays the first declared start; permuting later
150// windows cannot change the union, while changing the first window can
151// change origin and trading-date assignment.
152//
153// Trading date is the civil date of the final nominal UNMASKED recurring
154// coverage endpoint minus one instant, computed in civil minutes before
155// weekday masks and before UTC DST-gap collapse. A rejected mask or empty
156// resolved window never moves that date onto the next live cycle; there is
157// no cycle-end-next-day fallback. Inverse lookup matches this date only, so
158// an empty Sunday cannot alias Monday. Empty resolved/allowed coverage has
159// no membership or synthesis; D/W/M bounds still contain the query
160// (open <= query < next_period_open). A DST gap that maps both ends of one
161// instance to the same epoch omits that instance only.
162//
163// Native origin is that first-window start. Pine's 17:00 day stamp on an
164// 1800-1700 session is a compatibility label, not this component's default.
165//
166// Fold tie-break (civil -> epoch): a repeated local civil time maps to the
167// earlier UTC occurrence (first time through). Adjacent intervals that share
168// a civil boundary reuse that same epoch, so conversion cannot invent a gap
169// or overlap. A missing local time (DST gap) maps to the first representable
170// instant at or after the declared civil. Conversion does not copy tm_isdst
171// from a query instant and does not use a UTC-day offset cache as authority.
172
173class SessionCalendar;
174std::optional<SessionCalendar> parse_session(std::string_view session,
175 std::string_view timezone);
176
177// Single calendar-owned timezone acceptance boundary. Empty is accepted and
178// means UTC for this component (native full-spec nonempty scheduling TZ is
179// separate). Also used by public resolve_civil. Not for hot interval loops.
180//
181// Accepted:
182// IANA/TZif names and aliases installed under libc's effective zoneinfo
183// root (not a search of trees libc will not read):
184// charset [A-Za-z0-9/_+-], no absolute path, no `.` / `..` components;
185// realpath stays in that root and the file magic is TZif. Leading `:`
186// is a tzfile reference only.
187// macOS: /var/db/timezone/zoneinfo (TZDIR ignored; tzset(3) does not
188// use it). glibc: nonempty TZDIR is exclusive (relative allowed;
189// missing/non-directory fails closed). Unset/empty TZDIR uses
190// /usr/share/zoneinfo.
191// UTC, GMT, Etc/UTC, Etc/GMT
192// UTC/GMT conventional offsets (TV sign):
193// (UTC|GMT)[+-](H|HH|HMM|HHMM|H:MM|HH:MM) hours 0–23, minutes 0–59;
194// digit length is bounded before conversion so overflow cannot escape.
195// POSIX TZ, including DST rule forms:
196// std offset [dst[offset][,start[/time],end[/time]]]
197// A slash before any comma is a tzfile path; slash after a comma is
198// POSIX rule time (M3.2.0/2), not an IANA name.
199// Rejected before libc setenv/mktime: unknown names, UTC+24:00 / UTC+01:99,
200// overflowed digit strings, whitespace/control/NUL, absolute paths, `..`
201// traversal, and non-TZif zoneinfo files (zone.tab, iso3166.tab, +VERSION).
202bool timezone_accepted(std::string_view timezone);
203
204// Observational identity for the native runner (setup/identity time only).
205// Shares the acceptance/normalization/effective-root resolver. Does not hash.
206// Semantics version 1. nullopt = backing facts could not be established;
207// the runner must refuse. Does not relax timezone_accepted.
208enum class TimezoneSourceKind : std::uint32_t {
209 Utc = 1,
214};
215
217 static constexpr std::uint32_t kSemanticsVersion = 1;
218 std::uint32_t semantics_version = 0;
220 std::string input;
222 std::string zoneinfo_root;
223 std::vector<std::string> resource_paths;
224 bool valid() const noexcept;
225};
226
228timezone_identity_descriptor(std::string_view timezone);
229
231public:
232 SessionWindow() = default;
233 bool valid() const noexcept {
234 if (start_minutes_ < 0 || start_minutes_ >= 1440) return false;
235 if (end_minutes_ < 0 || end_minutes_ > 1440) return false;
236 const bool expect_full = (end_minutes_ != 1440 && start_minutes_ == end_minutes_);
237 const bool expect_wrap = (!expect_full && end_minutes_ < start_minutes_);
238 return full_day_ == expect_full && wraps_ == expect_wrap;
239 }
240 int start_minutes() const noexcept { return start_minutes_; }
241 int end_minutes() const noexcept { return end_minutes_; }
242 bool wraps() const noexcept { return wraps_; }
243 bool full_day() const noexcept { return full_day_; }
244
245private:
246 friend std::optional<SessionCalendar> parse_session(std::string_view, std::string_view);
247 SessionWindow(int start, int end, bool wraps, bool full_day)
248 : start_minutes_(start), end_minutes_(end), wraps_(wraps), full_day_(full_day) {}
249 int start_minutes_ = 0; // [0, 1440)
250 int end_minutes_ = 0; // (0, 1440]; 1440 = 24:00
251 bool wraps_ = false;
252 bool full_day_ = false;
253};
254
255// Parsed session. Default and moved-from objects are invalid. Windows, mask,
256// origin and flags are immutable after a successful parse. 2400 is stored as
257// end_minutes 1440; cyclic coverage is computed internally and is not a
258// public field that can be set to 25:00.
260public:
261 SessionCalendar() = default;
264 SessionCalendar(SessionCalendar&& other) noexcept { *this = std::move(other); }
266 if (this == &other) return *this;
267 timezone_ = std::move(other.timezone_);
268 literal_ = std::move(other.literal_);
269 windows_ = std::move(other.windows_);
270 day_mask_ = other.day_mask_;
271 origin_minutes_ = other.origin_minutes_;
272 all_day_ = other.all_day_;
273 valid_ = other.valid_;
274 other.valid_ = false;
275 other.day_mask_ = 0;
276 other.origin_minutes_ = 0;
277 other.all_day_ = false;
278 other.timezone_.clear();
279 other.literal_.clear();
280 other.windows_.clear();
281 return *this;
282 }
283
284 bool valid() const noexcept;
285 const std::string& timezone() const noexcept { return timezone_; }
286 const std::string& literal() const noexcept { return literal_; }
287 const std::vector<SessionWindow>& windows() const noexcept { return windows_; }
288 std::uint8_t day_mask() const noexcept { return day_mask_; }
289 int origin_minutes() const noexcept { return origin_minutes_; }
290 bool all_day() const noexcept { return all_day_; }
291
292private:
293 friend std::optional<SessionCalendar> parse_session(std::string_view, std::string_view);
294 SessionCalendar(std::string timezone,
295 std::string literal,
296 std::vector<SessionWindow> windows,
297 std::uint8_t day_mask,
298 int origin_minutes,
299 bool all_day)
300 : timezone_(std::move(timezone)),
301 literal_(std::move(literal)),
302 windows_(std::move(windows)),
303 day_mask_(day_mask),
304 origin_minutes_(origin_minutes),
305 all_day_(all_day),
306 valid_(true) {}
307 std::string timezone_;
308 std::string literal_;
309 std::vector<SessionWindow> windows_;
310 std::uint8_t day_mask_ = 0;
311 int origin_minutes_ = 0;
312 bool all_day_ = false;
313 bool valid_ = false;
314};
315
316std::optional<SessionCalendar> parse_session(std::string_view session,
317 std::string_view timezone);
318
319bool in_session(const SessionCalendar& calendar, int64_t ms);
320
321// ---------------------------------------------------------------------------
322// Civil conversion
323// ---------------------------------------------------------------------------
324
325enum class CivilKind { Unique, Gap, Fold };
326
331
332// Resolve (timezone, civil) to a Unix-ms epoch. Invalid calendar dates and
333// out-of-range fields return nullopt. Fold -> earlier UTC; gap -> first
334// representable instant >= declared. Independent of prior queries.
335std::optional<CivilResolution> resolve_civil(std::string_view timezone,
336 int year,
337 int month,
338 int day,
339 int hour,
340 int minute,
341 int second = 0);
342
343// ---------------------------------------------------------------------------
344// Period keys (stable, independent of the first supplied sample)
345// ---------------------------------------------------------------------------
346//
347// Session-day ordinal: Howard Hinnant days_from_civil of the session-day's
348// trading date. Day 0 is 1970-01-01. Dates before that are negative; nD uses
349// floor division toward -inf: key = floor(ordinal / n) * n.
350//
351// Session-week origin: Monday 1969-12-29, the Monday of the week containing
352// 1970-01-01. Week index 0 is that week. nW key = floor(week_index / n) * n.
353// An overnight session whose trading date is Monday opens at the previous
354// civil day's first-window start (Sunday 18:00 for 1800-1700) — derived from
355// the windows, not from a forex special case.
356//
357// Session-month ordinal: year * 12 + (month - 1) of the trading date.
358// 3M groups Jan-Mar, Apr-Jun, Jul-Sep, Oct-Dec (January remainder 0).
359// Dates before year 0 continue with floor division toward -inf.
360//
361// Incomplete leading input does not move these anchors.
362
363std::optional<int64_t> session_day_ordinal(const SessionCalendar& calendar, int64_t ms);
364std::optional<int64_t> session_week_ordinal(const SessionCalendar& calendar, int64_t ms);
365std::optional<int64_t> session_month_ordinal(const SessionCalendar& calendar, int64_t ms);
366std::optional<int64_t> period_key(const SessionCalendar& calendar, const Timeframe& tf, int64_t ms);
367
368// ---------------------------------------------------------------------------
369// Interval
370// ---------------------------------------------------------------------------
371//
372// open_ms nominal origin of this interval (session origin or
373// fixed-grid anchor from the declared first-window
374// origin). A split-session reopen can fall after this
375// anchor (hourly grid 12:30 vs 13:00 lunch reopen).
376// eligible_open_ms first in-session instant of this interval; equal to
377// open_ms when the nominal anchor is itself eligible.
378// last_traded_close_ms exclusive end of trading in this interval
379// next_period_open_ms nominal open of the next interval of this (unit,
380// count), including a closed grid slot
381// next_input_open_ms next actual eligible input opening at or after
382// last_traded_close_ms; skips declared closed time
383// (13:00, not a closed 12:30). Not a resolution-
384// stepped cursor; it jumps to the next normalized
385// span. Off-session observed ticks are a later host
386// concern and are not forced through this field.
387//
388// RTH Friday 16:00 last-traded close is not Monday 09:30. Those are separate
389// fields. Fixed buckets add elapsed UTC seconds/minutes from the session-day
390// origin and clip to the union of windows. Calendar periods use civil
391// session-day / week / month boundaries and every session window.
392//
393// A timestamp in a declared closed gap still belongs to the period whose
394// [open, next period open) covers it; last_traded_close may then precede it.
395
397 int64_t open_ms = 0;
398 int64_t eligible_open_ms = 0;
402};
403
404// Uses tf as both the period unit and the input unit.
405std::optional<NativeInterval> interval_containing(const SessionCalendar& calendar,
406 const Timeframe& tf,
407 int64_t ms);
408
409std::optional<NativeInterval> interval_containing(const SessionCalendar& calendar,
410 const Timeframe& script_tf,
411 const Timeframe& input_tf,
412 int64_t ms);
413
414} // inline namespace native_calendar_v2
415} // 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)
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)
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< 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)