1#include "raid/data/calendar.hpp"2#include "raid/core/error.hpp"3#include <algorithm>45namespace raid {67namespace {8// nth (1-based) weekday (0=Sun..6=Sat) of a month; n = -1 → last.9Date nth_weekday(int y, unsigned m, int wd, int n) {10 if (n > 0) {11 Date first = Date::from_ymd(y, m, 1);12 int delta = (wd - first.weekday() + 7) % 7;13 return first + (delta + 7 * (n - 1));14 }15 // last16 const unsigned nm = m == 12 ? 1 : m + 1; const int ny = m == 12 ? y + 1 : y;17 Date last = Date::from_ymd(ny, nm, 1) - 1;18 int delta = (last.weekday() - wd + 7) % 7;19 return last - delta;20}21// Western Easter (Anonymous Gregorian algorithm)22Date easter(int y) {23 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;24 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;25 const int m = (a + 11 * h + 22 * l) / 451;26 const int month = (h + l - 7 * m + 114) / 31, day = ((h + l - 7 * m + 114) % 31) + 1;27 return Date::from_ymd(y, static_cast<unsigned>(month), static_cast<unsigned>(day));28}29// Observed date of a fixed holiday: Saturday → Friday, Sunday → Monday. NYSE rule: if Saturday, the preceding Friday is a30// holiday unless that Friday is the last trading day of the month/year (then no day off) — for New Year's Day on a31// Saturday the exchange does NOT close on Friday Dec 31.32std::optional<Date> observed(int y, unsigned m, unsigned d, bool is_new_year) {33 Date dt = Date::from_ymd(y, m, d);34 const int wd = dt.weekday();35 if (wd == 6) { if (is_new_year) return std::nullopt; return dt - 1; }36 if (wd == 0) return dt + 1;37 return dt;38}39struct Holiday { Date date; const char* name; };40std::vector<Holiday> holidays_for_year(int y) {41 std::vector<Holiday> h;42 if (auto d = observed(y, 1, 1, true)) h.push_back({*d, "New Year's Day"});43 h.push_back({nth_weekday(y, 1, 1, 3), "Martin Luther King Jr. Day"}); // since 199844 h.push_back({nth_weekday(y, 2, 1, 3), "Presidents' Day"});45 h.push_back({easter(y) - 2, "Good Friday"});46 h.push_back({nth_weekday(y, 5, 1, -1), "Memorial Day"});47 if (y >= 2022) if (auto d = observed(y, 6, 19, false)) h.push_back({*d, "Juneteenth"});48 if (auto d = observed(y, 7, 4, false)) h.push_back({*d, "Independence Day"});49 h.push_back({nth_weekday(y, 9, 1, 1), "Labor Day"});50 h.push_back({nth_weekday(y, 11, 4, 4), "Thanksgiving Day"});51 if (auto d = observed(y, 12, 25, false)) h.push_back({*d, "Christmas Day"});52 // Special closures (NYSE), 2000–202653 static const struct { int y; unsigned m, d; const char* n; } specials[] = {54 {2001, 9, 11, "September 11 attacks"}, {2001, 9, 12, "September 11 attacks"}, {2001, 9, 13, "September 11 attacks"}, {2001, 9, 14, "September 11 attacks"},55 {2004, 6, 11, "Reagan funeral"}, {2007, 1, 2, "Ford funeral"}, {2012, 10, 29, "Hurricane Sandy"}, {2012, 10, 30, "Hurricane Sandy"},56 {2018, 12, 5, "G.H.W. Bush funeral"}, {2025, 1, 9, "Carter funeral"},57 };58 for (const auto& s : specials) if (s.y == y) h.push_back({Date::from_ymd(s.y, s.m, s.d), s.n});59 return h;60}61} // namespace6263std::optional<std::string> MarketCalendar::holiday_name(Date d) const {64 const int wd = d.weekday();65 if (wd == 0) return "Sunday";66 if (wd == 6) return "Saturday";67 for (const auto& h : holidays_for_year(d.ymd().year)) if (h.date == d) return h.name;68 return std::nullopt;69}7071bool MarketCalendar::is_trading_day(Date d) const { return !holiday_name(d).has_value(); }7273bool MarketCalendar::is_early_close(Date d) const {74 if (!is_trading_day(d)) return false;75 const auto [y, m, dd] = d.ymd();76 // Historical one-offs (NYSE), verified against exchange_calendars 4.13 XNYS 2000–2026:77 if (d == Date::from_ymd(2002, 7, 5) || d == Date::from_ymd(2003, 12, 26)) return true; // Friday after a Thursday holiday78 if (d == Date::from_ymd(2002, 7, 3)) return false; // full day in 200279 // Day after Thanksgiving80 if (d == nth_weekday(y, 11, 4, 4) + 1) return true;81 // Christmas Eve when it is a weekday (Mon–Fri) and a trading day; since 1993 rule, 13:00 close82 if (m == 12 && dd == 24 && d.weekday() >= 1 && d.weekday() <= 5) return true;83 // 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)84 if (m == 7 && dd == 3 && d.weekday() >= 1 && d.weekday() <= 5) {85 const int wd4 = Date::from_ymd(y, 7, 4).weekday();86 if (wd4 >= 2 && wd4 <= 5) return true;87 }88 return false;89}9091i64 MarketCalendar::new_york_offset(Timestamp utc) {92 // Determine year from UTC date (safe: transitions are in March/November, far from year boundaries)93 const i64 days = utc.us >= 0 ? utc.us / 86'400'000'000LL : -((-utc.us + 86'400'000'000LL - 1) / 86'400'000'000LL);94 const int y = Date(static_cast<i32>(days)).ymd().year;95 Date start, end;96 if (y >= 2007) { start = nth_weekday(y, 3, 0, 2); end = nth_weekday(y, 11, 0, 1); }97 else { start = nth_weekday(y, 4, 0, 1); end = nth_weekday(y, 10, 0, -1); }98 // DST starts at 02:00 EST = 07:00 UTC; ends at 02:00 EDT = 06:00 UTC99 const Timestamp dst_start(static_cast<i64>(start.days) * 86'400'000'000LL + 7LL * 3'600'000'000LL);100 const Timestamp dst_end(static_cast<i64>(end.days) * 86'400'000'000LL + 6LL * 3'600'000'000LL);101 return (utc >= dst_start && utc < dst_end) ? -14400 : -18000;102}103104MarketCalendar::Local MarketCalendar::to_new_york(Timestamp utc) {105 const i64 off = new_york_offset(utc);106 const i64 local_us = utc.us + off * 1'000'000LL;107 i64 days = local_us / 86'400'000'000LL, rem = local_us % 86'400'000'000LL;108 if (rem < 0) { rem += 86'400'000'000LL; days -= 1; }109 return {Date(static_cast<i32>(days)), static_cast<int>(rem / 60'000'000LL), off};110}111112Timestamp MarketCalendar::from_new_york(Date d, int minute_of_day) {113 // First guess with the standard offset, then correct with the offset valid at that instant.114 const i64 naive = static_cast<i64>(d.days) * 86'400'000'000LL + static_cast<i64>(minute_of_day) * 60'000'000LL;115 Timestamp guess(naive + 18000LL * 1'000'000LL);116 const i64 off = new_york_offset(guess);117 Timestamp t(naive - off * 1'000'000LL);118 // re-check (handles instants near the transition)119 const i64 off2 = new_york_offset(t);120 if (off2 != off) t = Timestamp(naive - off2 * 1'000'000LL);121 return t;122}123124SessionBounds MarketCalendar::session(Date d) const {125 RAID_REQUIRE(is_trading_day(d), invalid_argument, d.iso() + " is not a trading day");126 SessionBounds s; s.session = d; s.early_close = is_early_close(d);127 s.open_utc = from_new_york(d, 9 * 60 + 30);128 s.close_utc = from_new_york(d, s.early_close ? 13 * 60 : 16 * 60);129 s.ext_open_utc = from_new_york(d, 4 * 60);130 s.ext_close_utc = from_new_york(d, s.early_close ? 17 * 60 : 20 * 60);131 s.utc_offset_seconds = new_york_offset(s.open_utc);132 return s;133}134135usize MarketCalendar::expected_regular_bars(Date d, Interval iv) const {136 const auto s = session(d);137 const i64 minutes = (s.close_utc - s.open_utc).us / 60'000'000LL;138 switch (iv) {139 case Interval::m1: return static_cast<usize>(minutes) + 1; // 391 / 211, includes the closing print bar140 case Interval::m5: return static_cast<usize>(minutes / 5) + 1; // 79 / 43141 case Interval::m30: return static_cast<usize>(minutes / 30) + 1;142 case Interval::h1: return static_cast<usize>(minutes / 60) + 1;143 case Interval::d1: return 1;144 }145 return 0;146}147148std::vector<Date> MarketCalendar::sessions(Date start, Date end) const {149 std::vector<Date> out;150 for (Date d = start; d <= end; d = d + 1) if (is_trading_day(d)) out.push_back(d);151 return out;152}153Date MarketCalendar::next_session(Date d) const { Date x = d + 1; while (!is_trading_day(x)) x = x + 1; return x; }154Date MarketCalendar::previous_session(Date d) const { Date x = d - 1; while (!is_trading_day(x)) x = x - 1; return x; }155156usize trading_days_between(const MarketCalendar& cal, Date a, Date b) {157 if (b < a) std::swap(a, b);158 usize n = 0; for (Date d = a + 1; d <= b; d = d + 1) if (cal.is_trading_day(d)) ++n;159 return n;160}161162} // namespace raid163