Skip to content

Commit

Permalink
Merge branch 'release/0.7.0'
Browse files Browse the repository at this point in the history
  • Loading branch information
jenskeiner committed Jan 31, 2024
2 parents bd1f56f + 0c0273c commit 57f12d3
Show file tree
Hide file tree
Showing 7 changed files with 702 additions and 238 deletions.
135 changes: 135 additions & 0 deletions docs/additional_calendars.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,138 @@ This gives the following output:
2023-12-15 quarterly expiry
dtype: object
```

## Monthly Expiry Days

Similarly to quarterly expiry days, one can observe monthly expiry days during the remaining months. They are likewise
special trading sessions when certain derivatives expire, but typically with less volume and volatility than quarterly
expiry days. They are supported for the same subset of exchanges as quarterly expiry days.

```python
import exchange_calendars_extensions.core as ecx
import exchange_calendars as ec

ecx.apply_extensions()

calendar = ec.get_calendar('XLON')
print(calendar.monthly_expiries.holidays(start='2023-01-01', end='2023-12-31', return_name=True))
```
gives the following output:
```text
2023-01-20 monthly expiry
2023-02-17 monthly expiry
2023-04-21 monthly expiry
2023-05-19 monthly expiry
2023-07-21 monthly expiry
2023-08-18 monthly expiry
2023-10-20 monthly expiry
2023-11-17 monthly expiry
dtype: object
```

## Last trading day of the month

It may be of interest to observe the last trading day of any month. While these trading sessions are often not special
per se, they may be interesting nevertheless due to effects around the end of the month. For example, some funds may
re-balance their portfolios at the end of the month, which may lead to increased trading volume and volatility.

```python
import exchange_calendars_extensions.core as ecx
import exchange_calendars as ec

ecx.apply_extensions()

calendar = ec.get_calendar('XLON')
print(calendar.last_trading_days_of_months.holidays(start='2023-01-01', end='2023-12-31', return_name=True))
```
gives the following output:
```text
2023-01-31 last trading day of month
2023-02-28 last trading day of month
2023-03-31 last trading day of month
2023-04-28 last trading day of month
2023-05-31 last trading day of month
2023-06-30 last trading day of month
2023-07-31 last trading day of month
2023-08-31 last trading day of month
2023-09-29 last trading day of month
2023-10-31 last trading day of month
2023-11-30 last trading day of month
2023-12-29 last trading day of month
dtype: object
```

{{% note %}}
The last trading day of any month is defined as the last day that the exchange is open in the respective month. This may
sometimes overlap with special trading sessions. For example, the last trading session in December may be a half day on
some exchanges, e.g. XLON. For the last regular trading session of the month, consider
`last_regular_trading_days_of_months` instead.
{{% /note %}}

## Last regular trading day of the month

The last regular trading day of the month is the last trading day of the month that is not a special trading session.
In many cases, the last regular trading day of a month will also be the last trading day of the month, but overlaps with
special sessions, such as half days, are possible.

```python
import exchange_calendars_extensions.core as ecx
import exchange_calendars as ec

ecx.apply_extensions()

calendar = ec.get_calendar('XLON')
print(calendar.last_regular_trading_days_of_months.holidays(start='2023-01-01', end='2023-12-31', return_name=True))
print(calendar.special_closes_all.holidays(start='2023-12-29', end='2023-12-31', return_name=True))
```
gives the following output:
```text
2023-01-31 last regular trading day of month
2023-02-28 last regular trading day of month
2023-03-31 last regular trading day of month
2023-04-28 last regular trading day of month
2023-05-31 last regular trading day of month
2023-06-30 last regular trading day of month
2023-07-31 last regular trading day of month
2023-08-31 last regular trading day of month
2023-09-29 last regular trading day of month
2023-10-31 last regular trading day of month
2023-11-30 last regular trading day of month
2023-12-28 last regular trading day of month
dtype: object
2023-12-29 New Year's Eve
dtype: object
```

Note that the last regular trading day in December is the 28th, not the 29th, as the 29th was a special trading session
(New Year's Eve) on XLON.

## Conflicts with other special days
Special sessions like expiry days or the last trading day of the month typically need to be adjusted for holidays or
early open/close days that may occur on the original provisioned dates. The exact rules for these adjustments are
exchange-specific, can be difficult to find in writing, and may also change over time.

In most cases, rolling back to the previous trading day is the correct approach. Currently, quarterly/monthly expiries
and the last (regular) trading days of the month are rolled back to the previous day until all conflicts with other
special days are resolved.

{{% note %}}
The current conflict resolution rules seem to be correct for most exchanges. However, there may be exceptions. If you
find any, please open an issue on GitHub, ideally with a link to the exchange's official documentation.
{{% /note %}}

Quarterly/monthly expiries and the last regular trading days of the month adjust for holidays and special open/close
days. In contrast, the last trading days of the month do *not* adjust for special open/close days, but only for
holidays.



{{% note %}}
The current conflict resolution rules do not allow a day to be rolled back over a month boundary. In a scenario where
this would otherwise occur, the special session in question is just dropped. This is because the definition of expiry
days as well as last trading days of the month does not make sense when rolled back over a month boundary.

This case may seem like a theoretical scenario, but it has occurred in the past. For example,
ASEX was closed on all days in July 2015. Therefore, the last trading day of July 2015 would roll back to the last
trading day of June 2015. This obviously does not make sense, so this special session is dropped from the calendar.
{{% /note %}}
6 changes: 3 additions & 3 deletions exchange_calendars_extensions/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
from pydantic import validate_call
from typing_extensions import ParamSpec, Concatenate

from exchange_calendars_extensions.api.changes import ChangeSet, DayType, DaySpec, DaySpecWithTime, TimestampLike
from exchange_calendars_extensions.api.changes import ChangeSet, ChangeSetDict, DayType, DaySpec, DaySpecWithTime, TimestampLike
from exchange_calendars_extensions.core.holiday_calendar import extend_class, ExtendedExchangeCalendar, ExchangeCalendarExtensions

# Dictionary that maps from exchange key to ExchangeCalendarChangeSet. Contains all changesets to apply when creating a
Expand Down Expand Up @@ -576,7 +576,7 @@ def get_changes_for_calendar(exchange: str) -> ChangeSet:
return cs


def get_changes_for_all_calendars() -> dict:
def get_changes_for_all_calendars() -> ChangeSetDict:
"""
Get the changes for all exchange calendars.
Expand All @@ -585,7 +585,7 @@ def get_changes_for_all_calendars() -> dict:
dict
The changes for all exchange calendars.
"""
return {k: v.model_copy(deep=True) for k, v in _changesets.items()}
return ChangeSetDict({k: v.model_copy(deep=True) for k, v in _changesets.items()})


# Declare public names.
Expand Down
86 changes: 66 additions & 20 deletions exchange_calendars_extensions/core/holiday_calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,14 @@ def holidays(self, start=None, end=None, return_name=False):
return holidays.drop_duplicates()


def get_conflicts(holidays_dates: List[pd.Timestamp], other_holidays: pd.DatetimeIndex, weekend_days: Iterable[int]) -> List[int]:
def get_conflicts(holidays_dates: List[Union[pd.Timestamp, None]], other_holidays: pd.DatetimeIndex, weekend_days: Iterable[int]) -> List[int]:
"""
Get the indices of holidays that coincide with holidays from the other calendar or the given weekend days.
Parameters
----------
holidays_dates : List[pd.Timestamp]
The dates of the holidays.
holidays_dates : List[Union[pd.Timestamp, None]]
The dates of the holidays. A date may be None and is then ignored.
other_holidays : pd.DatetimeIndex
The dates of the holidays from the other calendar.
weekend_days : Iterable[int]
Expand All @@ -65,46 +65,84 @@ def get_conflicts(holidays_dates: List[pd.Timestamp], other_holidays: pd.Datetim
"""

# Determine the indices of holidays that coincide with holidays from the other calendar.
return [i for i in range(len(holidays_dates)) if holidays_dates[i] in other_holidays or holidays_dates[i].weekday() in weekend_days]
return [i for i in range(len(holidays_dates)) if holidays_dates[i] is not None and (holidays_dates[i] in other_holidays or holidays_dates[i].weekday() in weekend_days or holidays_dates[i] in holidays_dates[i+1:])]


# A function that takes a date and returns a date or None.
RollFn = Callable[[pd.Timestamp], Union[pd.Timestamp, None]]


def roll_one_day_same_month(d: pd.Timestamp) -> Union[pd.Timestamp, None]:
"""
Roll the given date back one day and return the result if the month is still the same. Return None otherwise.
This function can be used to prevent certain days from being rolled back into the previous month. For example, the
last trading day of July 2015 on ASEX is not defined since the exchange was closed the entire month. Hence, this day
should not be rolled into June.
Parameters
----------
d : pd.Timestamp
The date to roll back.
Returns
-------
pd.Timestamp | None
The rolled back date, if the month is still the same, or None otherwise.
"""
# Month.
month = d.month

# Roll back one day.
d = d - pd.Timedelta(days=1)

# If the month changed, return None.
if d.month != month:
return None

# Return the rolled back date.
return d


class AdjustedHolidayCalendar(ExchangeCalendarsHolidayCalendar):

def __init__(self, rules, other: ExchangeCalendarsHolidayCalendar, weekmask: str) -> None:
def __init__(self, rules, other: ExchangeCalendarsHolidayCalendar, weekmask: str,
roll_fn: RollFn = lambda d: d - pd.Timedelta(days=1)) -> None:
super().__init__(rules=rules)
self.other = other
self.weekend_days = {d for d in range(7) if weekmask[d] == '0'}
self._other = other
self._weekend_days = {d for d in range(7) if weekmask[d] == '0'}
self._roll_fn = roll_fn

def holidays(self, start=None, end=None, return_name=False):
# Get the holidays from the parent class.
holidays = super().holidays(start=start, end=end, return_name=return_name)

# Get the holidays from the other calendar.
other_holidays = self.other.holidays(start=start, end=end, return_name=False)
other_holidays = self._other.holidays(start=start, end=end, return_name=False)

holidays_dates = list(holidays.index if return_name else holidays)

conflicts = get_conflicts(holidays_dates, other_holidays, self.weekend_days)
conflicts = get_conflicts(holidays_dates, other_holidays, self._weekend_days)

if len(conflicts) == 0:
return holidays

while True:
# For each index of a conflicting holiday, adjust the date by one day into the past.
# For each index of a conflicting holiday, adjust the date by using the roll function.
for i in conflicts:
holidays_dates[i] = holidays_dates[i] - pd.Timedelta(days=1)
holidays_dates[i] = self._roll_fn(holidays_dates[i])

conflicts = get_conflicts(holidays_dates, other_holidays, self.weekend_days)
conflicts = get_conflicts(holidays_dates, other_holidays, self._weekend_days)

if len(conflicts) == 0:
break

holidays_index = pd.DatetimeIndex(holidays_dates)

if return_name:
return pd.Series(holidays.values, index=holidays_index)
# Return a series, filter out dates that are None.
return pd.Series({d: n for d, n in zip(holidays_dates, holidays.values) if d is not None and (start is None or d >= start) and (end is None or d <= end)})
else:
return holidays_index
# Return index, filter out dates that are None.
return pd.DatetimeIndex([d for d in holidays_dates if d is not None and (start is None or d >= start) and (end is None or d <= end)])


def get_holiday_calendar_from_timestamps(timestamps: Iterable[pd.Timestamp],
Expand Down Expand Up @@ -866,19 +904,27 @@ def special_closes_all(self) -> Union[HolidayCalendar, None]:

@property
def monthly_expiries(self) -> Union[HolidayCalendar, None]:
return AdjustedHolidayCalendar(rules=self._adjusted_properties.monthly_expiries, other=self._holidays_and_special_business_days_shared, weekmask=self.weekmask)
return AdjustedHolidayCalendar(rules=self._adjusted_properties.monthly_expiries,
other=self._holidays_and_special_business_days_shared, weekmask=self.weekmask,
roll_fn=roll_one_day_same_month)

@property
def quarterly_expiries(self) -> Union[HolidayCalendar, None]:
return AdjustedHolidayCalendar(rules=self._adjusted_properties.quarterly_expiries, other=self._holidays_and_special_business_days_shared, weekmask=self.weekmask)
return AdjustedHolidayCalendar(rules=self._adjusted_properties.quarterly_expiries,
other=self._holidays_and_special_business_days_shared, weekmask=self.weekmask,
roll_fn=roll_one_day_same_month)

@property
def last_trading_days_of_months(self) -> Union[HolidayCalendar, None]:
return AdjustedHolidayCalendar(rules=self._adjusted_properties.last_trading_days_of_months, other=self._holidays_shared, weekmask=self.weekmask)
return AdjustedHolidayCalendar(rules=self._adjusted_properties.last_trading_days_of_months,
other=self._holidays_shared, weekmask=self.weekmask,
roll_fn=roll_one_day_same_month)

@property
def last_regular_trading_days_of_months(self) -> Union[HolidayCalendar, None]:
return AdjustedHolidayCalendar(rules=self._adjusted_properties.last_regular_trading_days_of_months, other=self._holidays_and_special_business_days_shared, weekmask=self.weekmask)
return AdjustedHolidayCalendar(rules=self._adjusted_properties.last_regular_trading_days_of_months,
other=self._holidays_and_special_business_days_shared, weekmask=self.weekmask,
roll_fn=roll_one_day_same_month)

# Use type to create a new class.
extended = type(cls.__name__ + "Extended", (cls, ExtendedExchangeCalendar), {
Expand Down
Loading

0 comments on commit 57f12d3

Please sign in to comment.