from datetime import date, datetime import pandas as pd from pandas._libs.tslibs.conversion import localize_pydatetime from pandas.tseries.holiday import Day, Holiday from pandas.tseries.offsets import Easter from pyluach import dates, hebrewcal try: from pandas._libs.tslibs.offsets import apply_wraps except ImportError: from pandas.tseries.offsets import apply_wraps # Auxiliary functions to get Hebrew dates for holidays observed by TASE for a # given Hebrew calendar year. These are just the raw dates with no adjustments # applied. # # Note: pyluach uses the biblical month numbering scheme where the year is # incremented when moving from the month of Elul (6) to Tishrei (7). See also # https://en.wikipedia.org/wiki/Hebrew_calendar. def _purim(year): """ Return the Hebrew date for Purim in the given Hebrew year. """ # Purim is observed in Adar (12), or Adar II (13) if in a leap year. return dates.HebrewDate(year.year, 13 if year.leap else 12, 14) def _passover(year): """ Return the Hebrew date for the first day of Passover in the given Hebrew year. """ return dates.HebrewDate(year.year, 1, 15) def _memorial_day(year): """ Return the Hebrew date for Memorial Day in the given Hebrew year. If either Memorial Day (MD) or Independence Day (ID) were to interfere with Sabbat, including night before, then they are shifted forward or backward to avoid Sabbat. ID always celebrated day after MD. Wiki: https://en.wikipedia.org/wiki/Yom_HaZikaron#Timing Better explanation: https://ph.yhb.org.il/en/05-04-09 """ d = dates.HebrewDate(year.year, 2, 4) if d.weekday() == 1: # MD on Sunday => shift forward to Monday d = dates.HebrewDate(year.year, 2, 5) elif d.weekday() == 6: # MD on Friday => ID on Sabbat => shift back 2 days d = dates.HebrewDate(year.year, 2, 2) elif d.weekday() == 5: # MD on Thursday => ID night before Sabbat => shift back 1 day d = dates.HebrewDate(year.year, 2, 3) return d def _pentecost(year): """ Return the Hebrew date for Pentecost in the given Hebrew year. """ return dates.HebrewDate(year.year, 3, 6) def _fast_day(year): """ Return the Hebrew date for Tisha B'Av in the given Hebrew year. """ return dates.HebrewDate(year.year, 5, 9) def _new_year(year): """ Return the Hebrew date for the first day of a new year in the given Hebrew year. """ return dates.HebrewDate(year.year, 7, 1) def _yom_kippur(year): """ Return the Hebrew date for Yom Kippur in the given Hebrew year. """ return dates.HebrewDate(year.year, 7, 10) def _sukkoth(year): """ Return the Hebrew date for Sukkoth in the given Hebrew year. """ return dates.HebrewDate(year.year, 7, 15) def _simchat_torah(year): """ Return the Hebrew date for Simchat Torah in the given Hebrew year. """ return dates.HebrewDate(year.year, 7, 22) def _hebrew_year(year): """ Return the Hebrew calendar year that corresponds to 1st January of the given Gregorian calendar year. 1st January of any Gregorian calendar year, say x, always falls into the month of Tevet (10) or Shevat (11) of some Hebrew year f(x). Also, we have f(x+1) = f(x) + 1, so that any year in the Gregorian calendar always overlaps with two consecutive years in the Hebrew calendar and vice versa. """ return hebrewcal.Year(dates.GregorianDate(year, 1, 1).to_heb().year) # Auxilliary functions to calculate Gregorian dates for holidays observed by # TASE for a given Gregorian calendar year. Adjustments are also applied. def purim(year): """ Return the Gregorian date for Purim in the given Gregorian calendar year. """ return _purim(_hebrew_year(year)).to_greg() def passover(year): """ Return the Gregorian date for the first day of Passover in the given Gregorian calendar year. """ return _passover(_hebrew_year(year)).to_greg() def memorial_day(year): """ Return the Gregorian date for Memorial Day in the given Gregorian calendar year. """ # Regular Memorial Day date. d = _memorial_day(_hebrew_year(year)).to_greg() # Reschedule to avoid Sabbath desecration, maybe. if d.weekday() == 5: # Falls on a Thursday, so Independency Day falls on the Friday. # Moved down by one day. return d - 1 if d.weekday() == 6: # Falls on a Friday, so Independence Day falls on the Saturday. # Moved down by two days. return d - 2 if d.weekday() == 7: # Falls on a Saturday, therefore moved up by one day. return d + 1 return d def pentecost(year): """ Return the Gregorian date for Pentecost in the given Gregorian calendar year. """ return _pentecost(_hebrew_year(year)).to_greg() def fast_day(year): """ Return the Gregorian date for Tisha B'Av in the given Gregorian calendar year. """ d = _fast_day(_hebrew_year(year)).to_greg() # Reschedule if it falls on Sabbath (Saturday), maybe. if d.weekday() == 7: # Falls on a Saturday, therefore moved up by one day. return d + 1 return d def new_year(year): """ Return the Gregorian date for the first day of a new year in the given Gregorian calendar year. """ return _new_year(_hebrew_year(year + 1)).to_greg() def yom_kippur(year): """ Return the Gregorian date for Yom Kippur in the given Gregorian calendar year. """ return _yom_kippur(_hebrew_year(year + 1)).to_greg() def sukkoth(year): """ Return the Gregorian date for Sukkoth in the given Gregorian calendar year. """ return _sukkoth(_hebrew_year(year + 1)).to_greg() def simchat_torah(year): """ Return the Gregorian date for Simchat Torah in the given Gregorian calendar year. """ return _simchat_torah(_hebrew_year(year + 1)).to_greg() def _is_normalized(dt): if dt.hour != 0 or dt.minute != 0 or dt.second != 0 or dt.microsecond != 0: # Regardless of whether dt is datetime vs Timestamp return False if isinstance(dt, pd.Timestamp): return dt.nanosecond == 0 return True class _HolidayOffset(Easter): """ Auxiliary class for DateOffset instances for the different holidays. """ @property def holiday(self): """ Return the Gregorian date for the holiday in a given Gregorian calendar year. """ @apply_wraps def _apply(self, other): current = self.holiday(other.year).to_pydate() current = datetime(current.year, current.month, current.day) current = localize_pydatetime(current, other.tzinfo) n = self.n if n >= 0 and other < current: n -= 1 elif n < 0 and other > current: n += 1 # TODO: Why does this handle the 0 case the opposite of others? # NOTE: self.holiday a dates.GregorianDate so we have to convert to # type of other new = self.holiday(other.year + n).to_pydate() return datetime( new.year, new.month, new.day, other.hour, other.minute, other.second, other.microsecond, ) # backwards compat apply = _apply def is_on_offset(self, dt): if self.normalize and not _is_normalized(dt): return False return date(dt.year, dt.month, dt.day) == self.holiday(dt.year).to_pydate() # DateOffset subclasses for holidays observed by TASE. class _Purim(_HolidayOffset): @property def holiday(self): return purim class _Passover(_HolidayOffset): @property def holiday(self): return passover class _MemorialDay(_HolidayOffset): @property def holiday(self): return memorial_day class _Pentecost(_HolidayOffset): @property def holiday(self): return pentecost class _FastDay(_HolidayOffset): @property def holiday(self): return fast_day class _NewYear(_HolidayOffset): @property def holiday(self): return new_year class _YomKippur(_HolidayOffset): @property def holiday(self): return yom_kippur class _YomKippurEveObserved(_HolidayOffset): """ Custom offset for Yom Kippur Eve with previous_friday observance. Applies both the Day(-1) offset and moves to previous Friday if needed. """ @property def holiday(self): return yom_kippur @apply_wraps def _apply(self, other): current = self.holiday(other.year).to_pydate() # Get the day before (Yom Kippur Eve) current = current - pd.Timedelta(days=1) current = datetime(current.year, current.month, current.day) # Apply previous_friday observance (move to previous Friday if on weekend) weekday = current.weekday() if weekday == 5: # Saturday current = current - pd.Timedelta(days=1) elif weekday == 6: # Sunday current = current - pd.Timedelta(days=2) current = localize_pydatetime(current, other.tzinfo) n = self.n if n >= 0 and other < current: n -= 1 elif n < 0 and other > current: n += 1 new = self.holiday(other.year + n).to_pydate() # Get the day before (Yom Kippur Eve) new = new - pd.Timedelta(days=1) new = datetime(new.year, new.month, new.day) # Apply previous_friday observance weekday = new.weekday() if weekday == 5: # Saturday new = new - pd.Timedelta(days=1) elif weekday == 6: # Sunday new = new - pd.Timedelta(days=2) return datetime( new.year, new.month, new.day, other.hour, other.minute, other.second, other.microsecond, ) def is_on_offset(self, dt): if self.normalize and not _is_normalized(dt): return False # Get the expected date and apply the same logic expected = self.holiday(dt.year).to_pydate() - pd.Timedelta(days=1) expected = date(expected.year, expected.month, expected.day) weekday = expected.weekday() if weekday == 5: # Saturday expected = expected - pd.Timedelta(days=1) elif weekday == 6: # Sunday expected = expected - pd.Timedelta(days=2) return date(dt.year, dt.month, dt.day) == expected.date() class _Sukkoth(_HolidayOffset): @property def holiday(self): return sukkoth class _SimchatTorah(_HolidayOffset): @property def holiday(self): return simchat_torah # Holiday instances for holidays observed by TASE. Purim = Holiday("Purim", month=1, day=1, offset=[_Purim()]) PassoverEve = Holiday("Passover Eve", month=1, day=1, offset=[_Passover(), Day(-1)]) Passover = Holiday("Passover", month=1, day=1, offset=[_Passover()]) Passover2Eve = Holiday("Passover II Eve", month=1, day=1, offset=[_Passover(), Day(5)]) Passover2 = Holiday("Passover II", month=1, day=1, offset=[_Passover(), Day(6)]) PentecostEve = Holiday("Pentecost Eve", month=1, day=1, offset=[_Pentecost(), Day(-1)]) Pentecost = Holiday("Pentecost", month=1, day=1, offset=[_Pentecost()]) FastDay = Holiday("Tisha B'Av", month=1, day=1, offset=[_FastDay()]) MemorialDay = Holiday("Memorial Day", month=1, day=1, offset=[_MemorialDay()]) IndependenceDay = Holiday( "Independence Day", month=1, day=1, offset=[_MemorialDay(), Day(1)] ) NewYearsEve = Holiday("New Year's Eve", month=1, day=1, offset=[_NewYear(), Day(-1)]) NewYear = Holiday("New Year", month=1, day=1, offset=[_NewYear()]) NewYear2 = Holiday("New Year II", month=1, day=1, offset=[_NewYear(), Day(1)]) YomKippurEve = Holiday("Yom Kippur Eve", month=1, day=1, offset=[_YomKippur(), Day(-1)]) YomKippurEveObserved = Holiday( "Market Holiday", month=1, day=1, offset=[_YomKippurEveObserved()], start_date=pd.Timestamp("2026-01-05"), ) YomKippur = Holiday("Yom Kippur", month=1, day=1, offset=[_YomKippur()]) SukkothEve = Holiday("Sukkoth Eve", month=1, day=1, offset=[_Sukkoth(), Day(-1)]) Sukkoth = Holiday("Sukkoth", month=1, day=1, offset=[_Sukkoth()]) SimchatTorahEve = Holiday( "Simchat Torah Eve", month=1, day=1, offset=[_SimchatTorah(), Day(-1)] ) SimchatTorah = Holiday("Simchat Torah", month=1, day=1, offset=[_SimchatTorah()]) DaysOfWeekBefore2026 = (0, 1, 2, 3, 6) DaysOfWeek = (0, 1, 2, 3, 4) StartDate = pd.Timestamp("2026-01-05") EndDate = pd.Timestamp("2026-01-05") # Sukkoth interim days - the 3 days following Sukkoth SukkothInterimDay1 = Holiday( "Sukkoth Interim Day", month=1, day=1, offset=[_Sukkoth(), Day(1)], start_date=StartDate, days_of_week=DaysOfWeek, ) SukkothInterimDay1Before2026 = Holiday( "Sukkoth Interim Day", month=1, day=1, offset=[_Sukkoth(), Day(1)], end_date=EndDate, days_of_week=DaysOfWeekBefore2026, ) SukkothInterimDay2 = Holiday( "Sukkoth Interim Day", month=1, day=1, offset=[_Sukkoth(), Day(2)], start_date=StartDate, days_of_week=DaysOfWeek, ) SukkothInterimDay2Before2026 = Holiday( "Sukkoth Interim Day", month=1, day=1, offset=[_Sukkoth(), Day(2)], end_date=EndDate, days_of_week=DaysOfWeekBefore2026, ) SukkothInterimDay3 = Holiday( "Sukkoth Interim Day", month=1, day=1, offset=[_Sukkoth(), Day(3)], start_date=StartDate, days_of_week=DaysOfWeek, ) SukkothInterimDay3Before2026 = Holiday( "Sukkoth Interim Day", month=1, day=1, offset=[_Sukkoth(), Day(3)], end_date=EndDate, days_of_week=DaysOfWeekBefore2026, ) SukkothInterimDay4 = Holiday( "Sukkoth Interim Day", month=1, day=1, offset=[_Sukkoth(), Day(4)], start_date=StartDate, days_of_week=DaysOfWeek, ) SukkothInterimDay4Before2026 = Holiday( "Sukkoth Interim Day", month=1, day=1, offset=[_Sukkoth(), Day(4)], end_date=EndDate, days_of_week=DaysOfWeekBefore2026, ) SukkothInterimDay5 = Holiday( "Sukkoth Interim Day", month=1, day=1, offset=[_Sukkoth(), Day(5)], start_date=StartDate, days_of_week=DaysOfWeek, ) SukkothInterimDay5Before2026 = Holiday( "Sukkoth Interim Day", month=1, day=1, offset=[_Sukkoth(), Day(5)], end_date=EndDate, days_of_week=DaysOfWeekBefore2026, ) # Passover interim days are the days between beginning and end of passover. # Any otherwise regular business day in that period becomes an early close day. PassoverInterimDay1 = Holiday( "Passover Interim Day", month=1, day=1, offset=[_Passover(), Day(1)], start_date=StartDate, days_of_week=DaysOfWeek, ) PassoverInterimDay1Before2026 = Holiday( "Passover Interim Day", month=1, day=1, offset=[_Passover(), Day(1)], end_date=EndDate, days_of_week=DaysOfWeekBefore2026, ) PassoverInterimDay2 = Holiday( "Passover Interim Day", month=1, day=1, offset=[_Passover(), Day(2)], start_date=StartDate, days_of_week=DaysOfWeek, ) PassoverInterimDay2Before2026 = Holiday( "Passover Interim Day", month=1, day=1, offset=[_Passover(), Day(2)], end_date=EndDate, days_of_week=DaysOfWeekBefore2026, ) PassoverInterimDay3 = Holiday( "Passover Interim Day", month=1, day=1, offset=[_Passover(), Day(3)], start_date=StartDate, days_of_week=DaysOfWeek, ) PassoverInterimDay3Before2026 = Holiday( "Passover Interim Day", month=1, day=1, offset=[_Passover(), Day(3)], end_date=EndDate, days_of_week=DaysOfWeekBefore2026, ) PassoverInterimDay4 = Holiday( "Passover Interim Day", month=1, day=1, offset=[_Passover(), Day(4)], start_date=StartDate, days_of_week=DaysOfWeek, ) PassoverInterimDay4Before2026 = Holiday( "Passover Interim Day", month=1, day=1, offset=[_Passover(), Day(4)], end_date=EndDate, days_of_week=DaysOfWeekBefore2026, )