diff --git a/financepy/utils/calendar.py b/financepy/utils/calendar.py index a4ea3a9c..13dfdbd5 100644 --- a/financepy/utils/calendar.py +++ b/financepy/utils/calendar.py @@ -363,8 +363,37 @@ class Calendar: convention and then applies that to any date that falls on a holiday in the specified calendar.""" - def __init__(self, cal_type: CalendarTypes): - """Create a calendar based on a specified calendar type.""" + def __init__(self, cal_type: CalendarTypes | list | tuple): + """Create a calendar based on a specified calendar type. + + A list or tuple of calendar types creates a joint calendar whose + holidays are the union of the holidays of the constituent calendars, + so a date is a business day only if it is a business day in every + one of them. This is needed for cross-market products, e.g. a swap + with a USD SOFR leg and a EUR fixed leg would use + Calendar([CalendarTypes.UNITED_STATES, CalendarTypes.TARGET]).""" + + if isinstance(cal_type, (list, tuple)): + + cal_types = [] + for ct in cal_type: + if not isinstance(ct, CalendarTypes): + raise FinError("Invalid calendar type " + str(ct)) + if ct not in cal_types: + cal_types.append(ct) + + if len(cal_types) == 0: + raise FinError("Calendar type list is empty") + + if len(cal_types) == 1: + # A single-entry list is just that calendar + cal_type = cal_types[0] + else: + # Joint calendar: cal_type holds the constituent types as a + # tuple so it never compares equal to a CalendarTypes member + self.cal_type = tuple(cal_types) + self._joint_cals = [Calendar(ct) for ct in cal_types] + return if cal_type not in CalendarTypes: raise FinError("Invalid calendar type " + str(cal_type)) @@ -642,6 +671,13 @@ def is_holiday(self, dt: Date): calendar. Weekends are not holidays unless the holiday falls on a weekend date.""" + if isinstance(self.cal_type, tuple): + # Joint calendar: a holiday in any constituent calendar + for cal in self._joint_cals: + if cal.is_holiday(dt): + return True + return False + start_dt = Date(1, 1, dt.y) day_in_year = dt.excel_dt - start_dt.excel_dt + 1 weekday = dt.weekday @@ -1492,14 +1528,16 @@ def easter_monday(self, year: float): ########################################################################### def __str__(self): + if isinstance(self.cal_type, tuple): + s = "JOINT(" + ",".join(ct.name for ct in self.cal_type) + ")" + return s s = self.cal_type.name return s ########################################################################### def __repr__(self): - s = self.cal_type.name - return s + return self.__str__() ######################################################################################## diff --git a/unit_tests/test_FinCalendar.py b/unit_tests/test_FinCalendar.py index 8e47dfd9..7c4b71c6 100644 --- a/unit_tests/test_FinCalendar.py +++ b/unit_tests/test_FinCalendar.py @@ -5,6 +5,7 @@ from financepy.utils.date import Date from financepy.utils.date_format import set_date_format from financepy.utils.date_format import DateFormatTypes +from financepy.utils.error import FinError bus_days_in_decade = { CalendarTypes.NONE: 3653, @@ -232,3 +233,127 @@ def test_fast_adjust_matches_adjust(cal_type, bd_type): for dt in dates: assert cal.fast_adjust(dt, bd_type) == cal.adjust(dt, bd_type) + + +######################################################################################## +# Joint calendars — a list of calendar types unions their holidays +######################################################################################## + + +JOINT_US_UK = [CalendarTypes.UNITED_STATES, CalendarTypes.UNITED_KINGDOM] + + +def test_joint_calendar_holiday_is_union(): + + us = Calendar(CalendarTypes.UNITED_STATES) + uk = Calendar(CalendarTypes.UNITED_KINGDOM) + joint = Calendar(JOINT_US_UK) + + july_4_2023 = Date(4, 7, 2023) # US Independence Day, UK business day + assert us.is_holiday(july_4_2023) + assert not uk.is_holiday(july_4_2023) + assert joint.is_holiday(july_4_2023) + + aug_28_2023 = Date(28, 8, 2023) # UK late summer bank holiday, US business day + assert not us.is_holiday(aug_28_2023) + assert uk.is_holiday(aug_28_2023) + assert joint.is_holiday(aug_28_2023) + + plain_day = Date(6, 6, 2023) + assert not joint.is_holiday(plain_day) + assert joint.is_business_day(plain_day) + + +def test_joint_business_day_is_intersection_over_full_years(): + + us = Calendar(CalendarTypes.UNITED_STATES) + uk = Calendar(CalendarTypes.UNITED_KINGDOM) + joint = Calendar(JOINT_US_UK) + + dt = Date(1, 1, 2020) + end = Date(1, 1, 2024) + + while dt < end: + assert joint.is_business_day(dt) == ( + us.is_business_day(dt) and uk.is_business_day(dt) + ), f"joint disagrees with single-calendar intersection on {dt}" + dt = dt.add_days(1) + + +def test_joint_adjust_skips_holidays_of_both_markets(): + + # Good Friday 2021-04-02 and Easter Monday 2021-04-05 are UK bank + # holidays but US business days, so a joint US+UK calendar must jump + # from the Friday all the way to Tuesday 2021-04-06 + us = Calendar(CalendarTypes.UNITED_STATES) + joint = Calendar(JOINT_US_UK) + + good_friday = Date(2, 4, 2021) + assert us.adjust(good_friday, BusDayAdjustTypes.FOLLOWING) == good_friday + assert joint.adjust(good_friday, BusDayAdjustTypes.FOLLOWING) == Date(6, 4, 2021) + + +@pytest.mark.parametrize("bd_type", list(BusDayAdjustTypes)) +def test_joint_fast_adjust_matches_adjust(bd_type): + + joint = Calendar(JOINT_US_UK) + + dates = [ + Date(2, 4, 2021), + Date(31, 1, 2021), + Date(24, 12, 2021), + Date(25, 12, 2021), + Date(31, 12, 2021), + ] + + for dt in dates: + assert joint.fast_adjust(dt, bd_type) == joint.adjust(dt, bd_type) + + +def test_joint_add_business_days_round_trip(): + + joint = Calendar(JOINT_US_UK) + start = Date(3, 1, 2020) + + for n in [0, 1, 5, 50, 250, -1, -5, -50, -250]: + end = joint.add_business_days(start, n) + back = joint.add_business_days(end, -n) + assert back == start + + +def test_joint_holiday_list_is_union_of_lists(): + + us = Calendar(CalendarTypes.UNITED_STATES) + uk = Calendar(CalendarTypes.UNITED_KINGDOM) + joint = Calendar(JOINT_US_UK) + + year = 2023 + us_holidays = set(us.get_holiday_list(year)) + uk_holidays = set(uk.get_holiday_list(year)) + + assert set(joint.get_holiday_list(year)) == us_holidays | uk_holidays + + +def test_joint_single_entry_and_duplicates_collapse(): + + single = Calendar([CalendarTypes.UNITED_STATES]) + assert single.cal_type == CalendarTypes.UNITED_STATES + + duplicate = Calendar([CalendarTypes.UNITED_STATES, CalendarTypes.UNITED_STATES]) + assert duplicate.cal_type == CalendarTypes.UNITED_STATES + + +def test_joint_str_and_repr(): + + joint = Calendar(JOINT_US_UK) + assert str(joint) == "JOINT(UNITED_STATES,UNITED_KINGDOM)" + assert repr(joint) == str(joint) + + +def test_joint_invalid_inputs_raise(): + + with pytest.raises(FinError): + Calendar([]) + + with pytest.raises(FinError): + Calendar([CalendarTypes.UNITED_STATES, "TARGET"])