diff --git a/.github/workflows/publish-rc.yml b/.github/workflows/publish-rc.yml index 9dfa50c..47f6ba7 100644 --- a/.github/workflows/publish-rc.yml +++ b/.github/workflows/publish-rc.yml @@ -46,6 +46,6 @@ jobs: i=0 while (($i<120)) && [[ ! $(curl --max-time 120 -s https://test.pypi.org/pypi/${{ env.PACKAGE_NAME }}/json | jq -r '.releases | keys[]') =~ (^|[[:space:]])${{ env.RC_VERSION }}($|[[:space:]]) ]];\ do echo waiting for package to appear in test index, sleeping 5s; sleep 5s; let i++; done - pip install --index-url https://test.pypi.org/simple ${{ env.PACKAGE_NAME }}==${{ env.RC_VERSION }} --no-deps + pip install --no-cache-dir --index-url https://test.pypi.org/simple ${{ env.PACKAGE_NAME }}==${{ env.RC_VERSION }} --no-deps pip install -r requirements.txt python -c 'import ${{ env.PACKAGE_NAME }};print(${{ env.PACKAGE_NAME }}.__version__)' diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4c2c3b4..bc47c5a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -38,7 +38,7 @@ jobs: i=0 while (($i<120)) && [[ ! $(curl --max-time 120 -s https://test.pypi.org/pypi/${{ env.PACKAGE_NAME }}/json | jq -r '.releases | keys[]') =~ (^|[[:space:]])${{ github.ref_name }}($|[[:space:]]) ]];\ do echo waiting for package to appear in test index, sleeping 5s; sleep 5s; let i++; done - pip install --index-url https://test.pypi.org/simple ${{ env.PACKAGE_NAME }}==${{ github.ref_name }} --no-deps + pip install --no-cache-dir --index-url https://test.pypi.org/simple ${{ env.PACKAGE_NAME }}==${{ github.ref_name }} --no-deps pip install -r requirements.txt python -c 'import ${{ env.PACKAGE_NAME }};print(${{ env.PACKAGE_NAME }}.__version__)' - name: Clean pip @@ -55,5 +55,5 @@ jobs: i=0 while (($i<120)) && [[ ! $(curl --max-time 120 -s https://pypi.org/pypi/${{ env.PACKAGE_NAME }}/json | jq -r '.releases | keys[]') =~ (^|[[:space:]])${{ github.ref_name }}($|[[:space:]]) ]];\ do echo waiting for package to appear in index, sleeping 5s; sleep 5s; let i++; done - pip install --index-url https://pypi.org/simple ${{ env.PACKAGE_NAME }}==${{ github.ref_name }} + pip install --no-cache-dir --index-url https://pypi.org/simple ${{ env.PACKAGE_NAME }}==${{ github.ref_name }} python -c 'import ${{ env.PACKAGE_NAME }};print(${{ env.PACKAGE_NAME }}.__version__)' diff --git a/README.md b/README.md index 925f6aa..8e98d4f 100644 --- a/README.md +++ b/README.md @@ -5,20 +5,22 @@ A Python package that transparently adds some features to the [exchange-calendar package. For all exchanges, this package adds the following: -- Calendars that combine existing regular and ad-hoc holidays or special open/close days into a single +- 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 last *regular* trading session of each month. -- The ability to modify exising calendars by adding or removing holidays, special open/close days, or others, - programmatically at runtime. +- 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 quarterly expiry days (aka quadruple witching). +- 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 calendars This package adds combined calendars for holidays and special open/close days, respectively. These calendars combine -regular with ad-hoc occurrences of each type of day. Note that for special open/close days, this may -aggregate days with different open/close times into a single calendar. From the calendar, the open/close time for each -contained day cannot be recovered. +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 calendars In addition to information that is already available in @@ -27,9 +29,9 @@ the following trading sessions: - last trading session of the month, and - last *regular* trading session of the month. -For select exchanges (see [below](#supported-exchanges)), this package also adds calendars for: +For select exchanges (see [below](#supported-exchanges-for-monthlyquarterly-expiry)), this package also adds calendars for: - quarterly expiry days (aka quadruple witching), and -- monthly expiry days (in all months without quarterly expiry day). +- monthly 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. @@ -41,9 +43,8 @@ This package also adds the ability to modify existing calendars at runtime. This - quarterly expiry days, and - monthly expiry days. -This is useful for example when an exchange announces a special trading session on short notice, or when the exchange -announces a change to the regular trading schedule, and the next release of the exchange-calendars package may not be -in time. +This is useful, for example, when an exchange announces a change to the regular trading schedule on short notice, and +the next release of the `exchange-calendars` package, including these changes, is not available yet. ## Installation The package is available on [PyPI](https://pypi.org/project/exchange-calendars-extensions/) and can be installed via @@ -57,45 +58,52 @@ pip install exchange-calendars-extensions ## Usage Import the package and register extended exchange calendar classes with the `exchange_calendars` module. ```python -import exchange_calendars_extensions as ece +import exchange_calendars_extensions as ecx -ece.apply_extensions() +ecx.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 -class `ece.ExtendedExchangeCalendar`. This class inherits both from +class `ecx.ExtendedExchangeCalendar`. This class inherits both from `ec.ExchangeCalendar` and the new protocol class -`ece.ExchangeCalendarExtensions` which defines the extended properties. +`ecx.ExchangeCalendarExtensions` which defines the extended properties. ```python -import exchange_calendars_extensions as ece -ece.apply_extensions() +import exchange_calendars_extensions as ecx +ecx.apply_extensions() import exchange_calendars as ec calendar = ec.get_calendar('XLON') -assert isinstance(calendar, ece.ExtendedExchangeCalendar) +# It's still a regular exchange calendar. assert isinstance(calendar, ec.ExchangeCalendar) -assert isinstance(calendar, ece.ExchangeCalendarExtensions) + +# But it's also an extended exchange calendar... +assert isinstance(calendar, ecx.ExtendedExchangeCalendar) +# ...and implements the extended protocol. +assert isinstance(calendar, ecx.ExchangeCalendarExtensions) ``` -The original classes can be reinstated by calling `ece.remove_extensions()`. +The original classes can be re-instated by calling `ecx.remove_extensions()`. ```python -import exchange_calendars_extensions as ece -ece.apply_extensions() +import exchange_calendars_extensions as ecx +ecx.apply_extensions() import exchange_calendars as ec ... -ece.remove_extensions() +ecx.remove_extensions() calendar = ec.get_calendar('XLON') -assert not isinstance(calendar, ece.ExtendedExchangeCalendar) +# It's a regular exchange calendar. assert isinstance(calendar, ec.ExchangeCalendar) -assert not isinstance(calendar, ece.ExchangeCalendarExtensions) + +# But it's not an extended exchange calendar anymore. +assert not isinstance(calendar, ecx.ExtendedExchangeCalendar) +assert not isinstance(calendar, ecx.ExchangeCalendarExtensions) ``` ### Additional properties @@ -116,9 +124,10 @@ Extended exchange calendars provide the following calendars as properties: - `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, ```python -import exchange_calendars_extensions as ece -ece.apply_extensions() +import exchange_calendars_extensions as ecx +ecx.apply_extensions() import exchange_calendars as ec calendar = ec.get_calendar('XLON') @@ -137,13 +146,13 @@ will output 2020-12-28 Weekend Boxing Day dtype: object ``` -Note that the ad-hoc holiday on 2020-05-08 (Queen Elizabeth II 75th anniversary) is included in the holiday calendar, -even though it is not a regular holiday. +Note 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: ```python -import exchange_calendars_extensions as ece -ece.apply_extensions() +import exchange_calendars_extensions as ecx +ecx.apply_extensions() import exchange_calendars as ec calendar = ec.get_calendar('XLON') @@ -170,8 +179,8 @@ dtype: object Last trading days of months: ```python -import exchange_calendars_extensions as ece -ece.apply_extensions() +import exchange_calendars_extensions as ecx +ecx.apply_extensions() import exchange_calendars as ec @@ -208,53 +217,114 @@ dtype: object 2023-12-28 last regular trading day of month dtype: object ``` -Note the difference in December, where 2023-12-29 is a special close day, while 2023-12-28 is a regular trading day. +Note 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/removing holidays and special sessions -The extended classes provide methods of the form -`{add,remove}_{holiday,special_open,special_close,quarterly_expiry,monthly_expiry}(...)`at the package level to add or -remove certain holidays or special sessions programmatically. For example, +### Adding special days +The `exchange_calendars_extensions` module provides the methods `add_holiday(...)`, `add_special_open(...)`, +`add_special_close(...)`, `add_monthly_expiry(...)` and `add_quarterly_expiry(...)` to add holidays and other types of +special days. For example, ```python -import exchange_calendars_extensions as ece -ece.apply_extensions() +import exchange_calendars_extensions as ecx +ecx.apply_extensions() import exchange_calendars as ec -ece.add_holiday('XLON', '2022-12-28', 'Holiday') +ecx.add_holiday('XLON', date='2022-12-28', name='Holiday') calendar = ec.get_calendar('XLON') assert '2022-12-28' in calendar.regular_holidays.holidays() assert '2022-12-28' in calendar.holidays_all.holidays() ``` -will add a new holiday named `Holiday` to the calendar for the London Stock Exchange on 28 December 2022. Similarly, +will add a new holiday named `Holiday` to 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: +```python +import exchange_calendars_extensions as ecx + +ecx.apply_extensions() +import exchange_calendars as ec + +ecx.add_special_open('XLON', date='2022-12-28', time='11:00', name='Special Open') + +calendar = ec.get_calendar('XLON') + +assert '2022-12-28' in calendar.special_opens_all.holidays() +``` + +A more generic way to add a special day is via `add_day(...)` which takes either a `DaySpec` (holidays, +monthly/quarterly expiries) or `DaySpecWithTime` (special open/close days) Pydantic model: ```python -import exchange_calendars_extensions as ece -ece.apply_extensions() +import exchange_calendars_extensions as ecx +ecx.apply_extensions() import exchange_calendars as ec -ece.remove_holiday('XLON', '2022-12-27') +ecx.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' in calendar.regular_holidays.holidays() +assert '2022-12-27' in calendar.holidays_all.holidays() +assert '2022-12-28' in calendar.special_opens_all.holidays() +``` + +Thanks to Pydantic, an even easier way is to just use suitable dictionaries: +```python +import exchange_calendars_extensions as ecx +ecx.apply_extensions() +import exchange_calendars as ec + +ecx.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' in calendar.regular_holidays.holidays() +assert '2022-12-27' in calendar.holidays_all.holidays() +assert '2022-12-28' in calendar.special_opens_all.holidays() +``` +The dictionary format makes it particularly easy to read in changes from an external source like a file. + +### Removing special sessions + +To remove a day as a special day (of any type) from a calendar, use `remove_day(...)`. For example, +```python +import exchange_calendars_extensions as ecx +ecx.apply_extensions() +import exchange_calendars as ec + +ecx.remove_day('XLON', '2022-12-27') calendar = ec.get_calendar('XLON') assert '2022-12-27' not in calendar.regular_holidays.holidays() assert '2022-12-27' not in calendar.holidays_all.holidays() ``` -will remove the holiday on 27 December 2022 from the calendar. +will remove the holiday on 27 December 2022 from the calendar, thus turning this day into a regular trading day. -To specify the date, you can use anything that can be converted into a valid `pandas.Timestamp`. This includes strings -in the format `YYYY-MM-DD`, `datetime.date` objects, or `pandas.Timestamp` objects. The name of the holiday can be any -string. +Removing a day via `remove_day(...)` that is not actually a special day, results in no change and does not throw an +exception. -Holidays are always added as regular holidays to allow for an individual name. Removing holidays works for both regular -and ad-hoc holidays, regardless whether the affected days are in the original calendar or have been added -programmatically at an earlier stage. +### Specifying dates, times, and day types +Thanks 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 a `pandas.Timestamp`, a `datetime.date` object, or simply +a string in ISO format `YYYY-MM-DD`. Similarly, wall clock times can be passed as `datetime.time` objects or as strings +in the format `HH:MM:SS` or `HH:MM`. + +The enumeration type `ecx.DayType` represents types of special days, API calls accept either enumeration members or +their string value. For example, `ecx.DayType.HOLIDAY` and `'holiday'` are equivalent. + +### Change visibility Whenever a calendar has been modified programmatically, the changes are only reflected after obtaining a new exchange calendar instance. ```python -import exchange_calendars_extensions as ece -ece.apply_extensions() +import exchange_calendars_extensions as ecx +ecx.apply_extensions() import exchange_calendars as ec calendar = ec.get_calendar('XLON') @@ -263,9 +333,9 @@ calendar = ec.get_calendar('XLON') assert '2022-12-27' in calendar.holidays_all.holidays() assert '2022-12-28' not in calendar.holidays_all.holidays() -# Modify calendar. Clears the cache, so ec.get_calendar('XLON') will return a new instance next time. -ece.add_holiday('XLON', '2022-12-28', 'Holiday') -ece.remove_holiday('XLON', '2022-12-27') +# 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' in calendar.holidays_all.holidays() @@ -279,8 +349,7 @@ assert '2022-12-27' not in calendar.holidays_all.holidays() assert '2022-12-28' in calendar.holidays_all.holidays() # Revert the changes. -ece.remove_holiday('XLON', '2022-12-28') -ece.add_holiday('XLON', '2022-12-27', 'Weekend Christmas') +ecx.reset_calendar('XLON') # Get new instance. calendar = ec.get_calendar('XLON') @@ -290,116 +359,142 @@ assert '2022-12-27' in calendar.holidays_all.holidays() assert '2022-12-28' not in calendar.holidays_all.holidays() ``` -It is possible to add or remove holidays, special open/close days, and quarterly/monthly expiries. Adding special -open/close days requires to specify the open/close time in addition to the date. +### Changesets +When modifying an exchange calendar, the changes are recorded in an `ecx.ChangeSet` associated 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: ```python -import exchange_calendars_extensions as ece -ece.apply_extensions() +import exchange_calendars_extensions as ecx +ecx.apply_extensions() import exchange_calendars as ec -calendar = ec.get_calendar('XLON') - -assert '2022-12-28' not in calendar.special_opens_all.holidays() +changeset: ecx.ChangeSet = ecx.ChangeSet() +changeset.add_day({'date': '2022-12-28', 'type': 'holiday', 'name': 'Holiday'}) +changeset.remove_day('2022-12-27') -ece.add_special_open('XLON', '2022-12-28', '11:00', 'Special Open') +ecx.update_calendar('XLON', changeset) calendar = ec.get_calendar('XLON') -assert '2022-12-28' in calendar.special_opens_all.holidays() +assert '2022-12-27' not in calendar.holidays_all.holidays() +assert '2022-12-28' in calendar.holidays_all.holidays() ``` - -The open/close time can be in the format `HH:MM` or `HH:MM:SS`, or a `datetime.time` object. - -The ennumeration `DayType` can be used to add or remove holidays in a more generic way. - +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. ```python -import exchange_calendars_extensions as ece -ece.apply_extensions() +import exchange_calendars_extensions as ecx +ecx.apply_extensions() import exchange_calendars as ec -ece.add_day('XLON', '2022-12-28', {'name': 'Special Open', 'time': '11:00'}, ece.DayType.SPECIAL_OPEN) - -calendar = ec.get_calendar('XLON') - -assert '2022-12-28' in calendar.special_opens_all.holidays() +changeset: ecx.ChangeSet = ecx.ChangeSet(**{ + 'add': [{'date': '2022-12-28', 'type': 'holiday', 'name': 'Holiday'}], + 'remove': ['2022-12-27']}) -ece.remove_day('XLON', '2022-12-28', ece.DayType.SPECIAL_OPEN) +ecx.update_calendar('XLON', changeset) calendar = ec.get_calendar('XLON') -assert '2022-12-28' not in calendar.special_opens_all.holidays() +assert '2022-12-27' not in calendar.holidays_all.holidays() +assert '2022-12-28' in calendar.holidays_all.holidays() ``` -When removing a day, the day type is optional. In this case, the day is just removed for all day types. +### Adding and removing the same day + +The 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. ```python -import exchange_calendars_extensions as ece -ece.apply_extensions() +import exchange_calendars_extensions as ecx +ecx.apply_extensions() import exchange_calendars as ec -ece.add_day('XLON', '2022-12-28', {'name': 'Special Open', 'time': '11:00'}, ece.DayType.SPECIAL_OPEN) +ecx.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-27' in calendar.holidays_all.holidays() -assert '2022-12-28' in calendar.special_opens_all.holidays() +assert '2022-12-28' in calendar.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 date `2022-12-27` which was a +holiday for the calendar `XLON` in 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: +```python +import exchange_calendars_extensions as ecx +ecx.apply_extensions() +import exchange_calendars as ec -ece.remove_day('XLON', '2022-12-27') -ece.remove_day('XLON', '2022-12-28') +ecx.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' not in calendar.holidays_all.holidays() -assert '2022-12-28' not in calendar.special_opens_all.holidays() +assert '2022-12-27' in calendar.special_opens_all.holidays() ``` -This is useful to ensure that a given day does not mark a holiday or any special session. Note that a day could still be -a weekend day and that removing the day does not change it into a business day. - -### Reverting changes - -It is sometimes necessary to revert individual changes made to a calendar such as adding or removing holidays or special -sessions. To that end, the package provides the methods of the form -`reset_{holiday,special_open,special_close,quarterly_expiry,monthly_expiry}(...)` at the package level. +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: ```python -import exchange_calendars_extensions as ece -ece.apply_extensions() +import exchange_calendars_extensions as ecx +ecx.apply_extensions() import exchange_calendars as ec -ece.add_holiday('XLON', '2022-12-28', 'Holiday') -ece.remove_holiday('XLON', '2022-12-27') +# 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' not in calendar.holidays_all.holidays() -assert '2022-12-28' in calendar.holidays_all.holidays() +# Now a special open. +assert '2022-12-27' in calendar.special_opens_all.holidays() +``` -ece.reset_holiday('XLON', '2022-12-28') -ece.reset_holiday('XLON', '2022-12-27') +### Changeset consistency +As 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: +```python +import exchange_calendars_extensions as ecx +ecx.apply_extensions() -calendar = ec.get_calendar('XLON') +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. +```python +import exchange_calendars_extensions as ecx +ecx.apply_extensions() -# Calendar unchanged again. -assert '2022-12-27' in calendar.holidays_all.holidays() -assert '2022-12-28' not in calendar.holidays_all.holidays() +ecx.remove_day('XLON', date='2022-12-27') +ecx.remove_day('XLON', date='2022-12-27') ``` -The is also a more generic method `reset_day(...)` that can be used to reset a day for a given day type. Again, -`day_type` is optional and omitting the argument will just reset the day for all day types. +### Reverting changes + +It is sometimes necessary to revert individual changes made to a calendar. To that end the package provides the method +`reset_day(...)`: + ```python -import exchange_calendars_extensions as ece -ece.apply_extensions() +import exchange_calendars_extensions as ecx +ecx.apply_extensions() import exchange_calendars as ec -ece.add_holiday('XLON', '2022-12-28', 'Holiday') -ece.remove_holiday('XLON', '2022-12-27') +ecx.add_holiday('XLON', '2022-12-28', 'Holiday') +ecx.remove_day('XLON', '2022-12-27') calendar = ec.get_calendar('XLON') assert '2022-12-27' not in calendar.holidays_all.holidays() assert '2022-12-28' in calendar.holidays_all.holidays() -ece.reset_day('XLON', '2022-12-27', ece.DayType.HOLIDAY) -ece.reset_day('XLON', '2022-12-28') +ecx.reset_day('XLON', '2022-12-28') +ecx.reset_day('XLON', '2022-12-27') calendar = ec.get_calendar('XLON') @@ -408,22 +503,23 @@ assert '2022-12-27' in calendar.holidays_all.holidays() assert '2022-12-28' not in calendar.holidays_all.holidays() ``` -To reset an entire calendar to its original state, use the method `reset_calendar(...)`. - +To reset an entire calendar to its original state, use the method `reset_calendar(...)` or update the calendar with an +empty ChangeSet: ```python -import exchange_calendars_extensions as ece -ece.apply_extensions() +import exchange_calendars_extensions as ecx +ecx.apply_extensions() import exchange_calendars as ec -ece.add_holiday('XLON', '2022-12-28', 'Holiday') -ece.remove_holiday('XLON', '2022-12-27') +ecx.add_holiday('XLON', '2022-12-28', 'Holiday') +ecx.remove_day('XLON', '2022-12-27') calendar = ec.get_calendar('XLON') assert '2022-12-27' not in calendar.holidays_all.holidays() assert '2022-12-28' in calendar.holidays_all.holidays() -ece.reset_calendar('XLON') +# Same as ecx.update_calendar('XLON', ecx.ChangeSet()) +ecx.reset_calendar('XLON') calendar = ec.get_calendar('XLON') @@ -432,217 +528,31 @@ assert '2022-12-27' in calendar.holidays_all.holidays() assert '2022-12-28' not in calendar.holidays_all.holidays() ``` - -### Strict mode - -Removing a day is always handled gracefully when the day is not already present as a special day in the calendar, i.e. -this does not throw an exception. In contrast, the same day cannot be added for multiple day types at the same time. By default, this -is also handled gracefully by keeping just the last definition in place. - -This default behavior may sometimes be inadequate, e.g. when it is important to enforce consistency of all applied -changes. For example, the following code will not throw an exception: +### Retrieving changes +For any calendar, it is possible to retrieve a copy of the associated changeset: ```python -import exchange_calendars_extensions as ece -ece.apply_extensions() -import exchange_calendars as ec - -ece.add_day('XLON', '2022-12-28', {'name': 'Special Open', 'time': '11:00'}, ece.DayType.SPECIAL_OPEN) -ece.add_day('XLON', '2022-12-28', {'name': 'Special Close', 'time': '12:00'}, ece.DayType.SPECIAL_CLOSE) +import exchange_calendars_extensions as ecx +ecx.apply_extensions() -c = ec.get_calendar('XLON') +ecx.add_holiday('XLON', date='2022-12-28', name='Holiday') +ecx.remove_day('XLON', date='2022-12-27') -assert '2022-12-28' not in c.special_opens_all.holidays() -assert '2022-12-28' in c.special_closes_all.holidays() +changeset: ecx.ChangeSet = ecx.get_changes_for_calendar('XLON') +print(changeset) ``` - -This is because the default behavior keeps the special close day that supersedes the special open day. - -To enforce consistency of all changes at any stage, the optional keyword argument `strict` maybe be set to `True`. For -example, -```python -import exchange_calendars_extensions as ece -ece.apply_extensions() -import exchange_calendars as ec - -ece.add_day('XLON', '2022-12-28', {'name': 'Special Open', 'time': '11:00'}, ece.DayType.SPECIAL_OPEN, strict=True) -ece.add_day('XLON', '2022-12-28', {'name': 'Special Close', 'time': '12:00'}, ece.DayType.SPECIAL_CLOSE, strict=True) - -c = ec.get_calendar('XLON') +Output: ``` -will throw `ValidationError` when the same day is added a second time for a different day types. The same is true when -the same day is added and removed for the same day type. -```python -import exchange_calendars_extensions as ece -ece.apply_extensions() -import exchange_calendars as ec - -ece.add_day('XLON', '2022-12-28', {'name': 'Special Open', 'time': '11:00'}, ece.DayType.SPECIAL_OPEN, strict=True) -ece.remove_day('XLON', '2022-12-28', ece.DayType.SPECIAL_OPEN, strict=True) - -c = ec.get_calendar('XLON') +add=[DaySpec(date=Timestamp('2022-12-28 00:00:00'), name='Holiday', type=)] remove=[Timestamp('2022-12-27 00:00:00')] ``` -### Changesets +Since `ecx.get_changes_for_calendar(...)` returns a copy of the changeset, any modifications to the returned changeset +will not affect the calendar. -Internally, whenever a calendar for an exchange is modified through a call to an appropriate function, the resulting -changes are accumulated in a changeset, i.e. a collection of zero or more changes, that are specific to that exchange. -That is, subsequent calls for the same exchange will update the same changeset. - -When a new exchange calendar instance is created, the changes from the corresponding changeset are applied to the -underlying and still unmodified exchange calendar. This is why a new fresh instance of a calendar needs to be obtained -to reflect any previously made changes. - -As a user, you don't need to interact with changesets directly, but it is important to understand the concept to -understand the behavior of this package. - -Changesets have a notion of consistency. A changeset is consistent if and only if the following conditions are satisfied: -1) For each day type, the corresponding dates to add and dates to remove do not overlap. -2) For each distinct pair of day types, the dates to add do not overlap. - -The first condition ensures that the same day is not added and removed at the same time for the same day type. The -second condition ensures that the same day is not added for two different day types. Note that marking the same day as -a day to remove is valid for multiple day types at the same time since this it will be a no-op if the day is not -actually present in the calendar for a day type. - -In essence, consistency ensures that the changes in a changeset do not contradict each other. - -By default, any call to a function that modifies a calendar will ensure that the resulting changeset is consistent, as -described above. This can be disabled by setting the optional keyword argument `strict` to `False`. In this case, the -function throws an exception if the resulting changeset would become inconsistent. The underlying changeset remains in -the last consistent state. - - -### Applying changesets to a calendar - -In certain scenarios, it may be desirable to apply an entire set of changes to an exchange calendar. For example, the -changes could be read from a file or a database. This can be achieved using the `update_calendar(...)` function. For -example, -```python -import exchange_calendars_extensions as ece -ece.apply_extensions() -import exchange_calendars as ec - -changes = { - "holiday": { - "add": {"2022-01-10": {"name": "Holiday"}}, - "remove": ["2022-01-11"] - }, - "special_open": { - "add": {"2022-01-12": {"name": "Special Open", "time": "10:00"}}, - "remove": ["2022-01-13"] - }, - "special_close": { - "add": {"2022-01-14": {"name": "Special Close", "time": "16:00"}}, - "remove": ["2022-01-17"] - }, - "monthly_expiry": { - "add": {"2022-01-18": {"name": "Monthly Expiry"}}, - "remove": ["2022-01-19"] - }, - "quarterly_expiry": { - "add": {"2022-01-20": {"name": "Quarterly Expiry"}}, - "remove": ["2022-01-21"] - } -} - -ece.update_calendar('XLON', changes) - -calendar = ec.get_calendar('XLON') - -assert '2022-01-10' in calendar.holidays_all.holidays() -assert '2022-01-11' not in calendar.holidays_all.holidays() -assert '2022-01-12' in calendar.special_opens_all.holidays() -assert '2022-01-13' not in calendar.special_opens_all.holidays() -assert '2022-01-14' in calendar.special_closes_all.holidays() -assert '2022-01-17' not in calendar.special_closes_all.holidays() -assert '2022-01-18' in calendar.monthly_expiries.holidays() -assert '2022-01-19' not in calendar.monthly_expiries.holidays() -assert '2022-01-20' in calendar.quarterly_expiries.holidays() -assert '2022-01-21' not in calendar.quarterly_expiries.holidays() -``` - - -### Normalization -A changeset needs to be consistent before it can be applied to an exchange calendar. However, consistency alone is not -enough to ensure that an exchange calendar with a changeset applied is itself consistent. The reason this can happen is -that a changeset e.g. may add a holiday, but the unmodified exchange calendar may already contain the same day as a -special open day. This is to say that the resulting calendar would contain the same day with two different, but mutually -exclusive, day types. - -To ensure that an exchange calendar with a changeset applied is consistent, the changeset is normalized before it is -applied. Normalization ensures that the same day can only be contained with one day type in the resulting exchange -calendar. This is achieved by augmenting the changeset before it is applied to remove any day that is added with one day -type from all other day types. For example, this means that if a day is a holiday in the original exchange calendar, but -the changeset adds the same day as a special open day, the resulting calendar will contain the day as a special open -day. In essence, adding days may overwrite the day type if the original calendar already contained the same day. - -Normalization happens transparently to the user, this section is only included to explain the rationale behind it. -Ensuring consistency of a changeset is enough to make it compatible with any exchange calendar, owing to the -normalization behind the scenes. - -### Reading changesets from dictionaries. -Entire changesets can be applied to an exchange calendar can be imported through appropriately structured -dictionaries. This enables reading and then applying entire collections of changes from files and other sources. -```python -from exchange_calendars_extensions import update_calendar -from exchange_calendars_extensions import get_calendar - -changes = { - "holiday": { - "add": [{"date": "2020-01-01", "value": {"name": "Holiday"}}], - "remove": ["2020-01-02"] - }, - "special_open": { - "add": [{"date": "2020-02-01", "value": {"name": "Special Open", "time": "10:00"}}], - "remove": ["2020-02-02"] - }, - "special_close": { - "add": [{"date": "2020-03-01", "value": {"name": "Special Close", "time": "16:00"}}], - "remove": ["2020-03-02"] - }, - "monthly_expiry": { - "add": [{"date": "2020-04-01", "value": {"name": "Monthly Expiry"}}], - "remove": ["2020-04-02"] - }, - "quarterly_expiry": { - "add": [{"date": "2020-05-01", "value": {"name": "Quarterly Expiry"}}], - "remove": ["2020-05-02"] - } -} - -update_calendar('XLON', changes) - -calendar = get_calendar('XLON') -# Calendar now contains the changes from the dictionary. -``` -The above example lays out the complete schema that is expected for obtaining a changeset from a dictionary. Instead of -dates in ISO format, `pandas.Timestamp` instances may be used. Similarly, wall-clock times may be specified -as `datetime.time` instances. SO, the following woulw work as well: -```python -update_calendar('XLON', { - 'special_open': { - 'add': [{"date": pd.Timestamp("2020-02-01"), "value": {"name": "Special Open", "time": dt.time(10, 0)}}] - } -}) -``` - -Updating an exchange calendar from a dictionary removes any previous changes that have been recorded, i.e. the incoming -changes are not merged with the existing ones. This is to ensure that the resulting calendar is consistent. Of course, -the incoming changes must result in a consistent changeset themselves or an exception will be raised. - -A use case for updating an exchange calendar from a dictionary is to read changes from a file. The following example -reads changes from a JSON file and applies them to the exchange calendar. -```python -import json - -with open('changes.json', 'r') as f: - changes = json.load(f) - -update_calendar('XLON', changes) -``` +To get the changesets for all calendars, use `ecx.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 expiry -This package currently provides support for monthly/querterly expiry calendars for the following subset of exchanges +This package currently provides support for monthly/quarterly expiry calendars for the following subset of exchanges from `exchange_calendars`: - ASEX - BMEX @@ -704,21 +614,21 @@ apply_extensions() Here, `key` should be the name, i.e. not an alias, under which the extended class is registered with the `exchange_calendars` package, and `cls` should be the extended class. -## Caveat: Merging calendars +## Caveat: Merging holiday calendars For the various calendars, [exchange-calendars](https://pypi.org/project/exchange-calendars/) defines and uses the class `exchange_calendars.exchange_calendar.HolidayCalendar` which is a direct subclass of the abstract base class `pandas.tseries.holiday.AbstractHolidayCalendar`. -One of the assumptions of `AbstractHolidayCalendar` is 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. +One of the assumptions of `AbstractHolidayCalendar` is 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 backing `holidays_all` requires 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 name `ad-hoc holiday`. -As a result, the built-in merge functionality of `AbstractHolidayCalendar` will eliminate all but one of the ad-hoc +As a result, the built-in merge functionality of `AbstractHolidayCalendar` would 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 function `merge_calendars(calendars: Iterable[AbstractHolidayCalendar])` diff --git a/exchange_calendars_extensions/__init__.py b/exchange_calendars_extensions/__init__.py index 1bda630..20857a5 100644 --- a/exchange_calendars_extensions/__init__.py +++ b/exchange_calendars_extensions/__init__.py @@ -1,5 +1,5 @@ import functools -from datetime import time +import datetime as dt from typing import Optional, Callable, Type, Union, Any, Dict from exchange_calendars import calendar_utils, register_calendar_type, ExchangeCalendar, get_calendar_names @@ -28,9 +28,10 @@ from exchange_calendars.exchange_calendar_xtse import XTSEExchangeCalendar from exchange_calendars.exchange_calendar_xwar import XWARExchangeCalendar from exchange_calendars.exchange_calendar_xwbo import XWBOExchangeCalendar +from pydantic import validate_call from typing_extensions import ParamSpec, Concatenate -from .changeset import ChangeSet, DayType, DaySpec, DayWithTimeSpec +from .changeset import ChangeSet, DayType, DaySpec, DaySpecWithTime, TimestampLike from .holiday_calendar import extend_class, ExtendedExchangeCalendar, ExchangeCalendarExtensions # Dictionary that maps from exchange key to ExchangeCalendarChangeSet. Contains all changesets to apply when creating a @@ -97,7 +98,6 @@ def fn() -> ChangeSet: # Store the original class for later use. _original_classes[k] = cls # Create extended class. - print(f'Extending {k}: {cls}') cls = extend_class(cls, day_of_week_expiry=None, changeset_provider=get_changeset_fn(k)) # Register extended class. register_calendar_type(k, cls, force=True) @@ -204,9 +204,6 @@ def wrapper(exchange: str, *args: P.args, **kwargs: P.kwargs) -> None: # Call wrapped function with changeset as first positional argument. cs = f(cs, *args, **kwargs) -# if not cs.is_consistent(): -# raise ValueError(f'Changeset for {str} is inconsistent: {cs}.') - if cs is not None: # Save changeset back to _changesets. _changesets[exchange] = cs @@ -224,7 +221,7 @@ def wrapper(exchange: str, *args: P.args, **kwargs: P.kwargs) -> None: @_with_changeset -def _add_day(cs: ChangeSet, date: Any, value: Union[DaySpec, DayWithTimeSpec, dict], day_type: DayType, strict: bool) -> ChangeSet: +def _add_day(cs: ChangeSet, spec: Union[DaySpec, DaySpecWithTime, dict]) -> ChangeSet: """ Add a day of a given type to the changeset for a given exchange calendar. @@ -232,14 +229,8 @@ def _add_day(cs: ChangeSet, date: Any, value: Union[DaySpec, DayWithTimeSpec, di ---------- cs : ChangeSet The changeset to which to add the day. - date : Any - The date to add. Must be convertible to pandas.Timestamp. - value : Union[DaySpec, DaySpecWithTime, dict] + spec : Union[DaySpec, DaySpecWithTime, dict] The properties to add for the day. Must match the properties required by the given day type. - day_type : DayType - The type of the day to add. - strict : bool - Whether to raise an error if the changeset would be inconsistent after adding the day. Returns ------- @@ -251,13 +242,11 @@ def _add_day(cs: ChangeSet, date: Any, value: Union[DaySpec, DayWithTimeSpec, di ValueError If the changeset would be inconsistent after adding the day. """ - if not strict: - cs.clear_day(date) - - return cs.add_day(date, value, day_type) + return cs.add_day(spec) -def add_day(exchange: str, date: Any, value: Union[DaySpec, DayWithTimeSpec], day_type: DayType, strict: bool = False) -> None: +@validate_call +def add_day(exchange: str, spec: Union[DaySpec, DaySpecWithTime, dict]) -> None: """ Add a day of a given type to the given exchange calendar. @@ -265,14 +254,8 @@ def add_day(exchange: str, date: Any, value: Union[DaySpec, DayWithTimeSpec], da ---------- exchange : str The exchange key for which to add the day. - date : Any - The date to add. Must be convertible to pandas.Timestamp. - value : Union[DaySpec, DaySpecWithTime] + spec : Union[DaySpec, DaySpecWithTime, dict] The properties to add for the day. Must match the properties required by the given day type. - day_type : DayType - The type of the day to add. - strict : bool - Whether to raise an error if the changeset for the exchange would be inconsistent after adding the day. Returns ------- @@ -283,11 +266,11 @@ def add_day(exchange: str, date: Any, value: Union[DaySpec, DayWithTimeSpec], da ValidationError If strict is True and the changeset for the exchange would be inconsistent after adding the day. """ - _add_day(exchange, date, value, day_type, strict=strict) + _add_day(exchange, spec) @_with_changeset -def _remove_day(cs: ChangeSet, date: Any, day_type: DayType, strict: bool) -> ChangeSet: +def _remove_day(cs: ChangeSet, date: TimestampLike) -> ChangeSet: """ Remove a day of a given type from the changeset for a given exchange calendar. @@ -295,12 +278,8 @@ def _remove_day(cs: ChangeSet, date: Any, day_type: DayType, strict: bool) -> Ch ---------- cs : ChangeSet The changeset from which to remove the day. - date : Any + date : TimestampLike The date to remove. Must be convertible to pandas.Timestamp. - day_type : DayType - The type of the day to remove. - strict : bool - Whether to raise an error if the changeset would be inconsistent after removing the day. Returns ------- @@ -312,13 +291,10 @@ def _remove_day(cs: ChangeSet, date: Any, day_type: DayType, strict: bool) -> Ch ValidationError If strict is True and the changeset for the exchange would be inconsistent after removing the day. """ - if not strict: - cs.clear_day(date) - - return cs.remove_day(date, day_type) + return cs.remove_day(date) -def remove_day(exchange: str, date: Any, day_type: Optional[DayType] = None, strict: bool = False) -> None: +def remove_day(exchange: str, date: TimestampLike) -> None: """ Remove a day of a given type from the given exchange calendar. @@ -326,12 +302,8 @@ def remove_day(exchange: str, date: Any, day_type: Optional[DayType] = None, str ---------- exchange : str The exchange key for which to remove the day. - date : Any + date : TimestampLike The date to remove. Must be convertible to pandas.Timestamp. - day_type : Optional[DayType] - The type of the day to remove. If None, removes the day for all types of days. - strict : bool - Whether to raise an error if the changeset for the exchange would be inconsistent after removing the day. Returns ------- @@ -342,11 +314,11 @@ def remove_day(exchange: str, date: Any, day_type: Optional[DayType] = None, str ValidationError If strict is True and the changeset for the exchange would be inconsistent after removing the day. """ - _remove_day(exchange, date, day_type, strict=strict) + _remove_day(exchange, date) @_with_changeset -def _reset_day(cs: ChangeSet, date: Any, day_type: Optional[DayType] = None) -> ChangeSet: +def _reset_day(cs: ChangeSet, date: TimestampLike) -> ChangeSet: """ Clear a day of a given type from the changeset for a given exchange calendar. @@ -354,20 +326,18 @@ def _reset_day(cs: ChangeSet, date: Any, day_type: Optional[DayType] = None) -> ---------- cs : ChangeSet The changeset from which to clear the day. - date : Any + date : TimestampLike The date to clear. Must be convertible to pandas.Timestamp. - day_type : Optional[DayType] - The type of the day to clear. If None, clears all types of days. Returns ------- ChangeSet The changeset with the cleared day. """ - return cs.clear_day(date, day_type) + return cs.clear_day(date) -def reset_day(exchange: str, date: Any, day_type: Optional[DayType] = None) -> None: +def reset_day(exchange: str, date: TimestampLike) -> None: """ Clear a day of a given type from the given exchange calendar. @@ -375,19 +345,17 @@ def reset_day(exchange: str, date: Any, day_type: Optional[DayType] = None) -> N ---------- exchange : str The exchange key for which to clear the day. - date : Any + date : TimestampLike The date to clear. Must be convertible to pandas.Timestamp. - day_type : Optional[DayType] - The type of the day to clear. If None, clears all types of days. Returns ------- None """ - _reset_day(exchange, date, day_type) + _reset_day(exchange, date) -def add_holiday(exchange: str, date: Any, name: str = "Holiday", strict: bool = False) -> None: +def add_holiday(exchange: str, date: TimestampLike, name: str = "Holiday") -> None: """ Add a holiday to an exchange calendar. @@ -395,12 +363,10 @@ def add_holiday(exchange: str, date: Any, name: str = "Holiday", strict: bool = ---------- exchange : str The exchange key for which to add the day. - date : Any + date : TimestampLike The date to add. Must be convertible to pandas.Timestamp. name : str The name of the holiday. - strict : bool - Whether to raise an error if the changeset for the exchange would be inconsistent after adding the day. Returns ------- @@ -411,53 +377,10 @@ def add_holiday(exchange: str, date: Any, name: str = "Holiday", strict: bool = ValidationError If strict is True and the changeset for the exchange would be inconsistent after adding the day. """ - _add_day(exchange, date, {"name": name}, DayType.HOLIDAY, strict=strict) - - -def remove_holiday(exchange: str, date: Any, strict: bool = False) -> None: - """ - Remove a holiday from an exchange calendar. - - Parameters - ---------- - exchange : str - The exchange key for which to remove the day. - date : Any - The date to remove. Must be convertible to pandas.Timestamp. - strict : bool - Whether to raise an error if the changeset for the exchange would be inconsistent after removing the day. + _add_day(exchange, {'date': date, 'type': DayType.HOLIDAY, 'name': name}) - Returns - ------- - None - Raises - ------ - ValidationError - If strict is True and the changeset for the exchange would be inconsistent after removing the day. - """ - _remove_day(exchange, date, DayType.HOLIDAY, strict=strict) - - -def reset_holiday(exchange: str, date: Any) -> None: - """ - Clear a holiday from an exchange calendar. - - Parameters - ---------- - exchange : str - The exchange key for which to clear the day. - date : Any - The date to clear. Must be convertible to pandas.Timestamp. - - Returns - ------- - None - """ - _reset_day(exchange, date, DayType.HOLIDAY) - - -def add_special_open(exchange: str, date: Any, t: Union[time, str], name: str = "Special Open", strict: bool = False) -> None: +def add_special_open(exchange: str, date: TimestampLike, time: Union[dt.time, str], name: str = "Special Open") -> None: """ Add a special open to an exchange calendar. @@ -465,14 +388,12 @@ def add_special_open(exchange: str, date: Any, t: Union[time, str], name: str = ---------- exchange : str The exchange key for which to add the day. - date : Any + date : TimestampLike The date to add. Must be convertible to pandas.Timestamp. - t : Union[time, str] + time : Union[time, str] The time of the special open. If a string, must be in the format 'HH:MM' or 'HH:MM:SS'. name : str The name of the special open. - strict : bool - Whether to raise an error if the changeset for the exchange would be inconsistent after adding the day. Returns ------- @@ -483,53 +404,10 @@ def add_special_open(exchange: str, date: Any, t: Union[time, str], name: str = ValidationError If strict is True and the changeset for the exchange would be inconsistent after adding the day. """ - _add_day(exchange, date, {"name": name, "time": t}, DayType.SPECIAL_OPEN, strict=strict) - - -def remove_special_open(exchange: str, date: Any, strict: bool = False) -> None: - """ - Remove a special close from an exchange calendar. - - Parameters - ---------- - exchange : str - The exchange key for which to remove the day. - date : Any - The date to remove. Must be convertible to pandas.Timestamp. - strict : bool - Whether to raise an error if the changeset for the exchange would be inconsistent after removing the day. - - Returns - ------- - None - - Raises - ------ - ValidationError - If strict is True and the changeset for the exchange would be inconsistent after removing the day. - """ - _remove_day(exchange, date, DayType.SPECIAL_OPEN, strict=strict) + _add_day(exchange, {'date': date, 'type': DayType.SPECIAL_OPEN, 'name': name, 'time': time}) -def reset_special_open(exchange: str, date: Any) -> None: - """ - Clear a special open from an exchange calendar. - - Parameters - ---------- - exchange : str - The exchange key for which to clear the day. - date : Any - The date to clear. Must be convertible to pandas.Timestamp. - - Returns - ------- - None - """ - _reset_day(exchange, date, DayType.SPECIAL_OPEN) - - -def add_special_close(exchange: str, date: Any, t: Union[time, str], name: str = "Special Close", strict: bool = False) -> None: +def add_special_close(exchange: str, date: TimestampLike, time: Union[dt.time, str], name: str = "Special Close") -> None: """ Add a special close to an exchange calendar. @@ -537,14 +415,12 @@ def add_special_close(exchange: str, date: Any, t: Union[time, str], name: str = ---------- exchange : str The exchange key for which to add the day. - date : Any + date : TimestampLike The date to add. Must be convertible to pandas.Timestamp. - t : Union[time, str] + time : Union[time, str] The time of the special close. If a string, must be in the format 'HH:MM' or 'HH:MM:SS'. name : str The name of the special close. - strict : bool - Whether to raise an error if the changeset for the exchange would be inconsistent after adding the day. Returns ------- @@ -555,53 +431,10 @@ def add_special_close(exchange: str, date: Any, t: Union[time, str], name: str = ValidationError If strict is True and the changeset for the exchange would be inconsistent after adding the day. """ - _add_day(exchange, date, {"name": name, "time": t}, DayType.SPECIAL_CLOSE, strict=strict) - - -def remove_special_close(exchange: str, date: Any, strict: bool = False) -> None: - """ - Remove a special close from an exchange calendar. - - Parameters - ---------- - exchange : str - The exchange key for which to remove the day. - date : Any - The date to remove. Must be convertible to pandas.Timestamp. - strict : bool - Whether to raise an error if the changeset for the exchange would be inconsistent after removing the day. - - Returns - ------- - None - - Raises - ------ - ValidationError - If strict is True and the changeset for the exchange would be inconsistent after removing the day. - """ - _remove_day(exchange, date, DayType.SPECIAL_CLOSE, strict=strict) - - -def reset_special_close(exchange: str, date: Any) -> None: - """ - Clear a special close from an exchange calendar. - - Parameters - ---------- - exchange : str - The exchange key for which to clear the day. - date : Any - The date to clear. Must be convertible to pandas.Timestamp. - - Returns - ------- - None - """ - _reset_day(exchange, date, DayType.SPECIAL_CLOSE) + _add_day(exchange, {'date': date, 'type': DayType.SPECIAL_CLOSE, 'name': name, 'time': time}) -def add_quarterly_expiry(exchange: str, date: Any, name: str = "Quarterly Expiry", strict: bool = False) -> None: +def add_quarterly_expiry(exchange: str, date: TimestampLike, name: str = "Quarterly Expiry") -> None: """ Add a quarterly expiry to an exchange calendar. @@ -609,12 +442,10 @@ def add_quarterly_expiry(exchange: str, date: Any, name: str = "Quarterly Expiry ---------- exchange : str The exchange key for which to add the day. - date : Any + date : TimestampLike The date to add. Must be convertible to pandas.Timestamp. name : str The name of the quarterly expiry. - strict : bool - Whether to raise an error if the changeset for the exchange would be inconsistent after adding the day. Returns ------- @@ -625,53 +456,10 @@ def add_quarterly_expiry(exchange: str, date: Any, name: str = "Quarterly Expiry ValidationError If strict is True and the changeset for the exchange would be inconsistent after adding the day. """ - _add_day(exchange, date, {"name": name}, DayType.QUARTERLY_EXPIRY, strict=strict) - - -def remove_quarterly_expiry(exchange: str, date: Any, strict: bool = False) -> None: - """ - Remove a quarterly expiry from an exchange calendar. - - Parameters - ---------- - exchange : str - The exchange key for which to remove the day. - date : Any - The date to add. Must be convertible to pandas.Timestamp. - strict : bool - Whether to raise an error if the changeset for the exchange would be inconsistent after removing the day. - - Returns - ------- - None - - Raises - ------ - ValidationError - If strict is True and the changeset for the exchange would be inconsistent after removing the day. - """ - _remove_day(exchange, date, DayType.QUARTERLY_EXPIRY, strict=strict) - - -def reset_quarterly_expiry(exchange: str, date: Any) -> None: - """ - Clear a quarterly expiry from an exchange calendar. - - Parameters - ---------- - exchange : str - The exchange key for which to clear the day. - date : Any - The date to clear. Must be convertible to pandas.Timestamp. - - Returns - ------- - None - """ - _reset_day(exchange, date, DayType.QUARTERLY_EXPIRY) + _add_day(exchange, {'date': date, 'type': DayType.QUARTERLY_EXPIRY, 'name': name}) -def add_monthly_expiry(exchange: str, date: Any, name: str = "Monthly Expiry", strict: bool = False) -> None: +def add_monthly_expiry(exchange: str, date: Any, name: str = "Monthly Expiry") -> None: """ Add a monthly expiry to an exchange calendar. @@ -679,12 +467,10 @@ def add_monthly_expiry(exchange: str, date: Any, name: str = "Monthly Expiry", s ---------- exchange : str The exchange key for which to add the day. - date : Any + date : TimestampLike The date to add. Must be convertible to pandas.Timestamp. name : str The name of the monthly expiry. - strict : bool - Whether to raise an error if the changeset for the exchange would be inconsistent after adding the day. Returns ------- @@ -695,54 +481,11 @@ def add_monthly_expiry(exchange: str, date: Any, name: str = "Monthly Expiry", s ValidationError If strict is True and the changeset for the exchange would be inconsistent after adding the day. """ - _add_day(exchange, date, {"name": name}, DayType.MONTHLY_EXPIRY, strict=strict) - - -def remove_monthly_expiry(exchange: str, date: Any, strict: bool = False) -> None: - """ - Remove a monthly expiry from an exchange calendar. - - Parameters - ---------- - exchange : str - The exchange key for which to remove the day. - date : Any - The date to remove. Must be convertible to pandas.Timestamp. - strict : bool - Whether to raise an error if the changeset for the exchange would be inconsistent after removing the day. - - Returns - ------- - None - - Raises - ------ - ValidationError - If strict is True and the changeset for the exchange would be inconsistent after removing the day. - """ - _remove_day(exchange, date, DayType.MONTHLY_EXPIRY, strict=strict) - - -def reset_monthly_expiry(exchange: str, date: Any) -> None: - """ - Clear a monthly expiry from an exchange calendar. - - Parameters - ---------- - exchange : str - The exchange key for which to clear the day. - date : Any - The date to clear. Must be convertible to pandas.Timestamp. - - Returns - ------- - None - """ - _reset_day(exchange, date, DayType.MONTHLY_EXPIRY) + _add_day(exchange, {'date': date, 'type': DayType.MONTHLY_EXPIRY, 'name': name}) @_with_changeset -def _reset_calendar(cs: ChangeSet) -> None: +def _reset_calendar(cs: ChangeSet) -> ChangeSet: """ Reset an exchange calendar to its original state. @@ -753,7 +496,8 @@ def _reset_calendar(cs: ChangeSet) -> None: Returns ------- - None + ChangeSet + The reset changeset. """ return cs.clear() @@ -787,16 +531,19 @@ def reset_all_calendars() -> None: @_with_changeset -def _update_calendar(_: ChangeSet, changes: dict) -> ChangeSet: - return ChangeSet(**changes) +def _update_calendar(_: ChangeSet, changes: ChangeSet) -> ChangeSet: + return changes -def update_calendar(exchange: str, changes: dict) -> None: +@validate_call +def update_calendar(exchange: str, changes: Union[ChangeSet, dict]) -> None: """ Apply changes to an exchange calendar. Parameters ---------- + exchange : str + The exchange key for which to apply the changes. changes : dict The changes to apply. @@ -843,12 +590,10 @@ def get_changes_for_all_calendars() -> dict: # Declare public names. __all__ = ["apply_extensions", "remove_extensions", "register_extension", "extend_class", "DayType", "add_day", - "remove_day", "reset_day", "DaySpec", "DayWithTimeSpec", "add_holiday", "remove_holiday", "reset_holiday", - "add_special_close", "remove_special_close", "reset_special_close", "add_special_open", - "remove_special_open", "reset_special_open", "add_quarterly_expiry", "remove_quarterly_expiry", - "reset_quarterly_expiry", "add_monthly_expiry", "remove_monthly_expiry", "reset_monthly_expiry", - "reset_calendar", "reset_all_calendars", "update_calendar", "get_changes_for_calendar", - "get_changes_for_all_calendars", "ChangeSet", "ExtendedExchangeCalendar", "ExchangeCalendarExtensions"] + "remove_day", "reset_day", "DaySpec", "DaySpecWithTime", "add_holiday", "add_special_close", + "add_special_open", "add_quarterly_expiry", "add_monthly_expiry", "reset_calendar", "reset_all_calendars", + "update_calendar", "get_changes_for_calendar", "get_changes_for_all_calendars", "ChangeSet", + "ExtendedExchangeCalendar", "ExchangeCalendarExtensions"] __version__ = None diff --git a/exchange_calendars_extensions/changeset.py b/exchange_calendars_extensions/changeset.py index 7e48db5..1d957ed 100644 --- a/exchange_calendars_extensions/changeset.py +++ b/exchange_calendars_extensions/changeset.py @@ -1,67 +1,30 @@ import datetime as dt -from copy import deepcopy from enum import Enum, unique -from typing import Set, Generic, TypeVar, Dict, Union, Optional, Any +from functools import reduce +from typing import Union, List, Tuple import pandas as pd -from pydantic import BaseModel, Field, model_validator, ValidationInfo +from pydantic import BaseModel, Field, model_validator, validate_call from pydantic.functional_validators import BeforeValidator -from typing_extensions import Self, Annotated +from typing_extensions import Self, Annotated, Literal -class DaySpec(BaseModel, extra='forbid'): - """ - A model for a simple special day specification, for example, holidays. - """ - name: str # The name of the special day. - - -def _to_time(value: Union[dt.time, str]): - """ - Convert value to time. - - Parameters - ---------- - value : Union[dt.time, str] - The value to convert. - - Returns - ------- - dt.time - The converted value. - - Raises - ------ - ValueError - If the value cannot be converted to dt.time. +@unique +class DayType(str, Enum): """ - if not isinstance(value, dt.time): - for f in ('%H:%M', '%H:%M:%S'): - try: - value = dt.datetime.strptime(value, f).time() - break - except ValueError: - pass - - if not isinstance(value, dt.time): - raise ValueError(f'Failed to convert {value} to {dt.time}.') - - return value - - -# A type alias for dt.time that allows initialisation from suitably formatted string values. -TimeLike = Annotated[dt.time, BeforeValidator(_to_time)] - + Enum for the different types of holidays and special sessions. -class DayWithTimeSpec(BaseModel, extra='forbid'): - """ - A model for a special day specification that includes a time, for example, special opens. + HOLIDAY: A holiday. + SPECIAL_OPEN: A special session with a special opening time. + SPECIAL_CLOSE: A special session with a special closing time. + MONTHLY_EXPIRY: A monthly expiry. + QUARTERLY_EXPIRY: A quarterly expiry. """ - name: str # The name of the special day. - time: TimeLike # The open/close time of the special day. - - -DaySpecT = TypeVar('DaySpecT') + HOLIDAY = 'holiday' + SPECIAL_OPEN = 'special_open' + SPECIAL_CLOSE = 'special_close' + MONTHLY_EXPIRY = 'monthly_expiry' + QUARTERLY_EXPIRY = 'quarterly_expiry' def _to_timestamp(value: Union[pd.Timestamp, str]) -> pd.Timestamp: @@ -98,222 +61,73 @@ def _to_timestamp(value: Union[pd.Timestamp, str]) -> pd.Timestamp: TimestampLike = Annotated[pd.Timestamp, BeforeValidator(_to_timestamp)] -class Changes(BaseModel, Generic[DaySpecT], extra='forbid', arbitrary_types_allowed=True, validate_assignment=True): +class AbstractDaySpec(BaseModel, arbitrary_types_allowed=True, validate_assignment=True, extra='forbid'): """ - Generic internal class to represent a set of calendar changes for a specific day type. - - Changes consist of a set of dates to remove and a dictionary of dates to add. The type parameter T specifies the - properties to hold for the dates to add. For example, for a holiday calendar, T would be a type containing - just the name of the holiday. For special open/close days, T would be a type containing the name of the respective - special day and the associated open/close time. + Abstract base class for special day specification. """ - add: Dict[TimestampLike, DaySpecT] = Field(default=dict()) # The dictionary of dates to add. - remove: Set[TimestampLike] = Field(default=set()) # The set of dates to remove. - - # noinspection PyMethodParameters - @model_validator(mode='after') - def _validate_consistency(cls, info: Union[dict, 'Changes']) -> Any: - # Check if there are any dates that are both added and removed. - duplicates = info['add'].keys() & info['remove'] if isinstance(info, dict) else info.add.keys() & info.remove - - if duplicates: - raise ValueError(f'Inconsistent changes: Dates {", ".join([d.date().isoformat() for d in duplicates])} are ' - f'both added and removed.') - - return info - - def add_day(self, date: TimestampLike, value: Union[DaySpecT, dict]) -> Self: - """ - Add a date to the set of dates to add. - - If strict is True, raise ValueError if the given date is already in the set of days to remove. If strict is - False, gracefully remove the date from the set of days to remove, if required, and then add it to the days to - add. Effectively, setting strict to True raises an Exception before any inconsistent changes are made while - setting strict to False enforces consistency by removing the date from the set of days to remove before adding. - - Adding the same day twice will overwrite the previous value without raising an exception. - - Parameters - ---------- - date : Any - The date to add. Must be convertible to pandas.Timestamp. - value : T - The value to add. - - Returns - ------- - Changes : self - - Raises - ------ - ValidationError - If value is not of the expected type or adding date would make the changes inconsistent. - """ - # Validate date. - date = TimestampLike(date) - - # Save previous value for key. Note that an existing value cannot be None, so we can use it to indicate absence. - previous = self.add.get(date, None) - - # Add new key and value. - self.add[date] = value - - try: - # Trigger validation. - self.add = self.add - except Exception as e: - if previous is None: - # Delete new entry. - del self.add[date] - else: - # Restore previous entry. - self.add[date] = previous - - # Re-raise exception. - raise e - - return self - - def remove_day(self, date: TimestampLike) -> Self: - """ - Add a date to the set of dates to remove. - - If strict is True, raise ValueError if the given date is already in the set of days to add. If strict is - False, gracefully remove the date from the set of days to add, if required, and then add it to the days to - remove. Effectively, setting strict to True raises an Exception before any inconsistent changes are made while - setting strict to False enforces consistency by removing the date from the set of days to add before removing. - - Removing the same day twice will be a no-op without raising an exception. - - Parameters - ---------- - date : Any - The date to remove. Must be convertible to pandas.Timestamp. - - Returns - ------- - Changes : self - - Raises - ------ - ValidationError - If removing date would make the changes inconsistent. - """ - # Validate date. - date = TimestampLike(date) - - # Add the holiday to the set of holidays to remove. - self.remove.add(date) - - try: - # Trigger validation. - self.remove = self.remove - except Exception as e: - # Remove the date from the set again. Since an exception was thrown, the date could not have been in the set - # in the first place. - self.remove.remove(date) - - # Re-raise exception. - raise e - - return self - - def clear_day(self, date: TimestampLike) -> Self: - """ - Reset a date so that it is neither in the set of dates to add nor the set of dates to remove. - - Parameters - ---------- - date : Any - The date to remove. Must be convertible to pandas.Timestamp. - - Returns - ------- - Changes - Self - """ - # Validate date. - date = TimestampLike(date) + date: TimestampLike # The date of the special day. + name: str # The name of the special day. - # Remove the holiday from the set of holidays to add. - # Check if holiday to remove is already in the dictionary of holidays to add. - if self.add.get(date) is not None: - # Remove element from the dictionary. - del self.add[date] +class DaySpec(AbstractDaySpec): + """ + Vanilla special day specification. + """ + type: Literal[DayType.HOLIDAY, DayType.MONTHLY_EXPIRY, DayType.QUARTERLY_EXPIRY] # The type of the special day. - # Remove the holiday from the set of holidays to remove. + def __str__(self): + return f'{{date={self.date.date().isoformat()}, type={self.type.name}, name="{self.name}"}}' - # Check if holiday to add is already in the set of holidays to remove. - if date in self.remove: - # Remove the holiday from the set of holidays to remove. - self.remove.remove(date) - return self +def _to_time(value: Union[dt.time, str]): + """ + Convert value to time. - def clear(self) -> Self: - """ - Clear all changes. + Parameters + ---------- + value : Union[dt.time, str] + The value to convert. - Returns - ------- - Changes : self - """ - self.add.clear() - self.remove.clear() + Returns + ------- + dt.time + The converted value. - return self + Raises + ------ + ValueError + If the value cannot be converted to dt.time. + """ + if not isinstance(value, dt.time): + for f in ('%H:%M', '%H:%M:%S'): + try: + value = dt.datetime.strptime(value, f).time() + break + except ValueError: + pass - def __len__(self) -> int: - return len(self.add) + len(self.remove) + if not isinstance(value, dt.time): + raise ValueError(f'Failed to convert {value} to {dt.time}.') - def __bool__(self): - return len(self) > 0 + return value - def __eq__(self, other) -> bool: - # Check if other is an instance of Changes. - if not isinstance(other, Changes): - return False - # Check if the dictionaries of dates to add and the sets of dates to remove are both equal. - return self.add == other.add and self.remove == other.remove +# A type alias for dt.time that allows initialisation from suitably formatted string values. +TimeLike = Annotated[dt.time, BeforeValidator(_to_time)] -@unique -class DayType(str, Enum): +class DaySpecWithTime(AbstractDaySpec): """ - Enum for the different types of holidays and special sessions. - - HOLIDAY: A holiday. - SPECIAL_OPEN: A special session with a special opening time. - SPECIAL_CLOSE: A special session with a special closing time. - MONTHLY_EXPIRY: A monthly expiry. - QUARTERLY_EXPIRY: A quarterly expiry. + Special day specification that requires a (open/close) time. """ - HOLIDAY = 'holiday' - SPECIAL_OPEN = 'special_open' - SPECIAL_CLOSE = 'special_close' - MONTHLY_EXPIRY = 'monthly_expiry' - QUARTERLY_EXPIRY = 'quarterly_expiry' - - -def _to_day_type(value: Union[DayType, str]) -> DayType: - if not isinstance(value, DayType): - try: - # Lookup via lower-case value. - value = DayType(value.lower()) - except ValueError as e: - # Failed to convert value to DayType. - raise ValueError(f'Failed to convert {value} to {DayType}.') from e - - return value - + type: Literal[DayType.SPECIAL_OPEN, DayType.SPECIAL_CLOSE] # The type of the special day. + time: TimeLike # The open/close time of the special day. -# A type alias for DayType that allows initialisation from suitably formatted string values. -DayTypeLike = Annotated[DayType, BeforeValidator(DayType.HOLIDAY)] + def __str__(self): + return f'{{date={self.date.date().isoformat()}, type={self.type.name}, name="{self.name}", time={self.time}}}' -class ChangeSet(BaseModel, extra='forbid', validate_assignment=True): +class ChangeSet(BaseModel, arbitrary_types_allowed=True, validate_assignment=True, extra='forbid'): """ Represents a modification to an existing exchange calendar. @@ -357,87 +171,63 @@ class ChangeSet(BaseModel, extra='forbid', validate_assignment=True): ensures that adding a new day for a given day type becomes an upsert operation, i.e. the day is added if it does not already exist in any day type category, and updated/moved to the new day type if it does. """ - holiday: Changes[DaySpec] = Changes[DaySpec]() - special_open: Changes[DayWithTimeSpec] = Changes[DayWithTimeSpec]() - special_close: Changes[DayWithTimeSpec] = Changes[DayWithTimeSpec]() - monthly_expiry: Changes[DaySpec] = Changes[DaySpec]() - quarterly_expiry: Changes[DaySpec] = Changes[DaySpec]() + add: List[Annotated[Union[DaySpec, DaySpecWithTime], Field(discriminator='type')]] = Field(default_factory=list) + remove: List[TimestampLike] = Field(default_factory=list) - # noinspection PyMethodParameters @model_validator(mode='after') - def _validate_consistency(cls, info: ValidationInfo) -> ValidationInfo: - # Maps each date to add to the corresponding day type(s) it appears in. - date2day_types = dict() + def _validate_consistency(self) -> 'ChangeSet': + add = sorted(self.add, key=lambda x: x.date) + remove = sorted(set(self.remove)) - for day_type in cls.model_fields.keys(): - c: Changes = info[day_type] if isinstance(info, dict) else getattr(info, day_type) - dates = c.add.keys() - for d in dates: - date2day_types[d] = date2day_types.get(d, set()) | {day_type} + # Get list of duplicate days to add. + if len(add) > 0: + dupes = list(filter(lambda x: len(x) > 1, reduce(lambda x, y: x[:-1] + ([x[-1] + [y]] if x[-1][0].date == y.date else [x[-1], [y]]), add[1:], [[add[0]]]))) - # Remove all entries from date2day_types that only appear in one day type. - date2day_types = {k: v for k, v in date2day_types.items() if len(v) > 1} + if len(dupes) > 0: + raise ValueError(f'Duplicates in days to add: {", ".join(("[" + ", ".join(map(str, d)) + "]" for d in dupes))}.') - # Check if there are any dates that appear in multiple day types. - if len(date2day_types) > 0: - raise ValueError(f'Inconsistent changes: Dates ' - f'{", ".join([d.date().isoformat() for d in date2day_types.keys()])} are each added for ' - f'more than one day type.') - - return info + self.__dict__['add'] = add + self.__dict__['remove'] = remove + return self - def add_day(self, date: Any, value: Union[DaySpec, DayWithTimeSpec, dict], day_type: DayTypeLike) -> Self: + @validate_call(config={'arbitrary_types_allowed': True}) + def add_day(self, spec: Annotated[Union[DaySpec, DaySpecWithTime, dict], Field(discriminator='type')]) -> Self: """ Add a day to the change set. Parameters ---------- - date : Any - The date to add. Must be convertible to pandas.Timestamp. - value : Any - The value to add. - day_type : Union[str, DayType] - The day type to add. + spec : Annotated[Union[DaySpec, DaySpecWithTime], Field(discriminator='type')] + The day to add. Returns ------- ExchangeCalendarChangeSet : self """ - # Convert to string representation. - day_type = DayTypeLike(day_type).value + self.add.append(spec) - # Add day to set of changes for day type. If this fails, the set of changes should be unmodified as a result, so - # that the changeset remains consistent. Just let the exception bubble up. - getattr(self, day_type).add_day(date, value) - - # If we get here, then the day has been added to the set of changes successfully. - - # Trigger validation of the entire changeset. + # Trigger validation. try: - setattr(self, day_type, getattr(self, day_type)) + self.model_validate(self, strict=True) except Exception as e: - # If the changeset is no longer consistent, then this can only be because the day was already a day to - # add for another day type before and this call added it to the given day type as well, leading to an - # invalid second add entry. - - # Restore the state before the call by clearing the day from the given day type. - getattr(self, day_type).clear_day(date) + # If the days to add are no longer consistent, then this can only be because the date was already in the + # list. Remove the offending duplicate. + self.add.remove(spec) # Let exception bubble up. raise e return self - def remove_day(self, date: Any, day_type: Optional[DayTypeLike] = None) -> Self: + @validate_call(config={'arbitrary_types_allowed': True}) + def remove_day(self, date: TimestampLike) -> Self: """ Remove a day from the change set. Parameters ---------- - date : Any - The date to add. Must be convertible to pandas.Timestamp. - day_type : Union[str, DayType] - The day type to remove. + date : TimestampLike + The date to remove. Returns ------- @@ -446,37 +236,40 @@ def remove_day(self, date: Any, day_type: Optional[DayTypeLike] = None) -> Self: Raises ------ ValueError - If removing the given date would make the changeset inconsistent. + If removing the given date would make the changeset inconsistent. This can only be if the date is already in + the days to remove. """ - # Determine which day types to remove day from. - day_types = (DayTypeLike(day_type).value,) if day_type is not None else tuple(self.model_fields.keys()) + self.remove.append(date) + + try: + # Trigger validation. + self.model_validate(self, strict=True) + except Exception as e: + self.remove.remove(date) - for k in day_types: - getattr(self, k).remove_day(date) + # Let exception bubble up. + raise e return self - def clear_day(self, date: Any, day_type: Optional[DayTypeLike] = None) -> Self: + @validate_call(config={'arbitrary_types_allowed': True}) + def clear_day(self, date: TimestampLike) -> Self: """ Clear a day from the change set. Parameters ---------- - date : Any - The date to add. Must be convertible to pandas.Timestamp. - day_type : Optional[Union[str, DayType]] - The day type to clear. If None, all day types will be cleared. + date : TimestampLike + The date to clear. Must be convertible to pandas.Timestamp. Returns ------- ExchangeCalendarChangeSet : self """ - # Determine which day types to clear. - day_types = (DayTypeLike(day_type).value,) if day_type is not None else tuple(self.model_fields.keys()) - for k in day_types: - # Clear for the given day type. - getattr(self, k).clear_day(date) + # Avoid re-validation since this change cannot make the changeset inconsistent. + self.__dict__['add'] = [x for x in self.add if x.date != date] + self.__dict__['remove'] = [x for x in self.remove if x != date] return self @@ -488,55 +281,30 @@ def clear(self) -> Self: ------- ExchangeCalendarChangeSet : self """ - # Clear all changes for all day types. - for k in self.model_fields.keys(): - getattr(self, k).clear() + self.add.clear() + self.remove.clear() return self def __len__(self): - return sum(len(getattr(self, k)) for k in self.model_fields.keys()) + return len(self.add) + len(self.remove) def __eq__(self, other): if not isinstance(other, ChangeSet): return False - return all(getattr(self, k) == getattr(other, k) for k in self.model_fields.keys()) + return self.add == other.add and self.remove == other.remove - def normalize(self, inplace: bool = False) -> Self: + @property + def all_days(self) -> Tuple[pd.Timestamp]: """ - Normalize the change set. + All unique dates contained in the changeset. - A change set is normalized if - 1) It is consistent. - 2) When applied to an exchange calendar, the resulting calendar is consistent. + This is the union of the dates to add and the dates to remove, with any duplicates removed. - Normalization is performed by adding each day to add (for any day type category) also as a day to remove for all - other day type categories. - - Parameters - ---------- - inplace : bool - If True, normalize the change set in-place. If False, return a normalized copy of the change set. + Returns + ------- + Iterable[pd.Timestamp] + All days in the changeset. """ - - # Determine instance to normalize. - if inplace: - # This instance. - cs: ChangeSet = self - else: - # A copy of this instance. - cs: ChangeSet = deepcopy(self) - - for day_type in DayType: - # Get the dates to add for the day type. - dates_to_add = getattr(cs, day_type).add.keys() - - # Loop over all day types. - for day_type0 in DayType.__members__.values(): - if day_type0 != day_type: - # Add the dates to add for day_type to the dates to remove for day_type0. - for date in dates_to_add: - cs.remove_day(date, day_type0) - - return cs + return tuple(sorted(set(map(lambda x: x.date, self.add)).union(set(self.remove)))) diff --git a/exchange_calendars_extensions/holiday_calendar.py b/exchange_calendars_extensions/holiday_calendar.py index 474426d..496bc69 100644 --- a/exchange_calendars_extensions/holiday_calendar.py +++ b/exchange_calendars_extensions/holiday_calendar.py @@ -12,7 +12,7 @@ from exchange_calendars.pandas_extensions.holiday import Holiday as ExchangeCalendarsHoliday from pandas.tseries.holiday import Holiday as PandasHoliday -from exchange_calendars_extensions import ChangeSet +from exchange_calendars_extensions import ChangeSet, DayType from exchange_calendars_extensions.holiday import get_monthly_expiry_holiday, DayOfWeekPeriodicHoliday, \ get_last_day_of_month_holiday @@ -664,7 +664,7 @@ def add_special_session(name: str, ts: pd.Timestamp, t: datetime.time, special_s # Loop over all times and the respective rules. for t0, rules in special_sessions: - # CHeck if time matches. + # Check if time matches. if t == t0: # Add to existing list. rules.append(h) @@ -760,107 +760,60 @@ def __init__(self, *args, **kwargs): if changeset is not None and len(changeset) <= 0: changeset = None - # Normalize changeset, maybe. if changeset is not None: - # Creates a normalized copy of the changeset. - changeset = changeset.normalize() - - if changeset is not None: - # Apply all changes pertaining to regular exchange calendar properties. - - # Remove holidays. - for ts in changeset.holiday.remove: - a.regular_holidays, a.adhoc_holidays = remove_holiday(ts, a.regular_holidays, a.adhoc_holidays) - - # Add holidays. - for ts, spec in changeset.holiday.add.items(): - name = spec.name - - # Remove existing holiday, maybe. + # Remove all changed days from holidays, special opens, and special closes. + for ts in changeset.all_days: a.regular_holidays, a.adhoc_holidays = remove_holiday(ts, a.regular_holidays, a.adhoc_holidays) - - # Add the holiday. - a.regular_holidays.append(Holiday(name, year=ts.year, month=ts.month, day=ts.day)) - - # Remove special opens. - for ts in changeset.special_open.remove: a.special_opens, a.adhoc_special_opens = remove_special_session(ts, a.special_opens, a.adhoc_special_opens) - - # Add special opens. - for ts, spec in changeset.special_open.add.items(): - name, t = spec.name, spec.time - - # Remove existing special open, maybe. - a.special_opens, a.adhoc_special_opens = remove_special_session(ts, a.special_opens, - a.adhoc_special_opens) - - # Add the special open. - a.special_opens = add_special_session(name, ts, t, a.special_opens) - - # Remove special closes. - for ts in changeset.special_close.remove: a.special_closes, a.adhoc_special_closes = remove_special_session(ts, a.special_closes, a.adhoc_special_closes) - # Add special closes. - for ts, spec in changeset.special_close.add.items(): - name, t = spec.name, spec.time - - # Remove existing special close, maybe. - a.special_closes, a.adhoc_special_closes = remove_special_session(ts, a.special_closes, - a.adhoc_special_closes) - - # Add the special close. - a.special_closes = add_special_session(name, ts, t, a.special_closes) + # Add holiday, special opens, and special closes. + for spec in changeset.add: + if spec.type == DayType.HOLIDAY: + # Add the holiday. + a.regular_holidays.append(Holiday(spec.name, year=spec.date.year, month=spec.date.month, + day=spec.date.day)) + elif spec.type == DayType.SPECIAL_OPEN: + # Add the special open. + a.special_opens = add_special_session(spec.name, spec.date, spec.time, a.special_opens) + elif spec.type == DayType.SPECIAL_CLOSE: + # Add the special close. + a.special_closes = add_special_session(spec.name, spec.date, spec.time, a.special_closes) self._adjusted_properties = a # Call upstream init method. init_orig(self, *args, **kwargs) - a.quarterly_expiries = get_quadruple_witching_rules(day_of_week_expiry) if day_of_week_expiry is not None else [] + # Set up monthly and quarterly expiries. This can only be done after holidays, special opens, and special closes + # have been set up. a.monthly_expiries = get_monthly_expiry_rules(day_of_week_expiry) if day_of_week_expiry is not None else [] + a.quarterly_expiries = get_quadruple_witching_rules(day_of_week_expiry) if day_of_week_expiry is not None else [] if changeset is not None: - - # Remove quarterly expiries. - - # Loop over quarterly expiries to remove. - for ts in changeset.quarterly_expiry.remove: - a.quarterly_expiries, _ = remove_holiday(ts, a.quarterly_expiries) - - # Add quarterly expiries. - - # Loop over quarterly expiries to add. - for ts, spec in changeset.quarterly_expiry.add.items(): - name = spec.name - - # Remove existing quarterly expiry, maybe. - a.quarterly_expiries, _ = remove_holiday(ts, a.quarterly_expiries) - - # Add the quarterly expiry. - a.quarterly_expiries.append(Holiday(name, year=ts.year, month=ts.month, day=ts.day)) - - # Remove monthly expiries. - - # Loop over monthly expiries to remove. - for ts in changeset.monthly_expiry.remove: + # Remove all changed days from monthly and quarterly expiries. + for ts in changeset.all_days: a.monthly_expiries, _ = remove_holiday(ts, a.monthly_expiries) + a.quarterly_expiries, _ = remove_holiday(ts, a.quarterly_expiries) - # Add monthly expiries. - - # Loop over monthly expiries to add. - for ts, spec in changeset.monthly_expiry.add.items(): - name = spec.name - - # Remove existing monthly expiry, maybe. - a.monthly_expiries, _ = remove_holiday(ts, a.monthly_expiries) - - # Add the monthly expiry. - a.monthly_expiries.append(Holiday(name, year=ts.year, month=ts.month, day=ts.day)) - + # Add monthly and quarterly expiries. + for spec in changeset.add: + if spec.type == DayType.MONTHLY_EXPIRY: + # Add the monthly expiry. + a.monthly_expiries.append(Holiday(spec.name, year=spec.date.year, month=spec.date.month, + day=spec.date.day)) + elif spec.type == DayType.QUARTERLY_EXPIRY: + # Add the quarterly expiry. + a.quarterly_expiries.append(Holiday(spec.name, year=spec.date.year, month=spec.date.month, + day=spec.date.day)) + + # Set up last trading days of the month. a.last_trading_days_of_months = get_last_day_of_month_rules('last trading day of month') + + # Set up last regular trading days of the month. This can only be done after holidays, special opens, + # special closes, monthly expiries, and quarterly expiries have been set up. a.last_regular_trading_days_of_months = get_last_day_of_month_rules('last regular trading day of month') # Save a calendar with all holidays and another one with all holidays and special business days for later use. diff --git a/poetry.lock b/poetry.lock index 72108bc..97721b7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,9 +1,10 @@ -# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. +# This file is automatically @generated by Poetry and should not be changed by hand. [[package]] name = "annotated-types" version = "0.5.0" description = "Reusable constraint types to use with typing.Annotated" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -18,6 +19,7 @@ typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.9\""} name = "cfgv" version = "3.3.1" description = "Validate configuration and produce human readable error messages." +category = "dev" optional = false python-versions = ">=3.6.1" files = [ @@ -29,6 +31,7 @@ files = [ name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." +category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ @@ -40,6 +43,7 @@ files = [ name = "coverage" version = "7.2.7" description = "Code coverage measurement for Python" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -113,19 +117,21 @@ toml = ["tomli"] [[package]] name = "distlib" -version = "0.3.6" +version = "0.3.7" description = "Distribution utilities" +category = "dev" optional = false python-versions = "*" files = [ - {file = "distlib-0.3.6-py2.py3-none-any.whl", hash = "sha256:f35c4b692542ca110de7ef0bea44d73981caeb34ca0b9b6b2e6d7790dda8f80e"}, - {file = "distlib-0.3.6.tar.gz", hash = "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46"}, + {file = "distlib-0.3.7-py2.py3-none-any.whl", hash = "sha256:2e24928bc811348f0feb63014e97aaae3037f2cf48712d51ae61df7fd6075057"}, + {file = "distlib-0.3.7.tar.gz", hash = "sha256:9dafe54b34a028eafd95039d5e5d4851a13734540f1331060d31c9916e7147a8"}, ] [[package]] name = "exceptiongroup" version = "1.1.2" description = "Backport of PEP 654 (exception groups)" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -140,6 +146,7 @@ test = ["pytest (>=6)"] name = "exchange-calendars" version = "4.2.8" description = "Calendars for securities exchanges" +category = "main" optional = false python-versions = "~=3.8" files = [ @@ -163,6 +170,7 @@ dev = ["flake8", "hypothesis", "pip-tools", "pytest", "pytest-benchmark", "pytes name = "filelock" version = "3.12.2" description = "A platform independent file lock." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -176,13 +184,14 @@ testing = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "diff-cover (>=7.5)", "p [[package]] name = "identify" -version = "2.5.24" +version = "2.5.26" description = "File identification library for Python" +category = "dev" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "identify-2.5.24-py2.py3-none-any.whl", hash = "sha256:986dbfb38b1140e763e413e6feb44cd731faf72d1909543178aa79b0e258265d"}, - {file = "identify-2.5.24.tar.gz", hash = "sha256:0aac67d5b4812498056d28a9a512a483f5085cc28640b02b258a59dac34301d4"}, + {file = "identify-2.5.26-py2.py3-none-any.whl", hash = "sha256:c22a8ead0d4ca11f1edd6c9418c3220669b3b7533ada0a0ffa6cc0ef85cf9b54"}, + {file = "identify-2.5.26.tar.gz", hash = "sha256:7243800bce2f58404ed41b7c002e53d4d22bcf3ae1b7900c2d7aefd95394bf7f"}, ] [package.extras] @@ -192,6 +201,7 @@ license = ["ukkonen"] name = "iniconfig" version = "2.0.0" description = "brain-dead simple config-ini parsing" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -203,6 +213,7 @@ files = [ name = "korean-lunar-calendar" version = "0.3.1" description = "Korean Lunar Calendar" +category = "main" optional = false python-versions = "*" files = [ @@ -214,6 +225,7 @@ files = [ name = "nodeenv" version = "1.8.0" description = "Node.js virtual environment builder" +category = "dev" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" files = [ @@ -228,6 +240,7 @@ setuptools = "*" name = "numpy" version = "1.24.4" description = "Fundamental package for array computing in Python" +category = "main" optional = false python-versions = ">=3.8" files = [ @@ -265,6 +278,7 @@ files = [ name = "packaging" version = "23.1" description = "Core utilities for Python packages" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -276,6 +290,7 @@ files = [ name = "pandas" version = "2.0.3" description = "Powerful data structures for data analysis, time series, and statistics" +category = "main" optional = false python-versions = ">=3.8" files = [ @@ -341,23 +356,25 @@ xml = ["lxml (>=4.6.3)"] [[package]] name = "platformdirs" -version = "3.8.0" +version = "3.10.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "platformdirs-3.8.0-py3-none-any.whl", hash = "sha256:ca9ed98ce73076ba72e092b23d3c93ea6c4e186b3f1c3dad6edd98ff6ffcca2e"}, - {file = "platformdirs-3.8.0.tar.gz", hash = "sha256:b0cabcb11063d21a0b261d557acb0a9d2126350e63b70cdf7db6347baea456dc"}, + {file = "platformdirs-3.10.0-py3-none-any.whl", hash = "sha256:d7c24979f292f916dc9cbf8648319032f551ea8c49a4c9bf2fb556a02070ec1d"}, + {file = "platformdirs-3.10.0.tar.gz", hash = "sha256:b45696dab2d7cc691a3226759c0d3b00c47c8b6e293d96f6436f733303f77f6d"}, ] [package.extras] -docs = ["furo (>=2023.5.20)", "proselint (>=0.13)", "sphinx (>=7.0.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)"] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] [[package]] name = "pluggy" version = "1.2.0" description = "plugin and hook calling mechanisms for python" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -373,6 +390,7 @@ testing = ["pytest", "pytest-benchmark"] name = "pre-commit" version = "3.3.3" description = "A framework for managing and maintaining multi-language pre-commit hooks." +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -391,6 +409,7 @@ virtualenv = ">=20.10.0" name = "pydantic" version = "2.1.1" description = "Data validation using Python type hints" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -410,6 +429,7 @@ email = ["email-validator (>=2.0.0)"] name = "pydantic-core" version = "2.4.0" description = "" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -523,6 +543,7 @@ typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" name = "pyluach" version = "2.2.0" description = "A Python package for dealing with Hebrew (Jewish) calendar dates." +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -538,6 +559,7 @@ test = ["beautifulsoup4", "flake8", "pytest", "pytest-cov"] name = "pytest" version = "7.4.0" description = "pytest: simple powerful testing with Python" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -560,6 +582,7 @@ testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "no name = "pytest-cov" version = "4.1.0" description = "Pytest plugin for measuring coverage." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -578,6 +601,7 @@ testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtuale name = "python-dateutil" version = "2.8.2" description = "Extensions to the standard Python datetime module" +category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" files = [ @@ -592,6 +616,7 @@ six = ">=1.5" name = "pytz" version = "2023.3" description = "World timezone definitions, modern and historical" +category = "main" optional = false python-versions = "*" files = [ @@ -601,57 +626,59 @@ files = [ [[package]] name = "pyyaml" -version = "6.0" +version = "6.0.1" description = "YAML parser and emitter for Python" +category = "dev" optional = false python-versions = ">=3.6" files = [ - {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, - {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, - {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"}, - {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"}, - {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"}, - {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"}, - {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"}, - {file = "PyYAML-6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358"}, - {file = "PyYAML-6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1"}, - {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d"}, - {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f"}, - {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782"}, - {file = "PyYAML-6.0-cp311-cp311-win32.whl", hash = "sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7"}, - {file = "PyYAML-6.0-cp311-cp311-win_amd64.whl", hash = "sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf"}, - {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"}, - {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"}, - {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"}, - {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"}, - {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"}, - {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"}, - {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"}, - {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"}, - {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"}, - {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"}, - {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"}, - {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"}, - {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"}, - {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"}, - {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"}, - {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"}, - {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"}, - {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"}, - {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"}, - {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"}, - {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"}, - {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"}, - {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"}, - {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"}, - {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, - {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, + {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, + {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, + {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, + {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, + {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, + {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, + {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, + {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, + {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, ] [[package]] name = "setuptools" version = "68.0.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -668,6 +695,7 @@ testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs ( name = "six" version = "1.16.0" description = "Python 2 and 3 compatibility utilities" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -679,6 +707,7 @@ files = [ name = "tomli" version = "2.0.1" description = "A lil' TOML parser" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -690,6 +719,7 @@ files = [ name = "toolz" version = "0.12.0" description = "List processing tools and functional utilities" +category = "main" optional = false python-versions = ">=3.5" files = [ @@ -701,6 +731,7 @@ files = [ name = "typing-extensions" version = "4.7.1" description = "Backported and Experimental Type Hints for Python 3.7+" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -712,6 +743,7 @@ files = [ name = "tzdata" version = "2023.3" description = "Provider of IANA time zone data" +category = "main" optional = false python-versions = ">=2" files = [ @@ -721,23 +753,24 @@ files = [ [[package]] name = "virtualenv" -version = "20.23.1" +version = "20.24.2" description = "Virtual Python Environment builder" +category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "virtualenv-20.23.1-py3-none-any.whl", hash = "sha256:34da10f14fea9be20e0fd7f04aba9732f84e593dac291b757ce42e3368a39419"}, - {file = "virtualenv-20.23.1.tar.gz", hash = "sha256:8ff19a38c1021c742148edc4f81cb43d7f8c6816d2ede2ab72af5b84c749ade1"}, + {file = "virtualenv-20.24.2-py3-none-any.whl", hash = "sha256:43a3052be36080548bdee0b42919c88072037d50d56c28bd3f853cbe92b953ff"}, + {file = "virtualenv-20.24.2.tar.gz", hash = "sha256:fd8a78f46f6b99a67b7ec5cf73f92357891a7b3a40fd97637c27f854aae3b9e0"}, ] [package.dependencies] -distlib = ">=0.3.6,<1" -filelock = ">=3.12,<4" -platformdirs = ">=3.5.1,<4" +distlib = ">=0.3.7,<1" +filelock = ">=3.12.2,<4" +platformdirs = ">=3.9.1,<4" [package.extras] docs = ["furo (>=2023.5.20)", "proselint (>=0.13)", "sphinx (>=7.0.1)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.3.1)", "pytest-env (>=0.8.1)", "pytest-freezer (>=0.4.6)", "pytest-mock (>=3.10)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=67.8)", "time-machine (>=2.9)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] [metadata] lock-version = "2.0" diff --git a/tests/test_api.py b/tests/test_api.py index bd67e5d..3d57751 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -25,6 +25,7 @@ ADDED_SPECIAL_CLOSE = 'Added Special Close' INSERTED_HOLIDAY = 'Inserted Holiday' + def apply_extensions(): """ Apply the extensions to the exchange_calendars module. """ import exchange_calendars_extensions as ece @@ -485,7 +486,7 @@ def test_remove_existing_regular_holiday(): import exchange_calendars as ec import exchange_calendars_extensions as ece - ece.remove_holiday("TEST", pd.Timestamp("2023-01-01")) + ece.remove_day("TEST", pd.Timestamp("2023-01-01")) c = ec.get_calendar("TEST") @@ -513,7 +514,7 @@ def test_remove_existing_adhoc_holiday(): import exchange_calendars as ec import exchange_calendars_extensions as ece - ece.remove_holiday("TEST", pd.Timestamp("2023-02-01")) + ece.remove_day("TEST", pd.Timestamp("2023-02-01")) c = ec.get_calendar("TEST") @@ -542,7 +543,7 @@ def test_remove_non_existent_holiday(): import exchange_calendars as ec import exchange_calendars_extensions as ece - ece.remove_holiday("TEST", pd.Timestamp("2023-07-03")) + ece.remove_day("TEST", pd.Timestamp("2023-07-03")) c = ec.get_calendar("TEST") @@ -572,86 +573,66 @@ def test_add_and_remove_new_holiday(): import exchange_calendars as ec import exchange_calendars_extensions as ece - # Add and then remove the same day. This should be a no-op. + # Add and then remove the same day. The day should stay added. ece.add_holiday("TEST", pd.Timestamp("2023-07-03"), ADDED_HOLIDAY) - ece.remove_holiday("TEST", pd.Timestamp("2023-07-03")) + ece.remove_day("TEST", pd.Timestamp("2023-07-03")) c = ec.get_calendar("TEST") start = pd.Timestamp("2022-01-01") end = pd.Timestamp("2024-12-31") - # Regular holidays should be unchanged. + # Regular holidays should have new day. assert c.regular_holidays.holidays(start=start, end=end, return_name=True).compare(pd.Series({ pd.Timestamp("2022-01-01"): HOLIDAY_0, pd.Timestamp("2023-01-01"): HOLIDAY_0, + pd.Timestamp("2023-07-03"): ADDED_HOLIDAY, pd.Timestamp("2024-01-01"): HOLIDAY_0})).empty # Ad-hoc holidays should be unchanged. assert c.adhoc_holidays == [pd.Timestamp("2023-02-01")] - # Calendar holidays_all should be unchanged. + # Calendar holidays_all should have new day. assert c.holidays_all.holidays(start=start, end=end, return_name=True).compare(pd.Series({ pd.Timestamp("2022-01-01"): HOLIDAY_0, pd.Timestamp("2023-01-01"): HOLIDAY_0, pd.Timestamp("2023-02-01"): AD_HOC_HOLIDAY, + pd.Timestamp("2023-07-03"): ADDED_HOLIDAY, pd.Timestamp("2024-01-01"): HOLIDAY_0})).empty -@pytest.mark.isolated -def test_add_and_remove_new_holiday_strict(): - add_test_calendar_and_apply_extensions() - import exchange_calendars_extensions as ece - - # Add and then remove the same day. This should be a no-op. - ece.add_holiday("TEST", pd.Timestamp("2023-07-03"), ADDED_HOLIDAY, strict=True) - - with pytest.raises(ValueError): - ece.remove_holiday("TEST", pd.Timestamp("2023-07-03"), strict=True) - - @pytest.mark.isolated def test_add_and_remove_existing_holiday(): add_test_calendar_and_apply_extensions() import exchange_calendars as ec import exchange_calendars_extensions as ece - # Add and then remove the same existing holiday. The day should be removed. + # Add and then remove the same existing holiday. The day should still be added. ece.add_holiday("TEST", pd.Timestamp("2023-01-01"), ADDED_HOLIDAY) - ece.remove_holiday("TEST", pd.Timestamp("2023-01-01")) + ece.remove_day("TEST", pd.Timestamp("2023-01-01")) c = ec.get_calendar("TEST") start = pd.Timestamp("2022-01-01") end = pd.Timestamp("2024-12-31") - # Day should be removed from regular holidays. + # Updated day should be in regular holidays. assert c.regular_holidays.holidays(start=start, end=end, return_name=True).compare(pd.Series({ pd.Timestamp("2022-01-01"): HOLIDAY_0, + pd.Timestamp('2023-01-01'): ADDED_HOLIDAY, pd.Timestamp("2024-01-01"): HOLIDAY_0})).empty # Ad-hoc holidays should be unchanged. assert c.adhoc_holidays == [pd.Timestamp("2023-02-01")] - # Day should be removed from holidays_all. + # Updated day should be in holidays_all. assert c.holidays_all.holidays(start=start, end=end, return_name=True).compare(pd.Series({ pd.Timestamp("2022-01-01"): HOLIDAY_0, + pd.Timestamp('2023-01-01'): ADDED_HOLIDAY, pd.Timestamp("2023-02-01"): AD_HOC_HOLIDAY, pd.Timestamp("2024-01-01"): HOLIDAY_0})).empty -@pytest.mark.isolated -def test_add_and_remove_existing_holiday_strict(): - add_test_calendar_and_apply_extensions() - import exchange_calendars_extensions as ece - - # Add and then remove the same existing holiday. The day should be removed. - ece.add_holiday("TEST", pd.Timestamp("2023-01-01"), ADDED_HOLIDAY, strict=True) - - with pytest.raises(ValueError): - ece.remove_holiday("TEST", pd.Timestamp("2023-01-01"), strict=True) - - @pytest.mark.isolated def test_remove_and_add_new_holiday(): add_test_calendar_and_apply_extensions() @@ -660,7 +641,7 @@ def test_remove_and_add_new_holiday(): # Remove and then add the same new holiday. The removal of a non-existent holiday should be ignored, so the day # should be added eventually. - ece.remove_holiday("TEST", pd.Timestamp("2023-07-03")) + ece.remove_day("TEST", pd.Timestamp("2023-07-03")) ece.add_holiday("TEST", pd.Timestamp("2023-07-03"), ADDED_HOLIDAY) c = ec.get_calendar("TEST") @@ -687,19 +668,6 @@ def test_remove_and_add_new_holiday(): pd.Timestamp("2024-01-01"): HOLIDAY_0})).empty -@pytest.mark.isolated -def test_remove_and_add_new_holiday_strict(): - add_test_calendar_and_apply_extensions() - import exchange_calendars_extensions as ece - - # Remove and then add the same new holiday. The removal of a non-existent holiday should be ignored, so the day - # should be added eventually. - ece.remove_holiday("TEST", pd.Timestamp("2023-07-03"), strict=True) - - with pytest.raises(ValueError): - ece.add_holiday("TEST", pd.Timestamp("2023-07-03"), ADDED_HOLIDAY, strict=True) - - @pytest.mark.isolated def test_remove_and_add_existing_regular_holiday(): add_test_calendar_and_apply_extensions() @@ -708,7 +676,7 @@ def test_remove_and_add_existing_regular_holiday(): # Remove and then add the same existent holiday. This should be equivalent to just adding (and thereby overwriting) # the existing regular holiday. - ece.remove_holiday("TEST", pd.Timestamp("2023-01-01")) + ece.remove_day("TEST", pd.Timestamp("2023-01-01")) ece.add_holiday("TEST", pd.Timestamp("2023-01-01"), ADDED_HOLIDAY) c = ec.get_calendar("TEST") @@ -733,19 +701,6 @@ def test_remove_and_add_existing_regular_holiday(): pd.Timestamp("2024-01-01"): HOLIDAY_0})).empty -@pytest.mark.isolated -def test_remove_and_add_existing_regular_holiday_strict(): - add_test_calendar_and_apply_extensions() - import exchange_calendars_extensions as ece - - # Remove and then add the same existent holiday. This should be equivalent to just adding (and thereby overwriting) - # the existing regular holiday. - ece.remove_holiday("TEST", pd.Timestamp("2023-01-01"), strict=True) - - with pytest.raises(ValueError): - ece.add_holiday("TEST", pd.Timestamp("2023-01-01"), ADDED_HOLIDAY, strict=True) - - @pytest.mark.isolated def test_remove_and_add_existing_adhoc_holiday(): add_test_calendar_and_apply_extensions() @@ -754,7 +709,7 @@ def test_remove_and_add_existing_adhoc_holiday(): # Remove and then add the same existent holiday. This should be equivalent to just adding (and thereby overwriting) # the existing regular holiday. - ece.remove_holiday("TEST", pd.Timestamp("2023-02-01")) + ece.remove_day("TEST", pd.Timestamp("2023-02-01")) ece.add_holiday("TEST", pd.Timestamp("2023-02-01"), ADDED_HOLIDAY) c = ec.get_calendar("TEST") @@ -780,80 +735,6 @@ def test_remove_and_add_existing_adhoc_holiday(): pd.Timestamp("2024-01-01"): HOLIDAY_0})).empty -@pytest.mark.isolated -def test_remove_and_add_existing_adhoc_holiday_strict(): - add_test_calendar_and_apply_extensions() - import exchange_calendars_extensions as ece - - # Remove and then add the same existent holiday. This should be equivalent to just adding (and thereby overwriting) - # the existing regular holiday. - ece.remove_holiday("TEST", pd.Timestamp("2023-02-01"), strict=True) - - with pytest.raises(ValueError): - ece.add_holiday("TEST", pd.Timestamp("2023-02-01"), ADDED_HOLIDAY, strict=True) - - -@pytest.mark.isolated -def test_add_and_remove_new_holiday_multiple_times(): - add_test_calendar_and_apply_extensions() - import exchange_calendars as ec - import exchange_calendars_extensions as ece - - # Add and then remove the same day. This should be a no-op. - ece.add_holiday("TEST", pd.Timestamp("2023-07-03"), ADDED_HOLIDAY) - ece.remove_holiday("TEST", pd.Timestamp("2023-07-03")) - - c = ec.get_calendar("TEST") - - start = pd.Timestamp("2022-01-01") - end = pd.Timestamp("2024-12-31") - - # Regular holidays should be unchanged. - assert c.regular_holidays.holidays(start=start, end=end, return_name=True).compare(pd.Series({ - pd.Timestamp("2022-01-01"): HOLIDAY_0, - pd.Timestamp("2023-01-01"): HOLIDAY_0, - pd.Timestamp("2024-01-01"): HOLIDAY_0})).empty - - # Ad-hoc holidays should be unchanged. - assert c.adhoc_holidays == [pd.Timestamp("2023-02-01")] - - # Calendar holidays_all should be unchanged. - assert c.holidays_all.holidays(start=start, end=end, return_name=True).compare(pd.Series({ - pd.Timestamp("2022-01-01"): HOLIDAY_0, - pd.Timestamp("2023-01-01"): HOLIDAY_0, - pd.Timestamp("2023-02-01"): AD_HOC_HOLIDAY, - pd.Timestamp("2024-01-01"): HOLIDAY_0})).empty - - # Now, remove and then add the same day. The removal of a non-existent holiday should be ignored, so the day should - # be added eventually. - ece.remove_holiday("TEST", pd.Timestamp("2023-07-03")) - ece.add_holiday("TEST", pd.Timestamp("2023-07-03"), ADDED_HOLIDAY) - - # This should return a fresh instance, reflecting above changes. - c = ec.get_calendar("TEST") - - start = pd.Timestamp("2022-01-01") - end = pd.Timestamp("2024-12-31") - - # Added holiday should show as regular holiday. - assert c.regular_holidays.holidays(start=start, end=end, return_name=True).compare(pd.Series({ - pd.Timestamp("2022-01-01"): HOLIDAY_0, - pd.Timestamp("2023-01-01"): HOLIDAY_0, - pd.Timestamp("2023-07-03"): ADDED_HOLIDAY, - pd.Timestamp("2024-01-01"): HOLIDAY_0})).empty - - # Added holiday should not be in ad-hoc holidays, i.e. this should be unmodified. - assert c.adhoc_holidays == [pd.Timestamp("2023-02-01")] - - # Added holiday should be in holidays_all calendar. - assert c.holidays_all.holidays(start=start, end=end, return_name=True).compare(pd.Series({ - pd.Timestamp("2022-01-01"): HOLIDAY_0, - pd.Timestamp("2023-01-01"): HOLIDAY_0, - pd.Timestamp("2023-02-01"): AD_HOC_HOLIDAY, - pd.Timestamp("2023-07-03"): ADDED_HOLIDAY, - pd.Timestamp("2024-01-01"): HOLIDAY_0})).empty - - @pytest.mark.isolated def test_add_new_special_open_with_new_time(): add_test_calendar_and_apply_extensions() @@ -1123,7 +1004,7 @@ def test_remove_existing_regular_special_open(): import exchange_calendars as ec import exchange_calendars_extensions as ece - ece.remove_special_open("TEST", pd.Timestamp("2023-05-01")) + ece.remove_day("TEST", pd.Timestamp("2023-05-01")) c = ec.get_calendar("TEST") @@ -1164,7 +1045,7 @@ def test_remove_existing_ad_hoc_special_open(): import exchange_calendars as ec import exchange_calendars_extensions as ece - ece.remove_special_open("TEST", pd.Timestamp("2023-06-01")) + ece.remove_day("TEST", pd.Timestamp("2023-06-01")) c = ec.get_calendar("TEST") @@ -1197,7 +1078,7 @@ def test_remove_non_existent_special_open(): import exchange_calendars as ec import exchange_calendars_extensions as ece - ece.remove_special_open("TEST", pd.Timestamp("2023-07-03")) + ece.remove_day("TEST", pd.Timestamp("2023-07-03")) c = ec.get_calendar("TEST") @@ -1494,7 +1375,7 @@ def test_remove_existing_regular_special_close(): import exchange_calendars as ec import exchange_calendars_extensions as ece - ece.remove_special_close("TEST", pd.Timestamp("2023-03-01")) + ece.remove_day("TEST", pd.Timestamp("2023-03-01")) c = ec.get_calendar("TEST") @@ -1535,7 +1416,7 @@ def test_remove_existing_ad_hoc_special_close(): import exchange_calendars as ec import exchange_calendars_extensions as ece - ece.remove_special_close("TEST", pd.Timestamp("2023-04-03")) + ece.remove_day("TEST", pd.Timestamp("2023-04-03")) c = ec.get_calendar("TEST") @@ -1568,7 +1449,7 @@ def test_remove_non_existent_special_close(): import exchange_calendars as ec import exchange_calendars_extensions as ece - ece.remove_special_close("TEST", pd.Timestamp("2023-07-03")) + ece.remove_day("TEST", pd.Timestamp("2023-07-03")) c = ec.get_calendar("TEST") @@ -1634,7 +1515,7 @@ def test_remove_quarterly_expiry(): import exchange_calendars_extensions as ece # Add quarterly expiry. - ece.remove_quarterly_expiry("TEST", pd.Timestamp("2023-06-16")) + ece.remove_day("TEST", pd.Timestamp("2023-06-16")) c = ec.get_calendar("TEST") @@ -1756,13 +1637,14 @@ def test_apply_changeset(): import exchange_calendars_extensions as ece changes = { - "holiday": {"add": {"2023-01-02": {"name": INSERTED_HOLIDAY}}, "remove": ["2023-01-01"]}, - "special_open": {"add": {"2023-05-02": {"name": "Inserted Special Open", "time": "11:00"}}, - "remove": ["2023-05-01"]}, - "special_close": {"add": {"2023-03-02": {"name": "Inserted Special Close", "time": "14:00"}}, - "remove": ["2023-03-01"]}, - "monthly_expiry": {"add": {"2023-08-17": {"name": "Inserted Monthly Expiry"}}, "remove": ["2023-08-18"]}, - "quarterly_expiry": {"add": {"2023-09-14": {"name": "Inserted Quarterly Expiry"}}, "remove": ["2023-09-15"]}, + 'add': [ + {'date': '2023-01-02', 'type': 'holiday', 'name': INSERTED_HOLIDAY}, + {'date': '2023-05-02', 'type': 'special_open', 'name': "Inserted Special Open", 'time': '11:00'}, + {'date': '2023-03-02', 'type': 'special_close', 'name': "Inserted Special Close", 'time': '14:00'}, + {'date': '2023-08-17', 'type': 'monthly_expiry', 'name': "Inserted Monthly Expiry"}, + {'date': '2023-09-14', 'type': 'quarterly_expiry', 'name': "Inserted Quarterly Expiry"}, + ], + 'remove': ['2023-01-01', '2023-05-01', '2023-03-01', '2023-08-18', '2023-09-15'] } ece.update_calendar("TEST", changes) c = ec.get_calendar("TEST") @@ -1881,26 +1763,14 @@ def test_test(): import exchange_calendars as ec changes = { - "holiday": { - "add": {"2022-01-10": {"name": "Holiday"}}, - "remove": ["2022-01-11"] - }, - "special_open": { - "add": {"2022-01-12": {"name": "Special Open", "time": "10:00"}}, - "remove": ["2022-01-13"] - }, - "special_close": { - "add": {"2022-01-14": {"name": "Special Close", "time": "16:00"}}, - "remove": ["2022-01-17"] - }, - "monthly_expiry": { - "add": {"2022-01-18": {"name": MONTHLY_EXPIRY}}, - "remove": ["2022-01-19"] - }, - "quarterly_expiry": { - "add": {"2022-01-20": {"name": QUARTERLY_EXPIRY}}, - "remove": ["2022-01-21"] - } + 'add': [ + {'date': '2022-01-10', 'type': 'holiday', 'name': 'Holiday'}, + {'date': '2022-01-12', 'type': 'special_open', 'name': 'Special Open', 'time': '10:00'}, + {'date': '2022-01-14', 'type': 'special_close', 'name': 'Special Close', 'time': '16:00'}, + {'date': '2022-01-18', 'type': 'monthly_expiry', 'name': MONTHLY_EXPIRY}, + {'date': '2022-01-20', 'type': 'quarterly_expiry', 'name': QUARTERLY_EXPIRY} + ], + 'remove': ['2022-01-11', '2022-01-13', '2022-01-17', '2022-01-19', '2022-01-21'] } ece.update_calendar('XLON', changes) diff --git a/tests/test_changeset.py b/tests/test_changeset.py index cb9ced6..27223b6 100644 --- a/tests/test_changeset.py +++ b/tests/test_changeset.py @@ -1,212 +1,18 @@ import datetime as dt -from copy import copy, deepcopy -from typing import Union, Type +from typing import Union import pandas as pd import pytest -from pydantic import ValidationError +from pydantic import ValidationError, Field, validate_call +from typing_extensions import Annotated from exchange_calendars_extensions import DayType -from exchange_calendars_extensions.changeset import Changes, ChangeSet, DaySpec, DayWithTimeSpec +from exchange_calendars_extensions.changeset import ChangeSet, DaySpec, DaySpecWithTime -class TestChanges: - def test_empty_changes(self): - c = Changes[DaySpec]() - assert c.add == dict() - assert c.remove == set() - assert len(c) == 0 - assert not c - - @pytest.mark.parametrize('typ, d', [ - (DaySpec, {"add": {pd.Timestamp("2020-01-01"): {"name": "Holiday"}}}), - (DaySpec, - {"add": {pd.Timestamp("2020-01-01"): {"name": "Holiday"}, pd.Timestamp("2020-01-02"): {"name": "Holiday"}}}), - (DaySpec, {"add": {dt.date.fromisoformat("2020-01-01"): {"name": "Holiday"}, - dt.date.fromisoformat("2020-01-02"): {"name": "Holiday"}}}), - (DaySpec, {"add": {"2020-01-01": {"name": "Holiday"}, pd.Timestamp("2020-01-02"): {"name": "Holiday"}}}), - (DaySpec, {"remove": [pd.Timestamp("2020-01-03")]}), - (DaySpec, {"remove": [pd.Timestamp("2020-01-03"), pd.Timestamp("2020-01-04")]}), - (DaySpec, {"remove": [dt.date.fromisoformat("2020-01-03"), dt.date.fromisoformat("2020-01-04")]}), - (DaySpec, {"remove": ["2020-01-03", "2020-01-04"]}), - (DayWithTimeSpec, {"add": {"2020-01-01": {"name": "Holiday", "time": dt.time(10, 0)}}}), - (DayWithTimeSpec, {"add": {"2020-01-01": {"name": "Holiday", "time": dt.time(10, 0)}, - "2020-01-02": {"name": "Holiday", "time": dt.time(10, 0)}}}), - (DayWithTimeSpec, {"add": {"2020-01-01": {"name": "Holiday", "time": "10:00"}, - "2020-01-02": {"name": "Holiday", "time": "10:00"}}}), - (DayWithTimeSpec, {"add": {"2020-01-01": {"name": "Holiday", "time": "10:00:00"}, - "2020-01-02": {"name": "Holiday", "time": "10:00:00"}}}), - ]) - def test_from_valid_dict(self, typ: Union[Type[DaySpec], Type[DayWithTimeSpec]], d: dict): - _ = Changes[typ](**d) - - @pytest.mark.parametrize('typ, d', [ - # Invalid top-level key. - (DaySpec, {"foo": {pd.Timestamp("2020-01-01"): {"name": "Holiday"}}}), - (DaySpec, {"add": {pd.Timestamp("2020-01-01"): {"name": "Holiday"}}, "foo": ["2020-01-03"]}), - (DaySpec, {"remove": [pd.Timestamp("2020-01-03")], "foo": ["2020-01-04"]}), - # Other. - (DaySpec, {"add": {dt.time(10, 0): {"name": "Holiday"}}}), # Invalid timestamp. - (DaySpec, {"add": {"202020-01-01": {"name": "Holiday"}}}), # Invalid timestamp. - (DaySpec, {"add": {"2020-01-01": {"foo": "Holiday"}}}), # Invalid key in day spec. - (DaySpec, {"add": {"2020-01-01": {"name": "Holiday", "time": "10:00"}}}), # Extra key in day spec. - (DaySpec, {"add": {"2020-01-01": {"name": "Holiday", "foo": "bar"}}}), # Extra key in day spec. - (DayWithTimeSpec, {"add": {"2020-01-01": {"foo": "Holiday", "time": "10:00"}}}), # Invalid key in day spec. - (DayWithTimeSpec, {"add": {"2020-01-01": {"name": "Holiday", "time": "10:00", "foo": "bar"}}}), # Extra key in day spec. - (DaySpec, {"remove": ["202020-01-03"]}), # Invalid timestamp. - ]) - def test_from_invalid_dict(self, typ: Union[Type[DaySpec], Type[DayWithTimeSpec]], d: dict): - with pytest.raises((ValidationError, TypeError)): - _ = Changes[typ](**d) - - def test_add_day(self): - c = Changes[DaySpec]() - c.add_day(date="2020-01-01", value={"name": "Holiday"}) - assert c.add == {pd.Timestamp("2020-01-01"): DaySpec(**{"name": "Holiday"})} - assert c.remove == set() - assert len(c) == 1 - assert c - - def test_add_day_duplicate(self): - c = Changes[DaySpec]() - c.remove_day(date=pd.Timestamp("2020-01-01")) - with pytest.raises(ValidationError): - c.add_day(date=pd.Timestamp("2020-01-01"), value={"name": "Holiday"}) - assert c.add == dict() - assert c.remove == {pd.Timestamp("2020-01-01")} - assert len(c) == 1 - assert c - - def test_remove_clear_add_day(self): - c = Changes[DaySpec]() - c.remove_day(date="2020-01-01") - c.clear_day(date="2020-01-01") - c.add_day(date="2020-01-01", value={"name": "Holiday"}) - assert c.add == {pd.Timestamp("2020-01-01"): DaySpec(**{"name": "Holiday"})} - assert c.remove == set() - assert len(c) == 1 - assert c - - def test_add_day_twice(self): - c = Changes[DaySpec]() - c.add_day(date="2020-01-01", value={"name": "Holiday"}) - c.add_day(date="2020-01-01", value={"name": "Foo"}) - assert c.add == {pd.Timestamp("2020-01-01"): DaySpec(**{"name": "Foo"})} - assert c.remove == set() - assert len(c) == 1 - assert c - - def test_remove_day(self): - c = Changes[DaySpec]() - c.remove_day(date="2020-01-01") - assert c.add == dict() - assert c.remove == {pd.Timestamp("2020-01-01")} - assert len(c) == 1 - assert c - - def test_remove_day_duplicate(self): - c = Changes[DaySpec]() - c.add_day(date="2020-01-01", value={"name": "Holiday"}) - with pytest.raises(ValidationError): - c.remove_day(date="2020-01-01") - assert c.add == {pd.Timestamp("2020-01-01"): DaySpec(**{"name": "Holiday"})} - assert c.remove == set() - assert len(c) == 1 - assert c - - def test_add_clear_remove_day(self): - c = Changes[DaySpec]() - c.add_day(date="2020-01-01", value={"name": "Holiday"}) - c.clear_day(date="2020-01-01") - c.remove_day(date="2020-01-01") - assert c.add == dict() - assert c.remove == {pd.Timestamp("2020-01-01")} - assert len(c) == 1 - assert c - - def test_remove_day_twice(self): - c = Changes[DaySpec]() - c.remove_day(date="2020-01-01") - c.remove_day(date="2020-01-01") - assert c.add == dict() - assert c.remove == {pd.Timestamp("2020-01-01")} - assert len(c) == 1 - assert c - - def test_clear_day(self): - c = Changes[DaySpec]() - c.add_day(date="2020-01-01", value={"name": "Holiday"}) - assert c.add == {pd.Timestamp("2020-01-01"): DaySpec(**{"name": "Holiday"})} - assert c.remove == set() - assert len(c) == 1 - assert c - c.clear_day(date="2020-01-01") - assert c.add == dict() - assert c.remove == set() - assert len(c) == 0 - assert not c - - c.remove_day(date="2020-01-01") - assert c.add == dict() - assert c.remove == {pd.Timestamp("2020-01-01")} - assert len(c) == 1 - assert c - c.clear_day(date="2020-01-01") - assert c.add == dict() - assert c.remove == set() - assert len(c) == 0 - assert not c - - def test_clear(self): - c = Changes[DaySpec]() - c.add_day(date="2020-01-01", value={"name": "Holiday"}) - c.remove_day(date="2020-01-02") - assert c.add == {pd.Timestamp("2020-01-01"): DaySpec(**{"name": "Holiday"})} - assert c.remove == {pd.Timestamp("2020-01-02")} - assert len(c) == 2 - assert c - c.clear() - assert c.add == dict() - assert c.remove == set() - assert len(c) == 0 - assert not c - - def test_eq(self): - c1 = Changes[DaySpec]() - c1.add_day(date="2020-01-01", value={"name": "Holiday"}) - c1.remove_day(date="2020-01-02") - c2 = Changes[DaySpec]() - c2.add_day(date="2020-01-01", value={"name": "Holiday"}) - c2.remove_day(date="2020-01-02") - assert c1 == c2 - - c3 = Changes[DaySpec]() - c3.add_day(date="2020-01-01", value={"name": "Holiday"}) - assert c1 != c3 - - c3 = Changes[DaySpec]() - c3.remove_day(date="2020-01-02") - assert c1 != c3 - - def test_copy(self): - c1 = Changes[DaySpec]() - c1.add_day(date="2020-01-01", value={"name": "Holiday"}) - c1.remove_day(date="2020-01-02") - c2 = copy(c1) - assert c1 == c2 - assert c1 is not c2 - assert c1.add is c2.add - assert c1.remove is c2.remove - - def test_deepcopy(self): - c1 = Changes[DaySpec]() - c1.add_day(date="2020-01-01", value={"name": "Holiday"}) - c1.remove_day(date="2020-01-02") - c2 = deepcopy(c1) - assert c1 == c2 - assert c1 is not c2 - assert c1.add is not c2.add - assert c1.remove is not c2.remove +@validate_call +def to_day_spec(value: Annotated[Union[DaySpec, DaySpecWithTime, dict], Field(discriminator='type')]): + return value class TestChangeSet: @@ -214,400 +20,245 @@ def test_empty_changeset(self): cs = ChangeSet() assert len(cs) == 0 - @pytest.mark.parametrize(["date", "value", "day_type"], [ - ("2020-01-01", {"name": "Holiday"}, DayType.HOLIDAY), - (pd.Timestamp("2020-01-01"), {"name": "Holiday"}, DayType.HOLIDAY), - (pd.Timestamp("2020-01-01").date(), {"name": "Holiday"}, "holiday"), - ("2020-01-01", {"name": "Special Open", "time": "10:00"}, DayType.SPECIAL_OPEN), - (pd.Timestamp("2020-01-01"), {"name": "Special Open", "time": "10:00:00"}, DayType.SPECIAL_OPEN), - (pd.Timestamp("2020-01-01").date(), {"name": "Special Open", "time": dt.time(10, 0)}, "special_open"), - ("2020-01-01", {"name": "Special Close", "time": "16:00"}, DayType.SPECIAL_CLOSE), - (pd.Timestamp("2020-01-01"), {"name": "Special Close", "time": "16:00:00"}, DayType.SPECIAL_CLOSE), - (pd.Timestamp("2020-01-01").date(), {"name": "Special Close", "time": dt.time(16, 0)}, "special_close"), - ("2020-01-01", {"name": "Monthly Expiry"}, DayType.MONTHLY_EXPIRY), - (pd.Timestamp("2020-01-01"), {"name": "Monthly Expiry"}, DayType.MONTHLY_EXPIRY), - (pd.Timestamp("2020-01-01").date(), {"name": "Monthly Expiry"}, "monthly_expiry"), - ("2020-01-01", {"name": "Quarterly Expiry"}, DayType.QUARTERLY_EXPIRY), - (pd.Timestamp("2020-01-01"), {"name": "Quarterly Expiry"}, DayType.QUARTERLY_EXPIRY), - (pd.Timestamp("2020-01-01").date(), {"name": "Quarterly Expiry"}, "quarterly_expiry"), + @pytest.mark.parametrize(["day"], [ + ({'date': '2020-01-01', 'type': 'holiday', 'name': 'Holiday'},), + (DaySpec(**{'date': '2020-01-01', 'type': 'holiday', 'name': 'Holiday'}),), + ({'date': pd.Timestamp('2020-01-01'), 'type': DayType.HOLIDAY, 'name': 'Holiday'},), + ({'date': pd.Timestamp('2020-01-01').date(), 'type': 'holiday', 'name': 'Holiday'},), + ({'date': '2020-01-01', 'type': 'special_open', 'name': 'Special Open', 'time': '10:00'},), + (DaySpecWithTime(**{'date': '2020-01-01', 'type': 'special_open', 'name': 'Special Open', 'time': '10:00'}),), + ({'date': pd.Timestamp('2020-01-01'), 'type': DayType.SPECIAL_OPEN, 'name': 'Special Open', 'time': '10:00:00'},), + ({'date': pd.Timestamp('2020-01-01').date(), 'type': 'special_open', 'name': 'Special Open', 'time': dt.time(10, 0)},), + ({'date': '2020-01-01', 'type': 'special_close', 'name': 'Special Close', 'time': '16:00'},), + (DaySpecWithTime(**{'date': '2020-01-01', 'type': 'special_close', 'name': 'Special Close', 'time': '16:00'}),), + ({'date': pd.Timestamp('2020-01-01'), 'type': DayType.SPECIAL_CLOSE, 'name': 'Special Close', 'time': '16:00:00'},), + ({'date': pd.Timestamp('2020-01-01').date(), 'type': 'special_close', 'name': 'Special Close', 'time': dt.time(16, 0)},), + ({'date': '2020-01-01', 'type': 'monthly_expiry', 'name': 'Monthly Expiry'},), + (DaySpec(**{'date': '2020-01-01', 'type': 'monthly_expiry', 'name': 'Monthly Expiry'}),), + ({'date': pd.Timestamp('2020-01-01'), 'type': DayType.MONTHLY_EXPIRY, 'name': 'Monthly Expiry'},), + ({'date': pd.Timestamp('2020-01-01').date(), 'type': 'monthly_expiry', 'name': 'Monthly Expiry'},), + ({'date': '2020-01-01', 'type': 'quarterly_expiry', 'name': 'Quarterly Expiry'},), + (DaySpec(**{'date': '2020-01-01', 'type': 'quarterly_expiry', 'name': 'Quarterly Expiry'}),), + ({'date': pd.Timestamp('2020-01-01'), 'type': DayType.QUARTERLY_EXPIRY, 'name': 'Quarterly Expiry'},), + ({'date': pd.Timestamp('2020-01-01').date(), 'type': 'quarterly_expiry', 'name': 'Quarterly Expiry'},), ]) - def test_add_valid_day(self, date, value, day_type): + def test_add_valid_day(self, day: Union[DaySpec, DaySpecWithTime, dict]): + # Empty changeset. cs = ChangeSet() - cs.add_day(date, value, day_type) - assert len(cs) == 1 - # Normalise day type to string. - day_type = day_type.value if isinstance(day_type, DayType) else day_type + # Add day. + cs.add_day(day) - # The day types where the day was not added. - other_day_types = (x.value for x in DayType if x != day_type) + # Check length. + assert len(cs) == 1 - # Determine day spec type to use for day type. - spec_t = DaySpec if day_type in ("holiday", "monthly_expiry", "quarterly_expiry") else DayWithTimeSpec + # Convert input to validated object, maybe. + day = to_day_spec(day) - # Check given day type. - assert getattr(cs, day_type).add == {pd.Timestamp("2020-01-01"): spec_t(**value)} - assert getattr(cs, day_type).remove == set() + # Get the only element from the list. + day0 = cs.add[0] - # Check other day types. - for other_day_type in other_day_types: - assert getattr(cs, other_day_type).add == dict() - assert getattr(cs, other_day_type).remove == set() + # Check it's identical to the input. + assert day0 == day - @pytest.mark.parametrize(["date", "day_type"], [ - ("2020-01-01", DayType.HOLIDAY), - (pd.Timestamp("2020-01-01"), DayType.HOLIDAY), - (pd.Timestamp("2020-01-01").date(), "holiday"), - ("2020-01-01", DayType.SPECIAL_OPEN), - (pd.Timestamp("2020-01-01"), DayType.SPECIAL_OPEN), - (pd.Timestamp("2020-01-01").date(), "special_open"), - ("2020-01-01", DayType.SPECIAL_CLOSE), - (pd.Timestamp("2020-01-01"), DayType.SPECIAL_CLOSE), - (pd.Timestamp("2020-01-01").date(), "special_close"), - ("2020-01-01", DayType.MONTHLY_EXPIRY), - (pd.Timestamp("2020-01-01"), DayType.MONTHLY_EXPIRY), - (pd.Timestamp("2020-01-01").date(), "monthly_expiry"), - ("2020-01-01", DayType.QUARTERLY_EXPIRY), - (pd.Timestamp("2020-01-01"), DayType.QUARTERLY_EXPIRY), - (pd.Timestamp("2020-01-01").date(), "quarterly_expiry"), + @pytest.mark.parametrize(["date"], [ + ("2020-01-01",), + (pd.Timestamp("2020-01-01"),), + (pd.Timestamp("2020-01-01").date(),), ]) - def test_remove_day(self, date, day_type): + def test_remove_day(self, date): cs = ChangeSet() - cs.remove_day(date, day_type) + cs.remove_day(date) assert len(cs) == 1 - # Normalise day type to string. - day_type = day_type.value if isinstance(day_type, DayType) else day_type - - # The day types where the day was not added. - other_day_types = (x.value for x in DayType if x != day_type) - # Check given day type. - assert getattr(cs, day_type).add == dict() - assert getattr(cs, day_type).remove == {pd.Timestamp("2020-01-01")} - - # Check other day types. - for other_day_type in other_day_types: - assert getattr(cs, other_day_type).add == dict() - assert getattr(cs, other_day_type).remove == set() - - def test_remove_day_all_day_types(self): - cs = ChangeSet() - cs.remove_day("2020-01-01") - assert len(cs) == len(DayType) - - # Check all day types. - for day_type in DayType: - assert getattr(cs, day_type.value).add == dict() - assert getattr(cs, day_type.value).remove == {pd.Timestamp("2020-01-01")} - - @pytest.mark.parametrize(["date", "value", "day_type"], [ - ("2020-01-01", {"name": "Holiday"}, DayType.HOLIDAY), - (pd.Timestamp("2020-01-01"), {"name": "Holiday"}, DayType.HOLIDAY), - (pd.Timestamp("2020-01-01").date(), {"name": "Holiday"}, "holiday"), - ("2020-01-01", {"name": "Special Open", "time": "10:00"}, DayType.SPECIAL_OPEN), - (pd.Timestamp("2020-01-01"), {"name": "Special Open", "time": "10:00:00"}, DayType.SPECIAL_OPEN), - (pd.Timestamp("2020-01-01").date(), {"name": "Special Open", "time": dt.time(10, 0)}, "special_open"), - ("2020-01-01", {"name": "Special Close", "time": "16:00"}, DayType.SPECIAL_CLOSE), - (pd.Timestamp("2020-01-01"), {"name": "Special Close", "time": "16:00:00"}, DayType.SPECIAL_CLOSE), - (pd.Timestamp("2020-01-01").date(), {"name": "Special Close", "time": dt.time(16, 0)}, "special_close"), - ("2020-01-01", {"name": "Monthly Expiry"}, DayType.MONTHLY_EXPIRY), - (pd.Timestamp("2020-01-01"), {"name": "Monthly Expiry"}, DayType.MONTHLY_EXPIRY), - (pd.Timestamp("2020-01-01").date(), {"name": "Monthly Expiry"}, "monthly_expiry"), - ("2020-01-01", {"name": "Quarterly Expiry"}, DayType.QUARTERLY_EXPIRY), - (pd.Timestamp("2020-01-01"), {"name": "Quarterly Expiry"}, DayType.QUARTERLY_EXPIRY), - (pd.Timestamp("2020-01-01").date(), {"name": "Quarterly Expiry"}, "quarterly_expiry"), + assert cs.remove == [pd.Timestamp("2020-01-01")] + + @pytest.mark.parametrize(["date", "value"], [ + ('2020-01-01', {'type': 'holiday', 'name': 'Holiday'}), + (pd.Timestamp('2020-01-01'), {'type': 'holiday', 'name': 'Holiday'}), + (pd.Timestamp('2020-01-01').date(), {'type': 'holiday', 'name': 'Holiday'}), + ('2020-01-01', {'type': 'special_open', 'name': 'Special Open', 'time': '10:00'}), + (pd.Timestamp('2020-01-01'), {'type': 'special_open', 'name': 'Special Open', 'time': '10:00:00'}), + (pd.Timestamp('2020-01-01').date(), {'type': 'special_open', 'name': 'Special Open', 'time': dt.time(10, 0)}), + ('2020-01-01', {'type': 'special_close', 'name': 'Special Close', 'time': '16:00'}), + (pd.Timestamp('2020-01-01'), {'type': 'special_close', 'name': 'Special Close', 'time': '16:00:00'}), + (pd.Timestamp('2020-01-01').date(), {'type': 'special_close', 'name': 'Special Close', 'time': dt.time(16, 0)}), + ('2020-01-01', {'type': 'monthly_expiry', 'name': 'Monthly Expiry'}), + (pd.Timestamp('2020-01-01'), {'type': 'monthly_expiry', 'name': 'Monthly Expiry'}), + (pd.Timestamp('2020-01-01').date(), {'type': 'monthly_expiry', 'name': 'Monthly Expiry'}), + ('2020-01-01', {'type': 'quarterly_expiry', 'name': 'Quarterly Expiry'}), + (pd.Timestamp('2020-01-01'), {'type': 'quarterly_expiry', 'name': 'Quarterly Expiry'}), + (pd.Timestamp('2020-01-01').date(), {'type': 'quarterly_expiry', 'name': 'Quarterly Expiry'}), ]) - def test_clear_day(self, date, value, day_type): + def test_clear_day(self, date, value: dict): + # Add day. cs = ChangeSet() - cs.add_day(date, value, day_type) + cs.add_day({**value, 'date': date}) + assert len(cs) == 1 + + # Clear day. cs.clear_day(date) assert len(cs) == 0 - # Check all day types. - for x in DayType: - assert getattr(cs, x.value).add == dict() - assert getattr(cs, x.value).remove == set() + # Remove day. + cs.remove_day(date) + assert len(cs) == 1 - def test_clear_day_all_day_types(self): - cs = ChangeSet() - cs.add_day("2020-01-01", {"name": "Holiday"}, DayType.HOLIDAY) - cs.remove_day("2020-01-01", DayType.SPECIAL_OPEN) - cs.remove_day("2020-01-01", DayType.SPECIAL_CLOSE) - cs.remove_day("2020-01-01", DayType.MONTHLY_EXPIRY) - cs.remove_day("2020-01-01", DayType.QUARTERLY_EXPIRY) - cs.clear_day("2020-01-01") + # Clear day. + cs.clear_day(date) assert len(cs) == 0 - # Check all day types. - for x in DayType: - assert getattr(cs, x.value).add == dict() - assert getattr(cs, x.value).remove == set() - def test_clear(self): cs = ChangeSet() - cs.add_day("2020-01-01", {"name": "Holiday"}, DayType.HOLIDAY) - cs.add_day("2020-01-02", {"name": "Special Open", "time": "10:00"}, DayType.SPECIAL_OPEN) - cs.add_day("2020-01-03", {"name": "Special Close", "time": "16:00"}, DayType.SPECIAL_CLOSE) - cs.add_day("2020-01-04", {"name": "Monthly Expiry"}, DayType.MONTHLY_EXPIRY) - cs.add_day("2020-01-05", {"name": "Quarterly Expiry"}, DayType.QUARTERLY_EXPIRY) - cs.remove_day("2020-01-06", DayType.HOLIDAY) - cs.remove_day("2020-01-07", DayType.SPECIAL_OPEN) - cs.remove_day("2020-01-08", DayType.SPECIAL_CLOSE) - cs.remove_day("2020-01-09", DayType.MONTHLY_EXPIRY) - cs.remove_day("2020-01-10", DayType.QUARTERLY_EXPIRY) + cs.add_day({'date': '2020-01-01', 'type': 'holiday', 'name': 'Holiday'}) + cs.add_day({'date': '2020-01-02', 'type': 'special_open', 'name': 'Special Open', 'time': '10:00'}) + cs.add_day({'date': '2020-01-03', 'type': 'special_close', 'name': 'Special Close', 'time': '16:00'}) + cs.add_day({'date': '2020-01-04', 'type': 'monthly_expiry', 'name': 'Monthly Expiry'}) + cs.add_day({'date': '2020-01-05', 'type': 'quarterly_expiry', 'name': 'Quarterly Expiry'}) + cs.remove_day('2020-01-06') + cs.remove_day('2020-01-07') + cs.remove_day('2020-01-08') + cs.remove_day('2020-01-09') + cs.remove_day('2020-01-10') assert len(cs) == 10 - assert cs.holiday.add == {pd.Timestamp("2020-01-01"): DaySpec(**{"name": "Holiday"})} - assert cs.holiday.remove == {pd.Timestamp("2020-01-06")} - assert cs.special_open.add == { - pd.Timestamp("2020-01-02"): DayWithTimeSpec(**{"name": "Special Open", "time": "10:00"})} - assert cs.special_open.remove == {pd.Timestamp("2020-01-07")} - assert cs.special_close.add == { - pd.Timestamp("2020-01-03"): DayWithTimeSpec(**{"name": "Special Close", "time": "16:00"})} - assert cs.special_close.remove == {pd.Timestamp("2020-01-08")} - assert cs.monthly_expiry.add == {pd.Timestamp("2020-01-04"): DaySpec(**{"name": "Monthly Expiry"})} - assert cs.monthly_expiry.remove == {pd.Timestamp("2020-01-09")} - assert cs.quarterly_expiry.add == {pd.Timestamp("2020-01-05"): DaySpec(**{"name": "Quarterly Expiry"})} - assert cs.quarterly_expiry.remove == {pd.Timestamp("2020-01-10")} cs.clear() assert not cs - - # Check all day types. - for x in DayType: - assert getattr(cs, x.value).add == dict() - assert getattr(cs, x.value).remove == set() - - @pytest.mark.parametrize(["date", "value", "day_type"], [ - ("2020-01-01", {"name": "Holiday"}, DayType.HOLIDAY), - ("2020-01-01", {"name": "Special Open", "time": "10:00"}, DayType.SPECIAL_OPEN), - ("2020-01-01", {"name": "Special Close", "time": "16:00"}, DayType.SPECIAL_CLOSE), - ("2020-01-01", {"name": "Monthly Expiry"}, DayType.MONTHLY_EXPIRY), - ("2020-01-01", {"name": "Quarterly Expiry"}, DayType.QUARTERLY_EXPIRY), + assert cs.add == [] + assert cs.remove == [] + + @pytest.mark.parametrize(['date', 'value'], [ + ('2020-01-01', {'type': 'holiday', 'name': 'Holiday'},), + ('2020-01-01', {'type': 'special_open', 'name': 'Special Open', 'time': '10:00'},), + ('2020-01-01', {'type': 'special_close', 'name': 'Special Close', 'time': '16:00'},), + ('2020-01-01', {'type': 'monthly_expiry', 'name': 'Monthly Expiry'},), + ('2020-01-01', {'type': 'quarterly_expiry', 'name': 'Quarterly Expiry'},), ]) - def test_add_remove_day_for_same_day_type(self, date, value, day_type: DayType): + def test_add_remove_day_for_same_day_type(self, date, value): cs = ChangeSet() - cs.remove_day(date, day_type) - with pytest.raises(ValidationError): - cs.add_day(date, value, day_type) - assert getattr(cs, day_type.value).add == dict() - assert getattr(cs, day_type.value).remove == {pd.Timestamp(date)} - assert len(cs) == 1 - assert cs - - # Determine day spec type to use for day type. - spec_t = DaySpec if day_type in ( - DayType.HOLIDAY, DayType.MONTHLY_EXPIRY, DayType.QUARTERLY_EXPIRY) else DayWithTimeSpec - - cs = ChangeSet() - cs.add_day(date, value, day_type) - with pytest.raises(ValidationError): - cs.remove_day(date, day_type) - assert getattr(cs, day_type.value).add == {pd.Timestamp(date): spec_t(**value)} - assert getattr(cs, day_type.value).remove == set() - assert len(cs) == 1 + cs.add_day({**value, 'date': date}) + cs.remove_day(date) + assert len(cs) == 2 assert cs def test_add_same_day_twice(self): cs = ChangeSet() - cs.add_day("2020-01-01", {"name": "Holiday"}, DayType.HOLIDAY) + d = {'date': '2020-01-01', 'type': 'holiday', 'name': 'Holiday'} + cs.add_day(d) with pytest.raises(ValueError): - cs.add_day("2020-01-01", {"name": "Special Open", "time": "10:00"}, DayType.SPECIAL_OPEN) + cs.add_day({'date': '2020-01-01', 'type': 'special_open', 'name': 'Special Open', 'time': '10:00'}) assert len(cs) == 1 + assert cs.add == [to_day_spec(d)] assert cs - @pytest.mark.parametrize(["d", "cs"], [ - ({"holiday": {"add": {"2020-01-01": {"name": "Holiday"}}}}, - ChangeSet().add_day("2020-01-01", {"name": "Holiday"}, DayType.HOLIDAY)), - ({"special_open": {"add": {"2020-01-01": {"name": "Special Open", "time": "10:00"}}}}, - ChangeSet().add_day("2020-01-01", {"name": "Special Open", "time": "10:00"}, DayType.SPECIAL_OPEN)), - ({"special_close": {"add": {"2020-01-01": {"name": "Special Close", "time": "16:00"}}}}, - ChangeSet().add_day("2020-01-01", {"name": "Special Close", "time": "16:00"}, DayType.SPECIAL_CLOSE)), - ({"monthly_expiry": {"add": {"2020-01-01": {"name": "Monthly Expiry"}}}}, - ChangeSet().add_day("2020-01-01", {"name": "Monthly Expiry"}, DayType.MONTHLY_EXPIRY)), - ({"quarterly_expiry": {"add": {"2020-01-01": {"name": "Quarterly Expiry"}}}}, - ChangeSet().add_day("2020-01-01", {"name": "Quarterly Expiry"}, DayType.QUARTERLY_EXPIRY)), - ({"holiday": {"remove": ["2020-01-01"]}}, - ChangeSet().remove_day("2020-01-01", DayType.HOLIDAY)), - ({"special_open": {"remove": ["2020-01-01"]}}, - ChangeSet().remove_day("2020-01-01", DayType.SPECIAL_OPEN)), - ({"special_close": {"remove": ["2020-01-01"]}}, - ChangeSet().remove_day("2020-01-01", DayType.SPECIAL_CLOSE)), - ({"monthly_expiry": {"remove": ["2020-01-01"]}}, - ChangeSet().remove_day("2020-01-01", DayType.MONTHLY_EXPIRY)), - ({"quarterly_expiry": {"remove": ["2020-01-01"]}}, - ChangeSet().remove_day("2020-01-01", DayType.QUARTERLY_EXPIRY)), - ({"special_open": {"remove": ["2020-01-01"]}}, - ChangeSet().remove_day("2020-01-01", DayType.SPECIAL_OPEN)), - ({"special_close": {"remove": ["2020-01-01"]}}, - ChangeSet().remove_day("2020-01-01", DayType.SPECIAL_CLOSE)), - ({"monthly_expiry": {"remove": ["2020-01-01"]}}, - ChangeSet().remove_day("2020-01-01", DayType.MONTHLY_EXPIRY)), - ({"quarterly_expiry": {"remove": ["2020-01-01"]}}, - ChangeSet().remove_day("2020-01-01", DayType.QUARTERLY_EXPIRY)), + @pytest.mark.parametrize(['d', 'cs'], [ + ({'add': [{'date': '2020-01-01', 'type': 'holiday', 'name': 'Holiday'}]}, + ChangeSet().add_day({'date': '2020-01-01', 'type': 'holiday', 'name': 'Holiday'})), + ({'add': [{'date': '2020-01-01', 'type': 'special_open', 'name': 'Special Open', 'time': '10:00'}]}, + ChangeSet().add_day({'date': '2020-01-01', 'type': 'special_open', 'name': 'Special Open', 'time': '10:00'})), + ({'add': [{'date': '2020-01-01', 'type': 'special_close', 'name': 'Special Close', 'time': '16:00'}]}, + ChangeSet().add_day({'date': '2020-01-01', 'type': 'special_close', 'name': 'Special Close', 'time': '16:00'})), + ({'add': [{'date': '2020-01-01', 'type': 'monthly_expiry', 'name': 'Monthly Expiry'}]}, + ChangeSet().add_day({'date': '2020-01-01', 'type': 'monthly_expiry', 'name': 'Monthly Expiry'})), + ({'add': [{'date': '2020-01-01', 'type': 'quarterly_expiry', 'name': 'Quarterly Expiry'}]}, + ChangeSet().add_day({'date': '2020-01-01', 'type': 'quarterly_expiry', 'name': 'Quarterly Expiry'})), + ({'remove': ['2020-01-01']}, + ChangeSet().remove_day('2020-01-01')), ({ - "holiday": {"add": {"2020-01-01": {"name": "Holiday"}}, "remove": ["2020-01-02"]}, - "special_open": {"add": {"2020-02-01": {"name": "Special Open", "time": "10:00"}}, - "remove": ["2020-02-02"]}, - "special_close": {"add": {"2020-03-01": {"name": "Special Close", "time": "16:00"}}, - "remove": ["2020-03-02"]}, - "monthly_expiry": {"add": {"2020-04-01": {"name": "Monthly Expiry"}}, "remove": ["2020-04-02"]}, - "quarterly_expiry": {"add": {"2020-05-01": {"name": "Quarterly Expiry"}}, "remove": ["2020-05-02"]} + 'add': [ + {'date': '2020-01-01', 'type': 'holiday', 'name': 'Holiday'}, + {'date': '2020-02-01', 'type': 'special_open', 'name': 'Special Open', 'time': '10:00'}, + {'date': '2020-03-01', 'type': 'special_close', 'name': 'Special Close', 'time': '16:00'}, + {'date': '2020-04-01', 'type': 'monthly_expiry', 'name': 'Monthly Expiry'}, + {'date': '2020-05-01', 'type': 'quarterly_expiry', 'name': 'Quarterly Expiry'}, + ], + 'remove': ['2020-01-02','2020-02-02','2020-03-02','2020-04-02','2020-05-02'] }, ChangeSet() - .add_day("2020-01-01", {"name": "Holiday"}, DayType.HOLIDAY) - .remove_day("2020-01-02", day_type=DayType.HOLIDAY) - .add_day("2020-02-01", {"name": "Special Open", "time": dt.time(10, 0)}, DayType.SPECIAL_OPEN) - .remove_day("2020-02-02", day_type=DayType.SPECIAL_OPEN) - .add_day("2020-03-01", {"name": "Special Close", "time": dt.time(16, 0)}, DayType.SPECIAL_CLOSE) - .remove_day("2020-03-02", day_type=DayType.SPECIAL_CLOSE) - .add_day("2020-04-01", {"name": "Monthly Expiry"}, DayType.MONTHLY_EXPIRY) - .remove_day("2020-04-02", day_type=DayType.MONTHLY_EXPIRY) - .add_day("2020-05-01", {"name": "Quarterly Expiry"}, DayType.QUARTERLY_EXPIRY) - .remove_day(pd.Timestamp("2020-05-02"), day_type=DayType.QUARTERLY_EXPIRY)), - ]) + .add_day({'date': '2020-01-01', 'type': 'holiday', 'name': 'Holiday'}) + .remove_day('2020-01-02') + .add_day({'date': '2020-02-01', 'type': 'special_open', 'name': 'Special Open', 'time': '10:00'}) + .remove_day('2020-02-02') + .add_day({'date': '2020-03-01', 'type': 'special_close', 'name': 'Special Close', 'time': '16:00'}) + .remove_day('2020-03-02') + .add_day({'date': '2020-04-01', 'type': 'monthly_expiry', 'name': 'Monthly Expiry'}) + .remove_day('2020-04-02') + .add_day({'date': '2020-05-01', 'type': 'quarterly_expiry', 'name': 'Quarterly Expiry'}) + .remove_day('2020-05-02'), + )]) def test_changeset_from_valid_non_empty_dict(self, d: dict, cs: ChangeSet): cs0 = ChangeSet(**d) assert cs0 == cs - @pytest.mark.parametrize(["d"], [ + @pytest.mark.parametrize(['d'], [ # Invalid day type. - ({"foo": {"add": {"2020-01-01": {"name": "Holiday"}}}},), - ({"foo": {"add": {"2020-01-01": {"name": "Holiday"}}}},), - ({"foo": {"add": {"2020-01-01": {"name": "Holiday"}}}},), + ({'add': [{'date': '2020-01-01', 'type': 'foo', 'name': 'Holiday'}]},), # Invalid date. - ({"holiday": {"add": {"foo": {"name": "Holiday"}}}},), - ({"monthly_expiry": {"add": {"foo": {"name": "Monthly Expiry"}}}},), - ({"quarterly_expiry": {"add": {"foo": {"name": "Quarterly Expiry"}}}},), - # Invalid value. - ({"holiday": {"add": {"2020-01-01": {"foo": "Holiday"}}}},), - ({"holiday": {"add": {"2020-01-01": {"name": "Holiday", "time": "10:00"}}}},), - ({"holiday": {"add": {"2020-01-01": {"name": "Holiday", "foo": "bar"}}}},), - ({"monthly_expiry": {"add": {"2020-01-01": {"foo": "Monthly Expiry"}}}},), - ({"quarterly_expiry": {"add": {"2020-01-01": {"foo": "Quarterly Expiry"}}}},), + ({'add': [{'date': 'foo', 'type': 'holiday', 'name': 'Holiday'}]},), + ({'add': [{'date': 'foo', 'type': 'monthly_expiry', 'name': 'Holiday'}]},), + ({'add': [{'date': 'foo', 'type': 'quarterly_expiry', 'name': 'Holiday'}]},), + # # Invalid value. + ({'add': [{'date': '2020-01-01', 'type': 'holiday', 'foo': 'Holiday'}]},), + ({'add': [{'date': '2020-01-01', 'type': 'holiday', 'name': 'Holiday', 'time': '10:00'}]},), + ({'add': [{'date': '2020-01-01', 'type': 'holiday', 'name': 'Holiday', 'foo': 'bar'}]},), + ({'add': [{'date': '2020-01-01', 'type': 'monthly_expiry', 'foo': 'Monthly Expiry'}]},), + ({'add': [{'date': '2020-01-01', 'type': 'quarterly_expiry', 'foo': 'Quarterly Expiry'}]},), # Invalid day type. - ({"foo": {"add": {"2020-01-01": {"name": "Special Open", "time": "10:00"}}}},), - ({"foo": {"add": {"2020-01-01": {"name": "Special Close", "time": "10:00"}}}},), + ({'add': [{'date': '2020-01-01', 'type': 'foo', 'name': 'Special Open', 'time': '10:00'}]},), + ({'add': [{'date': '2020-01-01', 'type': 'foo', 'name': 'Special Close', 'time': '10:00'}]},), # Invalid date. - ({"special_open": {"add": {"foo": {"name": "Special Open", "time": "10:00"}}}},), - ({"special_close": {"add": {"foo": {"name": "Special Close", "time": "10:00"}}}},), + ({'add': [{'date': 'foo', 'type': 'special_open', 'name': 'Special Open', 'time': '10:00'}]},), + ({'add': [{'date': 'foo', 'type': 'special_close', 'name': 'Special Close', 'time': '10:00'}]},), # Invalid value key. - ({"special_open": {"add": {"2020-01-01": {"foo": "Special Open", "time": "10:00"}}}},), - ({"special_open": {"add": {"2020-01-01": {"name": "Special Open", "foo": "10:00"}}}},), - ({"special_close": {"add": {"2020-01-01": {"foo": "Special Close", "time": "10:00"}}}},), - ({"special_close": {"add": {"2020-01-01": {"name": "Special Close", "foo": "10:00"}}}},), + ({'add': [{'date': '2020-01-01', 'type': 'special_open', 'foo': 'Special Open', 'time': '10:00'}]},), + ({'add': [{'date': '2020-01-01', 'type': 'special_open', 'name': 'Special Open', 'foo': '10:00'}]},), + ({'add': [{'date': '2020-01-01', 'type': 'special_close', 'foo': 'Special Close', 'time': '10:00'}]},), + ({'add': [{'date': '2020-01-01', 'type': 'special_close', 'name': 'Special Close', 'foo': '10:00'}]},), + # Invalid date. + ({'remove': ['2020-01-01', 'foo']},), ]) def test_changeset_from_invalid_dict(self, d: dict): with pytest.raises(ValidationError): ChangeSet(**d) - @pytest.mark.parametrize(["d"], [ + @pytest.mark.parametrize(['d'], [ # Same day added twice for different day types. - ({ - "holiday": {"add": {"2020-01-01": {"name": "Holiday"}}}, - "special_open": {"add": {"2020-01-01": {"name": "Special Open", "time": "10:00"}}} - },), - ({ - "holiday": {"add": {"2020-01-01": {"name": "Holiday"}}}, - "special_close": {"add": {"2020-01-01": {"name": "Special Close", "time": "16:00"}}} - },), - ({ - "holiday": {"add": {"2020-01-01": {"name": "Holiday"}}}, - "monthly_expiry": {"add": {"2020-01-01": {"name": "Monthly Expiry"}}} - },), - ({ - "holiday": {"add": {"2020-01-01": {"name": "Holiday"}}}, - "quarterly_expiry": {"add": {"2020-01-01": {"name": "Quarterly Expiry"}}} - },), - # Same day added and removed for same day type. - ({"holiday": {"add": {"2020-01-01": {"name": "Holiday"}}, "remove": ["2020-01-01"]}},), - ({"special_open": {"add": {"2020-01-01": {"name": "Special Open", "time": "10:00"}}, - "remove": ["2020-01-01"]}},), - ({"special_close": {"add": {"2020-01-01": {"name": "Special Close", "time": "16:00"}}, - "remove": ["2020-01-01"]}},), - ({"monthly_expiry": {"add": {"2020-01-01": {"name": "Holiday"}}, "remove": ["2020-01-01"]}},), - ({"quarterly_expiry": {"add": {"2020-01-01": {"name": "Holiday"}}, "remove": ["2020-01-01"]}},), ]) + ({'add': [ + {'date': '2020-01-01', 'type': 'holiday', 'name': 'Holiday'}, + {'date': '2020-01-01', 'type': 'special_open', 'name': 'Special Open', 'time': '10:00'} + ]},), + ({'add': [ + {'date': '2020-01-01', 'type': 'holiday', 'name': 'Holiday'}, + {'date': '2020-01-01', 'type': 'special_close', 'name': 'Special Close', 'time': '10:00'} + ]},), + ({'add': [ + {'date': '2020-01-01', 'type': 'holiday', 'name': 'Holiday'}, + {'date': '2020-01-01', 'type': 'monthly_expiry', 'name': 'Monthly Expiry'} + ]},), + ({'add': [ + {'date': '2020-01-01', 'type': 'holiday', 'name': 'Holiday'}, + {'date': '2020-01-01', 'type': 'quarterly_expiry', 'name': 'Quarterly Expiry'} + ]},), + ]) def test_changeset_from_inconsistent_dict(self, d: dict): with pytest.raises(ValidationError): ChangeSet(**d) - @pytest.mark.parametrize(["cs", "cs_normalized"], [ - (ChangeSet().add_day("2020-01-01", {"name": "Holiday"}, DayType.HOLIDAY), - ChangeSet().remove_day("2020-01-01").clear_day("2020-01-01", DayType.HOLIDAY).add_day("2020-01-01", - {"name": "Holiday"}, - DayType.HOLIDAY)), - (ChangeSet().add_day("2020-01-01", {"name": "Special Open", "time": "10:00"}, DayType.SPECIAL_OPEN), - ChangeSet().remove_day("2020-01-01").clear_day("2020-01-01", DayType.SPECIAL_OPEN).add_day("2020-01-01", { - "name": "Special Open", "time": "10:00"}, DayType.SPECIAL_OPEN)), - (ChangeSet().add_day("2020-01-01", {"name": "Special Close", "time": "16:00"}, DayType.SPECIAL_CLOSE), - ChangeSet().remove_day("2020-01-01").clear_day("2020-01-01", DayType.SPECIAL_CLOSE).add_day("2020-01-01", { - "name": "Special Close", "time": "16:00"}, DayType.SPECIAL_CLOSE)), - (ChangeSet().add_day("2020-01-01", {"name": "Monthly Expiry"}, DayType.MONTHLY_EXPIRY), - ChangeSet().remove_day("2020-01-01").clear_day("2020-01-01", DayType.MONTHLY_EXPIRY).add_day("2020-01-01", { - "name": "Monthly Expiry"}, DayType.MONTHLY_EXPIRY)), - (ChangeSet().add_day("2020-01-01", {"name": "Quarterly Expiry"}, DayType.QUARTERLY_EXPIRY), - ChangeSet().remove_day("2020-01-01").clear_day("2020-01-01", DayType.QUARTERLY_EXPIRY).add_day("2020-01-01", { - "name": "Quarterly Expiry"}, DayType.QUARTERLY_EXPIRY)), - (ChangeSet().remove_day("2020-01-01", DayType.HOLIDAY), - ChangeSet().remove_day("2020-01-01", DayType.HOLIDAY)), - (ChangeSet().remove_day("2020-01-01", DayType.SPECIAL_OPEN), - ChangeSet().remove_day("2020-01-01", DayType.SPECIAL_OPEN)), - (ChangeSet().remove_day("2020-01-01", DayType.SPECIAL_CLOSE), - ChangeSet().remove_day("2020-01-01", DayType.SPECIAL_CLOSE)), - (ChangeSet().remove_day("2020-01-01", DayType.MONTHLY_EXPIRY), - ChangeSet().remove_day("2020-01-01", DayType.MONTHLY_EXPIRY)), - (ChangeSet().remove_day("2020-01-01", DayType.QUARTERLY_EXPIRY), - ChangeSet().remove_day("2020-01-01", DayType.QUARTERLY_EXPIRY)), - (ChangeSet() - .add_day("2020-01-01", {"name": "Holiday"}, DayType.HOLIDAY) - .remove_day("2020-01-02", DayType.HOLIDAY) - .add_day("2020-02-01", {"name": "Special Open", "time": "10:00"}, DayType.SPECIAL_OPEN) - .remove_day("2020-02-02", DayType.SPECIAL_OPEN) - .add_day("2020-03-01", {"name": "Special Close", "time": "16:00"}, DayType.SPECIAL_CLOSE) - .remove_day("2020-03-02", DayType.SPECIAL_CLOSE) - .add_day("2020-04-01", {"name": "Monthly Expiry"}, DayType.MONTHLY_EXPIRY) - .remove_day("2020-04-02", DayType.MONTHLY_EXPIRY) - .add_day("2020-05-01", {"name": "Quarterly Expiry"}, DayType.QUARTERLY_EXPIRY) - .remove_day("2020-05-02", DayType.QUARTERLY_EXPIRY), - ChangeSet() - .remove_day("2020-01-01") - .clear_day("2020-01-01", DayType.HOLIDAY) - .add_day("2020-01-01", {"name": "Holiday"}, DayType.HOLIDAY) - .remove_day("2020-01-02", DayType.HOLIDAY) - .remove_day("2020-02-01") - .clear_day("2020-02-01", DayType.SPECIAL_OPEN) - .add_day("2020-02-01", {"name": "Special Open", "time": "10:00"}, DayType.SPECIAL_OPEN) - .remove_day("2020-02-02", DayType.SPECIAL_OPEN) - .remove_day("2020-03-01") - .clear_day("2020-03-01", DayType.SPECIAL_CLOSE) - .add_day("2020-03-01", {"name": "Special Close", "time": "16:00"}, DayType.SPECIAL_CLOSE) - .remove_day("2020-03-02", DayType.SPECIAL_CLOSE) - .remove_day("2020-04-01") - .clear_day("2020-04-01", DayType.MONTHLY_EXPIRY) - .add_day("2020-04-01", {"name": "Monthly Expiry"}, DayType.MONTHLY_EXPIRY) - .remove_day("2020-04-02", DayType.MONTHLY_EXPIRY) - .remove_day("2020-05-01") - .clear_day("2020-05-01", DayType.QUARTERLY_EXPIRY) - .add_day("2020-05-01", {"name": "Quarterly Expiry"}, DayType.QUARTERLY_EXPIRY) - .remove_day("2020-05-02", DayType.QUARTERLY_EXPIRY)), - ]) - def test_normalize(self, cs: ChangeSet, cs_normalized: ChangeSet): - # Return copy. - cs_normalized0 = cs.normalize(inplace=False) - # Should have returned a new copy. - assert id(cs_normalized0) != id(cs) - assert id(cs_normalized0) != id(cs_normalized) - # Should be identical to passed in normalized changeset. - assert cs_normalized0 == cs_normalized - # Idempotency. - assert cs_normalized0.normalize(inplace=False) == cs_normalized0 - - # In-place. - cs_normalized0 = cs.normalize(inplace=True) - # Should have returned the same object. - assert id(cs_normalized0) == id(cs) - assert id(cs_normalized0) != id(cs_normalized) - # Should be identical to passed in normalized changeset. - assert cs_normalized0 == cs_normalized + def test_all_days(self): + cs = ChangeSet( + add=[ + {'date': '2020-01-01', 'type': 'holiday', 'name': 'Holiday'}, + {'date': '2020-02-01', 'type': 'special_open', 'name': 'Special Open', 'time': '10:00'}, + {'date': '2020-03-01', 'type': 'special_close', 'name': 'Special Close', 'time': '16:00'}, + {'date': '2020-04-01', 'type': 'monthly_expiry', 'name': 'Monthly Expiry'}, + {'date': '2020-05-01', 'type': 'quarterly_expiry', 'name': 'Quarterly Expiry'}, + ], + remove=['2020-01-02', '2020-02-02', '2020-03-02', '2020-04-02', '2020-05-02'] + ) + assert cs.all_days == tuple(map(lambda x: pd.Timestamp(x), ['2020-01-01', '2020-01-02', '2020-02-01', + '2020-02-02', '2020-03-01', '2020-03-02', + '2020-04-01', '2020-04-02', '2020-05-01', + '2020-05-02']))