Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 42 additions & 4 deletions financepy/utils/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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__()


########################################################################################
125 changes: 125 additions & 0 deletions unit_tests/test_FinCalendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"])