#include "raid/data/calendar.hpp" #include "raid/core/error.hpp" #include namespace raid { namespace { // nth (1-based) weekday (0=Sun..6=Sat) of a month; n = -1 → last. Date nth_weekday(int y, unsigned m, int wd, int n) { if (n > 0) { Date first = Date::from_ymd(y, m, 1); int delta = (wd - first.weekday() + 7) % 7; return first + (delta + 7 * (n - 1)); } // last const unsigned nm = m == 12 ? 1 : m + 1; const int ny = m == 12 ? y + 1 : y; Date last = Date::from_ymd(ny, nm, 1) - 1; int delta = (last.weekday() - wd + 7) % 7; return last - delta; } // Western Easter (Anonymous Gregorian algorithm) Date easter(int y) { const int a = y % 19, b = y / 100, c = y % 100, d = b / 4, e = b % 4, f = (b + 8) / 25, g = (b - f + 1) / 3; const int h = (19 * a + b - d - g + 15) % 30, i = c / 4, k = c % 4, l = (32 + 2 * e + 2 * i - h - k) % 7; const int m = (a + 11 * h + 22 * l) / 451; const int month = (h + l - 7 * m + 114) / 31, day = ((h + l - 7 * m + 114) % 31) + 1; return Date::from_ymd(y, static_cast(month), static_cast(day)); } // Observed date of a fixed holiday: Saturday → Friday, Sunday → Monday. NYSE rule: if Saturday, the preceding Friday is a // holiday unless that Friday is the last trading day of the month/year (then no day off) — for New Year's Day on a // Saturday the exchange does NOT close on Friday Dec 31. std::optional observed(int y, unsigned m, unsigned d, bool is_new_year) { Date dt = Date::from_ymd(y, m, d); const int wd = dt.weekday(); if (wd == 6) { if (is_new_year) return std::nullopt; return dt - 1; } if (wd == 0) return dt + 1; return dt; } struct Holiday { Date date; const char* name; }; std::vector holidays_for_year(int y) { std::vector h; if (auto d = observed(y, 1, 1, true)) h.push_back({*d, "New Year's Day"}); h.push_back({nth_weekday(y, 1, 1, 3), "Martin Luther King Jr. Day"}); // since 1998 h.push_back({nth_weekday(y, 2, 1, 3), "Presidents' Day"}); h.push_back({easter(y) - 2, "Good Friday"}); h.push_back({nth_weekday(y, 5, 1, -1), "Memorial Day"}); if (y >= 2022) if (auto d = observed(y, 6, 19, false)) h.push_back({*d, "Juneteenth"}); if (auto d = observed(y, 7, 4, false)) h.push_back({*d, "Independence Day"}); h.push_back({nth_weekday(y, 9, 1, 1), "Labor Day"}); h.push_back({nth_weekday(y, 11, 4, 4), "Thanksgiving Day"}); if (auto d = observed(y, 12, 25, false)) h.push_back({*d, "Christmas Day"}); // Special closures (NYSE), 2000–2026 static const struct { int y; unsigned m, d; const char* n; } specials[] = { {2001, 9, 11, "September 11 attacks"}, {2001, 9, 12, "September 11 attacks"}, {2001, 9, 13, "September 11 attacks"}, {2001, 9, 14, "September 11 attacks"}, {2004, 6, 11, "Reagan funeral"}, {2007, 1, 2, "Ford funeral"}, {2012, 10, 29, "Hurricane Sandy"}, {2012, 10, 30, "Hurricane Sandy"}, {2018, 12, 5, "G.H.W. Bush funeral"}, {2025, 1, 9, "Carter funeral"}, }; for (const auto& s : specials) if (s.y == y) h.push_back({Date::from_ymd(s.y, s.m, s.d), s.n}); return h; } } // namespace std::optional MarketCalendar::holiday_name(Date d) const { const int wd = d.weekday(); if (wd == 0) return "Sunday"; if (wd == 6) return "Saturday"; for (const auto& h : holidays_for_year(d.ymd().year)) if (h.date == d) return h.name; return std::nullopt; } bool MarketCalendar::is_trading_day(Date d) const { return !holiday_name(d).has_value(); } bool MarketCalendar::is_early_close(Date d) const { if (!is_trading_day(d)) return false; const auto [y, m, dd] = d.ymd(); // Historical one-offs (NYSE), verified against exchange_calendars 4.13 XNYS 2000–2026: if (d == Date::from_ymd(2002, 7, 5) || d == Date::from_ymd(2003, 12, 26)) return true; // Friday after a Thursday holiday if (d == Date::from_ymd(2002, 7, 3)) return false; // full day in 2002 // Day after Thanksgiving if (d == nth_weekday(y, 11, 4, 4) + 1) return true; // Christmas Eve when it is a weekday (Mon–Fri) and a trading day; since 1993 rule, 13:00 close if (m == 12 && dd == 24 && d.weekday() >= 1 && d.weekday() <= 5) return true; // July 3 when July 4 is a weekday (Tue–Fri) — 13:00 close (rule in force since 1995 roughly; NYSE: when the 4th is a weekday) if (m == 7 && dd == 3 && d.weekday() >= 1 && d.weekday() <= 5) { const int wd4 = Date::from_ymd(y, 7, 4).weekday(); if (wd4 >= 2 && wd4 <= 5) return true; } return false; } i64 MarketCalendar::new_york_offset(Timestamp utc) { // Determine year from UTC date (safe: transitions are in March/November, far from year boundaries) const i64 days = utc.us >= 0 ? utc.us / 86'400'000'000LL : -((-utc.us + 86'400'000'000LL - 1) / 86'400'000'000LL); const int y = Date(static_cast(days)).ymd().year; Date start, end; if (y >= 2007) { start = nth_weekday(y, 3, 0, 2); end = nth_weekday(y, 11, 0, 1); } else { start = nth_weekday(y, 4, 0, 1); end = nth_weekday(y, 10, 0, -1); } // DST starts at 02:00 EST = 07:00 UTC; ends at 02:00 EDT = 06:00 UTC const Timestamp dst_start(static_cast(start.days) * 86'400'000'000LL + 7LL * 3'600'000'000LL); const Timestamp dst_end(static_cast(end.days) * 86'400'000'000LL + 6LL * 3'600'000'000LL); return (utc >= dst_start && utc < dst_end) ? -14400 : -18000; } MarketCalendar::Local MarketCalendar::to_new_york(Timestamp utc) { const i64 off = new_york_offset(utc); const i64 local_us = utc.us + off * 1'000'000LL; i64 days = local_us / 86'400'000'000LL, rem = local_us % 86'400'000'000LL; if (rem < 0) { rem += 86'400'000'000LL; days -= 1; } return {Date(static_cast(days)), static_cast(rem / 60'000'000LL), off}; } Timestamp MarketCalendar::from_new_york(Date d, int minute_of_day) { // First guess with the standard offset, then correct with the offset valid at that instant. const i64 naive = static_cast(d.days) * 86'400'000'000LL + static_cast(minute_of_day) * 60'000'000LL; Timestamp guess(naive + 18000LL * 1'000'000LL); const i64 off = new_york_offset(guess); Timestamp t(naive - off * 1'000'000LL); // re-check (handles instants near the transition) const i64 off2 = new_york_offset(t); if (off2 != off) t = Timestamp(naive - off2 * 1'000'000LL); return t; } SessionBounds MarketCalendar::session(Date d) const { RAID_REQUIRE(is_trading_day(d), invalid_argument, d.iso() + " is not a trading day"); SessionBounds s; s.session = d; s.early_close = is_early_close(d); s.open_utc = from_new_york(d, 9 * 60 + 30); s.close_utc = from_new_york(d, s.early_close ? 13 * 60 : 16 * 60); s.ext_open_utc = from_new_york(d, 4 * 60); s.ext_close_utc = from_new_york(d, s.early_close ? 17 * 60 : 20 * 60); s.utc_offset_seconds = new_york_offset(s.open_utc); return s; } usize MarketCalendar::expected_regular_bars(Date d, Interval iv) const { const auto s = session(d); const i64 minutes = (s.close_utc - s.open_utc).us / 60'000'000LL; switch (iv) { case Interval::m1: return static_cast(minutes) + 1; // 391 / 211, includes the closing print bar case Interval::m5: return static_cast(minutes / 5) + 1; // 79 / 43 case Interval::m30: return static_cast(minutes / 30) + 1; case Interval::h1: return static_cast(minutes / 60) + 1; case Interval::d1: return 1; } return 0; } std::vector MarketCalendar::sessions(Date start, Date end) const { std::vector out; for (Date d = start; d <= end; d = d + 1) if (is_trading_day(d)) out.push_back(d); return out; } Date MarketCalendar::next_session(Date d) const { Date x = d + 1; while (!is_trading_day(x)) x = x + 1; return x; } Date MarketCalendar::previous_session(Date d) const { Date x = d - 1; while (!is_trading_day(x)) x = x - 1; return x; } usize trading_days_between(const MarketCalendar& cal, Date a, Date b) { if (b < a) std::swap(a, b); usize n = 0; for (Date d = a + 1; d <= b; d = d + 1) if (cal.is_trading_day(d)) ++n; return n; } } // namespace raid