Package: exchange-calendars-extensions
exchange-calendars-extensionsA Python package that transparently adds some features to theexchange-calendarspackage.For all exchanges, this package adds the following:Calendars that combine existing regular and ad-hoc holidays/special open days/special close days into a single
calendar, respectively.Calendars for the last trading session of each month, and the lastregulartrading session of each month.The ability to modify existing calendars by adding or removing special days programmatically at runtime.For select exchanges, this packages also adds:Calendars for additional special trading sessions, such as monthly and quarterly expiry days (aka quadruple witching).The ability to add or remove these additional special trading sessions programmatically at runtime.Combined calendarsThis package adds combined calendars for holidays and special open/close days, respectively. These calendars combine
regular with ad-hoc occurrences.Note that for special open/close days, this may aggregate days with different
open/close times into a single calendar. From the combined calendar, the open/close time for each contained day cannot
be recovered.Additional calendarsIn addition to information that is already available inexchange-calendars, this package also adds calendars for
the following trading sessions:last trading session of the month, andlastregulartrading session of the month.For select exchanges (seebelow), this package also adds calendars for:quarterly expiry days (aka quadruple witching), andmonthly expiry days (in all remaining months that don't have a quarterly expiry day).Finally, a new calendar that contains all weekend days as per the underlying weekmask is also available.Calendar modificationsThis package also adds the ability to modify existing calendars at runtime. This can be used to add or removeholidays (regular and ad-hoc),special open days (regular and ad-hoc),special close days (regular and ad-hoc),quarterly expiry days, andmonthly expiry days.This is useful, for example, when an exchange announces a change to the regular trading schedule on short notice, and
the next release of theexchange-calendarspackage, including these changes, is not available yet.InstallationThe package is available onPyPIand can be installed viapipor any other suitable package/dependency management tool, e.g.Poetry.pipinstallexchange-calendars-extensionsUsageImport the package and register extended exchange calendar classes with theexchange_calendarsmodule.importexchange_calendars_extensionsasecxecx.apply_extensions()This replaces the default exchange calendar classes with the extended versions.Get an exchange calendar instance and verify that extended exchange calendars are subclasses of the abstract base
classecx.ExtendedExchangeCalendar. This class inherits both fromec.ExchangeCalendarand the new protocol classecx.ExchangeCalendarExtensionswhich defines the extended properties.importexchange_calendars_extensionsasecxecx.apply_extensions()importexchange_calendarsaseccalendar=ec.get_calendar('XLON')# It's still a regular exchange calendar.assertisinstance(calendar,ec.ExchangeCalendar)# But it's also an extended exchange calendar...assertisinstance(calendar,ecx.ExtendedExchangeCalendar)# ...and implements the extended protocol.assertisinstance(calendar,ecx.ExchangeCalendarExtensions)The original classes can be re-instated by callingecx.remove_extensions().importexchange_calendars_extensionsasecxecx.apply_extensions()importexchange_calendarsasec...ecx.remove_extensions()calendar=ec.get_calendar('XLON')# It's a regular exchange calendar.assertisinstance(calendar,ec.ExchangeCalendar)# But it's not an extended exchange calendar anymore.assertnotisinstance(calendar,ecx.ExtendedExchangeCalendar)assertnotisinstance(calendar,ecx.ExchangeCalendarExtensions)Additional propertiesExtended exchange calendars provide the following calendars as properties:holidays_all: Regular and ad-hoc holidays combined into a single calendar.special_opens_all: Regular and ad-hoc special open days combined into a single calendar.special_closes_all: Regular and ad-hoc special close days combined into a single calendar.weekend_days: All weekend days, as defined by the underlying weekmask, in a single calendar.quarterly_expiries: Quarterly expiry days, also known as quadruple witching. Many exchanges observe special business
days on which market index futures, options futures, stock options and stock futures expire, typically resulting in
increased volatility and traded volume. Quadruple witching is typically observed on the third Friday of March, June,
September and December, although some exchanges observe it on Thursday instead. Note that in the case of collisions
with holidays or special open/close days, a quarterly expiry day is usually rolled backward to the previous and
otherwise regular business day.monthly_expiries: Monthly expiry days. Similar to quarterly expiry days, but for all remaining months of the year.
Provided in a separate calendar as they typically result in less extreme trading patterns.last_session_of_months: The last trading session for each month of the year.last_regular_session_of_months: Last regular trading session of each month of the year, i.e. not a special
open/close or otherwise irregular day.For example,importexchange_calendars_extensionsasecxecx.apply_extensions()importexchange_calendarsaseccalendar=ec.get_calendar('XLON')print(calendar.holidays_all.holidays(start='2020-01-01',end='2020-12-31',return_name=True))will output2020-01-01         New Year's Day
2020-04-10            Good Friday
2020-04-13          Easter Monday
2020-05-08         ad-hoc holiday
2020-05-25    Spring Bank Holiday
2020-08-31    Summer Bank Holiday
2020-12-25              Christmas
2020-12-26             Boxing Day
2020-12-28     Weekend Boxing Day
dtype: objectNote that the ad-hoc holiday on 2020-05-08 (Queen Elizabeth II 75th anniversary) is included in the combined holiday
calendar, together with all regular holidays during the period.Quarterly and monthly expiry days:importexchange_calendars_extensionsasecxecx.apply_extensions()importexchange_calendarsaseccalendar=ec.get_calendar('XLON')print(calendar.quarterly_expiries.holidays(start='2023-01-01',end='2023-12-31',return_name=True))print(calendar.monthly_expiries.holidays(start='2023-01-01',end='2023-12-31',return_name=True))will output2023-03-17    quarterly expiry
2023-06-16    quarterly expiry
2023-09-15    quarterly expiry
2023-12-15    quarterly expiry
dtype: object
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: objectLast trading days of months:importexchange_calendars_extensionsasecxecx.apply_extensions()importexchange_calendarsaseccalendar=ec.get_calendar('XLON')print(calendar.last_trading_days_of_months.holidays(start='2023-01-01',end='2023-12-31',return_name=True))print(calendar.last_regular_trading_days_of_months.holidays(start='2023-01-01',end='2023-12-31',return_name=True))will output2023-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
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: objectNote the difference in December, where 2023-12-29 is a special close day, so 2023-12-28 is the last regular trading day
in that month.Adding special daysTheexchange_calendars_extensionsmodule provides the methodsadd_holiday(...),add_special_open(...),add_special_close(...),add_monthly_expiry(...)andadd_quarterly_expiry(...)to add holidays and other types of
special days. For example,importexchange_calendars_extensionsasecxecx.apply_extensions()importexchange_calendarsasececx.add_holiday('XLON',date='2022-12-28',name='Holiday')calendar=ec.get_calendar('XLON')assert'2022-12-28'incalendar.regular_holidays.holidays()assert'2022-12-28'incalendar.holidays_all.holidays()will add a new holiday namedHolidayto the calendar for the London Stock Exchange on 28 December 2022. Holidays are
always added as regular holidays to allow for an individual name.Adding special open or close days works similarly, but needs the respective special open or close time:importexchange_calendars_extensionsasecxecx.apply_extensions()importexchange_calendarsasececx.add_special_open('XLON',date='2022-12-28',time='11:00',name='Special Open')calendar=ec.get_calendar('XLON')assert'2022-12-28'incalendar.special_opens_all.holidays()A more generic way to add a special day is viaadd_day(...)which takes either aDaySpec(holidays,
monthly/quarterly expiries) orDaySpecWithTime(special open/close days) Pydantic model:importexchange_calendars_extensionsasecxecx.apply_extensions()importexchange_calendarsasececx.add_day('XLON',ecx.DaySpec(date='2022-12-27',type=ecx.DayType.HOLIDAY,name='Holiday'))ecx.add_day('XLON',ecx.DaySpecWithTime(date='2022-12-28',type=ecx.DayType.SPECIAL_OPEN,name='Special Open',time='11:00'))calendar=ec.get_calendar('XLON')assert'2022-12-27'incalendar.regular_holidays.holidays()assert'2022-12-27'incalendar.holidays_all.holidays()assert'2022-12-28'incalendar.special_opens_all.holidays()Thanks to Pydantic, an even easier way is to just use suitable dictionaries:importexchange_calendars_extensionsasecxecx.apply_extensions()importexchange_calendarsasececx.add_day('XLON',{'date':'2022-12-27','type':'holiday','name':'Holiday'})ecx.add_day('XLON',{'date':'2022-12-28','type':'special_open','name':'Special Open','time':'11:00'})calendar=ec.get_calendar('XLON')assert'2022-12-27'incalendar.regular_holidays.holidays()assert'2022-12-27'incalendar.holidays_all.holidays()assert'2022-12-28'incalendar.special_opens_all.holidays()The dictionary format makes it particularly easy to read in changes from an external source like a file.Removing special sessionsTo remove a day as a special day (of any type) from a calendar, useremove_day(...). For example,importexchange_calendars_extensionsasecxecx.apply_extensions()importexchange_calendarsasececx.remove_day('XLON','2022-12-27')calendar=ec.get_calendar('XLON')assert'2022-12-27'notincalendar.regular_holidays.holidays()assert'2022-12-27'notincalendar.holidays_all.holidays()will remove the holiday on 27 December 2022 from the calendar, thus turning this day into a regular trading day.Removing a day viaremove_day(...)that is not actually a special day, results in no change and does not throw an
exception.Specifying dates, times, and day typesThanks to Pydantic, dates, times, and the types of special day can typically be specified in different formats and will
safely be parsed into the correct data type that is used internally.For example, wherever the API expects a date, you may pass in apandas.Timestamp, adatetime.dateobject, or simply
a string in ISO formatYYYY-MM-DD. Similarly, wall clock times can be passed asdatetime.timeobjects or as strings
in the formatHH:MM:SSorHH:MM.The enumeration typeecx.DayTyperepresents types of special days, API calls accept either enumeration members or
their string value. For example,ecx.DayType.HOLIDAYand'holiday'are equivalent.Change visibilityWhenever a calendar has been modified programmatically, the changes are only reflected after obtaining a new exchange
calendar instance.importexchange_calendars_extensionsasecxecx.apply_extensions()importexchange_calendarsaseccalendar=ec.get_calendar('XLON')# Unchanged calendar.assert'2022-12-27'incalendar.holidays_all.holidays()assert'2022-12-28'notincalendar.holidays_all.holidays()# Modify calendar. This clears the cache, so ec.get_calendar('XLON') will return a new instance next time.ecx.add_holiday('XLON','2022-12-28','Holiday')ecx.remove_day('XLON','2022-12-27')# Changes not reflected in existing instance.assert'2022-12-27'incalendar.holidays_all.holidays()assert'2022-12-28'notincalendar.holidays_all.holidays()# Get new instance.calendar=ec.get_calendar('XLON')# Changes reflected in new instance.assert'2022-12-27'notincalendar.holidays_all.holidays()assert'2022-12-28'incalendar.holidays_all.holidays()# Revert the changes.ecx.reset_calendar('XLON')# Get new instance.calendar=ec.get_calendar('XLON')# Changes reverted in new instance.assert'2022-12-27'incalendar.holidays_all.holidays()assert'2022-12-28'notincalendar.holidays_all.holidays()ChangesetsWhen modifying an exchange calendar, the changes are recorded in anecx.ChangeSetassociated with the corresponding
exchange. When a new calendar instance is created, the changes are applied to the calendar, as seen above.It is also possible to create a changeset separately and then associate it with a particular exchange:importexchange_calendars_extensionsasecxecx.apply_extensions()importexchange_calendarsasecchangeset:ecx.ChangeSet=ecx.ChangeSet()changeset.add_day({'date':'2022-12-28','type':'holiday','name':'Holiday'})changeset.remove_day('2022-12-27')ecx.update_calendar('XLON',changeset)calendar=ec.get_calendar('XLON')assert'2022-12-27'notincalendar.holidays_all.holidays()assert'2022-12-28'incalendar.holidays_all.holidays()Again, an entire changeset can also be created from a suitably formatted dictionary, making it particularly easy to read
in and apply changes from an external source like a file.importexchange_calendars_extensionsasecxecx.apply_extensions()importexchange_calendarsasecchangeset:ecx.ChangeSet=ecx.ChangeSet(**{'add':[{'date':'2022-12-28','type':'holiday','name':'Holiday'}],'remove':['2022-12-27']})ecx.update_calendar('XLON',changeset)calendar=ec.get_calendar('XLON')assert'2022-12-27'notincalendar.holidays_all.holidays()assert'2022-12-28'incalendar.holidays_all.holidays()Adding and removing the same dayThe API permits to add and remove the same day as a special day. For example, the following code will add a holiday on
28 December 2022 to the calendar, and then remove the same day as well.importexchange_calendars_extensionsasecxecx.apply_extensions()importexchange_calendarsasececx.add_holiday('XLON',date='2022-12-28',name='Holiday')ecx.remove_day('XLON',date='2022-12-28')calendar=ec.get_calendar('XLON')assert'2022-12-28'incalendar.holidays_all.holidays()The result is that the day is a holiday in the changed calendar. These semantics of the API may be surprising, but make
more sense in a case where a day is added to change its type of special day. Consider the date2022-12-27which was a
holiday for the calendarXLONin the original version of the calendar. The following code will change the type of
special day to a special open by first removing the day (as a holiday), and then adding it back as a special open day:importexchange_calendars_extensionsasecxecx.apply_extensions()importexchange_calendarsasececx.remove_day('XLON',date='2022-12-27')ecx.add_special_open('XLON',date='2022-12-27',name='Special Open',time='11:00')calendar=ec.get_calendar('XLON')assert'2022-12-27'notincalendar.holidays_all.holidays()assert'2022-12-27'incalendar.special_opens_all.holidays()Removing a day does not consider the type of special day and thus will convert any type of special day into a regular
trading day (if the weekmask permits). Adding a day will add it as the specified type of special day. Together, this
allows to change the type of special day in an existing calendar from one to another.In fact, internally, each added days is always implicitly also removed from the calendar first, so that it strictly is
not necessary (but allowed) to explicitly remove a day, and then adding it back as a different type of special day:importexchange_calendars_extensionsasecxecx.apply_extensions()importexchange_calendarsasec# It is enough to add an existing special day with a new type to change the type of special day.ecx.add_special_open('XLON',date='2022-12-27',name='Special Open',time='11:00')calendar=ec.get_calendar('XLON')# No longer a holiday.assert'2022-12-27'notincalendar.holidays_all.holidays()# Now a special open.assert'2022-12-27'incalendar.special_opens_all.holidays()Changeset consistencyAs seen above, changesets may contain the same day both in the list of days to add and in the list of days to remove.
However, changesets enforce consistency and will raise an exception if the same day is added more than once.
For example, the following code will raise an exception:importexchange_calendars_extensionsasecxecx.apply_extensions()ecx.add_holiday('XLON',date='2022-12-28',name='Holiday')ecx.add_special_open('XLON',date='2022-12-28',name='Special Open',time='11:00')In contrast, removing a day is an idempotent operation, i.e. doing it twice will not raise an exception and keep the
corresponding changeset the same as after the first removal.importexchange_calendars_extensionsasecxecx.apply_extensions()ecx.remove_day('XLON',date='2022-12-27')ecx.remove_day('XLON',date='2022-12-27')Reverting changesIt is sometimes necessary to revert individual changes made to a calendar. To that end the package provides the methodreset_day(...):importexchange_calendars_extensionsasecxecx.apply_extensions()importexchange_calendarsasececx.add_holiday('XLON','2022-12-28','Holiday')ecx.remove_day('XLON','2022-12-27')calendar=ec.get_calendar('XLON')assert'2022-12-27'notincalendar.holidays_all.holidays()assert'2022-12-28'incalendar.holidays_all.holidays()ecx.reset_day('XLON','2022-12-28')ecx.reset_day('XLON','2022-12-27')calendar=ec.get_calendar('XLON')# Calendar unchanged again.assert'2022-12-27'incalendar.holidays_all.holidays()assert'2022-12-28'notincalendar.holidays_all.holidays()To reset an entire calendar to its original state, use the methodreset_calendar(...)or update the calendar with an
empty ChangeSet:importexchange_calendars_extensionsasecxecx.apply_extensions()importexchange_calendarsasececx.add_holiday('XLON','2022-12-28','Holiday')ecx.remove_day('XLON','2022-12-27')calendar=ec.get_calendar('XLON')assert'2022-12-27'notincalendar.holidays_all.holidays()assert'2022-12-28'incalendar.holidays_all.holidays()# Same as ecx.update_calendar('XLON', ecx.ChangeSet())ecx.reset_calendar('XLON')calendar=ec.get_calendar('XLON')# Calendar unchanged again.assert'2022-12-27'incalendar.holidays_all.holidays()assert'2022-12-28'notincalendar.holidays_all.holidays()Retrieving changesFor any calendar, it is possible to retrieve a copy of the associated changeset:importexchange_calendars_extensionsasecxecx.apply_extensions()ecx.add_holiday('XLON',date='2022-12-28',name='Holiday')ecx.remove_day('XLON',date='2022-12-27')changeset:ecx.ChangeSet=ecx.get_changes_for_calendar('XLON')print(changeset)Output:add=[DaySpec(date=Timestamp('2022-12-28 00:00:00'), name='Holiday', type=<DayType.HOLIDAY: 'holiday'>)] remove=[Timestamp('2022-12-27 00:00:00')]Sinceecx.get_changes_for_calendar(...)returns a copy of the changeset, any modifications to the returned changeset
will not affect the calendar.To get the changesets for all calendars, useecx.get_changes_for_all_calendars(). This returns a dictionary that
mapping the exchange name/key to a copy of the corresponding changeset.Supported exchanges for monthly/quarterly expiryThis package currently provides support for monthly/quarterly expiry calendars for the following subset of exchanges
fromexchange_calendars:ASEXBMEXXAMSXBRUXBUDXCSEXDUBXETRXHELXISTXJSEXLISXLONXMADXMILXNASXNYSXOSLXPARXPRAXSTOXSWXXTAEXTSEXWARXWBOAdvanced usageAdding an extended calendar for a new exchangeTo facilitate the creation of extended exchange calendar classes, the functionextend_classis provided in the
sub-moduleexchange_calendars_extensions.holiday_calendar.fromexchange_calendars.exchange_calendar_xlonimportXLONExchangeCalendarfromexchange_calendars_extensionsimportextend_classxlon_extended_cls=extend_class(XLONExchangeCalendar,day_of_week_expiry=4)The first argument toextend_classshould be the class of the exchange calendar to extend. The second and optional
parameter, which defaults toNone, is the day of the week on which expiry days are normally observed. If this parameter
isNone, this assumes that the underlying exchange does not support monthly or quarterly expiry days and the respective
calendars will not be added.The returned extended class directly inherits from the passed base class and adds the additional attributes likeholidays_allet cetera. The returned class also supports programmatic modifications using the corresponding exchange
key of the parent class.To register a new extended class for an exchange, use theregister_extension()function before callingapply_extensions().fromexchange_calendars_extensionsimportregister_extension,apply_extensionsregister_extension(key,cls)apply_extensions()...Here,keyshould be the name, i.e. not an alias, under which the extended class is registered with theexchange_calendarspackage, andclsshould be the extended class.Caveat: Merging holiday calendarsFor the various calendars,exchange-calendarsdefines and uses the classexchange_calendars.exchange_calendar.HolidayCalendarwhich is a direct subclass of the abstract base classpandas.tseries.holiday.AbstractHolidayCalendar.One of the assumptions ofAbstractHolidayCalendaris that each contained rule that defines a holiday has a unique
name. Thus, when merging two calendars via the.merge()method, the resulting calendar will only retain a single rule
for each name, eliminating any duplicates.This creates a problem with the calendars provided by this package. For example, constructing the holiday calendar
backingholidays_allrequires to add a rule for each ad-hoc holiday. However, since ad-hoc holidays don't define a
unique name, each rule would either have to generate a unique name for itself, or use the same name as the other rules.
This package uses the latter approach, i.e. all ad-hoc holidays are assigned the same namead-hoc holiday.As a result, the built-in merge functionality ofAbstractHolidayCalendarwould eliminate all but one of the ad-hoc
holidays when merging with another calendar. This is not the desired behavior.To avoid this problem, this package defines the functionmerge_calendars(calendars: Iterable[AbstractHolidayCalendar])which returns a calendar that simply concatenates, in order, all rules from the passed-in calendars. The returned
calendar is a subclass ofHolidayCalendarthat handles possible duplicates by filtering them out before returning
from a call toholidays().In essence: Always usemerge_calendars(...)instead ofAbstractHolidayCalendar.merge(...)when merging involves
any of the calendars added by this package. Keep in mind that for duplicate elimination, rules more to the front of the
list have higher priority.ContributingContributions are welcome. Please open an issue or submit a pull request on GitHub.