diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5b65604..6c824c4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,61 +1,59 @@ name: Publish Python distributions to PyPI on: - workflow_dispatch: - inputs: - ref: - description: 'The git ref to build and publish' - required: true - default: 'main' - type: string + release: + types: [published] + +env: + PACKAGE_NAME: exchange_calendars_extensions jobs: build-and-publish: - name: Build and publish Python distributions to TestPyPI + name: Build and publish Python distributions to TestPyPI and PyPI. runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - with: - ref: ${{ inputs.ref }} - - name: Install poetry + - name: Install poetry. run: pipx install poetry - - name: Set up Python 3.10 + - name: Set up Python 3.10. uses: actions/setup-python@v4 with: python-version: "3.10" cache: 'poetry' - - name: Generate requirements.txt + - name: Set poetry package version from tag. + run: poetry version ${{ github.ref_name }} + - name: Generate requirements.txt. run: poetry export -f requirements.txt --without-hashes > requirements.txt - - name: Build package + - name: Build package with poetry. run: poetry build - - name: Publish distribution 📦 to Test PyPI + - name: Publish package to Test PyPI. uses: pypa/gh-action-pypi-publish@release/v1 with: user: __token__ password: ${{ secrets.TEST_PYPI_PASSWORD }} repository_url: https://test.pypi.org/legacy/ skip_existing: true - - name: Install from testpypi and import + - name: Install from testpypi and import. run: | i=0 - while (($i<12)) && [ "0.1.1" != $(pip index versions -i https://test.pypi.org/simple --pre exchange_calendars_extensions | cut -d'(' -f2 | cut -d')' -f1 | sed 1q) ];\ + while (($i<36)) && [[ ! $(curl -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 exchange_calendars_extensions==0.1.1 --no-deps + pip install --index-url https://test.pypi.org/simple ${{ env.PACKAGE_NAME }}==${{ github.ref_name }} --no-deps pip install -r requirements.txt - python -c 'import exchange_calendars_extensions;print(exchange_calendars_extensions.__version__)' + python -c 'import ${{ env.PACKAGE_NAME }};print(${{ env.PACKAGE_NAME }}.__version__)' - name: Clean pip run: | - pip uninstall -y exchange_calendars_extensions + pip uninstall -y ${{ env.PACKAGE_NAME }} pip cache purge - - name: Publish distribution 📦 to PyPI + - name: Publish package to PyPI. uses: pypa/gh-action-pypi-publish@release/v1 with: user: __token__ password: ${{ secrets.PYPI_PASSWORD }} - - name: Install and import + - name: Install and import. run: | i=0 - while (($i<12)) && [ "0.1.1" != $(pip index versions -i https://pypi.org/simple --pre exchange_calendars_extensions | cut -d'(' -f2 | cut -d')' -f1 | sed 1q) ];\ + while (($i<36)) && [[ ! $(curl -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 exchange_calendars_extensions==0.1.1 - python -c 'import exchange_calendars_extensions;print(exchange_calendars_extensions.__version__)' + pip install --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 0cbacb2..bc29663 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,53 @@ # exchange-calendars-extensions -A Python package that transparently adds some features to the [exchange_calendars](https://github.com/gerrymanoim/exchange_calendars) +[![PyPI](https://img.shields.io/pypi/v/exchange-calendars-extensions)](https://pypi.org/project/exchange-calendars-extensions/) ![Python Support](https://img.shields.io/pypi/pyversions/exchange_calendars_extensions) ![PyPI Downloads](https://img.shields.io/pypi/dd/exchange-calendars-extensions) + +A Python package that transparently adds some features to the [exchange-calendars](https://pypi.org/project/exchange-calendars/) package. -For select exchanges: -- Add holiday calendar for regular and ad-hoc holidays combined. -- Add holiday calendar for regular and ad-hoc special open days combined. -- Add holiday calendar for regular and ad-hoc special close days combined. -- Add holiday calendar for weekend days. -- Add holiday calendar for quarterly expiry days (aka quadruple witching). -- Add holiday calendar for monthly expiry days (in month without quarterly expiry). -- Add holiday calendar for last trading day of the month -- Add holiday calendar for last *regular* trading day of the month. +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 + 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. -## Installation +For select exchanges, this packages also adds: +- Calendars for additional special trading sessions, such as quarterly expiry days (aka quadruple witching). + +## 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. + +## Additional calendars +In addition to information that is already available in +[exchange-calendars](https://pypi.org/project/exchange-calendars/), this package also adds calendars for +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: +- quarterly expiry days (aka quadruple witching), and +- monthly expiry days (in all months without quarterly expiry day). + +Finally, a new calendar that contains all weekend days as per the underlying weekmask is also available. +## Calendar modifications +This package also adds the ability to modify existing calendars at runtime. This can be used to add or remove +- holidays (regular and ad-hoc), +- special open days (regular and ad-hoc), +- special close days (regular and ad-hoc), +- 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 +available yet. + +## Installation The package is available on [PyPI](https://pypi.org/project/exchange-calendars-extensions/) and can be installed via -[pip](https://pip.pypa.io/en/stable/) or other suitable dependency management tool like +[pip](https://pip.pypa.io/en/stable/) or any other suitable dependency management tool, e.g. [Poetry](https://python-poetry.org/). ```bash @@ -23,17 +55,17 @@ pip install exchange-calendars-extensions ``` ## Usage - Import the package. ```python import exchange_calendars_extensions ``` -Register extended exchange calendar classes with the `exchange_calendars` package. +Register extended exchange calendar classes with the `exchange_calendars` module. ```python exchange_calendars_extensions.apply_extensions() ``` -This will replace the default exchange calendar classes with the extended versions for supported exchanges; see [below](#supported-exchanges). +This will replace the default exchange calendar classes with the extended versions. Note that this action currently +cannot be undone. A new Python interpreter session is required to revert to the original classes. Get an exchange calendar instance. ```python @@ -42,35 +74,33 @@ from exchange_calendars import get_calendar calendar = get_calendar('XLON') ``` -Extended calendars are subclasses of the abstract base class -`exchange_calendars_extensions.ExtendedExchangeCalendar` which inherits both from `exchange_calendars.ExchangeCalendar` -and the protocol class `exchange_calendars_extensions.ExchangeCalendarExtensions`. +Extended exchange calendars are subclasses of the abstract base class +`exchange_calendars_extensions.ExtendedExchangeCalendar`. This class inherits both from `exchange_calendars.ExchangeCalendar` +and the new protocol class `exchange_calendars_extensions.ExchangeCalendarExtensions` which defines the extended properties. ```python assert isinstance(calendar, exchange_calendars_extensions.ExtendedExchangeCalendar) assert isinstance(calendar, exchange_calendars.ExchangeCalendar) assert isinstance(calendar, exchange_calendars_extensions.ExchangeCalendarExtensions) ``` -The extended calendars provide the following additional holiday calendars, all instances of -`exchange_calendars.exchange_calendar.HolidayCalendar`: +### Additional properties +Extended exchange calendars provide the following calendars as properties: - `holidays_all`: Regular and ad-hoc holidays combined into a single calendar. - `special_opens_all`: Regular and ad-hoc special open days combined into a single calendar. - `special_closes_all`: Regular and ad-hoc special close days combined into a single calendar. -- `weekend_days`: All weekend days, as defined by the underlying calendar's weekmask, in a single calendar. +- `weekend_days`: All weekend days, as defined by the underlying weekmask, in a single calendar. - `quarterly_expiries`: Quarterly expiry days, also known as quadruple witching. Many exchanges observe special business days on which market index futures, options futures, stock options and stock futures expire, typically resulting in increased volatility and traded volume. Quadruple witching is typically observed on the third Friday of March, June, - September and December, although some exchanges observe it on Thursday instead. Also, collisions with holidays or - special open/close days may result in the quarterly expiry day being rolled backward to an otherwise regular business -- day. -- `monthly_expiries`: Monthly expiry days. Similar to quarterly expiry days, but for the remaining months of the year. - Monthly expiries are similar to quarterly expiries, but typically result in less extreme trading patterns and may thus - be treated separately. -- `last_trading_days_of_months`: Last trading day of each month of the year. -- `last_regular_trading_days_of_months`: Last regular trading day of each month of the year, i.e. not a special + September and December, although some exchanges observe it on Thursday instead. Note that in the case of collisions + with holidays or special open/close days, a quarterly expiry day is usually rolled backward to the previous and + otherwise regular business day. +- `monthly_expiries`: Monthly expiry days. Similar to quarterly expiry days, but for all remaining months of the year. + Provided in a separate calendar as they typically result in less extreme trading patterns. +- `last_session_of_months`: The last trading session for each month of the year. +- `last_regular_session_of_months`: Last regular trading session of each month of the year, i.e. not a special open/close or otherwise irregular day. -## Examples ```python calendar = get_calendar('XLON') print(calendar.holidays_all.holidays(start='2020-01-01', end='2020-12-31', return_name=True)) @@ -152,8 +182,213 @@ 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. -## Supported exchanges -This package currently provides extensions for the following subset of exchanges supported by `exchange_calendars`: +### Adding/removing holidays and special sessions +Extended exchange calendars provide the 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, + +```python +import pandas as pd +from exchange_calendars_extensions import add_holiday + +add_holiday('XLON', pd.Timestamp('2021-12-27'), 'Holiday') +``` +will add a new holiday named `Holiday` to the calendar for the London Stock Exchange on 27 December 2021. Similarly, +```python +import pandas as pd +from exchange_calendars_extensions import remove_holiday + +remove_holiday('XLON', pd.Timestamp('2021-12-27')) +``` +will remove the holiday from the calendar again. + +Holidays are always added as regular holidays. Removing holidays works for both regular and ad-hoc holidays, regardless +whether the affected days were in the original calendar or had been added programmatically at an earlier stage. + +Whenever a calendar has been modified programmatically, the changes are only reflected after obtaining a new exchange +calendar instance. +```python +# Changes not reflected in existing instances. +... +calendar = get_calendar('XLON') +# Changes reflected in new instance. +... +``` + +The day types that can be added are 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. +```python +import pandas as pd +import datetime as dt +from exchange_calendars_extensions import add_special_open + +add_special_open('XLON', pd.Timestamp('2021-12-27'), dt.time(11, 0), 'Special Open') +``` + +The numeration type `exchange_Calendars_extensions.HolidaysAndSpecialSessions` can be used to add or remove holidays in +a more generic way. +```python +import pandas as pd +import datetime as dt +from exchange_calendars_extensions import add_day, remove_day, HolidaysAndSpecialSessions + +add_day('XLON', HolidaysAndSpecialSessions.SPECIAL_OPEN, pd.Timestamp('2021-12-27'), {'name': 'Special Open', 'time': dt.time(11, 0)}) +remove_day('XLON', pd.Timestamp('2021-12-27'), HolidaysAndSpecialSessions.SPECIAL_OPEN) +``` + +When removing a day, the day type is optional. +```python +remove_day('XLON', pd.Timestamp('2021-12-27')) +``` +If not given, the day will be removed from all calendars it is present in. This is useful to make sure 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. + +Removing a day is always handled gracefully when the day is not already present in the calendar, i.e. this does not +throw an exception. + +### Changesets +When a calendar is modified programmatically, the changes are recorded in a changeset. When a new calendar instance is +obtained, the changeset is applied to the underlying unmodified calendar. + +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 must 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 already +present in the calendar for a day type. + +### Strict mode +Multiple calls to add or remove holidays or special sessions can lead to an inconsistent changeset for a calendar or +situations where the semantics of each action may not be immediately clear without further specification. For example, +what should happen if the same day is added as a holiday and then removed? +```python +... +add_holiday('XLON', pd.Timestamp('2021-12-27'), 'Holiday') +remove_holiday('XLON', pd.Timestamp('2021-12-27')) + +calendar = get_calendar('XLON') +``` +By default, situations are handled gracefully as far as possible. Here, the holiday is first added to the changeset and +then marked as a day to remove for all day types. This would normally lead to an inconsistent changeset since the same +day would now be marked as a holiday to add as well as a day to remove from the holidays (as well as all other day +types). To remain consistent, the day is is removed from the holidays to add. Now, the changeset only contains the day +as a day to remove for all day types. + +This behaviour may not be desired in all cases which is why the `strict` flag can be set to `True` when adding or +removing a day. In strict mode, conflicting actions such as the ones above will raise an exception. +```python +... +add_holiday('XLON', pd.Timestamp('2021-12-27'), 'Holiday', strict=True) +remove_holiday('XLON', pd.Timestamp('2021-12-27'), strict=True) +# The second call will raise an exception. +``` + +Another case to consider is trying to add the same day twice with two different day types. +```python +... +add_holiday('XLON', pd.Timestamp('2021-12-27'), 'Holiday') +add_special_open('XLON', pd.Timestamp('2021-12-27'), dt.time(11, 0), 'Special Open') + +calendar = get_calendar('XLON') +``` +By default, this will not raise an exception. Instead, the second action will overwrite the first one. The resulting +calendar will therefore just have the day marked as a special open day. In strict mode, however, this will raise an +exception. +```python +... +add_holiday('XLON', pd.Timestamp('2021-12-27'), 'Holiday', strict=True) +add_special_open('XLON', pd.Timestamp('2021-12-27'), dt.time(11, 0), 'Special Open', strict=True) +# The second call will raise an exception. +``` + +Strict mode may be particularly useful when an entire changeset is built up through multiple calls that are all expected +to be compatible with each other. + +### 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) +``` + +## Supported exchanges for monthly/quarterly expiry +This package currently provides support for monthly/querterly expiry calendars for the following subset of exchanges +from `exchange_calendars`: - ASEX - BMEX - XAMS @@ -167,6 +402,7 @@ This package currently provides extensions for the following subset of exchanges - XJSE - XLIS - XLON +- XMAD - XMIL - XNAS - XNYS @@ -192,22 +428,52 @@ from exchange_calendars_extensions.holiday_calendar import extend_class xlon_extended_cls = extend_class(XLONExchangeCalendar, day_of_week_expiry=4) ``` -The first argument to `extend_class` should be the class of the exchange calendar to be extended. The second parameter -is the day of the week on which expiry days are normally observed. The returned extended class directly inherits from -the passed base class, but also adds the additional attributes like `quarterly_expiries` et cetera. +The first argument to `extend_class` should be the class of the exchange calendar to extend. The second and optional +parameter, which defaults to `None`, is the day of the week on which expiry days are normally observed. If this parameter +is `None`, this assumes that the underlying exchange does not support monthly or quarterly expiry days and the respective +calendars will not be added. + +The returned extended class directly inherits from the passed base class and adds the additional attributes like +`holidays_all` et cetera. The returned class also supports programmatic modifications using the corresponding exchange +key of the parent class. -To register a new extended class for an exchange, use the `register_extension` function before calling `apply_extensions()`. +To register a new extended class for an exchange, use the `register_extension()` function before calling +`apply_extensions()`. ```python from exchange_calendars_extensions import register_extension, apply_extensions register_extension(key, cls) - 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 +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. + +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 +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])` +which returns a calendar that simply concatenates, in order, all rules from the passed-in calendars. The returned +calendar is a subclass of `HolidayCalendar` that handles possible duplicates by filtering them out before returning +from a call to `holidays()`. + +**In essence: Always use `merge_calendars(...)` instead of `AbstractHolidayCalendar.merge(...)` when merging involves +any of the calendars added by this package. Keep in mind that for duplicate elimination, rules more to the front of the +list have higher priority.** + ## Contributing Contributions are welcome. Please open an issue or submit a pull request on GitHub. diff --git a/exchange_calendars_extensions/__init__.py b/exchange_calendars_extensions/__init__.py index 718d3ad..ff6b68f 100644 --- a/exchange_calendars_extensions/__init__.py +++ b/exchange_calendars_extensions/__init__.py @@ -1,4 +1,11 @@ -from exchange_calendars import register_calendar_type +import functools +from datetime import time +from typing import Optional, Callable, Type, Union +from typing_extensions import ParamSpec, Concatenate + +import pandas as pd +from exchange_calendars import calendar_utils, register_calendar_type, ExchangeCalendar, get_calendar_names +from exchange_calendars.calendar_utils import _default_calendar_factories from exchange_calendars.exchange_calendar_asex import ASEXExchangeCalendar from exchange_calendars.exchange_calendar_xams import XAMSExchangeCalendar from exchange_calendars.exchange_calendar_xbru import XBRUExchangeCalendar @@ -6,10 +13,12 @@ from exchange_calendars.exchange_calendar_xcse import XCSEExchangeCalendar from exchange_calendars.exchange_calendar_xdub import XDUBExchangeCalendar from exchange_calendars.exchange_calendar_xetr import XETRExchangeCalendar +from exchange_calendars.exchange_calendar_xhel import XHELExchangeCalendar from exchange_calendars.exchange_calendar_xist import XISTExchangeCalendar from exchange_calendars.exchange_calendar_xjse import XJSEExchangeCalendar from exchange_calendars.exchange_calendar_xlis import XLISExchangeCalendar from exchange_calendars.exchange_calendar_xlon import XLONExchangeCalendar +from exchange_calendars.exchange_calendar_xmad import XMADExchangeCalendar from exchange_calendars.exchange_calendar_xmil import XMILExchangeCalendar from exchange_calendars.exchange_calendar_xnys import XNYSExchangeCalendar from exchange_calendars.exchange_calendar_xosl import XOSLExchangeCalendar @@ -22,62 +31,628 @@ from exchange_calendars.exchange_calendar_xwar import XWARExchangeCalendar from exchange_calendars.exchange_calendar_xwbo import XWBOExchangeCalendar +from .changeset import ChangeSet, HolidaysAndSpecialSessions, DaySpec, DaySpecWithTime from .holiday_calendar import extend_class, ExtendedExchangeCalendar +# Dictionary that maps from exchange key to ExchangeCalendarChangeSet. Contains all changesets to apply when creating a +# new calendar instance. +_changesets = dict() -class BMEXExchangeCalendar: - pass +# Dictionary that maps from exchange key to ExtendedExchangeCalendar. Contains all extended calendars classes that +# replace the vanilla classes in exchange_calendars when calling apply_extensions(). +# +# Note: The values in this dictionary use extend_class() to create the extended classes, respectively for each exchange, +# based on the respective vanilla class in exchange_calendars. Also, the changeset_provider is set to a lambda +# function that returns the changeset for the respective exchange in _changesets, or None, if no changeset exists. +_extensions = { + "ASEX": (ASEXExchangeCalendar, 4), + "XAMS": (XAMSExchangeCalendar, 4), + "XBRU": (XBRUExchangeCalendar, 4), + "XBUD": (XBUDExchangeCalendar, 4), + "XCSE": (XCSEExchangeCalendar, 4), + "XDUB": (XDUBExchangeCalendar, 4), + "XETR": (XETRExchangeCalendar, 4), + "XHEL": (XHELExchangeCalendar, 4), + "XIST": (XISTExchangeCalendar, 4), + "XJSE": (XJSEExchangeCalendar, 3), + "XLIS": (XLISExchangeCalendar, 4), + "XLON": (XLONExchangeCalendar, 4), + "XMAD": (XMADExchangeCalendar, 4), + "XMIL": (XMILExchangeCalendar, 4), + "XNYS": (XNYSExchangeCalendar, 4), + "XOSL": (XOSLExchangeCalendar, 4), + "XPAR": (XPARExchangeCalendar, 4), + "XPRA": (XPRAExchangeCalendar, 4), + "XSTO": (XSTOExchangeCalendar, 4), + "XSWX": (XSWXExchangeCalendar, 4), + "XTAE": (XTAEExchangeCalendar, 4), + "XTSE": (XTSEExchangeCalendar, 4), + "XWAR": (XWARExchangeCalendar, 4), + "XWBO": (XWBOExchangeCalendar, 4), +} -class XHELEXchangeCalendar: - pass +def apply_extensions() -> None: + """ + Apply extensions to exchange_calendars. + This registers all extended calendars in exchange_calendars, overwriting the respective vanilla calendars. + """ + calendar_names = set(get_calendar_names()) -class XNASExchangeCalendar: - pass + for k in calendar_names - set(_extensions.keys()): + cls = _default_calendar_factories.get(k) + if cls is not None: + cls = extend_class(cls, day_of_week_expiry=None, changeset_provider=lambda: _changesets.get(k)) + register_calendar_type(k, cls, force=True) + for k, v in _extensions.items(): + cls, day_of_week_expiry = v + cls = extend_class(cls, day_of_week_expiry=day_of_week_expiry, changeset_provider=lambda: _changesets.get(k)) + register_calendar_type(k, cls, force=True) -_extensions = { - "ASEX": extend_class(ASEXExchangeCalendar, day_of_week_expiry=4), - "BMEX": extend_class(BMEXExchangeCalendar, day_of_week_expiry=4), - "XAMS": extend_class(XAMSExchangeCalendar, day_of_week_expiry=4), - "XBRU": extend_class(XBRUExchangeCalendar, day_of_week_expiry=4), - "XBUD": extend_class(XBUDExchangeCalendar, day_of_week_expiry=4), - "XCSE": extend_class(XCSEExchangeCalendar, day_of_week_expiry=4), - "XDUB": extend_class(XDUBExchangeCalendar, day_of_week_expiry=4), - "XETR": extend_class(XETRExchangeCalendar, day_of_week_expiry=4), - "XHEL": extend_class(XHELEXchangeCalendar, day_of_week_expiry=4), - "XIST": extend_class(XISTExchangeCalendar, day_of_week_expiry=4), - "XJSE": extend_class(XJSEExchangeCalendar, day_of_week_expiry=3), - "XLIS": extend_class(XLISExchangeCalendar, day_of_week_expiry=4), - "XLON": extend_class(XLONExchangeCalendar, day_of_week_expiry=4), - "XMIL": extend_class(XMILExchangeCalendar, day_of_week_expiry=4), - "XNAS": extend_class(XNASExchangeCalendar, day_of_week_expiry=4), - "XNYS": extend_class(XNYSExchangeCalendar, day_of_week_expiry=4), - "XOSL": extend_class(XOSLExchangeCalendar, day_of_week_expiry=4), - "XPAR": extend_class(XPARExchangeCalendar, day_of_week_expiry=4), - "XPRA": extend_class(XPRAExchangeCalendar, day_of_week_expiry=4), - "XSTO": extend_class(XSTOExchangeCalendar, day_of_week_expiry=4), - "XSWX": extend_class(XSWXExchangeCalendar, day_of_week_expiry=4), - "XTAE": extend_class(XTAEExchangeCalendar, day_of_week_expiry=4), - "XTSE": extend_class(XTSEExchangeCalendar, day_of_week_expiry=4), - "XWAR": extend_class(XWARExchangeCalendar, day_of_week_expiry=4), - "XWBO": extend_class(XWBOExchangeCalendar, day_of_week_expiry=4), -} +def register_extension(name: str, cls: Type[ExchangeCalendar], day_of_week_expiry: Optional[int] = None) -> None: + """ + Register an extended calendar class for a given exchange key and a given base class. -def apply_extensions(): - """Apply extensions to exchange_calendars.""" - for k, v in _extensions.items(): - register_calendar_type(k, v, force=True) + This creates and then registers an extended calendar class based on the given class, with support for all + additional properties of ExtendedExchangeCalendar. Expiry days are on the third instance of the given day of the + week in each month. The extended class also supports programmatic modifications; see e.g. add_holiday(). + + Parameters + ---------- + name : str + The exchange key for which to register the extended calendar class. + cls : type + The base class to extend. + day_of_week_expiry : Optional[int] + The day of the week on which options expire. If None, expiry days are not supported. + + Returns + ------- + None + """ + _extensions[name] = (cls, day_of_week_expiry) + + +def _remove_calendar_from_factory_cache(name: str): + """ + Remove a cached calendar instance for the given exchange key from the factory cache. + + Caveat: This function accesses the private attribute _factory_output_cache of the global calendar dispatcher. + """ + # noinspection PyProtectedMember + calendar_utils.global_calendar_dispatcher._factory_output_cache.pop(name, None) + + +P = ParamSpec('P') + + +def _with_changeset(f: Callable[Concatenate[ChangeSet, P], ChangeSet]) -> Callable[Concatenate[str, P], None]: + """ + An annotation that obtains the changeset from _changesets that corresponds to the exchange key passed as the first + positional argument to the wrapped function. Instead of passing the key, passes the retrieved changeset, or a newly + created empty one, if no entry for the key exists yet, to the wrapped function. + + After the wrapped function has finished, saves the changeset back to _changesets under the key. Note that this only + has an effect if the changeset was newly created upon retrieval, before the wrapped call. Otherwise, the changeset + will already be in _changesets. + + Finally, removes the calendar for the exchange key from the factory cache. This ensures that the next call to + get_calendar() for the exchange key will create a new calendar instance, which will reflect the changes made by the + wrapped function. + + Parameters + ---------- + f : Callable + The function to wrap. + + Returns + ------- + Callable + The wrapped function. + """ + @functools.wraps(f) + def wrapper(exchange: str, *args: P.args, **kwargs: P.kwargs) -> None: + # Retrieve changeset for key, create new empty one, if required. + cs: ChangeSet = _changesets.get(exchange, ChangeSet()) + + # 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}.') + + # Save changeset back to _changesets. + _changesets[exchange] = cs + + # Remove calendar for exchange key from factory cache. + _remove_calendar_from_factory_cache(exchange) + + # Return result of wrapped function. + return None + + return wrapper + + +@_with_changeset +def _add_day(cs: ChangeSet, day_type: HolidaysAndSpecialSessions, date: pd.Timestamp, value: Union[DaySpec, DaySpecWithTime], strict: bool) -> ChangeSet: + """ + Add a day of a given type to the changeset for a given exchange calendar. + + Parameters + ---------- + cs : ChangeSet + The changeset to which to add the day. + day_type : HolidaysAndSpecialSessions + The type of the day to add. + date : pd.Timestamp + The date to add. + value : Union[DaySpec, DaySpecWithTime] + The properties to add for the day. Must match the properties required by the given day type. + strict : bool + Whether to raise an error if the changeset would be inconsistent after adding the day. + + Returns + ------- + ChangeSet + The changeset with the added day. + + Raises + ------ + ValueError + If the changeset would be inconsistent after adding the day. + """ + return cs.add_day(date, value, day_type, strict=strict) + + +def add_day(exchange: str, day_type: HolidaysAndSpecialSessions, date: pd.Timestamp, value: Union[DaySpec, DaySpecWithTime], strict: bool = False) -> None: + """ + Add a day of a given type to the given exchange calendar. + + Parameters + ---------- + exchange : str + The exchange key for which to add the day. + day_type : HolidaysAndSpecialSessions + The type of the day to add. + date : pd.Timestamp + The date to add. + value : Union[DaySpec, DaySpecWithTime] + The properties to add for the day. Must match the properties required by the given day type. + strict : bool + Whether to raise an error if the changeset for the exchange would be inconsistent after adding the day. + + Returns + ------- + None + + Raises + ------ + ValueError + If the changeset for the exchange would be inconsistent after adding the day. + """ + _add_day(exchange, day_type, date, value, strict=strict) + + +@_with_changeset +def _clear_day(cs: ChangeSet, date: pd.Timestamp, day_type: Optional[HolidaysAndSpecialSessions] = None) -> ChangeSet: + """ + Clear a day of a given type from the changeset for a given exchange calendar. + + Parameters + ---------- + cs : ChangeSet + The changeset from which to clear the day. + date : pd.Timestamp + The date to clear. + day_type : Optional[HolidaysAndSpecialSessions] + 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) + + +def clear_day(exchange: str, date: pd.Timestamp, day_type: Optional[HolidaysAndSpecialSessions] = None) -> None: + """ + Clear a day of a given type from the given exchange calendar. + + Parameters + ---------- + exchange : str + The exchange key for which to clear the day. + date : pd.Timestamp + The date to clear. + day_type : Optional[HolidaysAndSpecialSessions] + The type of the day to clear. If None, clears all types of days. + + Returns + ------- + None + """ + _clear_day(exchange, date, day_type) + + +@_with_changeset +def _remove_day(cs: ChangeSet, date: pd.Timestamp, day_type: HolidaysAndSpecialSessions, strict: bool) -> ChangeSet: + """ + Remove a day of a given type from the changeset for a given exchange calendar. + + Parameters + ---------- + cs : ChangeSet + The changeset from which to remove the day. + date : pd.Timestamp + The date to remove. + day_type : HolidaysAndSpecialSessions + 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 + ------- + ChangeSet + The changeset with the removed day. + + Raises + ------ + ValueError + If the changeset would be inconsistent after removing the day. + """ + return cs.remove_day(date, day_type, strict=strict) + + +def remove_day(exchange: str, date: pd.Timestamp, day_type: Optional[HolidaysAndSpecialSessions] = None, strict: bool = False) -> None: + """ + Remove a day of a given type from the given exchange calendar. + + Parameters + ---------- + exchange : str + The exchange key for which to remove the day. + date : pd.Timestamp + The date to remove. + day_type : Optional[HolidaysAndSpecialSessions] + 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 + ------- + None + + Raises + ------ + ValueError + If the changeset for the exchange would be inconsistent after removing the day. + """ + _remove_day(exchange, date, day_type, strict=strict) + + +def add_holiday(exchange: str, date: pd.Timestamp, name: str = "Holiday", strict: bool = False) -> None: + """ + Add a holiday to an exchange calendar. + + Parameters + ---------- + exchange : str + The exchange key for which to add the day. + date : pd.Timestamp + The date of the holiday. + 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 + ------- + None + + Raises + ------ + ValueError + If the changeset for the exchange would be inconsistent after adding the day. + """ + _add_day(exchange, HolidaysAndSpecialSessions.HOLIDAY, date, {"name": name}, strict=strict) + + +def remove_holiday(exchange: str, date: pd.Timestamp, strict: bool = False) -> None: + """ + Remove a holiday from an exchange calendar. + + Parameters + ---------- + exchange : str + The exchange key for which to remove the day. + date : pd.Timestamp + The date of the holiday to remove. + strict : bool + Whether to raise an error if the changeset for the exchange would be inconsistent after removing the day. + + Returns + ------- + None + + Raises + ------ + ValueError + If the changeset for the exchange would be inconsistent after removing the day. + """ + _remove_day(exchange, date, HolidaysAndSpecialSessions.HOLIDAY, strict=strict) + + +def add_special_open(exchange: str, date: pd.Timestamp, t: time, name: str = "Special Open", strict: bool = False) -> None: + """ + Add a special open to an exchange calendar. + + Parameters + ---------- + exchange : str + The exchange key for which to add the day. + date : pd.Timestamp + The date of the special open. + t : time + The time of the special open. + 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 + ------- + None + + Raises + ------ + ValueError + If the changeset for the exchange would be inconsistent after adding the day. + """ + _add_day(exchange, HolidaysAndSpecialSessions.SPECIAL_OPEN, date, {"name": name, "time": t}, strict=strict) + + +def remove_special_open(exchange: str, date: pd.Timestamp, 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 : pd.Timestamp + The date of the special open to remove. + strict : bool + Whether to raise an error if the changeset for the exchange would be inconsistent after removing the day. + + Returns + ------- + None + + Raises + ------ + ValueError + If the changeset for the exchange would be inconsistent after removing the day. + """ + _remove_day(exchange, date, HolidaysAndSpecialSessions.SPECIAL_OPEN, strict=strict) + + +def add_special_close(exchange: str, date: pd.Timestamp, t: time, name: str = "Special Close", strict: bool = False) -> None: + """ + Add a special close to an exchange calendar. + + Parameters + ---------- + exchange : str + The exchange key for which to add the day. + date : pd.Timestamp + The date of the special close. + t : time + The time of the special close. + 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 + ------- + None + + Raises + ------ + ValueError + If the changeset for the exchange would be inconsistent after adding the day. + """ + _add_day(exchange, HolidaysAndSpecialSessions.SPECIAL_CLOSE, date, {"name": name, "time": t}, strict=strict) + + +def remove_special_close(exchange: str, date: pd.Timestamp, 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 : pd.Timestamp + The date of the special close to remove. + strict : bool + Whether to raise an error if the changeset for the exchange would be inconsistent after removing the day. + + Returns + ------- + None + + Raises + ------ + ValueError + If the changeset for the exchange would be inconsistent after removing the day. + """ + _remove_day(exchange, date, HolidaysAndSpecialSessions.SPECIAL_CLOSE, strict=strict) + + +def add_quarterly_expiry(exchange: str, date: pd.Timestamp, name: str = "Quarterly Expiry", strict: bool = False) -> None: + """ + Add a quarterly expiry to an exchange calendar. + + Parameters + ---------- + exchange : str + The exchange key for which to add the day. + date : pd.Timestamp + The date of the quarterly expiry. + 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 + ------- + None + + Raises + ------ + ValueError + If the changeset for the exchange would be inconsistent after adding the day. + """ + _add_day(exchange, HolidaysAndSpecialSessions.QUARTERLY_EXPIRY, date, {"name": name}, strict=strict) + + +def remove_quarterly_expiry(exchange: str, date: pd.Timestamp, 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 : pd.Timestamp + The date of the quarterly expiry to remove. + strict : bool + Whether to raise an error if the changeset for the exchange would be inconsistent after removing the day. + + Returns + ------- + None + """ + _remove_day(exchange, date, HolidaysAndSpecialSessions.QUARTERLY_EXPIRY, strict=strict) + + +def add_monthly_expiry(exchange: str, date: pd.Timestamp, name: str = "Monthly Expiry", strict: bool = False) -> None: + """ + Add a monthly expiry to an exchange calendar. + + Parameters + ---------- + exchange : str + The exchange key for which to add the day. + date : pd.Timestamp + The date of the monthly expiry. + 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 + ------- + None + + Raises + ------ + ValueError + If the changeset for the exchange would be inconsistent after adding the day. + """ + _add_day(exchange, HolidaysAndSpecialSessions.MONTHLY_EXPIRY, date, {"name": name}, strict=strict) + + +def remove_monthly_expiry(exchange: str, date: pd.Timestamp, 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 : pd.Timestamp + The date of the monthly expiry to remove. + strict : bool + Whether to raise an error if the changeset for the exchange would be inconsistent after removing the day. + + Returns + ------- + None + + Raises + ------ + ValueError + If the changeset for the exchange would be inconsistent after removing the day. + """ + _remove_day(exchange, date, HolidaysAndSpecialSessions.MONTHLY_EXPIRY) + + +@_with_changeset +def _reset_calendar(cs: ChangeSet) -> None: + """ + Reset an exchange calendar to its original state. + + Parameters + ---------- + cs : ChangeSet + The changeset to reset. + + Returns + ------- + None + """ + return cs.clear() + + +def reset_calendar(exchange: str) -> None: + """ + Reset an exchange calendar to its original state. + + Parameters + ---------- + exchange : str + The exchange key for which to reset the calendar. + + Returns + ------- + None + """ + _reset_calendar(exchange) + + +@_with_changeset +def _update_calendar(_: ChangeSet, changes: dict) -> ChangeSet: + return ChangeSet.from_dict(changes) + + +def update_calendar(exchange: str, changes: dict) -> None: + """ + Apply changes to an exchange calendar. + Parameters + ---------- + changes : dict + The changes to apply. -def register_extension(name, cls): - """Set an extension for a given exchange calendar.""" - _extensions[name] = cls + Returns + ------- + None + """ + _update_calendar(exchange, changes) -__all__ = ["apply_extensions", "register_extension", "extend_class", "ExtendedExchangeCalendar"] +# Declare public names. +__all__ = ["apply_extensions", "register_extension", "extend_class", "HolidaysAndSpecialSessions", "add_day", + "remove_day", "DaySpec", "DaySpecWithTime", "add_holiday", "remove_holiday", "add_special_close", + "remove_special_close", "add_special_open", "remove_special_open", "add_quarterly_expiry", + "remove_quarterly_expiry", "add_monthly_expiry", "remove_monthly_expiry", "reset_calendar", + "update_calendar", "ExtendedExchangeCalendar"] __version__ = None diff --git a/exchange_calendars_extensions/changeset.py b/exchange_calendars_extensions/changeset.py new file mode 100644 index 0000000..58e55cf --- /dev/null +++ b/exchange_calendars_extensions/changeset.py @@ -0,0 +1,774 @@ +import datetime as dt +import itertools +from copy import deepcopy +from dataclasses import dataclass +from enum import Enum +from types import MappingProxyType +from typing import Set, Generic, TypeVar, Any, Dict, TypedDict, Union, Optional +from typing_extensions import Self + + +import pandas as pd +import schema as s + +T = TypeVar('T') + + +@dataclass +class Changes(Generic[T]): + """ + Generic internal class to represent a set of changes to a calendar. + + Changes consist of a set of dates to remove and a dictionary of dates to add. The type parameter T is the type of + the value for dates to add to the calendar. For example, for a holiday calendar, T would be a type containing + just the name of the holiday, while for special open/close days, T would be a type containing the name of the + special day and the open/close time. + """ + + @property + def add(self) -> Dict[pd.Timestamp, T]: + # Return a read-only view of the dictionary of dates to add. + return MappingProxyType(self._add) + + @property + def remove(self) -> Set[pd.Timestamp]: + # Return a read-only view of the set of dates to remove. + return frozenset(self._remove) + + def __init__(self, schema: s.Schema) -> None: + """ + Constructor. + + Parameters + ---------- + schema : Schema + The schema to use to validate the values to add. + + Returns + ------- + None + """ + # Save schema. + self._schema = schema + # Initialize properties. + self._add = dict() + self._remove = set() + + def _remove_from_add(self, date: pd.Timestamp) -> None: + """ + Remove a date from the dictionary of dates to add. + + Gracefully returns directly if the given date is not in the dictionary of dates to add. + + Parameters + ---------- + date : pd.Timestamp + The date to remove. + + Returns + ------- + None + """ + # 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] + + def _remove_from_remove(self, date: pd.Timestamp) -> None: + """ + Remove a date from the set of dates to remove. + + Gracefully returns directly if the given date is not in the set of dates to remove. + + Parameters + ---------- + date : pd.Timestamp + The date to remove. + + Returns + ------- + None + """ + # 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) + + def add_day(self, date: pd.Timestamp, value: T, strict: bool) -> 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 : pd.Timestamp + The date to add. + value : T + The value to add. + strict : bool, optional + If True, raise ValueError if the given date is already in the set of days to remove. If False, gracefully + remove the date from the set of days to remove, if required, and then add it to the days to add. + + Returns + ------- + Changes : self + + Raises + ------ + SchemaError + If the value does not match the schema. + ValueError + If strict is True and the given date is already in the set of days to remove. + """ + # Validate value against schema. + value = self._schema.validate(value) + + # Ensure consistency by removing from days to remove, maybe. + if strict: + if date in self._remove: + raise ValueError(f'Date {date} is already in the set of days to remove.') + else: + self._remove_from_remove(date) + + # Add the holiday to the set of holidays to add. Also overwrites any previous entry for the date. + self._add[date] = value + + return self + + def remove_day(self, date: pd.Timestamp, strict: bool) -> 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 : pd.Timestamp + The date to remove. + strict : bool, optional + If True, raise ValueError if the given date is already in the set of days to add. If False, gracefully + remove the date from the set of days to add, if required, and then add it to the days to remove. + + Returns + ------- + Changes : self + + Raises + ------ + ValueError + If strict is True and the given date is already in the set of days to add. + """ + # Ensure consistency by removing from days to add, maybe. + if strict: + if self._add.get(date) is not None: + raise ValueError(f'Date {date} is already in the set of days to add.') + else: + self._remove_from_add(date) + + # Add the holiday to the set of holidays to remove. Will be a no-op if the date is already in the set. + self._remove.add(date) + + return self + + def clear_day(self, date: pd.Timestamp) -> 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 : pd.Timestamp + The date to reset. + + Returns + ------- + Changes + Self + """ + # Remove the holiday from the set of holidays to add. + self._remove_from_add(date) + + # Remove the holiday from the set of holidays to remove. + self._remove_from_remove(date) + + return self + + def clear(self) -> Self: + """ + Clear all changes. + + Returns + ------- + Changes : self + """ + self._add.clear() + self._remove.clear() + + return self + + def is_consistent(self) -> bool: + """ + Return whether the changes are consistent. + + Changes are consistent if and only if dates to add and dates to remove do not overlap. + + Returns + ------- + bool + True if the changes are consistent, False otherwise. + """ + # Check if any dates are in both the set of holidays to add and the set of holidays to remove. + return len(self.add.keys() & self.remove) == 0 + + def is_empty(self) -> bool: + """ + Check if the changes are empty. + + Changes are empty when both the set of dates to add and the set of dates to remove are empty. + + Returns + ------- + bool + True if the changes are empty, False otherwise. + """ + return len(self.add) == 0 and len(self.remove) == 0 + + 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 + + def __copy__(self): + c = Changes() + c._add = self._add + c_remove = self._remove + return c + + def __deepcopy__(self, memo): + c = Changes(self._schema) + c._add = deepcopy(self._add, memo) + c._remove = deepcopy(self._remove, memo) + return c + + @staticmethod + def _format_dict(d: Dict[pd.Timestamp, T]) -> str: + """ + Format a dictionary of dates to values as a string. + + Parameters + ---------- + d : Dict[pd.Timestamp, T] + The dictionary to format. + + Returns + ------- + str + The formatted string. + """ + return '{' + ', '.join([f'{k.date().isoformat()}: {v}' for k, v in d.items()]) + '}' + + @staticmethod + def _format_set(s: Set[pd.Timestamp]) -> str: + """ + Format a set of dates as a string. + + Parameters + ---------- + s : Set[pd.Timestamp] + The set to format. + + Returns + ------- + str + The formatted string. + """ + return '{' + ', '.join([d.date().isoformat() for d in s]) + '}' + + def __str__(self) -> str: + return f'Changes(add={self._format_dict(self._add)}, remove={self._format_set(self._remove)})' + + +def _to_time(input: Union[str, dt.time]) -> dt.time: + """ + Gracefully convert an input value to a datetime.time. + + Parameters + ---------- + input : Union[str, dt.time] + The input value to convert. + + Returns + ------- + dt.time + The converted value. + + Raises + ------ + ValueError + If the input value cannot be converted to a datetime.time. + """ + if isinstance(input, dt.time): + return input + try: + return dt.datetime.strptime(input, '%H:%M').time() + except ValueError: + return dt.datetime.strptime(input, '%H:%M:%S').time() + + +def _to_timestamp(input: Union[str, pd.Timestamp]) -> pd.Timestamp: + """ + Gracefully convert an input value to a pandas.Timestamp. + + Parameters + ---------- + input : Union[str, pd.Timestamp] + The input value to convert. + + Returns + ------- + pd.Timestamp + The converted value. + + Raises + ------ + ValueError + If the input value cannot be converted to a pandas.Timestamp. + """ + if isinstance(input, pd.Timestamp): + return input + return pd.Timestamp(input) + + +# Define types and schemas for the different types of holidays and special sessions. +DaySpec = TypedDict('DAY_SPEC', {'name': str}) +_DaySchema = s.Schema({'name': str}) +DaySpecWithTime = TypedDict('DAY_SPEC_WITH_TIME', {'name': str, 'time': dt.time}) +_DayWithTimeSchema = s.Schema({'name': str, 'time': s.Use(_to_time)}) + + +class HolidaysAndSpecialSessions(Enum): + """ + 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. + + Each enum value is a tuple of the following form: + (id, type, schema) + where + id: int + Unique int to ensure enum value uniqueness. + type: Type[Union[DaySpec, DaySpecWithTime]] + Type of the value of the enum value. + schema: Schema + Schema for the value of the enum value. + """ + HOLIDAY = (1, DaySpec, _DaySchema) + SPECIAL_OPEN = (2, DaySpecWithTime, _DayWithTimeSchema) + SPECIAL_CLOSE = (3, DaySpecWithTime, _DayWithTimeSchema) + MONTHLY_EXPIRY = (4, DaySpec, _DaySchema) + QUARTERLY_EXPIRY = (5, DaySpec, _DaySchema) + + @staticmethod + def to_enum(key: Union[str, 'HolidaysAndSpecialSessions']): + """ + Return the enum value corresponding to the given key. Case-insensitive. + + Parameters + ---------- + key : Union[str, HolidaysAndSpecialSessions] + The key to look up. + + Returns + ------- + HolidaysAndSpecialSessions + The enum value corresponding to the given key. + """ + if isinstance(key, HolidaysAndSpecialSessions): + return key + + return HolidaysAndSpecialSessions[key.upper()] + + def __copy__(self): + # Enums are immutable, so return self. + return self + + def __deepcopy__(self, memodict={}): + # Enums are immutable, so return self. + return self + + +# Define a schema for a dictionary to represent a changeset containing changes to an exchange calendar. Note that the +# schema only defines the expected structure, i.e. the keys and the types of the values. It does not validate the values +# themselves. A dictionary that is valid with respect to this schema may still contain an invalid combination of dates. +# For example, it may contain a date that is in the set of dates to add for two different types of days like holidays +# and special open. This is obviously inconsistent as the same day can only be one of those two types of days. +_SCHEMA = s.Schema({ + s.Optional('holiday'): {s.Optional('add'): [{'date': s.Use(_to_timestamp), 'value': _DaySchema}], s.Optional('remove'): [s.Use(_to_timestamp)]}, + s.Optional('special_open'): {s.Optional('add'): [{'date': s.Use(_to_timestamp), 'value': _DayWithTimeSchema}], s.Optional('remove'): [s.Use(_to_timestamp)]}, + s.Optional('special_close'): {s.Optional('add'): [{'date': s.Use(_to_timestamp), 'value': _DayWithTimeSchema}], s.Optional('remove'): [s.Use(_to_timestamp)]}, + s.Optional('monthly_expiry'): {s.Optional('add'): [{'date': s.Use(_to_timestamp), 'value': _DaySchema}], s.Optional('remove'): [s.Use(_to_timestamp)]}, + s.Optional('quarterly_expiry'): {s.Optional('add'): [{'date': s.Use(_to_timestamp), 'value': _DaySchema}], s.Optional('remove'): [s.Use(_to_timestamp)]}, +}) + + +@dataclass +class ChangeSet: + """ + Represents a modification to an existing exchange calendar. + + A changeset consists of a set of dates to add and a set of dates to remove, respectively, for each of the following + types of days: + - holidays + - special open + - special close + - monthly expiry + - quarterly expiry + + 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 must not overlap + + Condition 1) ensures that the same day is not added and removed at the same time for the same day type. Condition 2) + ensures that the same day is not added for two different day types. + + Consistency does not require a condition similar to 2) for dates to remove. This is because removing a day from a + calendar can never make it inconsistent. For example, if a changeset contains the same day as a day to remove for + two different day types, then applying these changes to a calendar will result in the day being removed from the + calendar at most once (if it was indeed a holiday or special day in the original calendar) or not at all otherwise. + Therefore, changesets may specify the same day to be removed for multiple day types, just not for day types that + also add the same date. + + A changeset is normalized if and only if the following conditions are satisfied: + 1) It is consistent. + 2) When applied to an exchange calendar, the resulting calendar is consistent. + + A changeset that is consistent can still cause an exchange calendar to become inconsistent when applied. This is + because consistency of a changeset requires the days to be added to be mutually exclusive only across all day types + within the changeset. However, there may be conflicting holidays or special days already present in a given exchange + calendar to which a changeset is applied. For example, assume the date 2020-01-01 is a holiday in the original + calendar. Then, a changeset that adds 2020-01-01 as a special open day will cause the resulting calendar to be + inconsistent. This is because the same day is now both a holiday and a special open day. + + To resolve this issue, the date 2020-01-01 could be added to the changeset, respectively, for all day types (except + special opens) as a day to remove. Now, if the changeset is applied to the original calendar, 2020-01-01 will no + longer be a holiday and therefore no longer conflict with the new special open day. This form of sanitization + ensures that a consistent changeset can be applied safely to any exchange calendar. Effectively, normalization + 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. + """ + + @property + def changes(self) -> Dict[HolidaysAndSpecialSessions, Changes[Union[DaySpec, DaySpecWithTime]]]: + """ + The changes. + + Returns + ------- + Dict[HolidaysAndSpecialSessions, Changes[Any]] + The changes. + """ + # Return a read-only view of the _changes dictionary. + return MappingProxyType(self._changes) + + def __init__(self) -> None: + """ + Initialize a new instance of ChangeSet. + """ + # Initialize the _changes dictionary. + self._changes: Dict[HolidaysAndSpecialSessions, Changes[Any]] = {k: Changes[k.value[1]](schema=k.value[2]) for k in HolidaysAndSpecialSessions} + + def clear_day(self, date: pd.Timestamp, day_type: Optional[HolidaysAndSpecialSessions] = None) -> Self: + """ + Clear a day from the change set. + + Parameters + ---------- + date : pd.Timestamp + The date to clear. + day_type : Optional[HolidaysAndSpecialSessions] + The day type to clear. If None, all day types will be cleared. + + Returns + ------- + ExchangeCalendarChangeSet : self + """ + if day_type is None: + # Clear for all day types. + for c in self.changes.values(): + c.clear_day(date) + else: + # Clear for the given day type. + self.changes[day_type].clear_day(date) + + return self + + def clear(self) -> Self: + """ + Clear all changes. + + Returns + ------- + ExchangeCalendarChangeSet : self + """ + # Clear all changes for all day types. + for c in self.changes.values(): + c.clear() + + return self + + def add_day(self, date: pd.Timestamp, value: Any, day_type: HolidaysAndSpecialSessions, strict: bool = False) -> Self: + """ + Add a day to the change set. + + Parameters + ---------- + date : pd.Timestamp + The date to add. + value : Any + The value to add. + day_type : HolidaysAndSpecialSessions + The day type to add. + strict : bool + If True, raise ValueError if adding the given date would make the changeset inconsistent. If False, ensure + that the changeset remains consistent with the day added, by removing the date anywhere else in the + changeset, where required. + + Returns + ------- + ExchangeCalendarChangeSet : self + """ + # Check if the given date is already added somewhere. + is_added = any(date in c.add.keys() and k != day_type for k, c in self.changes.items()) + + # Exit early if strict is True and the date is already added somewhere. + if strict and is_added: + raise ValueError(f'Adding the given date {date} for day type {day_type} would make the changeset inconsistent.') + + # Add the day to the change set. This may raise ValueError if the date is already in the days to remove for the + # given day type. + self.changes[day_type].add_day(date, value, strict) + + if not strict and is_added: + # Remove the date from the changeset for all other day types. + for k, c in self.changes.items(): + if k != day_type and date in c.add.keys(): + c.clear_day(date) + + return self + + def remove_day(self, date: pd.Timestamp, day_type: Optional[HolidaysAndSpecialSessions] = None, strict: bool = False) -> Self: + """ + Remove a day from the change set. + + Parameters + ---------- + date : pd.Timestamp + The date to remove. + day_type : HolidaysAndSpecialSessions + The day type to remove. + strict : bool + If True, raise ValueError if removing the given date would make the changeset inconsistent. If False, ensure + that the changeset remains consistent by removing the day from the dates to add for any day type where it is + going to be added to the days to remove. + + Returns + ------- + ExchangeCalendarChangeSet : self + + Raises + ------ + ValueError + If removing the given date would make the changeset inconsistent. + """ + if day_type is not None: + self.changes[day_type].remove_day(date, strict) + else: + # Remove for all day types. + for c in self.changes.values(): + c.remove_day(date, strict) + + return self + + def is_empty(self): + """ + Return True if there are no changes. + + Returns + ------- + bool + True if there are no changes. + """ + return not any(changes.add or changes.remove for changes in self.changes.values()) + + def is_consistent(self): + """ + Return True if the change set is consistent. + + A change set is consistent iff + - the dates of all days to add do not overlap across the different day types, and + - the dates to add and the days to remove do not overlap for each day type, respectively. + + Returns + ------- + bool + True if the change set is consistent, False otherwise. + """ + # Check if all contained changes are consistent for each day type. + if not all(changes.is_consistent() for changes in self.changes.values()): + return False + + # Get all dates to add. + dates_to_add = sorted(list(itertools.chain.from_iterable(changes.add.keys() for changes in self.changes.values()))) + + # Check if there are any overlapping dates to add. + if len(dates_to_add) != len(set(dates_to_add)): + # Duplicates in the dates to add. This is invalid since the same day cannot be added multiple times with a + # different day type each. + return False + + if any([changes.add.keys() & changes.remove for changes in self.changes.values()]): + return False + + return True + + def __copy__(self) -> 'ChangeSet': + """ + Return a shallow copy of the change set. + + Returns + ------- + ChangeSet + The shallow copy. + """ + cs = ChangeSet() + cs._changes = self.changes + + return cs + + def __deepcopy__(self, memo) -> 'ChangeSet': + """ + Return a deep copy of the change set. + + Returns + ------- + ChangeSet + The deep copy. + """ + cs = ChangeSet() + cs._changes = deepcopy(self._changes) + + return cs + + def normalize(self, inplace: bool = False) -> Self: + """ + Normalize the change set. + + A change set is normalized if + 1) It is consistent. + 2) When applied to an exchange calendar, the resulting calendar is consistent. + + 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. + """ + + # 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 HolidaysAndSpecialSessions: + # Get the dates to add for the day type. + dates_to_add = cs._changes[day_type].add.keys() + # Loop over all day types. + for day_type0 in HolidaysAndSpecialSessions: + 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, strict=True) + + return cs + + def __eq__(self, other): + if not isinstance(other, ChangeSet): + return False + + return self.changes == other.changes + + def __str__(self): + changes_str = ", ".join([f'{k.name}: {c}' for k, c in self.changes.items() if not c.is_empty()]) + return f'ChangeSet({changes_str})' + + @classmethod + def from_dict(cls, d: dict) -> "ChangeSet": + """ + Create a change set from a dictionary. + + The changes represented by the input dictionary need to result in a consistent change set. Otherwise, a + ValueError is raised. + + Parameters + ---------- + d : dict + The dictionary to create the change set from. + + Returns + ------- + ExchangeCalendarChangeSet + The created change set. + + Raises + ------ + ValueError + If the given dictionary does not represent a consistent change set. + """ + try: + # Validate the input dictionary. + d = _SCHEMA.validate(d) + except Exception as e: + raise ValueError(f"Dictionary does not satisfy expected schema.") from e + + # Create empty change set. + cs: ChangeSet = cls() + + # Add the changes for each day type. + for day_type_str, changes_incoming in d.items(): + try: + # Convert the day type string to the corresponding enum value. + day_type: HolidaysAndSpecialSessions = HolidaysAndSpecialSessions.to_enum(day_type_str) + except ValueError as e: + raise ValueError(f"Invalid day type '{day_type_str}' in dictionary.") from e + + if changes_incoming.get('add') is not None: + for item in changes_incoming.get('add'): + cs.add_day(date=item['date'], value=item['value'], day_type=day_type, strict=True) + + if changes_incoming.get('remove') is not None: + for date in changes_incoming.get('remove'): + cs.remove_day(date=date, day_type=day_type, strict=True) + + return cs diff --git a/exchange_calendars_extensions/holiday.py b/exchange_calendars_extensions/holiday.py index c5c2ae3..cbe289b 100644 --- a/exchange_calendars_extensions/holiday.py +++ b/exchange_calendars_extensions/holiday.py @@ -1,8 +1,9 @@ -from datetime import timedelta -from typing import Optional, Callable +from datetime import timedelta, tzinfo +from typing import Optional, Callable, Union import pandas as pd from exchange_calendars.pandas_extensions.holiday import Holiday +from pandas import Series, DatetimeIndex from exchange_calendars_extensions.offset import LastDayOfMonthOffsetClasses, \ ThirdDayOfWeekInMonthOffsetClasses @@ -13,19 +14,36 @@ def get_monthly_expiry_holiday( day_of_week: int, month: int, observance: Optional[Callable[[pd.Timestamp], pd.Timestamp]] = None, - start_date=None, - end_date=None, - tz=None) -> Holiday: + start_date: Optional[pd.Timestamp] = None, + end_date: Optional[pd.Timestamp] = None, + tz: Optional[tzinfo] = None) -> Holiday: """ - Return a holiday that occurs yearly on the third day of the week in the given month. - :param name: The name of the holiday. - :param day_of_week: 0 = Monday, 1 = Tuesday, ..., 6 = Sunday. - :param month: 1 = January, 2 = February, ..., 12 = December. - :param observance: A function that takes a datetime and returns a datetime. - :param start_date: The first date on which this holiday is valid. - :param end_date: The last date on which this holiday is valid. - :param tz: The timezone in which to interpret the holiday. - :return: A Holiday object. + Return a holiday that occurs yearly on the third given day of the week in the given month of the year. + + For example, when day_of_week=2 and month=1, this returns a holiday that occurs yearly on the third Wednesday in + January. + + Parameters + ---------- + name : str + The name of the holiday. + day_of_week : int + 0 = Monday, 1 = Tuesday, ..., 6 = Sunday. + month : int + 1 = January, 2 = February, ..., 12 = December. + observance : Optional[Callable[[pd.Timestamp], pd.Timestamp]], optional + A function that takes a datetime and returns a datetime, by default None. + start_date : Optional[pd.Timestamp], optional + The first date on which this holiday is valid, by default None. + end_date : Optional[pd.Timestamp], optional + The last date on which this holiday is valid, by default None. + tz : Optional[tzinfo], optional + The timezone in which to interpret the holiday, by default None. + + Returns + ------- + Holiday + A new Holiday object as specified. """ return Holiday(name, month=1, day=1, offset=ThirdDayOfWeekInMonthOffsetClasses[day_of_week][month](), @@ -36,18 +54,33 @@ def get_last_day_of_month_holiday( name: str, month: int, observance: Optional[Callable[[pd.Timestamp], pd.Timestamp]] = None, - start_date=None, - end_date=None, - tz=None) -> Holiday: + start_date: Optional[pd.Timestamp] = None, + end_date: Optional[pd.Timestamp] = None, + tz: Optional[tzinfo] = None) -> Holiday: """ - Return a holiday that occurs yearly on the last day of the given month. - :param name: The name of the holiday. - :param month: 1 = January, 2 = February, ..., 12 = December. - :param observance: A function that takes a datetime and returns a datetime. - :param start_date: The first date on which this holiday is valid. - :param end_date: The last date on which this holiday is valid. - :param tz: The timezone in which to interpret the holiday. - :return: A Holiday object. + Return a holiday that occurs yearly on the last day of the given month of the year. + + For example, when month=1, this returns a holiday that occurs yearly on the last day of January. + + Parameters + ---------- + name : str + The name of the holiday. + month : int + 1 = January, 2 = February, ..., 12 = December. + observance : Optional[Callable[[pd.Timestamp], pd.Timestamp]], optional + A function that takes a datetime and returns a datetime, by default None. + start_date : Optional[pd.Timestamp], optional + The first date on which this holiday is valid, by default None. + end_date : Optional[pd.Timestamp], optional + The last date on which this holiday is valid, by default None. + tz : Optional[tzinfo], optional + The timezone in which to interpret the holiday, by default None. + + Returns + ------- + Holiday + A new Holiday object as specified. """ return Holiday(name, month=1, day=1, offset=LastDayOfMonthOffsetClasses[month](), @@ -61,19 +94,33 @@ class DayOfWeekPeriodicHoliday(Holiday): def __init__( self, - name, + name: str, day_of_week: int, - start_date=None, - end_date=None, - tz=None - ): + start_date: Optional[pd.Timestamp] = None, + end_date: Optional[pd.Timestamp] = None, + tz: Optional[tzinfo] = None + ) -> None: """ Constructor. - :param name: Name of holiday. - :param day_of_week: 0 = Monday, 1 = Tuesday, ..., 6 = Sunday. - :param start_date: The first date on which this holiday is valid. - :param end_date: The last date on which this holiday is valid. + + Parameters + ---------- + name : str + The name of the holiday. + day_of_week : int + 0 = Monday, 1 = Tuesday, ..., 6 = Sunday. + start_date : Optional[pd.Timestamp], optional + The first date on which this holiday is valid, by default None. + end_date : Optional[pd.Timestamp], optional + The last date on which this holiday is valid, by default None. + tz : Optional[tzinfo], optional + The timezone in which to interpret the holiday, by default None. + + Returns + ------- + None """ + # Super constructor. super().__init__( name, year=None, @@ -86,11 +133,23 @@ def __init__( days_of_week=None, tz=tz ) + + # Store day of week. self.day_of_week = day_of_week - def _dates(self, start_date, end_date): + def _dates(self, start_date, end_date) -> pd.DatetimeIndex: """ Return a list of dates on which this holiday occurs between start_date and end_date. + + Parameters + ---------- + start_date : starting date, datetime-like, optional + end_date : ending date, datetime-like, optional + + Returns + ------- + pd.DatetimeIndex + A list of dates on which this holiday occurs between start_date and end_date. """ # Determine effective start date. if self.start_date is not None: @@ -101,12 +160,14 @@ def _dates(self, start_date, end_date): end_date = min(end_date, self.end_date.tz_localize(end_date.tz)) if start_date > end_date: + # Empty result. return pd.DatetimeIndex([]) # Get the first date larger or equal to start_date where the day of the week is the same as day_of_week. first = start_date + pd.Timedelta(days=(self.day_of_week - start_date.dayofweek) % 7) if first > end_date: + # Empty result. return pd.DatetimeIndex([]) # Get the last date smaller or equal to end_date where the day of the week is the same as day_of_week. @@ -118,8 +179,9 @@ def _dates(self, start_date, end_date): # Return the dates. return dates - def dates(self, start_date, end_date, return_name=False): + def dates(self, start_date, end_date, return_name=False) -> Union[DatetimeIndex, Series]: # Get DateTimeIndex with the dates of the holidays. dates = self._dates(start_date, end_date) - return pd.Series(self.name, index=dates, dtype=pd.DatetimeTZDtype) if return_name else dates + # Return the dates, either as a series (return_name=True) or as a DateTimeIndex (return_name=False). + return pd.Series(self.name, index=dates) if return_name else dates diff --git a/exchange_calendars_extensions/holiday_calendar.py b/exchange_calendars_extensions/holiday_calendar.py index b969446..01acd69 100644 --- a/exchange_calendars_extensions/holiday_calendar.py +++ b/exchange_calendars_extensions/holiday_calendar.py @@ -1,57 +1,122 @@ +import datetime from abc import ABC +from dataclasses import field, dataclass from functools import reduce -from typing import Iterable, Optional, Callable, Union, Type, Protocol +from typing import Iterable, Optional, Callable, Union, Type, Protocol, List, Tuple, runtime_checkable +import pandas as pd from exchange_calendars import ExchangeCalendar -from exchange_calendars.exchange_calendar import HolidayCalendar as ExchangeHolidayCalendar +from exchange_calendars.exchange_calendar import HolidayCalendar as ExchangeCalendarsHolidayCalendar from exchange_calendars.pandas_extensions.holiday import Holiday -import pandas as pd +from exchange_calendars.pandas_extensions.holiday import Holiday as ExchangeCalendarsHoliday +from pandas.tseries.holiday import Holiday as PandasHoliday +from exchange_calendars_extensions import ChangeSet, HolidaysAndSpecialSessions from exchange_calendars_extensions.holiday import get_monthly_expiry_holiday, DayOfWeekPeriodicHoliday, \ get_last_day_of_month_holiday from exchange_calendars_extensions.observance import get_roll_backward_observance -class HolidayCalendar(ExchangeHolidayCalendar): - def __init__(self, rules): +class HolidayCalendar(ExchangeCalendarsHolidayCalendar): + """ + A subclass of exchange_calendars.exchange_calendar.HolidayCalendar that supports overlapping rules, i.e. rules that + apply to the same date. + + Duplicates are dropped from the result. Which duplicate is dropped is generally undefined, but if the rules + parameters has an ordering, rules with lower indices will have higher priority. + + Parameters + ---------- + rules : Iterable[PandasHoliday] + The rules that define this calendar. + """ + + def __init__(self, rules) -> None: super().__init__(rules=rules) def holidays(self, start=None, end=None, return_name=False): - """ - Return all holidays between start and end, inclusive. - """ + # Get the holidays from the parent class. holidays = super().holidays(start=start, end=end, return_name=return_name) + + # Drop duplicates, keeping the first occurrence. if return_name: return holidays[~holidays.index.duplicated()] else: return holidays.drop_duplicates() -def get_holiday_calendar_from_timestamps(timestamps: Iterable[pd.Timestamp], name: Optional[str] = None) -> HolidayCalendar: +def get_holiday_calendar_from_timestamps(timestamps: Iterable[pd.Timestamp], + name: Optional[str] = None) -> HolidayCalendar: """ - Return a calendar with the given holidays. + Return a holiday calendar with holidays given by a collection of timestamps. + + If name is specified, each holiday will use that given name. + + Parameters + ---------- + timestamps : Iterable[pd.Timestamp] + The timestamps of the holidays. + name : Optional[str], optional + The name to use for each holiday, by default None. + + Returns + ------- + HolidayCalendar + A new HolidayCalendar object as specified. """ - rules = [Holiday(name, year=ts.year, month=ts.month, day=ts.day) for ts in list(dict.fromkeys(timestamps))] # As of Python 3.7, dict preserves insertion order. + # Generate list of rules, one for each timestamp. + rules = [Holiday(name, year=ts.year, month=ts.month, day=ts.day) for ts in + list(dict.fromkeys(timestamps))] # As of Python 3.7, dict preserves insertion order. + + # Return a new HolidayCalendar with the given rules. return HolidayCalendar(rules=rules) def get_holiday_calendar_from_day_of_week(day_of_week: int, name: Optional[str] = None) -> HolidayCalendar: """ - Return a calendar with a holiday for each day of the week. + Return a holiday calendar with a periodic holiday occurring on each instance of the given day of the week. + + Parameters + ---------- + day_of_week : int + The day of the week to use, where 0 is Monday and 6 is Sunday. + name : Optional[str], optional + The name to use for the holiday, by default None. """ + # Generate list of rules. Actually contains only one rule for the given day of the week. rules = [DayOfWeekPeriodicHoliday(name, day_of_week)] - return ExchangeHolidayCalendar(rules=rules) + + # Return a new HolidayCalendar with the given rules. + return ExchangeCalendarsHolidayCalendar(rules=rules) def merge_calendars(calendars: Iterable[HolidayCalendar]) -> HolidayCalendar: """ - Return a calendar with all holidays from the given calendars merged into a single HolidayCalendar. + Return a holiday calendar with all holidays from the given calendars merged into a single HolidayCalendar. + + The rules of the returned calendar will be the concatenation of the rules of the given calendars. Note that rules + that occur earlier take precedence in case of conflicts, i.e. rules that apply to the same date. """ - x = reduce(lambda x, y: HolidayCalendar(rules=[r for r in x.rules] + [r for r in y.rules]), calendars, HolidayCalendar(rules=[])) + x = reduce(lambda x, y: HolidayCalendar(rules=[r for r in x.rules] + [r for r in y.rules]), calendars, + HolidayCalendar(rules=[])) return x def get_holidays_calendar(exchange_calendar: ExchangeCalendar) -> HolidayCalendar: + """ + Return a holiday calendar with all holidays, regular and ad-hoc, from the given exchange calendar merged into a single + HolidayCalendar. + + Parameters + ---------- + exchange_calendar : ExchangeCalendar + The exchange calendar to use. + + Returns + ------- + HolidayCalendar + A new HolidayCalendar with all holidays from the given EchangeCalendar. + """ holiday_calendars = [get_holiday_calendar_from_timestamps(exchange_calendar.adhoc_holidays, name='ad-hoc holiday'), exchange_calendar.regular_holidays] @@ -61,22 +126,33 @@ def get_holidays_calendar(exchange_calendar: ExchangeCalendar) -> HolidayCalenda def get_special_opens_calendar(exchange_calendar: ExchangeCalendar) -> HolidayCalendar: """ - Return a calendar with all special days from the given exchange calendar merged into a single HolidayCalendar. + Return a holiday calendar with all special opens, regular and ad-hoc, from the given exchange calendar merged into a single + HolidayCalendar. + + Parameters + ---------- + exchange_calendar : ExchangeCalendar + The exchange calendar to use. + + Returns + ------- + HolidayCalendar + A new HolidayCalendar with all special opens from the given EchangeCalendar. """ holiday_calendars = [] # Add ad-hoc special opens. for item in exchange_calendar.special_opens_adhoc: _, definition = item - holiday_calendars.append(get_holiday_calendar_from_timestamps(definition, name='ad-hoc special open day')) + holiday_calendars.append(get_holiday_calendar_from_timestamps(definition, name='ad-hoc special open')) # Add regular special open days. for item in exchange_calendar.special_opens: _, definition = item - if isinstance(definition, ExchangeHolidayCalendar): + if isinstance(definition, ExchangeCalendarsHolidayCalendar): holiday_calendars.append(definition) elif isinstance(definition, int): - holiday_calendars.append(get_holiday_calendar_from_day_of_week(definition, name='special open day')) + holiday_calendars.append(get_holiday_calendar_from_day_of_week(definition, name='special open')) # Merge all calendars by reducing the list of calendars into one, calling the merge method on each pair. return merge_calendars(holiday_calendars) @@ -84,98 +160,289 @@ def get_special_opens_calendar(exchange_calendar: ExchangeCalendar) -> HolidayCa def get_special_closes_calendar(exchange_calendar: ExchangeCalendar) -> HolidayCalendar: """ - Return a calendar with all special days from the given exchange calendar merged into a single HolidayCalendar. + Return a holiday calendar with all special closes, regular and ad-hoc, from the given exchange calendar merged into a single + HolidayCalendar. + + Parameters + ---------- + exchange_calendar : ExchangeCalendar + The exchange calendar to use. + + Returns + ------- + HolidayCalendar + A new HolidayCalendar with all special closes from the given EchangeCalendar. """ holiday_calendars = [] # Add ad-hoc special closes. for item in exchange_calendar.special_closes_adhoc: _, definition = item - holiday_calendars.append(get_holiday_calendar_from_timestamps(definition, name='ad-hoc special close day')) + holiday_calendars.append(get_holiday_calendar_from_timestamps(definition, name='ad-hoc special close')) # Add regular special close days. for item in exchange_calendar.special_closes: _, definition = item - if isinstance(definition, ExchangeHolidayCalendar): + if isinstance(definition, ExchangeCalendarsHolidayCalendar): holiday_calendars.append(definition) elif isinstance(definition, int): - holiday_calendars.append(get_holiday_calendar_from_day_of_week(definition, name='special close day')) + holiday_calendars.append(get_holiday_calendar_from_day_of_week(definition, name='special close')) # Merge all calendars by reducing the list of calendars into one, calling the merge method on each pair. return merge_calendars(holiday_calendars) def get_weekend_days_calendar(exchange_calendar: ExchangeCalendar) -> HolidayCalendar: - rules = [DayOfWeekPeriodicHoliday('weekend day', day_of_week) for day_of_week, v in enumerate(exchange_calendar.weekmask) if v == '0'] - return ExchangeHolidayCalendar(rules=rules) + """ + Return a holiday calendar with all weekend days from the given exchange calendar as holidays. + + Parameters + ---------- + exchange_calendar : ExchangeCalendar + The exchange calendar to use. + + Returns + ------- + HolidayCalendar + A new HolidayCalendar with all weekend days from the given EchangeCalendar. + """ + rules = [DayOfWeekPeriodicHoliday('weekend day', day_of_week) for day_of_week, v in + enumerate(exchange_calendar.weekmask) if v == '0'] + return ExchangeCalendarsHolidayCalendar(rules=rules) -def get_monthly_expiry_calendar(day_of_week: int, observance: Optional[Callable[[pd.Timestamp], pd.Timestamp]] = None) -> HolidayCalendar: +def get_monthly_expiry_calendar(day_of_week: int, + observance: Optional[Callable[[pd.Timestamp], pd.Timestamp]] = None) -> HolidayCalendar: """ - Return a calendar with a holiday for each month's expiry, but exclude quarterly expiry days aka quadruple witching. + Return a holiday calendar with a holiday for each month's expiry, but excluding quarterly expiry days. + + Parameters + ---------- + day_of_week : int + The day of the week to use, where 0 is Monday and 6 is Sunday. + observance : Optional[Callable[[pd.Timestamp], pd.Timestamp]], optional + The observance function to use, by default None. + + Returns + ------- + HolidayCalendar + A new HolidayCalendar with a holiday for each month's expiry, but excluding quarterly expiry days. """ - rules = [get_monthly_expiry_holiday('monthly expiry', day_of_week, month, observance) for month in [1, 2, 4, 5, 7, 8, 10, 11]] - return ExchangeHolidayCalendar(rules=rules) + rules = [get_monthly_expiry_holiday('monthly expiry', day_of_week, month, observance) for month in + [1, 2, 4, 5, 7, 8, 10, 11]] + return ExchangeCalendarsHolidayCalendar(rules=rules) -def get_quadruple_witching_calendar(day_of_week: int, observance: Optional[Callable[[pd.Timestamp], pd.Timestamp]] = None) -> HolidayCalendar: +def get_quadruple_witching_calendar(day_of_week: int, observance: Optional[ + Callable[[pd.Timestamp], pd.Timestamp]] = None) -> HolidayCalendar: """ - Return a calendar with a holiday for each quarterly expiry aka quadruple witching. + Return a holiday calendar with a holiday for each quarterly expiry aka quadruple witching. + + Parameters + ---------- + day_of_week : int + The day of the week to use, where 0 is Monday and 6 is Sunday. + observance : Optional[Callable[[pd.Timestamp], pd.Timestamp]], optional + The observance function to use, by default None. + + Returns + ------- + HolidayCalendar + A new HolidayCalendar with a holiday for each quarterly expiry. """ rules = [get_monthly_expiry_holiday('quarterly expiry', day_of_week, month, observance) for month in [3, 6, 9, 12]] - return ExchangeHolidayCalendar(rules=rules) + return ExchangeCalendarsHolidayCalendar(rules=rules) -def get_last_day_of_month_calendar(name: Optional[str] = 'last trading day of month', observance: Optional[Callable[[pd.Timestamp], pd.Timestamp]] = None) -> HolidayCalendar: +def get_last_day_of_month_calendar(name: Optional[str] = 'last trading day of month', observance: Optional[ + Callable[[pd.Timestamp], pd.Timestamp]] = None) -> HolidayCalendar: """ - Return a calendar with a holiday for each last day of the month. + Return a holiday calendar with a holiday for each last trading day of the month. + + Parameters + ---------- + name : Optional[str], optional + The name to use for the holidays, by default 'last trading day of month'. + observance : Optional[Callable[[pd.Timestamp], pd.Timestamp]], optional + The observance function to use, by default None. + + Returns + ------- + HolidayCalendar + A new HolidayCalendar with a holiday for each last trading day of the month. """ rules = [get_last_day_of_month_holiday(name, i, observance=observance) for i in range(1, 13)] - return ExchangeHolidayCalendar(rules=rules) + return ExchangeCalendarsHolidayCalendar(rules=rules) +@runtime_checkable class ExchangeCalendarExtensions(Protocol): + """ + A protocol for extensions to the exchange_calendars.ExchangeCalendar class. + """ @property def weekend_days(self) -> HolidayCalendar: + """ + Return holiday calendar containing all weekend days as holidays. + + Returns + ------- + HolidayCalendar + A new HolidayCalendar with all weekend days as holidays. + """ ... # pragma: no cover @property def holidays_all(self) -> HolidayCalendar: + """ + Return a holiday calendar containing all holidays, regular and ad-hoc. + + Returns + ------- + HolidayCalendar + A new HolidayCalendar with all holidays. + """ ... # pragma: no cover @property def special_opens_all(self) -> HolidayCalendar: + """ + Return a holiday calendar with all special opens, regular and ad-hoc. + + Returns + ------- + HolidayCalendar + A new HolidayCalendar with all special opens. + """ ... # pragma: no cover @property def special_closes_all(self) -> HolidayCalendar: + """ + Return a holiday calendar with all special closes, regular and ad-hoc. + + Returns + ------- + HolidayCalendar + A new HolidayCalendar with all special closes. + """ ... # pragma: no cover + @property def monthly_expiries(self) -> Union[HolidayCalendar, None]: + """ + Return a holiday calendar with a holiday for each monthly expiry, but excluding quarterly expiry days. + + Returns + ------- + HolidayCalendar + A new HolidayCalendar with a holiday for each monthly expiry. + """ ... # pragma: no cover @property def quarterly_expiries(self) -> Union[HolidayCalendar, None]: + """ + Return a holiday calendar with a holiday for each quarterly expiry aka quadruple witching. + + Returns + ------- + HolidayCalendar + A new HolidayCalendar with a holiday for each quarterly expiry. + """ ... # pragma: no cover @property def last_trading_days_of_months(self) -> Union[HolidayCalendar, None]: + """ + Return a holiday calendar with a holiday for each last trading day of the month. + + Returns + ------- + HolidayCalendar + A new HolidayCalendar with a holiday for each last trading day of the month. + """ ... # pragma: no cover @property def last_regular_trading_days_of_months(self) -> Union[HolidayCalendar, None]: + """ + Return a holiday calendar with a holiday for each last regular trading day of the month. + + Returns + ------- + HolidayCalendar + A new HolidayCalendar with a holiday for each last regular trading day of the month. + """ ... +@dataclass +class AdjustedProperties: + """ + A dataclass for storing adjusted properties of an exchange calendar. + """ + + # The regular holidays of the exchange calendar. + regular_holidays: List[Holiday] + + # The ad-hoc holidays of the exchange calendar. + adhoc_holidays: List[pd.Timestamp] + + # The special closes of the exchange calendar. + special_closes: List[Tuple[datetime.time, Union[List[Holiday], int]]] + + # The ad-hoc special closes of the exchange calendar. + adhoc_special_closes: List[Tuple[datetime.time, pd.DatetimeIndex]] + + # The special opens of the exchange calendar. + special_opens: List[Tuple[datetime.time, Union[List[Holiday], int]]] + + # The ad-hoc special opens of the exchange calendar. + adhoc_special_opens: List[Tuple[datetime.time, pd.DatetimeIndex]] + + # The quarterly expiry days of the exchange calendar. + quarterly_expiries: List[Holiday] = field(default_factory=list) + + # The monthly expiry days of the exchange calendar. + monthly_expiries: List[Holiday] = field(default_factory=list) + + # The last trading days of the month of the exchange calendar. + last_trading_days_of_months: List[Holiday] = field(default_factory=list) + + # The last regular trading days of the month of the exchange calendar. + last_regular_trading_days_of_months: List[Holiday] = field(default_factory=list) + + class ExtendedExchangeCalendar(ExchangeCalendar, ExchangeCalendarExtensions, ABC): + """ + Abstract base class for exchange calendars with extended functionality. + """ ... -def extend_class(cls: Type[ExchangeCalendar], day_of_week_expiry: int = 4) -> type: +def extend_class(cls: Type[ExchangeCalendar], day_of_week_expiry: Optional[int] = None, + changeset_provider: Callable[[], ChangeSet] = None) -> type: """ Extend the given ExchangeCalendar class with additional properties. + Parameters + ---------- + cls : Type[ExchangeCalendar] + The input class to extend. + day_of_week_expiry : int, optional + The day of the week when expiry days are observed, where 0 is Monday and 6 is Sunday. Defaults to 4 (Friday). + changeset_provider : Callable[[], ExchangeCalendarChangeSet], optional + The optional function that returns a changeset to apply to the calendar. + + Returns + ------- + type + The extended class. + + Notes + ----- This method returns an extended version of the given ExchangeCalendar sub-class with additional properties. Specifically, this method adds the following properties: - holidays_all: a HolidayCalendar with all holidays, regular and ad-hoc. @@ -199,8 +466,8 @@ def extend_class(cls: Type[ExchangeCalendar], day_of_week_expiry: int = 4) -> ty this will be the previous regular business day. For exchanges that do not observe monthly expiry days, this property may throw NotImplementedError. - Similarly to monthly_expiries, the property quarterly_expiries returns expiry days for months March, June, September, - and December, also known as quarterly expiries or triple/quadruple witching. + Similarly to monthly_expiries, the property quarterly_expiries returns expiry days for months March, June, + September, and December, also known as quarterly expiries or triple/quadruple witching. The property last_trading_days_of_months returns the last trading day of each month. Note that the last trading day may be a special open/close day. @@ -209,62 +476,409 @@ def extend_class(cls: Type[ExchangeCalendar], day_of_week_expiry: int = 4) -> ty difference to last_trading_days_of_months is that this property always returns the last regular trading day and never a special open/close day. That is, if the last trading day of a month is a special open/close day, here the day is rolled back to the previous regular trading day instead. - - :param cls: the input class to extend. - :param day_of_week_expiry: the day of the week when expiry days are observed. Defaults to 4, which is Friday. - :return: the extended class. """ - init = cls.__init__ + # Store some original methods and properties for later use below. + init_orig = cls.__init__ + regular_holidays_orig = cls.regular_holidays.fget + adhoc_holidays_orig = cls.adhoc_holidays.fget + special_closes_orig = cls.special_closes.fget + adhoc_special_closes_orig = cls.special_closes_adhoc.fget + special_opens_orig = cls.special_opens.fget + adhoc_special_opens_orig = cls.special_opens_adhoc.fget + + def is_holiday(holiday: Holiday, ts: pd.Timestamp) -> bool: + """ + Determine if the given timestamp is a holiday. + + Parameters + ---------- + holiday : Holiday + The holiday to check. + ts : pd.Timestamp + The timestamp to check. + + Returns + ------- + bool + True if the timestamp is a holiday, False otherwise. + """ + return any([d == ts for d in holiday.dates(start_date=ts, end_date=ts)]) + + def clone_holiday(holiday: Union[PandasHoliday, ExchangeCalendarsHoliday, DayOfWeekPeriodicHoliday], + start_date: Optional[pd.Timestamp] = None, end_date: Optional[pd.Timestamp] = None) -> Union[ + PandasHoliday, ExchangeCalendarsHoliday, DayOfWeekPeriodicHoliday]: + """ + Return a copy of the given holiday. + + Parameters + ---------- + holiday : Union[PandasHoliday, ExchangeCalendarsHoliday, DayOfWeekPeriodicHoliday] + The holiday to copy. + start_date : Optional[pd.Timestamp], optional + The optional start date of the copy. If not given, the start date of the original holiday is used. + end_date : Optional[pd.Timestamp], optional + The optional end date of the copy. If not given, the end date of the original holiday is used. + + Returns + ------- + Union[PandasHoliday, ExchangeCalendarsHoliday, DayOfWeekPeriodicHoliday] + The copy of the given holiday. + """ + # Determine the effective start and end dates. + start_date_effective = start_date if start_date is not None else holiday.start_date + end_date_effective = end_date if end_date is not None else holiday.end_date + + if isinstance(holiday, DayOfWeekPeriodicHoliday): + return DayOfWeekPeriodicHoliday(name=holiday.name, day_of_week=holiday.day_of_week, + start_date=start_date_effective, end_date=end_date_effective, tz=holiday.tz) + elif isinstance(holiday, ExchangeCalendarsHoliday): + return ExchangeCalendarsHoliday(name=holiday.name, year=holiday.year, month=holiday.month, day=holiday.day, + offset=holiday.offset, observance=holiday.observance, + start_date=start_date_effective, end_date=end_date_effective, + days_of_week=holiday.days_of_week, tz=holiday.tz) + elif isinstance(holiday, PandasHoliday): + return PandasHoliday(name=holiday.name, year=holiday.year, month=holiday.month, day=holiday.day, + offset=holiday.offset, observance=holiday.observance, start_date=start_date_effective, + end_date=end_date_effective, days_of_week=holiday.days_of_week) + else: + raise NotImplementedError(f"Unsupported holiday type: {type(holiday)}") + + def remove_day_from_rules(ts: pd.Timestamp, rules: List[Holiday]) -> List[Holiday]: + """ + Parameters + ---------- + ts : pd.Timestamp + The timestamp to exclude. + rules : List[Holiday] + The list of rules to modify. + + Returns + ------- + List[Holiday] + The modified list of rules, with any rules that coincide with ts removed and replaced by two new rules that + don't contain ts. + """ + # Determine any rules that coincide with ts. + remove = [rule for rule in rules if is_holiday(rule, ts)] + + # Modify rules to exclude ts. + for rule in remove: + # Create copies of rule with end date set to ts - 1 day and ts + 1 day, respectively. + rule_before_ts = clone_holiday(rule, end_date=ts - pd.Timedelta(days=1)) + rule_after_ts = clone_holiday(rule, start_date=ts + pd.Timedelta(days=1)) + # Determine index of rule in list. + rule_index = rules.index(rule) + # Remove original rule. + rules.pop(rule_index) + # Add the new rules. + rules.insert(rule_index, rule_before_ts) + rules.insert(rule_index + 1, rule_after_ts) + + return rules + + def add_special_session(name: str, ts: pd.Timestamp, t: datetime.time, special_sessions: List[Tuple[ + datetime.time, List[Holiday]]]) -> List[Tuple[datetime.time, List[Holiday]]]: + """ + Add a special session to the given list of special sessions. + + Parameters + ---------- + name : str + The name of the special session to add. + ts : pd.Timestamp + The session's date + t : datetime.time + The special open/close time. + special_sessions : List[Holiday] + List of special sessions to add the new session to. + + Returns + ------- + List[Holiday] + The given list of special sessions with the new session added. + """ + # Define the new Holiday. + h = Holiday(name, year=ts.year, month=ts.month, day=ts.day) + + # Whether the new holiday has been added. + added = False + + # Loop over all times and the respective rules. + for t0, rules in special_sessions: + # CHeck if time matches. + if t == t0: + # Add to existing list. + rules.append(h) + # Flip the flag. + added = True + # Break the loop. + break + + # If the holiday was not added, add a new entry. + if not added: + special_sessions.append((t, [h])) + + return special_sessions + + def remove_holiday(ts: pd.Timestamp, regular_holidays_rules: List[Holiday], + adhoc_holidays: List[pd.Timestamp] = []) -> Tuple[List[Holiday], List[pd.Timestamp]]: + """ + Remove any holidays that coincide with ts. + + Parameters + ---------- + ts : pd.Timestamp + The timestamp to remove. + regular_holidays_rules : List[Holiday] + The list of regular holidays. + adhoc_holidays : List[pd.Timestamp] + The list of ad-hoc holidays. + + Returns + ------- + Tuple[List[Holiday], List[pd.Timestamp]] + The modified lists of regular and ad-hoc holidays. + """ + regular_holidays_rules = remove_day_from_rules(ts, regular_holidays_rules) + # Remove any ad-hoc holidays that coincide with ts, maybe. + adhoc_holidays = [adhoc_ts for adhoc_ts in adhoc_holidays if adhoc_ts != ts] + return regular_holidays_rules, adhoc_holidays + + def remove_special_session(ts: pd.Timestamp, regular_special_sessions: List[Tuple[datetime.time, List[Holiday]]], + adhoc_special_sessions: List[Tuple[datetime.time, pd.DatetimeIndex]]) -> Tuple[ + List[Tuple[datetime.time, List[Holiday]]], List[Tuple[datetime.time, pd.DatetimeIndex]]]: + """ + Remove any special sessions that coincide with ts. + + Parameters + ---------- + ts : pd.Timestamp + The timestamp to remove. + regular_special_sessions : List[Tuple[datetime.time, List[Holiday]]] + The list of regular special sessions. + adhoc_special_sessions : List[Tuple[datetime.time, pd.DatetimeIndex]] + The list of ad-hoc special sessions. + + Returns + ------- + Tuple[List[Tuple[datetime.time, List[Holiday]]], List[Tuple[datetime.time, pd.DatetimeIndex]]] + The modified lists of regular and ad-hoc special sessions. + """ + # Loop over all times in regular_special_sessions. + for _, rules in regular_special_sessions: + if isinstance(rules, int): + # Check if the day of week corresponding to ts is the same as rules. + if ts.dayofweek == rules: + raise NotImplementedError( + "Removing a special session date that corresponds to a day of week rule is not supported.") + else: + # List of rules. + _ = remove_day_from_rules(ts, rules) + + # Remove any ad-hoc special sessions that coincide with ts. + adhoc_special_sessions = [(t, adhoc_ts.drop(ts, errors='ignore')) for t, adhoc_ts in adhoc_special_sessions] + + # Remove empty DateTime indices. + adhoc_special_sessions = [(t, adhoc_ts) for t, adhoc_ts in adhoc_special_sessions if not adhoc_ts.empty] + + return regular_special_sessions, adhoc_special_sessions def __init__(self, *args, **kwargs): - init(self, *args, **kwargs) - self._holidays_all = get_holidays_calendar(self) - self._special_opens_all = get_special_opens_calendar(self) - self._special_closes_all = get_special_closes_calendar(self) - special_business_days = merge_calendars([self._special_opens_all, self._special_closes_all]) - self._weekend_days = get_weekend_days_calendar(self) - weekends_and_holidays = merge_calendars([self._weekend_days, self._holidays_all]) - weekends_holidays_and_special_business_days = merge_calendars([weekends_and_holidays, special_business_days]) - self._monthly_expiry_days = get_monthly_expiry_calendar(day_of_week_expiry, get_roll_backward_observance(weekends_holidays_and_special_business_days)) - self._quarterly_expiry_days = get_quadruple_witching_calendar(day_of_week_expiry, get_roll_backward_observance(weekends_holidays_and_special_business_days)) - self._last_trading_day_of_month = get_last_day_of_month_calendar('last trading day of month', get_roll_backward_observance(weekends_and_holidays)) - self._last_regular_trading_day_of_month = get_last_day_of_month_calendar('last regular trading day of month', get_roll_backward_observance(weekends_holidays_and_special_business_days)) + # Save adjusted properties. Initialize with copies of the original properties. + a = AdjustedProperties(regular_holidays=regular_holidays_orig(self).rules.copy(), + adhoc_holidays=adhoc_holidays_orig(self).copy(), + special_closes=[(t, d if isinstance(d, int) else d.rules.copy()) for t, d in + special_closes_orig(self).copy()], + adhoc_special_closes=adhoc_special_closes_orig(self).copy(), + special_opens=[(t, d if isinstance(d, int) else d.rules.copy()) for t, d in + special_opens_orig(self).copy()], + adhoc_special_opens=adhoc_special_opens_orig(self).copy()) + + # Get changeset from provider, maybe. + changeset: ChangeSet = changeset_provider() if changeset_provider is not None else None + + # Set changeset to None if it is empty. + if changeset is not None and changeset.is_empty(): + 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.changes[HolidaysAndSpecialSessions.HOLIDAY].remove: + a.regular_holidays, a.adhoc_holidays = remove_holiday(ts, a.regular_holidays, a.adhoc_holidays) + + # Add holidays. + for ts, spec in changeset.changes[HolidaysAndSpecialSessions.HOLIDAY].add.items(): + name = spec['name'] + # Remove existing holiday, maybe. + 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.changes[HolidaysAndSpecialSessions.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.changes[HolidaysAndSpecialSessions.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.changes[HolidaysAndSpecialSessions.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.changes[HolidaysAndSpecialSessions.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) + + self._adjusted_properties = a + + # Call upstream init method. + init_orig(self, *args, **kwargs) + + # All weekend days and holidays. + weekends_and_holidays = merge_calendars([get_weekend_days_calendar(self), get_holidays_calendar(self)]) + + # All weekend days, holidays and special business days. + weekends_holidays_and_special_business_days = merge_calendars( + [weekends_and_holidays, get_special_opens_calendar(self), get_special_closes_calendar(self)]) + + a.quarterly_expiries = get_quadruple_witching_calendar(day_of_week_expiry, get_roll_backward_observance( + weekends_holidays_and_special_business_days)).rules.copy() if day_of_week_expiry is not None else [] + a.monthly_expiries = get_monthly_expiry_calendar(day_of_week_expiry, get_roll_backward_observance( + weekends_holidays_and_special_business_days)).rules.copy() 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.changes[HolidaysAndSpecialSessions.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.changes[HolidaysAndSpecialSessions.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.changes[HolidaysAndSpecialSessions.MONTHLY_EXPIRY].remove: + a.monthly_expiries, _ = remove_holiday(ts, a.monthly_expiries) + + # Add monthly expiries. + + # Loop over monthly expiries to add. + for ts, spec in changeset.changes[HolidaysAndSpecialSessions.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)) + + a.last_trading_days_of_months = get_last_day_of_month_calendar('last trading day of month', + get_roll_backward_observance( + weekends_and_holidays)).rules.copy() + a.last_regular_trading_days_of_months = get_last_day_of_month_calendar('last regular trading day of month', + get_roll_backward_observance( + weekends_holidays_and_special_business_days)).rules.copy() + + @property + def regular_holidays(self) -> Union[HolidayCalendar, None]: + return HolidayCalendar(rules=self._adjusted_properties.regular_holidays) + + @property + def adhoc_holidays(self) -> List[pd.Timestamp]: + return self._adjusted_properties.adhoc_holidays.copy() + + @property + def special_closes(self) -> List[Tuple[datetime.time, Union[HolidayCalendar, int]]]: + return [(t, HolidayCalendar(rules=rules)) for t, rules in self._adjusted_properties.special_closes] + + @property + def special_closes_adhoc(self) -> List[Tuple[datetime.time, pd.DatetimeIndex]]: + return self._adjusted_properties.adhoc_special_closes.copy() + + @property + def special_opens(self) -> List[Tuple[datetime.time, Union[HolidayCalendar, int]]]: + return [(t, HolidayCalendar(rules=rules)) for t, rules in self._adjusted_properties.special_opens] + + @property + def special_opens_adhoc(self) -> List[Tuple[datetime.time, pd.DatetimeIndex]]: + return self._adjusted_properties.adhoc_special_opens.copy() @property def weekend_days(self) -> Union[HolidayCalendar, None]: - return self._weekend_days + return get_weekend_days_calendar(self) @property def holidays_all(self) -> Union[HolidayCalendar, None]: - return self._holidays_all + return get_holidays_calendar(self) @property def special_opens_all(self) -> Union[HolidayCalendar, None]: - return self._special_opens_all + return get_special_opens_calendar(self) @property def special_closes_all(self) -> Union[HolidayCalendar, None]: - return self._special_closes_all + return get_special_closes_calendar(self) @property def monthly_expiries(self) -> Union[HolidayCalendar, None]: - return self._monthly_expiry_days + return HolidayCalendar(rules=self._adjusted_properties.monthly_expiries) @property def quarterly_expiries(self) -> Union[HolidayCalendar, None]: - return self._quarterly_expiry_days + return HolidayCalendar(rules=self._adjusted_properties.quarterly_expiries) @property def last_trading_days_of_months(self) -> Union[HolidayCalendar, None]: - return self._last_trading_day_of_month + return HolidayCalendar(rules=self._adjusted_properties.last_trading_days_of_months) @property def last_regular_trading_days_of_months(self) -> Union[HolidayCalendar, None]: - return self._last_regular_trading_day_of_month + return HolidayCalendar(rules=self._adjusted_properties.last_regular_trading_days_of_months) # Use type to create a new class. extended = type(cls.__name__ + "Extended", (cls, ExtendedExchangeCalendar), { "__init__": __init__, + "regular_holidays": regular_holidays, + "adhoc_holidays": adhoc_holidays, + "special_closes": special_closes, + "special_closes_adhoc": special_closes_adhoc, + "special_opens": special_opens, + "special_opens_adhoc": special_opens_adhoc, "weekend_days": weekend_days, "holidays_all": holidays_all, "special_opens_all": special_opens_all, diff --git a/exchange_calendars_extensions/observance.py b/exchange_calendars_extensions/observance.py index b3652a3..e8fa5da 100644 --- a/exchange_calendars_extensions/observance.py +++ b/exchange_calendars_extensions/observance.py @@ -6,12 +6,23 @@ def get_roll_backward_observance(calendar: HolidayCalendar) -> Callable[[pd.Timestamp], pd.Timestamp]: """ - Return a function that rolls back a date to the last regular business day, according to a given holiday + Return a function that rolls back a date to the last regular business day, according to a given holiday + calendar. + + Regular business days are those days that are not defined as holidays by the given holiday calendar. This means, + for example, that the calendar also needs to define weekend days as holidays to avoid rolling a date back to + weekend day. + + Parameters + ---------- + calendar : HolidayCalendar + The holiday calendar to use for determining holidays. + + Returns + ------- + Callable[[pd.Timestamp], pd.Timestamp] + A function that takes a date and returns the last business day on or before that date, according to the given calendar. - - Regular business days are those days that are not defined as holidays by the given holiday calendar. This means, - for example, that the calendar must also define weekend days as holidays to avoid rolling the date back to a - such day. """ def f(date: pd.Timestamp) -> pd.Timestamp: diff --git a/exchange_calendars_extensions/offset.py b/exchange_calendars_extensions/offset.py index f44451c..15a4a32 100644 --- a/exchange_calendars_extensions/offset.py +++ b/exchange_calendars_extensions/offset.py @@ -1,4 +1,5 @@ from datetime import datetime, date +from typing import Type, Callable import pandas as pd from pandas._libs.tslibs import localize_pydatetime @@ -19,12 +20,8 @@ def _is_normalized(dt): return dt.nanosecond == 0 return True - """ - Auxiliary class for DateOffset instances for the different holidays. - """ - @property - def holiday(self): + def holiday(self, year: int): """ Return the Gregorian date for the holiday in a given Gregorian calendar year. @@ -65,19 +62,31 @@ def is_on_offset(self, dt): return date(dt.year, dt.month, dt.day) == self.holiday(dt.year).to_pydate() -def get_third_day_of_week_in_month_offset_class(day_of_week: int, month: int) -> AbstractHolidayOffset: +def get_third_day_of_week_in_month_offset_class(day_of_week: int, month: int) -> Type[AbstractHolidayOffset]: """ - Return a new class that represents an offset that when applied to the first day of a year, results in the third given - day of the week in the given month. + Return a new class that represents an offset that, when applied to the first day of a year, results in the third + given day of the week in the given month. - For example, to get the offset for the third Friday in June, call this function with day_of_week=4 and month=6. On many - exchanges, this will be the quadruple witching day for the second quarter of the year. + For example, to get the offset for the third Friday in June, call this function with day_of_week=4 and month=6. On + many exchanges, this will be the quadruple witching day for the second quarter of the year. + + Parameters + ---------- + day_of_week : int + The day of the week, where 0 is Monday and 6 is Sunday. + month : int + The month, where 1 is January and 12 is December. + + Returns + ------- + Type[AbstractHolidayOffset] + A new class that represents the offset. """ @property - def holiday(self): + def holiday(self) -> Callable[[int], pd.Timestamp]: """ - Return a function that returns the date of the day for a given year. + Return a function that returns the third instance of the given day of the week in the given month and year. """ return lambda year: third_day_of_week_in_month(day_of_week, month, year) @@ -96,25 +105,41 @@ def holiday(self): return offset -# A dictionary of dictionaries that maps day of week to month to corresponding offset class. +# A dictionary of dictionaries that maps day of week and month to corresponding offset class as returned by +# get_third_day_of_week_in_month_offset_class. Used as an internal cache to avoid unnecessarily creating classes with +# the same parameters. # -# For example, to get the offset class for the third Friday in June, use the following: OffsetClasses[4][6]. To -# instantiate the offset, use the following: OffsetClasses[4][6](). +# For example, to get the offset class for the third Friday in June, use the following: OffsetClasses[4][6], where 4 +# represents Friday (zero-based offset starting with 0 = Monday) and 6 represents June (one-based offset starting with +# 1 = January). To instantiate the offset, use the following: OffsetClasses[4][6](). # -# The offset classes can be used to define typical expiry days (options, futures, et cetera) on exchanges which -# happen on the third Friday or Thursday in a month on most exchanges. The quarterly expiry days in months March, June, -# September, and December are also called quadruple witching. +# The offset classes can be used to define typical expiry days (options, futures, et cetera) on exchanges which often +# happen on the third Friday or Thursday in a month. The quarterly expiry days in months March, June, September, and +# December are also called quadruple witching. # -# Currently, includes only Thursdays and Fridays to avoid unnecessarily creating classes that will never be used. Add -# more days here, if required. +# Currently, includes cases for Monday to Friday which should cover all real-world scenarios. ThirdDayOfWeekInMonthOffsetClasses = {day_of_week: {month: get_third_day_of_week_in_month_offset_class(day_of_week, month) for month in range(1, 13)} for day_of_week in range(5)} -def get_last_day_of_month_offset_class(month: int) -> AbstractHolidayOffset: +def get_last_day_of_month_offset_class(month: int) -> Type[AbstractHolidayOffset]: + """ + Return a new class that represents an offset that, when applied to the first day of a year, results in the last + day of the given month. + + Parameters + ---------- + month : int + The month, where 1 is January and 12 is December. + + Returns + ------- + Type[AbstractHolidayOffset] + A new class that represents the offset. + """ @property - def holiday(self): + def holiday(self) -> Callable[[int], pd.Timestamp]: """ - Return a function that returns the last day of the month for a given year. + Return a function that returns the last day of the month for a given year. """ return lambda year: last_day_in_month(month, year) @@ -130,5 +155,6 @@ def holiday(self): return offset -# A dictionary that maps month to corresponding offset class. +# A dictionary that maps month to corresponding offset class as returned by get_last_day_of_month_offset_class. Used +# as an internal cache to avoid unnecessarily creating classes with the same parameters. LastDayOfMonthOffsetClasses = {month: get_last_day_of_month_offset_class(month) for month in range(1, 13)} diff --git a/exchange_calendars_extensions/util.py b/exchange_calendars_extensions/util.py index a1dfc70..81d1e7a 100644 --- a/exchange_calendars_extensions/util.py +++ b/exchange_calendars_extensions/util.py @@ -1,20 +1,21 @@ -from datetime import timedelta -from functools import reduce -from typing import Iterable, Optional +import datetime from datetime import date - -from exchange_calendars import ExchangeCalendar -from exchange_calendars.exchange_calendar import HolidayCalendar -from exchange_calendars.pandas_extensions.holiday import Holiday -import pandas as pd +from datetime import timedelta -def get_month_name(month: int): +def get_month_name(month: int) -> str: """ - Convert month to capitalized name of month. + Convert month to capitalized name of month. - :param month: Month number (1-12). - :return: Name of month. + Parameters + ---------- + month : int + Month number (1-12). + + Returns + ------- + str + Name of month. """ if month < 1 or month > 12: raise ValueError("Month must be between 1 and 12.") @@ -26,12 +27,19 @@ def get_month_name(month: int): return month_name -def get_day_of_week_name(day_of_week: int): +def get_day_of_week_name(day_of_week: int) -> str: """ Convert day of week number to name. - :param day_of_week: Day of week number (0-6), where 0 is Monday and 6 is Sunday. - :return: Name of day of week. + Parameters + ---------- + day_of_week : int + Day of week number (0-6), where 0 is Monday and 6 is Sunday. + + Returns + ------- + str + Name of day of week. """ if day_of_week < 0 or day_of_week > 6: raise ValueError("Day of week must be between 0 and 6.") @@ -45,10 +53,19 @@ def third_day_of_week_in_month(day_of_week: int, month: int, year: int) -> date: """ Return the third given day of the week in the given month and year. - :param day_of_week: the day of the week, must be an integer between 0 (Monday) and 6 (Sunday). - :param year: the year, must be an integer - :param month: the month of the year, must be an integer between (inclusive) 1 and 12 - :return: the datetime.date representing the third Friday in the given month. + Parameters + ---------- + day_of_week : int + the day of the week, must be an integer between 0 (Monday) and 6 (Sunday). + year : int + the year, must be an integer + month : int + the month of the year, must be an integer between (inclusive) 1 and 12 + + Returns + ------- + datetime.date + the datetime.date representing the third Friday in the given month. """ # The third given day in a month cannot be earlier than the 15th. third = date(year, month, 15) @@ -63,8 +80,20 @@ def third_day_of_week_in_month(day_of_week: int, month: int, year: int) -> date: return third -def last_day_in_month(month: int, year: int): +def last_day_in_month(month: int, year: int) -> date: """ Return the last day in the given month and year. + + Parameters + ---------- + month : int + the month of the year, must be an integer between (inclusive) 1 and 12 + year : int + the year, must be an integer + + Returns + ------- + datetime.date + the datetime.date representing the last day in the given month. """ return (date(year, month, 1) + timedelta(days=32)).replace(day=1) - timedelta(days=1) diff --git a/poetry.lock b/poetry.lock index 8852b75..58a2e27 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,24 +1,5 @@ # This file is automatically @generated by Poetry and should not be changed by hand. -[[package]] -name = "attrs" -version = "22.2.0" -description = "Classes Without Boilerplate" -category = "dev" -optional = false -python-versions = ">=3.6" -files = [ - {file = "attrs-22.2.0-py3-none-any.whl", hash = "sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836"}, - {file = "attrs-22.2.0.tar.gz", hash = "sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99"}, -] - -[package.extras] -cov = ["attrs[tests]", "coverage-enable-subprocess", "coverage[toml] (>=5.3)"] -dev = ["attrs[docs,tests]"] -docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope.interface"] -tests = ["attrs[tests-no-zope]", "zope.interface"] -tests-no-zope = ["cloudpickle", "cloudpickle", "hypothesis", "hypothesis", "mypy (>=0.971,<0.990)", "mypy (>=0.971,<0.990)", "pympler", "pympler", "pytest (>=4.3.0)", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-mypy-plugins", "pytest-xdist[psutil]", "pytest-xdist[psutil]"] - [[package]] name = "colorama" version = "0.4.6" @@ -31,65 +12,86 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "contextlib2" +version = "21.6.0" +description = "Backports and enhancements for the contextlib module" +category = "main" +optional = false +python-versions = ">=3.6" +files = [ + {file = "contextlib2-21.6.0-py2.py3-none-any.whl", hash = "sha256:3fbdb64466afd23abaf6c977627b75b6139a5a3e8ce38405c5b413aed7a0471f"}, + {file = "contextlib2-21.6.0.tar.gz", hash = "sha256:ab1e2bfe1d01d968e1b7e8d9023bc51ef3509bba217bb730cee3827e1ee82869"}, +] + [[package]] name = "coverage" -version = "7.2.1" +version = "7.2.7" description = "Code coverage measurement for Python" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "coverage-7.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:49567ec91fc5e0b15356da07a2feabb421d62f52a9fff4b1ec40e9e19772f5f8"}, - {file = "coverage-7.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d2ef6cae70168815ed91388948b5f4fcc69681480a0061114db737f957719f03"}, - {file = "coverage-7.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3004765bca3acd9e015794e5c2f0c9a05587f5e698127ff95e9cfba0d3f29339"}, - {file = "coverage-7.2.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cca7c0b7f5881dfe0291ef09ba7bb1582cb92ab0aeffd8afb00c700bf692415a"}, - {file = "coverage-7.2.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b2167d116309f564af56f9aa5e75ef710ef871c5f9b313a83050035097b56820"}, - {file = "coverage-7.2.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:cb5f152fb14857cbe7f3e8c9a5d98979c4c66319a33cad6e617f0067c9accdc4"}, - {file = "coverage-7.2.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:87dc37f16fb5e3a28429e094145bf7c1753e32bb50f662722e378c5851f7fdc6"}, - {file = "coverage-7.2.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e191a63a05851f8bce77bc875e75457f9b01d42843f8bd7feed2fc26bbe60833"}, - {file = "coverage-7.2.1-cp310-cp310-win32.whl", hash = "sha256:e3ea04b23b114572b98a88c85379e9e9ae031272ba1fb9b532aa934c621626d4"}, - {file = "coverage-7.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:0cf557827be7eca1c38a2480484d706693e7bb1929e129785fe59ec155a59de6"}, - {file = "coverage-7.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:570c21a29493b350f591a4b04c158ce1601e8d18bdcd21db136fbb135d75efa6"}, - {file = "coverage-7.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9e872b082b32065ac2834149dc0adc2a2e6d8203080501e1e3c3c77851b466f9"}, - {file = "coverage-7.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fac6343bae03b176e9b58104a9810df3cdccd5cfed19f99adfa807ffbf43cf9b"}, - {file = "coverage-7.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abacd0a738e71b20e224861bc87e819ef46fedba2fb01bc1af83dfd122e9c319"}, - {file = "coverage-7.2.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9256d4c60c4bbfec92721b51579c50f9e5062c21c12bec56b55292464873508"}, - {file = "coverage-7.2.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:80559eaf6c15ce3da10edb7977a1548b393db36cbc6cf417633eca05d84dd1ed"}, - {file = "coverage-7.2.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:0bd7e628f6c3ec4e7d2d24ec0e50aae4e5ae95ea644e849d92ae4805650b4c4e"}, - {file = "coverage-7.2.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:09643fb0df8e29f7417adc3f40aaf379d071ee8f0350ab290517c7004f05360b"}, - {file = "coverage-7.2.1-cp311-cp311-win32.whl", hash = "sha256:1b7fb13850ecb29b62a447ac3516c777b0e7a09ecb0f4bb6718a8654c87dfc80"}, - {file = "coverage-7.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:617a94ada56bbfe547aa8d1b1a2b8299e2ec1ba14aac1d4b26a9f7d6158e1273"}, - {file = "coverage-7.2.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8649371570551d2fd7dee22cfbf0b61f1747cdfb2b7587bb551e4beaaa44cb97"}, - {file = "coverage-7.2.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d2b9b5e70a21474c105a133ba227c61bc95f2ac3b66861143ce39a5ea4b3f84"}, - {file = "coverage-7.2.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae82c988954722fa07ec5045c57b6d55bc1a0890defb57cf4a712ced65b26ddd"}, - {file = "coverage-7.2.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:861cc85dfbf55a7a768443d90a07e0ac5207704a9f97a8eb753292a7fcbdfcfc"}, - {file = "coverage-7.2.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:0339dc3237c0d31c3b574f19c57985fcbe494280153bbcad33f2cdf469f4ac3e"}, - {file = "coverage-7.2.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:5928b85416a388dd557ddc006425b0c37e8468bd1c3dc118c1a3de42f59e2a54"}, - {file = "coverage-7.2.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8d3843ca645f62c426c3d272902b9de90558e9886f15ddf5efe757b12dd376f5"}, - {file = "coverage-7.2.1-cp37-cp37m-win32.whl", hash = "sha256:6a034480e9ebd4e83d1aa0453fd78986414b5d237aea89a8fdc35d330aa13bae"}, - {file = "coverage-7.2.1-cp37-cp37m-win_amd64.whl", hash = "sha256:6fce673f79a0e017a4dc35e18dc7bb90bf6d307c67a11ad5e61ca8d42b87cbff"}, - {file = "coverage-7.2.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7f099da6958ddfa2ed84bddea7515cb248583292e16bb9231d151cd528eab657"}, - {file = "coverage-7.2.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:97a3189e019d27e914ecf5c5247ea9f13261d22c3bb0cfcfd2a9b179bb36f8b1"}, - {file = "coverage-7.2.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a81dbcf6c6c877986083d00b834ac1e84b375220207a059ad45d12f6e518a4e3"}, - {file = "coverage-7.2.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78d2c3dde4c0b9be4b02067185136b7ee4681978228ad5ec1278fa74f5ca3e99"}, - {file = "coverage-7.2.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a209d512d157379cc9ab697cbdbb4cfd18daa3e7eebaa84c3d20b6af0037384"}, - {file = "coverage-7.2.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f3d07edb912a978915576a776756069dede66d012baa503022d3a0adba1b6afa"}, - {file = "coverage-7.2.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8dca3c1706670297851bca1acff9618455122246bdae623be31eca744ade05ec"}, - {file = "coverage-7.2.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b1991a6d64231a3e5bbe3099fb0dd7c9aeaa4275ad0e0aeff4cb9ef885c62ba2"}, - {file = "coverage-7.2.1-cp38-cp38-win32.whl", hash = "sha256:22c308bc508372576ffa3d2dbc4824bb70d28eeb4fcd79d4d1aed663a06630d0"}, - {file = "coverage-7.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:b0c0d46de5dd97f6c2d1b560bf0fcf0215658097b604f1840365296302a9d1fb"}, - {file = "coverage-7.2.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4dd34a935de268a133e4741827ae951283a28c0125ddcdbcbba41c4b98f2dfef"}, - {file = "coverage-7.2.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0f8318ed0f3c376cfad8d3520f496946977abde080439d6689d7799791457454"}, - {file = "coverage-7.2.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:834c2172edff5a08d78e2f53cf5e7164aacabeb66b369f76e7bb367ca4e2d993"}, - {file = "coverage-7.2.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e4d70c853f0546855f027890b77854508bdb4d6a81242a9d804482e667fff6e6"}, - {file = "coverage-7.2.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a6450da4c7afc4534305b2b7d8650131e130610cea448ff240b6ab73d7eab63"}, - {file = "coverage-7.2.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:99f4dd81b2bb8fc67c3da68b1f5ee1650aca06faa585cbc6818dbf67893c6d58"}, - {file = "coverage-7.2.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bdd3f2f285ddcf2e75174248b2406189261a79e7fedee2ceeadc76219b6faa0e"}, - {file = "coverage-7.2.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:f29351393eb05e6326f044a7b45ed8e38cb4dcc38570d12791f271399dc41431"}, - {file = "coverage-7.2.1-cp39-cp39-win32.whl", hash = "sha256:e2b50ebc2b6121edf352336d503357321b9d8738bb7a72d06fc56153fd3f4cd8"}, - {file = "coverage-7.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:bd5a12239c0006252244f94863f1c518ac256160cd316ea5c47fb1a11b25889a"}, - {file = "coverage-7.2.1-pp37.pp38.pp39-none-any.whl", hash = "sha256:436313d129db7cf5b4ac355dd2bd3f7c7e5294af077b090b85de75f8458b8616"}, - {file = "coverage-7.2.1.tar.gz", hash = "sha256:c77f2a9093ccf329dd523a9b2b3c854c20d2a3d968b6def3b820272ca6732242"}, + {file = "coverage-7.2.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d39b5b4f2a66ccae8b7263ac3c8170994b65266797fb96cbbfd3fb5b23921db8"}, + {file = "coverage-7.2.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6d040ef7c9859bb11dfeb056ff5b3872436e3b5e401817d87a31e1750b9ae2fb"}, + {file = "coverage-7.2.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba90a9563ba44a72fda2e85302c3abc71c5589cea608ca16c22b9804262aaeb6"}, + {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7d9405291c6928619403db1d10bd07888888ec1abcbd9748fdaa971d7d661b2"}, + {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31563e97dae5598556600466ad9beea39fb04e0229e61c12eaa206e0aa202063"}, + {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ebba1cd308ef115925421d3e6a586e655ca5a77b5bf41e02eb0e4562a111f2d1"}, + {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cb017fd1b2603ef59e374ba2063f593abe0fc45f2ad9abdde5b4d83bd922a353"}, + {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d62a5c7dad11015c66fbb9d881bc4caa5b12f16292f857842d9d1871595f4495"}, + {file = "coverage-7.2.7-cp310-cp310-win32.whl", hash = "sha256:ee57190f24fba796e36bb6d3aa8a8783c643d8fa9760c89f7a98ab5455fbf818"}, + {file = "coverage-7.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:f75f7168ab25dd93110c8a8117a22450c19976afbc44234cbf71481094c1b850"}, + {file = "coverage-7.2.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06a9a2be0b5b576c3f18f1a241f0473575c4a26021b52b2a85263a00f034d51f"}, + {file = "coverage-7.2.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5baa06420f837184130752b7c5ea0808762083bf3487b5038d68b012e5937dbe"}, + {file = "coverage-7.2.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdec9e8cbf13a5bf63290fc6013d216a4c7232efb51548594ca3631a7f13c3a3"}, + {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:52edc1a60c0d34afa421c9c37078817b2e67a392cab17d97283b64c5833f427f"}, + {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63426706118b7f5cf6bb6c895dc215d8a418d5952544042c8a2d9fe87fcf09cb"}, + {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:afb17f84d56068a7c29f5fa37bfd38d5aba69e3304af08ee94da8ed5b0865833"}, + {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:48c19d2159d433ccc99e729ceae7d5293fbffa0bdb94952d3579983d1c8c9d97"}, + {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0e1f928eaf5469c11e886fe0885ad2bf1ec606434e79842a879277895a50942a"}, + {file = "coverage-7.2.7-cp311-cp311-win32.whl", hash = "sha256:33d6d3ea29d5b3a1a632b3c4e4f4ecae24ef170b0b9ee493883f2df10039959a"}, + {file = "coverage-7.2.7-cp311-cp311-win_amd64.whl", hash = "sha256:5b7540161790b2f28143191f5f8ec02fb132660ff175b7747b95dcb77ac26562"}, + {file = "coverage-7.2.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f2f67fe12b22cd130d34d0ef79206061bfb5eda52feb6ce0dba0644e20a03cf4"}, + {file = "coverage-7.2.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a342242fe22407f3c17f4b499276a02b01e80f861f1682ad1d95b04018e0c0d4"}, + {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:171717c7cb6b453aebac9a2ef603699da237f341b38eebfee9be75d27dc38e01"}, + {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49969a9f7ffa086d973d91cec8d2e31080436ef0fb4a359cae927e742abfaaa6"}, + {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b46517c02ccd08092f4fa99f24c3b83d8f92f739b4657b0f146246a0ca6a831d"}, + {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:a3d33a6b3eae87ceaefa91ffdc130b5e8536182cd6dfdbfc1aa56b46ff8c86de"}, + {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:976b9c42fb2a43ebf304fa7d4a310e5f16cc99992f33eced91ef6f908bd8f33d"}, + {file = "coverage-7.2.7-cp312-cp312-win32.whl", hash = "sha256:8de8bb0e5ad103888d65abef8bca41ab93721647590a3f740100cd65c3b00511"}, + {file = "coverage-7.2.7-cp312-cp312-win_amd64.whl", hash = "sha256:9e31cb64d7de6b6f09702bb27c02d1904b3aebfca610c12772452c4e6c21a0d3"}, + {file = "coverage-7.2.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:58c2ccc2f00ecb51253cbe5d8d7122a34590fac9646a960d1430d5b15321d95f"}, + {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d22656368f0e6189e24722214ed8d66b8022db19d182927b9a248a2a8a2f67eb"}, + {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a895fcc7b15c3fc72beb43cdcbdf0ddb7d2ebc959edac9cef390b0d14f39f8a9"}, + {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e84606b74eb7de6ff581a7915e2dab7a28a0517fbe1c9239eb227e1354064dcd"}, + {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:0a5f9e1dbd7fbe30196578ca36f3fba75376fb99888c395c5880b355e2875f8a"}, + {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:419bfd2caae268623dd469eff96d510a920c90928b60f2073d79f8fe2bbc5959"}, + {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2aee274c46590717f38ae5e4650988d1af340fe06167546cc32fe2f58ed05b02"}, + {file = "coverage-7.2.7-cp37-cp37m-win32.whl", hash = "sha256:61b9a528fb348373c433e8966535074b802c7a5d7f23c4f421e6c6e2f1697a6f"}, + {file = "coverage-7.2.7-cp37-cp37m-win_amd64.whl", hash = "sha256:b1c546aca0ca4d028901d825015dc8e4d56aac4b541877690eb76490f1dc8ed0"}, + {file = "coverage-7.2.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:54b896376ab563bd38453cecb813c295cf347cf5906e8b41d340b0321a5433e5"}, + {file = "coverage-7.2.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3d376df58cc111dc8e21e3b6e24606b5bb5dee6024f46a5abca99124b2229ef5"}, + {file = "coverage-7.2.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e330fc79bd7207e46c7d7fd2bb4af2963f5f635703925543a70b99574b0fea9"}, + {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e9d683426464e4a252bf70c3498756055016f99ddaec3774bf368e76bbe02b6"}, + {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d13c64ee2d33eccf7437961b6ea7ad8673e2be040b4f7fd4fd4d4d28d9ccb1e"}, + {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b7aa5f8a41217360e600da646004f878250a0d6738bcdc11a0a39928d7dc2050"}, + {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8fa03bce9bfbeeef9f3b160a8bed39a221d82308b4152b27d82d8daa7041fee5"}, + {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:245167dd26180ab4c91d5e1496a30be4cd721a5cf2abf52974f965f10f11419f"}, + {file = "coverage-7.2.7-cp38-cp38-win32.whl", hash = "sha256:d2c2db7fd82e9b72937969bceac4d6ca89660db0a0967614ce2481e81a0b771e"}, + {file = "coverage-7.2.7-cp38-cp38-win_amd64.whl", hash = "sha256:2e07b54284e381531c87f785f613b833569c14ecacdcb85d56b25c4622c16c3c"}, + {file = "coverage-7.2.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:537891ae8ce59ef63d0123f7ac9e2ae0fc8b72c7ccbe5296fec45fd68967b6c9"}, + {file = "coverage-7.2.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:06fb182e69f33f6cd1d39a6c597294cff3143554b64b9825d1dc69d18cc2fff2"}, + {file = "coverage-7.2.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:201e7389591af40950a6480bd9edfa8ed04346ff80002cec1a66cac4549c1ad7"}, + {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f6951407391b639504e3b3be51b7ba5f3528adbf1a8ac3302b687ecababf929e"}, + {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f48351d66575f535669306aa7d6d6f71bc43372473b54a832222803eb956fd1"}, + {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b29019c76039dc3c0fd815c41392a044ce555d9bcdd38b0fb60fb4cd8e475ba9"}, + {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:81c13a1fc7468c40f13420732805a4c38a105d89848b7c10af65a90beff25250"}, + {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:975d70ab7e3c80a3fe86001d8751f6778905ec723f5b110aed1e450da9d4b7f2"}, + {file = "coverage-7.2.7-cp39-cp39-win32.whl", hash = "sha256:7ee7d9d4822c8acc74a5e26c50604dff824710bc8de424904c0982e25c39c6cb"}, + {file = "coverage-7.2.7-cp39-cp39-win_amd64.whl", hash = "sha256:eb393e5ebc85245347950143969b241d08b52b88a3dc39479822e073a1a8eb27"}, + {file = "coverage-7.2.7-pp37.pp38.pp39-none-any.whl", hash = "sha256:b7b4c971f05e6ae490fef852c218b0e79d4e52f79ef0c8475566584a8fb3e01d"}, + {file = "coverage-7.2.7.tar.gz", hash = "sha256:924d94291ca674905fe9481f12294eb11f2d3d3fd1adb20314ba89e94f44ed59"}, ] [package.dependencies] @@ -100,14 +102,14 @@ toml = ["tomli"] [[package]] name = "exceptiongroup" -version = "1.1.0" +version = "1.1.1" description = "Backport of PEP 654 (exception groups)" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "exceptiongroup-1.1.0-py3-none-any.whl", hash = "sha256:327cbda3da756e2de031a3107b81ab7b3770a602c4d16ca618298c526f4bec1e"}, - {file = "exceptiongroup-1.1.0.tar.gz", hash = "sha256:bcb67d800a4497e1b404c2dd44fca47d3b7a5e5433dbab67f96c1a685cdfdf23"}, + {file = "exceptiongroup-1.1.1-py3-none-any.whl", hash = "sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e"}, + {file = "exceptiongroup-1.1.1.tar.gz", hash = "sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785"}, ] [package.extras] @@ -115,14 +117,14 @@ test = ["pytest (>=6)"] [[package]] name = "exchange-calendars" -version = "4.2.5" +version = "4.2.8" description = "Calendars for securities exchanges" category = "main" optional = false python-versions = "~=3.8" files = [ - {file = "exchange_calendars-4.2.5-py3-none-any.whl", hash = "sha256:9fb97e601c2ffb79ff78a1c7af32fa9e123e04091eedf313180084dfcf384330"}, - {file = "exchange_calendars-4.2.5.tar.gz", hash = "sha256:61282aae78c2ce3f3433efd3e1024c308efa1ad35f6cd5db957edacb3ec18e82"}, + {file = "exchange_calendars-4.2.8-py3-none-any.whl", hash = "sha256:3695afd0608c6507ce3016dfcb68a1698220016a049b45d42b4dfa9ecf85a15c"}, + {file = "exchange_calendars-4.2.8.tar.gz", hash = "sha256:1598b6219a58e7be218c640f389375e39c9c12513c7db82d7591ae56f64467f9"}, ] [package.dependencies] @@ -163,89 +165,87 @@ files = [ [[package]] name = "numpy" -version = "1.24.2" +version = "1.24.3" description = "Fundamental package for array computing in Python" category = "main" optional = false python-versions = ">=3.8" files = [ - {file = "numpy-1.24.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eef70b4fc1e872ebddc38cddacc87c19a3709c0e3e5d20bf3954c147b1dd941d"}, - {file = "numpy-1.24.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e8d2859428712785e8a8b7d2b3ef0a1d1565892367b32f915c4a4df44d0e64f5"}, - {file = "numpy-1.24.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6524630f71631be2dabe0c541e7675db82651eb998496bbe16bc4f77f0772253"}, - {file = "numpy-1.24.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a51725a815a6188c662fb66fb32077709a9ca38053f0274640293a14fdd22978"}, - {file = "numpy-1.24.2-cp310-cp310-win32.whl", hash = "sha256:2620e8592136e073bd12ee4536149380695fbe9ebeae845b81237f986479ffc9"}, - {file = "numpy-1.24.2-cp310-cp310-win_amd64.whl", hash = "sha256:97cf27e51fa078078c649a51d7ade3c92d9e709ba2bfb97493007103c741f1d0"}, - {file = "numpy-1.24.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7de8fdde0003f4294655aa5d5f0a89c26b9f22c0a58790c38fae1ed392d44a5a"}, - {file = "numpy-1.24.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4173bde9fa2a005c2c6e2ea8ac1618e2ed2c1c6ec8a7657237854d42094123a0"}, - {file = "numpy-1.24.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4cecaed30dc14123020f77b03601559fff3e6cd0c048f8b5289f4eeabb0eb281"}, - {file = "numpy-1.24.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a23f8440561a633204a67fb44617ce2a299beecf3295f0d13c495518908e910"}, - {file = "numpy-1.24.2-cp311-cp311-win32.whl", hash = "sha256:e428c4fbfa085f947b536706a2fc349245d7baa8334f0c5723c56a10595f9b95"}, - {file = "numpy-1.24.2-cp311-cp311-win_amd64.whl", hash = "sha256:557d42778a6869c2162deb40ad82612645e21d79e11c1dc62c6e82a2220ffb04"}, - {file = "numpy-1.24.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d0a2db9d20117bf523dde15858398e7c0858aadca7c0f088ac0d6edd360e9ad2"}, - {file = "numpy-1.24.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c72a6b2f4af1adfe193f7beb91ddf708ff867a3f977ef2ec53c0ffb8283ab9f5"}, - {file = "numpy-1.24.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c29e6bd0ec49a44d7690ecb623a8eac5ab8a923bce0bea6293953992edf3a76a"}, - {file = "numpy-1.24.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2eabd64ddb96a1239791da78fa5f4e1693ae2dadc82a76bc76a14cbb2b966e96"}, - {file = "numpy-1.24.2-cp38-cp38-win32.whl", hash = "sha256:e3ab5d32784e843fc0dd3ab6dcafc67ef806e6b6828dc6af2f689be0eb4d781d"}, - {file = "numpy-1.24.2-cp38-cp38-win_amd64.whl", hash = "sha256:76807b4063f0002c8532cfeac47a3068a69561e9c8715efdad3c642eb27c0756"}, - {file = "numpy-1.24.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4199e7cfc307a778f72d293372736223e39ec9ac096ff0a2e64853b866a8e18a"}, - {file = "numpy-1.24.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:adbdce121896fd3a17a77ab0b0b5eedf05a9834a18699db6829a64e1dfccca7f"}, - {file = "numpy-1.24.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:889b2cc88b837d86eda1b17008ebeb679d82875022200c6e8e4ce6cf549b7acb"}, - {file = "numpy-1.24.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f64bb98ac59b3ea3bf74b02f13836eb2e24e48e0ab0145bbda646295769bd780"}, - {file = "numpy-1.24.2-cp39-cp39-win32.whl", hash = "sha256:63e45511ee4d9d976637d11e6c9864eae50e12dc9598f531c035265991910468"}, - {file = "numpy-1.24.2-cp39-cp39-win_amd64.whl", hash = "sha256:a77d3e1163a7770164404607b7ba3967fb49b24782a6ef85d9b5f54126cc39e5"}, - {file = "numpy-1.24.2-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:92011118955724465fb6853def593cf397b4a1367495e0b59a7e69d40c4eb71d"}, - {file = "numpy-1.24.2-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9006288bcf4895917d02583cf3411f98631275bc67cce355a7f39f8c14338fa"}, - {file = "numpy-1.24.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:150947adbdfeceec4e5926d956a06865c1c690f2fd902efede4ca6fe2e657c3f"}, - {file = "numpy-1.24.2.tar.gz", hash = "sha256:003a9f530e880cb2cd177cba1af7220b9aa42def9c4afc2a2fc3ee6be7eb2b22"}, + {file = "numpy-1.24.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3c1104d3c036fb81ab923f507536daedc718d0ad5a8707c6061cdfd6d184e570"}, + {file = "numpy-1.24.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:202de8f38fc4a45a3eea4b63e2f376e5f2dc64ef0fa692838e31a808520efaf7"}, + {file = "numpy-1.24.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8535303847b89aa6b0f00aa1dc62867b5a32923e4d1681a35b5eef2d9591a463"}, + {file = "numpy-1.24.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d926b52ba1367f9acb76b0df6ed21f0b16a1ad87c6720a1121674e5cf63e2b6"}, + {file = "numpy-1.24.3-cp310-cp310-win32.whl", hash = "sha256:f21c442fdd2805e91799fbe044a7b999b8571bb0ab0f7850d0cb9641a687092b"}, + {file = "numpy-1.24.3-cp310-cp310-win_amd64.whl", hash = "sha256:ab5f23af8c16022663a652d3b25dcdc272ac3f83c3af4c02eb8b824e6b3ab9d7"}, + {file = "numpy-1.24.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9a7721ec204d3a237225db3e194c25268faf92e19338a35f3a224469cb6039a3"}, + {file = "numpy-1.24.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d6cc757de514c00b24ae8cf5c876af2a7c3df189028d68c0cb4eaa9cd5afc2bf"}, + {file = "numpy-1.24.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76e3f4e85fc5d4fd311f6e9b794d0c00e7002ec122be271f2019d63376f1d385"}, + {file = "numpy-1.24.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1d3c026f57ceaad42f8231305d4653d5f05dc6332a730ae5c0bea3513de0950"}, + {file = "numpy-1.24.3-cp311-cp311-win32.whl", hash = "sha256:c91c4afd8abc3908e00a44b2672718905b8611503f7ff87390cc0ac3423fb096"}, + {file = "numpy-1.24.3-cp311-cp311-win_amd64.whl", hash = "sha256:5342cf6aad47943286afa6f1609cad9b4266a05e7f2ec408e2cf7aea7ff69d80"}, + {file = "numpy-1.24.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7776ea65423ca6a15255ba1872d82d207bd1e09f6d0894ee4a64678dd2204078"}, + {file = "numpy-1.24.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ae8d0be48d1b6ed82588934aaaa179875e7dc4f3d84da18d7eae6eb3f06c242c"}, + {file = "numpy-1.24.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ecde0f8adef7dfdec993fd54b0f78183051b6580f606111a6d789cd14c61ea0c"}, + {file = "numpy-1.24.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4749e053a29364d3452c034827102ee100986903263e89884922ef01a0a6fd2f"}, + {file = "numpy-1.24.3-cp38-cp38-win32.whl", hash = "sha256:d933fabd8f6a319e8530d0de4fcc2e6a61917e0b0c271fded460032db42a0fe4"}, + {file = "numpy-1.24.3-cp38-cp38-win_amd64.whl", hash = "sha256:56e48aec79ae238f6e4395886b5eaed058abb7231fb3361ddd7bfdf4eed54289"}, + {file = "numpy-1.24.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4719d5aefb5189f50887773699eaf94e7d1e02bf36c1a9d353d9f46703758ca4"}, + {file = "numpy-1.24.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0ec87a7084caa559c36e0a2309e4ecb1baa03b687201d0a847c8b0ed476a7187"}, + {file = "numpy-1.24.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea8282b9bcfe2b5e7d491d0bf7f3e2da29700cec05b49e64d6246923329f2b02"}, + {file = "numpy-1.24.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:210461d87fb02a84ef243cac5e814aad2b7f4be953b32cb53327bb49fd77fbb4"}, + {file = "numpy-1.24.3-cp39-cp39-win32.whl", hash = "sha256:784c6da1a07818491b0ffd63c6bbe5a33deaa0e25a20e1b3ea20cf0e43f8046c"}, + {file = "numpy-1.24.3-cp39-cp39-win_amd64.whl", hash = "sha256:d5036197ecae68d7f491fcdb4df90082b0d4960ca6599ba2659957aafced7c17"}, + {file = "numpy-1.24.3-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:352ee00c7f8387b44d19f4cada524586f07379c0d49270f87233983bc5087ca0"}, + {file = "numpy-1.24.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a7d6acc2e7524c9955e5c903160aa4ea083736fde7e91276b0e5d98e6332812"}, + {file = "numpy-1.24.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:35400e6a8d102fd07c71ed7dcadd9eb62ee9a6e84ec159bd48c28235bbb0f8e4"}, + {file = "numpy-1.24.3.tar.gz", hash = "sha256:ab344f1bf21f140adab8e47fdbc7c35a477dc01408791f8ba00d018dd0bc5155"}, ] [[package]] name = "packaging" -version = "23.0" +version = "23.1" description = "Core utilities for Python packages" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "packaging-23.0-py3-none-any.whl", hash = "sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2"}, - {file = "packaging-23.0.tar.gz", hash = "sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97"}, + {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, + {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, ] [[package]] name = "pandas" -version = "1.5.3" +version = "2.0.2" description = "Powerful data structures for data analysis, time series, and statistics" category = "main" optional = false python-versions = ">=3.8" files = [ - {file = "pandas-1.5.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3749077d86e3a2f0ed51367f30bf5b82e131cc0f14260c4d3e499186fccc4406"}, - {file = "pandas-1.5.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:972d8a45395f2a2d26733eb8d0f629b2f90bebe8e8eddbb8829b180c09639572"}, - {file = "pandas-1.5.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:50869a35cbb0f2e0cd5ec04b191e7b12ed688874bd05dd777c19b28cbea90996"}, - {file = "pandas-1.5.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3ac844a0fe00bfaeb2c9b51ab1424e5c8744f89860b138434a363b1f620f354"}, - {file = "pandas-1.5.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a0a56cef15fd1586726dace5616db75ebcfec9179a3a55e78f72c5639fa2a23"}, - {file = "pandas-1.5.3-cp310-cp310-win_amd64.whl", hash = "sha256:478ff646ca42b20376e4ed3fa2e8d7341e8a63105586efe54fa2508ee087f328"}, - {file = "pandas-1.5.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6973549c01ca91ec96199e940495219c887ea815b2083722821f1d7abfa2b4dc"}, - {file = "pandas-1.5.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c39a8da13cede5adcd3be1182883aea1c925476f4e84b2807a46e2775306305d"}, - {file = "pandas-1.5.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f76d097d12c82a535fda9dfe5e8dd4127952b45fea9b0276cb30cca5ea313fbc"}, - {file = "pandas-1.5.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e474390e60ed609cec869b0da796ad94f420bb057d86784191eefc62b65819ae"}, - {file = "pandas-1.5.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f2b952406a1588ad4cad5b3f55f520e82e902388a6d5a4a91baa8d38d23c7f6"}, - {file = "pandas-1.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:bc4c368f42b551bf72fac35c5128963a171b40dce866fb066540eeaf46faa003"}, - {file = "pandas-1.5.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:14e45300521902689a81f3f41386dc86f19b8ba8dd5ac5a3c7010ef8d2932813"}, - {file = "pandas-1.5.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9842b6f4b8479e41968eced654487258ed81df7d1c9b7b870ceea24ed9459b31"}, - {file = "pandas-1.5.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:26d9c71772c7afb9d5046e6e9cf42d83dd147b5cf5bcb9d97252077118543792"}, - {file = "pandas-1.5.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5fbcb19d6fceb9e946b3e23258757c7b225ba450990d9ed63ccceeb8cae609f7"}, - {file = "pandas-1.5.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:565fa34a5434d38e9d250af3c12ff931abaf88050551d9fbcdfafca50d62babf"}, - {file = "pandas-1.5.3-cp38-cp38-win32.whl", hash = "sha256:87bd9c03da1ac870a6d2c8902a0e1fd4267ca00f13bc494c9e5a9020920e1d51"}, - {file = "pandas-1.5.3-cp38-cp38-win_amd64.whl", hash = "sha256:41179ce559943d83a9b4bbacb736b04c928b095b5f25dd2b7389eda08f46f373"}, - {file = "pandas-1.5.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c74a62747864ed568f5a82a49a23a8d7fe171d0c69038b38cedf0976831296fa"}, - {file = "pandas-1.5.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c4c00e0b0597c8e4f59e8d461f797e5d70b4d025880516a8261b2817c47759ee"}, - {file = "pandas-1.5.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a50d9a4336a9621cab7b8eb3fb11adb82de58f9b91d84c2cd526576b881a0c5a"}, - {file = "pandas-1.5.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd05f7783b3274aa206a1af06f0ceed3f9b412cf665b7247eacd83be41cf7bf0"}, - {file = "pandas-1.5.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f69c4029613de47816b1bb30ff5ac778686688751a5e9c99ad8c7031f6508e5"}, - {file = "pandas-1.5.3-cp39-cp39-win32.whl", hash = "sha256:7cec0bee9f294e5de5bbfc14d0573f65526071029d036b753ee6507d2a21480a"}, - {file = "pandas-1.5.3-cp39-cp39-win_amd64.whl", hash = "sha256:dfd681c5dc216037e0b0a2c821f5ed99ba9f03ebcf119c7dac0e9a7b960b9ec9"}, - {file = "pandas-1.5.3.tar.gz", hash = "sha256:74a3fd7e5a7ec052f183273dc7b0acd3a863edf7520f5d3a1765c04ffdb3b0b1"}, + {file = "pandas-2.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ebb9f1c22ddb828e7fd017ea265a59d80461d5a79154b49a4207bd17514d122"}, + {file = "pandas-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1eb09a242184092f424b2edd06eb2b99d06dc07eeddff9929e8667d4ed44e181"}, + {file = "pandas-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7319b6e68de14e6209460f72a8d1ef13c09fb3d3ef6c37c1e65b35d50b5c145"}, + {file = "pandas-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd46bde7309088481b1cf9c58e3f0e204b9ff9e3244f441accd220dd3365ce7c"}, + {file = "pandas-2.0.2-cp310-cp310-win32.whl", hash = "sha256:51a93d422fbb1bd04b67639ba4b5368dffc26923f3ea32a275d2cc450f1d1c86"}, + {file = "pandas-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:66d00300f188fa5de73f92d5725ced162488f6dc6ad4cecfe4144ca29debe3b8"}, + {file = "pandas-2.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:02755de164da6827764ceb3bbc5f64b35cb12394b1024fdf88704d0fa06e0e2f"}, + {file = "pandas-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0a1e0576611641acde15c2322228d138258f236d14b749ad9af498ab69089e2d"}, + {file = "pandas-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6b5f14cd24a2ed06e14255ff40fe2ea0cfaef79a8dd68069b7ace74bd6acbba"}, + {file = "pandas-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50e451932b3011b61d2961b4185382c92cc8c6ee4658dcd4f320687bb2d000ee"}, + {file = "pandas-2.0.2-cp311-cp311-win32.whl", hash = "sha256:7b21cb72958fc49ad757685db1919021d99650d7aaba676576c9e88d3889d456"}, + {file = "pandas-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:c4af689352c4fe3d75b2834933ee9d0ccdbf5d7a8a7264f0ce9524e877820c08"}, + {file = "pandas-2.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:69167693cb8f9b3fc060956a5d0a0a8dbfed5f980d9fd2c306fb5b9c855c814c"}, + {file = "pandas-2.0.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:30a89d0fec4263ccbf96f68592fd668939481854d2ff9da709d32a047689393b"}, + {file = "pandas-2.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a18e5c72b989ff0f7197707ceddc99828320d0ca22ab50dd1b9e37db45b010c0"}, + {file = "pandas-2.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7376e13d28eb16752c398ca1d36ccfe52bf7e887067af9a0474de6331dd948d2"}, + {file = "pandas-2.0.2-cp38-cp38-win32.whl", hash = "sha256:6d6d10c2142d11d40d6e6c0a190b1f89f525bcf85564707e31b0a39e3b398e08"}, + {file = "pandas-2.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:e69140bc2d29a8556f55445c15f5794490852af3de0f609a24003ef174528b79"}, + {file = "pandas-2.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b42b120458636a981077cfcfa8568c031b3e8709701315e2bfa866324a83efa8"}, + {file = "pandas-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f908a77cbeef9bbd646bd4b81214cbef9ac3dda4181d5092a4aa9797d1bc7774"}, + {file = "pandas-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:713f2f70abcdade1ddd68fc91577cb090b3544b07ceba78a12f799355a13ee44"}, + {file = "pandas-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf3f0c361a4270185baa89ec7ab92ecaa355fe783791457077473f974f654df5"}, + {file = "pandas-2.0.2-cp39-cp39-win32.whl", hash = "sha256:598e9020d85a8cdbaa1815eb325a91cfff2bb2b23c1442549b8a3668e36f0f77"}, + {file = "pandas-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:77550c8909ebc23e56a89f91b40ad01b50c42cfbfab49b3393694a50549295ea"}, + {file = "pandas-2.0.2.tar.gz", hash = "sha256:dd5476b6c3fe410ee95926873f377b856dbc4e81a9c605a0dc05aaccc6a7c6c6"}, ] [package.dependencies] @@ -254,11 +254,32 @@ numpy = [ {version = ">=1.21.0", markers = "python_version >= \"3.10\""}, {version = ">=1.23.2", markers = "python_version >= \"3.11\""}, ] -python-dateutil = ">=2.8.1" +python-dateutil = ">=2.8.2" pytz = ">=2020.1" +tzdata = ">=2022.1" [package.extras] -test = ["hypothesis (>=5.5.3)", "pytest (>=6.0)", "pytest-xdist (>=1.31)"] +all = ["PyQt5 (>=5.15.1)", "SQLAlchemy (>=1.4.16)", "beautifulsoup4 (>=4.9.3)", "bottleneck (>=1.3.2)", "brotlipy (>=0.7.0)", "fastparquet (>=0.6.3)", "fsspec (>=2021.07.0)", "gcsfs (>=2021.07.0)", "html5lib (>=1.1)", "hypothesis (>=6.34.2)", "jinja2 (>=3.0.0)", "lxml (>=4.6.3)", "matplotlib (>=3.6.1)", "numba (>=0.53.1)", "numexpr (>=2.7.3)", "odfpy (>=1.4.1)", "openpyxl (>=3.0.7)", "pandas-gbq (>=0.15.0)", "psycopg2 (>=2.8.6)", "pyarrow (>=7.0.0)", "pymysql (>=1.0.2)", "pyreadstat (>=1.1.2)", "pytest (>=7.0.0)", "pytest-asyncio (>=0.17.0)", "pytest-xdist (>=2.2.0)", "python-snappy (>=0.6.0)", "pyxlsb (>=1.0.8)", "qtpy (>=2.2.0)", "s3fs (>=2021.08.0)", "scipy (>=1.7.1)", "tables (>=3.6.1)", "tabulate (>=0.8.9)", "xarray (>=0.21.0)", "xlrd (>=2.0.1)", "xlsxwriter (>=1.4.3)", "zstandard (>=0.15.2)"] +aws = ["s3fs (>=2021.08.0)"] +clipboard = ["PyQt5 (>=5.15.1)", "qtpy (>=2.2.0)"] +compression = ["brotlipy (>=0.7.0)", "python-snappy (>=0.6.0)", "zstandard (>=0.15.2)"] +computation = ["scipy (>=1.7.1)", "xarray (>=0.21.0)"] +excel = ["odfpy (>=1.4.1)", "openpyxl (>=3.0.7)", "pyxlsb (>=1.0.8)", "xlrd (>=2.0.1)", "xlsxwriter (>=1.4.3)"] +feather = ["pyarrow (>=7.0.0)"] +fss = ["fsspec (>=2021.07.0)"] +gcp = ["gcsfs (>=2021.07.0)", "pandas-gbq (>=0.15.0)"] +hdf5 = ["tables (>=3.6.1)"] +html = ["beautifulsoup4 (>=4.9.3)", "html5lib (>=1.1)", "lxml (>=4.6.3)"] +mysql = ["SQLAlchemy (>=1.4.16)", "pymysql (>=1.0.2)"] +output-formatting = ["jinja2 (>=3.0.0)", "tabulate (>=0.8.9)"] +parquet = ["pyarrow (>=7.0.0)"] +performance = ["bottleneck (>=1.3.2)", "numba (>=0.53.1)", "numexpr (>=2.7.1)"] +plot = ["matplotlib (>=3.6.1)"] +postgresql = ["SQLAlchemy (>=1.4.16)", "psycopg2 (>=2.8.6)"] +spss = ["pyreadstat (>=1.1.2)"] +sql-other = ["SQLAlchemy (>=1.4.16)"] +test = ["hypothesis (>=6.34.2)", "pytest (>=7.0.0)", "pytest-asyncio (>=0.17.0)", "pytest-xdist (>=2.2.0)"] +xml = ["lxml (>=4.6.3)"] [[package]] name = "pluggy" @@ -294,18 +315,17 @@ test = ["beautifulsoup4", "flake8", "pytest", "pytest-cov"] [[package]] name = "pytest" -version = "7.2.2" +version = "7.3.1" description = "pytest: simple powerful testing with Python" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "pytest-7.2.2-py3-none-any.whl", hash = "sha256:130328f552dcfac0b1cec75c12e3f005619dc5f874f0a06e8ff7263f0ee6225e"}, - {file = "pytest-7.2.2.tar.gz", hash = "sha256:c99ab0c73aceb050f68929bc93af19ab6db0558791c6a0715723abe9d0ade9d4"}, + {file = "pytest-7.3.1-py3-none-any.whl", hash = "sha256:3799fa815351fea3a5e96ac7e503a96fa51cc9942c3753cda7651b93c1cfa362"}, + {file = "pytest-7.3.1.tar.gz", hash = "sha256:434afafd78b1d78ed0addf160ad2b77a30d35d4bdf8af234fe621919d9ed15e3"}, ] [package.dependencies] -attrs = ">=19.2.0" colorama = {version = "*", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" @@ -314,18 +334,18 @@ pluggy = ">=0.12,<2.0" tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] -testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] [[package]] name = "pytest-cov" -version = "4.0.0" +version = "4.1.0" description = "Pytest plugin for measuring coverage." category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "pytest-cov-4.0.0.tar.gz", hash = "sha256:996b79efde6433cdbd0088872dbc5fb3ed7fe1578b68cdbba634f14bb8dd0470"}, - {file = "pytest_cov-4.0.0-py3-none-any.whl", hash = "sha256:2feb1b751d66a8bd934e5edfa2e961d11309dc37b73b0eabe73b5945fee20f6b"}, + {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, + {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, ] [package.dependencies] @@ -352,16 +372,31 @@ six = ">=1.5" [[package]] name = "pytz" -version = "2022.7.1" +version = "2023.3" description = "World timezone definitions, modern and historical" category = "main" optional = false python-versions = "*" files = [ - {file = "pytz-2022.7.1-py2.py3-none-any.whl", hash = "sha256:78f4f37d8198e0627c5f1143240bb0206b8691d8d7ac6d78fee88b78733f8c4a"}, - {file = "pytz-2022.7.1.tar.gz", hash = "sha256:01a0681c4b9684a28304615eba55d1ab31ae00bf68ec157ec3708a8182dbbcd0"}, + {file = "pytz-2023.3-py2.py3-none-any.whl", hash = "sha256:a151b3abb88eda1d4e34a9814df37de2a80e301e68ba0fd856fb9b46bfbbbffb"}, + {file = "pytz-2023.3.tar.gz", hash = "sha256:1d8ce29db189191fb55338ee6d0387d82ab59f3d00eac103412d64e0ebd0c588"}, +] + +[[package]] +name = "schema" +version = "0.7.5" +description = "Simple data validation library" +category = "main" +optional = false +python-versions = "*" +files = [ + {file = "schema-0.7.5-py2.py3-none-any.whl", hash = "sha256:f3ffdeeada09ec34bf40d7d79996d9f7175db93b7a5065de0faa7f41083c1e6c"}, + {file = "schema-0.7.5.tar.gz", hash = "sha256:f06717112c61895cabc4707752b88716e8420a8819d71404501e114f91043197"}, ] +[package.dependencies] +contextlib2 = ">=0.5.5" + [[package]] name = "six" version = "1.16.0" @@ -398,7 +433,31 @@ files = [ {file = "toolz-0.12.0.tar.gz", hash = "sha256:88c570861c440ee3f2f6037c4654613228ff40c93a6c25e0eba70d17282c6194"}, ] +[[package]] +name = "typing-extensions" +version = "4.6.2" +description = "Backported and Experimental Type Hints for Python 3.7+" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "typing_extensions-4.6.2-py3-none-any.whl", hash = "sha256:3a8b36f13dd5fdc5d1b16fe317f5668545de77fa0b8e02006381fd49d731ab98"}, + {file = "typing_extensions-4.6.2.tar.gz", hash = "sha256:06006244c70ac8ee83fa8282cb188f697b8db25bc8b4df07be1873c43897060c"}, +] + +[[package]] +name = "tzdata" +version = "2023.3" +description = "Provider of IANA time zone data" +category = "main" +optional = false +python-versions = ">=2" +files = [ + {file = "tzdata-2023.3-py2.py3-none-any.whl", hash = "sha256:7e65763eef3120314099b6939b5546db7adce1e7d6f2e179e3df563c70511eda"}, + {file = "tzdata-2023.3.tar.gz", hash = "sha256:11ef1e08e54acb0d4f95bdb1be05da659673de4acbd21bf9c69e94cc5e907a3a"}, +] + [metadata] lock-version = "2.0" python-versions = "~=3.8" -content-hash = "c092dc4a8bba758fd0a4f5234ac1bd2a612fa7bdc6a47935205a0e833f58cee3" +content-hash = "4e1c84e81d714371f9da25c210dd0eb6ee49ccb951cd305b3c4a39d391f6f81a" diff --git a/pyproject.toml b/pyproject.toml index 6608be0..9058498 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "exchange-calendars-extensions" -version = "0.1.1" -description = "Extensions of the exchange_calendars package" +version = "0" +description = "Extensions of the exchange-calendars package" license = "Apache-2.0" authors = ["Jens Keiner "] readme = "README.md" @@ -31,10 +31,12 @@ packages = [{include = "exchange_calendars_extensions"}] [tool.poetry.dependencies] python = "~=3.8" exchange-calendars = ">=4.0.1" +schema = "~=0.7.5" [tool.poetry.group.dev.dependencies] -pytest = "~=7.2.1" -pytest-cov = "~=4.0.0" +pytest = "~=7.3.1" +pytest-cov = "~=4.1.0" +typing-extensions = "~=4.6.2" [tool.pytest.ini_options] addopts = "--cov=exchange_calendars_extensions --cov-report=term-missing" diff --git a/test.py b/test.py new file mode 100644 index 0000000..22d5da0 --- /dev/null +++ b/test.py @@ -0,0 +1,31 @@ +import pandas as pd +from exchange_calendars import get_calendar, ExchangeCalendar +from exchange_calendars.exchange_calendar_xetr import XETRExchangeCalendar +from exchange_calendars.exchange_calendar_xlon import XLONExchangeCalendar +from exchange_calendars.exchange_calendar_xmil import XMILExchangeCalendar + +from exchange_calendars_extensions import ExtendedExchangeCalendar, apply_extensions, add_holiday, remove_holiday +from exchange_calendars_extensions import __version__ as version +apply_extensions() +add_holiday("XETR", pd.Timestamp("2020-04-09"), "Gründonnerstag") +add_holiday("XETR", pd.Timestamp("2020-04-09"), "Test") +remove_holiday("XETR", pd.Timestamp("2020-04-10")) +calendar = get_calendar("XETR") +print(f"weekend: {calendar.weekend_days.holidays(start='2020-01-01', end='2020-12-31')}") +print(f"regular holidays: {calendar.regular_holidays.holidays(start='2020-01-01', end='2020-12-31', return_name=True)}") +print(f"all holidays: {calendar.holidays_all.holidays(start='2020-01-01', end='2020-12-31')}") +# print(f"special open: {calendar.special_opens_all.holidays(start='2020-01-01', end='2020-12-31')}") +# print(f"special close: {calendar.special_closes_all.holidays(start='2020-01-01', end='2020-12-31')}") +# print(f"monthly expiry: {calendar.monthly_expiries.holidays(start='2020-01-01', end='2020-12-31')}") +# print(f"quarterly expiry: {calendar.quarterly_expiries.holidays(start='2020-01-01', end='2020-12-31')}") +# print(f"last trading day of month: {calendar.last_trading_days_of_months.holidays(start='2020-01-01', end='2020-12-31')}") +# print(f"last regular trading day of month: {calendar.last_regular_trading_days_of_months.holidays(start='2020-01-01', end='2020-12-31')}") +# print(isinstance(calendar, ExchangeCalendar)) +# print(isinstance(calendar, ExtendedExchangeCalendar)) +# print(isinstance(calendar, XETRExchangeCalendar)) +# #print(calendar.holidays_all.holidays(start='2020-01-01', end='2020-12-31', return_name=True)) +# #print(calendar.quarterly_expiries.holidays(start='2023-01-01', end='2023-12-31', return_name=True)) +# #print(calendar.monthly_expiries.holidays(start='2023-01-01', end='2023-12-31', return_name=True)) +# print(calendar.last_trading_days_of_months.holidays(start='2023-01-01', end='2023-12-31', return_name=True)) +# print(calendar.last_regular_trading_days_of_months.holidays(start='2023-01-01', end='2023-12-31', return_name=True)) +# print(version) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4154b86 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,46 @@ +# conftest.py +import multiprocessing +from typing import Callable + +import pytest + + +def run_test_in_separate_process(test_function: Callable) -> Callable: + """ + Return a new function that, when called with some arguments, runs the given test function with those arguments in a + separate process and returns the result. + + Parameters + ---------- + test_function : Callable + The test function to run in a separate process. + + Returns + ------- + Callable + A new function that, when called with some arguments, runs the given test function with those arguments in a + separate process and returns the result. + """ + def wrapper(*args, **kwargs): + with multiprocessing.Pool(1) as pool: + result = pool.apply(test_function, args, kwargs) + return result + + return wrapper + + +def pytest_configure(config): + # Add the isolated marker. + config.addinivalue_line( + "markers", "isolated: mark test to run in a separate process" + ) + + +@pytest.hookimpl(hookwrapper=True) +def pytest_pyfunc_call(pyfuncitem): + # Run the test in a separate process if the isolated marker is present. + if "isolated" in pyfuncitem.keywords: + original_func = pyfuncitem.obj + pyfuncitem.obj = run_test_in_separate_process(original_func) + + outcome = yield diff --git a/tests/test_changeset.py b/tests/test_changeset.py new file mode 100644 index 0000000..762bc9a --- /dev/null +++ b/tests/test_changeset.py @@ -0,0 +1,438 @@ +import datetime as dt +import json + +import pandas as pd +import pytest +from schema import SchemaError + +from exchange_calendars_extensions import HolidaysAndSpecialSessions +from exchange_calendars_extensions.changeset import Changes, ChangeSet, DaySpec, _DaySchema, _to_time + + +class TestChanges: + def test_empty_changes(self): + c = Changes[DaySpec](schema=_DaySchema) + assert c.add == dict() + assert c.remove == set() + assert c.is_empty() + assert c.is_consistent() + + @pytest.mark.parametrize(["strict"], [(True,), (False,)]) + def test_correct_schema(self, strict: bool): + c = Changes[DaySpec](schema=_DaySchema) + c.add_day(date=pd.Timestamp("2020-01-01"), value={"name": "Holiday"}, strict=strict) + + @pytest.mark.parametrize(["strict"], [(True,), (False,)]) + def test_incorrect_schema(self, strict: bool): + c = Changes[DaySpec](schema=_DaySchema) + with pytest.raises(SchemaError): + # Wrong field name. + c.add_day(date=pd.Timestamp("2020-01-01"), value={"foo": "Holiday"}, strict=strict) + with pytest.raises(SchemaError): + # Too many fields. + c.add_day(date=pd.Timestamp("2020-01-01"), value={"name": "Holiday", "time": dt.time(10, 0)}, strict=strict) + + @pytest.mark.parametrize(["strict"], [(True,), (False,)]) + def test_add_day_no_duplicate(self, strict: bool): + c = Changes[DaySpec](schema=_DaySchema) + c.add_day(date=pd.Timestamp("2020-01-01"), value={"name": "Holiday"}, strict=strict) + assert c.add == {pd.Timestamp("2020-01-01"): {"name": "Holiday"}} + assert c.remove == set() + assert not c.is_empty() + assert c.is_consistent() + + def test_add_day_duplicate_strict(self): + c = Changes[DaySpec](schema=_DaySchema) + c.remove_day(date=pd.Timestamp("2020-01-01"), strict=True) + with pytest.raises(ValueError): + c.add_day(date=pd.Timestamp("2020-01-01"), value={"name": "Holiday"}, strict=True) + assert c.add == dict() + assert c.remove == {pd.Timestamp("2020-01-01")} + assert not c.is_empty() + assert c.is_consistent() + + def test_add_day_duplicate_lax(self): + c = Changes[DaySpec](schema=_DaySchema) + c.remove_day(date=pd.Timestamp("2020-01-01"), strict=True) + c.add_day(date=pd.Timestamp("2020-01-01"), value={"name": "Holiday"}, strict=False) + assert c.add == {pd.Timestamp("2020-01-01"): {"name": "Holiday"}} + assert c.remove == set() + assert not c.is_empty() + assert c.is_consistent() + + @pytest.mark.parametrize(["strict"], [(True,), (False,)]) + def add_day_twice(self, strict: bool): + c = Changes[DaySpec](schema=_DaySchema) + c.add_day(date=pd.Timestamp("2020-01-01"), value={"name": "Holiday"}, strict=strict) + c.add_day(date=pd.Timestamp("2020-01-01"), value={"name": "Foo"}, strict=strict) + assert c.add == {pd.Timestamp("2020-01-01"): {"name": "Foo"}} + assert c.remove == set() + assert not c.is_empty() + assert c.is_consistent() + + @pytest.mark.parametrize(["strict"], [(True,), (False,)]) + def test_remove_day_no_duplicate(self, strict: bool): + c = Changes[DaySpec](schema=_DaySchema) + c.remove_day(date=pd.Timestamp("2020-01-01"), strict=True) + assert c.add == dict() + assert c.remove == {pd.Timestamp("2020-01-01")} + assert not c.is_empty() + assert c.is_consistent() + + def test_remove_day_duplicate_strict(self): + c = Changes[DaySpec](schema=_DaySchema) + c.add_day(date=pd.Timestamp("2020-01-01"), value={"name": "Holiday"}, strict=True) + with pytest.raises(ValueError): + c.remove_day(date=pd.Timestamp("2020-01-01"), strict=True) + assert c.add == {pd.Timestamp("2020-01-01"): {"name": "Holiday"}} + assert c.remove == set() + assert not c.is_empty() + assert c.is_consistent() + + def test_remove_day_duplicate_lax(self): + c = Changes[DaySpec](schema=_DaySchema) + c.add_day(date=pd.Timestamp("2020-01-01"), value={"name": "Holiday"}, strict=True) + c.remove_day(date=pd.Timestamp("2020-01-01"), strict=False) + assert c.add == dict() + assert c.remove == {pd.Timestamp("2020-01-01")} + assert not c.is_empty() + assert c.is_consistent() + + @pytest.mark.parametrize(["strict"], [(True,), (False,)]) + def test_remove_day_twice(self, strict: bool): + c = Changes[DaySpec](schema=_DaySchema) + c.remove_day(date=pd.Timestamp("2020-01-01"), strict=strict) + c.remove_day(date=pd.Timestamp("2020-01-01"), strict=strict) + assert c.add == dict() + assert c.remove == {pd.Timestamp("2020-01-01")} + assert not c.is_empty() + assert c.is_consistent() + + @pytest.mark.parametrize(["strict"], [(True,), (False,)]) + def test_clear_day(self, strict: bool): + c = Changes[DaySpec](schema=_DaySchema) + c.add_day(date=pd.Timestamp("2020-01-01"), value={"name": "Holiday"}, strict=strict) + assert c.add == {pd.Timestamp("2020-01-01"): {"name": "Holiday"}} + assert c.remove == set() + assert not c.is_empty() + assert c.is_consistent() + c.clear_day(date=pd.Timestamp("2020-01-01")) + assert c.add == dict() + assert c.remove == set() + assert c.is_empty() + assert c.is_consistent() + + c.remove_day(date=pd.Timestamp("2020-01-01"), strict=strict) + assert c.add == dict() + assert c.remove == {pd.Timestamp("2020-01-01")} + assert not c.is_empty() + assert c.is_consistent() + c.clear_day(date=pd.Timestamp("2020-01-01")) + assert c.add == dict() + assert c.remove == set() + assert c.is_empty() + assert c.is_consistent() + + def test_clear(self): + c = Changes[DaySpec](schema=_DaySchema) + c.add_day(date=pd.Timestamp("2020-01-01"), value={"name": "Holiday"}, strict=True) + c.remove_day(date=pd.Timestamp("2020-01-02"), strict=True) + assert c.add == {pd.Timestamp("2020-01-01"): {"name": "Holiday"}} + assert c.remove == {pd.Timestamp("2020-01-02")} + assert not c.is_empty() + assert c.is_consistent() + c.clear() + assert c.add == dict() + assert c.remove == set() + assert c.is_empty() + assert c.is_consistent() + + def test_str(self): + c = Changes[DaySpec](schema=_DaySchema) + c.add_day(date=pd.Timestamp("2020-01-01"), value={"name": "Holiday"}, strict=True) + c.remove_day(date=pd.Timestamp("2020-01-02"), strict=True) + assert str(c) == "Changes(add={2020-01-01: {'name': 'Holiday'}}, remove={2020-01-02})" + + def test_eq(self): + c1 = Changes[DaySpec](schema=_DaySchema) + c1.add_day(date=pd.Timestamp("2020-01-01"), value={"name": "Holiday"}, strict=True) + c1.remove_day(date=pd.Timestamp("2020-01-02"), strict=True) + c2 = Changes[DaySpec](schema=_DaySchema) + c2.add_day(date=pd.Timestamp("2020-01-01"), value={"name": "Holiday"}, strict=True) + c2.remove_day(date=pd.Timestamp("2020-01-02"), strict=True) + assert c1 == c2 + + c3 = Changes[DaySpec](schema=_DaySchema) + c3.add_day(date=pd.Timestamp("2020-01-01"), value={"name": "Holiday"}, strict=True) + assert c1 != c3 + + c3 = Changes[DaySpec](schema=_DaySchema) + c3.remove_day(date=pd.Timestamp("2020-01-02"), strict=True) + assert c1 != c3 + + +class TestToTime: + def test_to_time_valid(self): + assert _to_time("10:00") == dt.time(10, 0) + assert _to_time("10:00:00") == dt.time(10, 0) + assert _to_time(dt.time(10, 0)) == dt.time(10, 0) + + def test_to_time_invalid(self): + with pytest.raises(ValueError): + _to_time("10:00:00.000") + with pytest.raises(ValueError): + _to_time("10:00:00.000000") + with pytest.raises(ValueError): + _to_time("10.00") + + +class TestHolidaysAndSpecialSessions: + def test_from_str_valid(self): + assert HolidaysAndSpecialSessions.to_enum("holiday") == HolidaysAndSpecialSessions.HOLIDAY + assert HolidaysAndSpecialSessions.to_enum("HoLiDaY") == HolidaysAndSpecialSessions.HOLIDAY + assert HolidaysAndSpecialSessions.to_enum("special_open") == HolidaysAndSpecialSessions.SPECIAL_OPEN + assert HolidaysAndSpecialSessions.to_enum("SpEcIaL_OpEn") == HolidaysAndSpecialSessions.SPECIAL_OPEN + assert HolidaysAndSpecialSessions.to_enum("special_close") == HolidaysAndSpecialSessions.SPECIAL_CLOSE + assert HolidaysAndSpecialSessions.to_enum("monthly_expiry") == HolidaysAndSpecialSessions.MONTHLY_EXPIRY + assert HolidaysAndSpecialSessions.to_enum("quarterly_expiry") == HolidaysAndSpecialSessions.QUARTERLY_EXPIRY + + def test_from_str_invalid(self): + with pytest.raises(KeyError): + HolidaysAndSpecialSessions.to_enum("invalid") + + +class TestChangeSet: + def test_empty_changeset_from_dict(self): + d = {} + cs = ChangeSet.from_dict(d) + assert cs.is_empty() + assert cs.is_consistent() + + @pytest.mark.parametrize(["d_str", "cs"], [ + ("""{"holiday": {"add": [{"date": "2020-01-01", "value": {"name": "Holiday"}}]}}""", + ChangeSet().add_day(pd.Timestamp("2020-01-01"), {"name": "Holiday"}, HolidaysAndSpecialSessions.HOLIDAY)), + ("""{"special_open": {"add": [{"date": "2020-01-01", "value": {"name": "Special Open", "time": "10:00"}}]}}""", + ChangeSet().add_day(pd.Timestamp("2020-01-01"), {"name": "Special Open", "time": dt.time(10, 0)}, + HolidaysAndSpecialSessions.SPECIAL_OPEN)), + ("""{"special_close": {"add": [{"date": "2020-01-01", "value": {"name": "Special Close", "time": "16:00"}}]}}""", + ChangeSet().add_day(pd.Timestamp("2020-01-01"), {"name": "Special Close", "time": dt.time(16, 0)}, + HolidaysAndSpecialSessions.SPECIAL_CLOSE)), + ("""{"monthly_expiry": {"add": [{"date": "2020-01-01", "value": {"name": "Monthly Expiry"}}]}}""", + ChangeSet().add_day(pd.Timestamp("2020-01-01"), {"name": "Monthly Expiry"}, + HolidaysAndSpecialSessions.MONTHLY_EXPIRY)), + ("""{"quarterly_expiry": {"add": [{"date": "2020-01-01", "value": {"name": "Quarterly Expiry"}}]}}""", + ChangeSet().add_day(pd.Timestamp("2020-01-01"), {"name": "Quarterly Expiry"}, + HolidaysAndSpecialSessions.QUARTERLY_EXPIRY)), + ("""{"holiday": {"remove": ["2020-01-01"]}}""", + ChangeSet().remove_day(pd.Timestamp("2020-01-01"), HolidaysAndSpecialSessions.HOLIDAY)), + ("""{"special_open": {"remove": ["2020-01-01"]}}""", + ChangeSet().remove_day(pd.Timestamp("2020-01-01"), HolidaysAndSpecialSessions.SPECIAL_OPEN)), + ("""{"special_close": {"remove": ["2020-01-01"]}}""", + ChangeSet().remove_day(pd.Timestamp("2020-01-01"), HolidaysAndSpecialSessions.SPECIAL_CLOSE)), + ("""{"monthly_expiry": {"remove": ["2020-01-01"]}}""", + ChangeSet().remove_day(pd.Timestamp("2020-01-01"), HolidaysAndSpecialSessions.MONTHLY_EXPIRY)), + ("""{"quarterly_expiry": {"remove": ["2020-01-01"]}}""", + ChangeSet().remove_day(pd.Timestamp("2020-01-01"), HolidaysAndSpecialSessions.QUARTERLY_EXPIRY)), + ("""{ + "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"]} + }""", + ChangeSet() + .add_day(pd.Timestamp("2020-01-01"), {"name": "Holiday"}, HolidaysAndSpecialSessions.HOLIDAY, strict=True) + .remove_day(pd.Timestamp("2020-01-02"), day_type=HolidaysAndSpecialSessions.HOLIDAY, strict=True) + .add_day(pd.Timestamp("2020-02-01"), {"name": "Special Open", "time": dt.time(10, 0)}, + HolidaysAndSpecialSessions.SPECIAL_OPEN, strict=True) + .remove_day(pd.Timestamp("2020-02-02"), day_type=HolidaysAndSpecialSessions.SPECIAL_OPEN, strict=True) + .add_day(pd.Timestamp("2020-03-01"), {"name": "Special Close", "time": dt.time(16, 0)}, + HolidaysAndSpecialSessions.SPECIAL_CLOSE, strict=True) + .remove_day(pd.Timestamp("2020-03-02"), day_type=HolidaysAndSpecialSessions.SPECIAL_CLOSE, strict=True) + .add_day(pd.Timestamp("2020-04-01"), {"name": "Monthly Expiry"}, HolidaysAndSpecialSessions.MONTHLY_EXPIRY, + strict=True) + .remove_day(pd.Timestamp("2020-04-02"), day_type=HolidaysAndSpecialSessions.MONTHLY_EXPIRY, strict=True) + .add_day(pd.Timestamp("2020-05-01"), {"name": "Quarterly Expiry"}, HolidaysAndSpecialSessions.QUARTERLY_EXPIRY, + strict=True) + .remove_day(pd.Timestamp("2020-05-02"), day_type=HolidaysAndSpecialSessions.QUARTERLY_EXPIRY, strict=True)), + ]) + def test_changeset_from_valid_non_empty_dict(self, d_str: str, cs: ChangeSet): + d = json.loads(d_str) + cs0 = ChangeSet.from_dict(d=d) + assert not cs0.is_empty() + assert cs0.is_consistent() + assert cs0 == cs + + @pytest.mark.parametrize(["d_str"], [ + # Invalid day type. + ("""{"foo": {"add": [{"date": "2020-01-01", "value": {"name": "Holiday"}}]}}""",), + ("""{"foo": {"add": [{"date": "2020-01-01", "value": {"name": "Holiday"}}]}}""",), + ("""{"foo": {"add": [{"date": "2020-01-01", "value": {"name": "Holiday"}}]}}""",), + # Missing date key. + ("""{"holiday": {"add": [{"value": {"name": "Holiday"}}]}}""",), + ("""{"monthly_expiry": {"add": [{"value": {"name": "Monthly Expiry"}}]}}""",), + ("""{"quarterly_expiry": {"add": [{"value": {"name": "Quarterly Expiry"}}]}}""",), + # Invalid date key. + ("""{"holiday": {"add": [{"date": "foo", "value": {"name": "Holiday"}}]}}""",), + ("""{"monthly_expiry": {"add": [{"date": "foo", "value": {"name": "Monthly Expiry"}}]}}""",), + ("""{"quarterly_expiry": {"add": [{"date": "foo", "value": {"name": "Quarterly Expiry"}}]}}""",), + # Missing value key. + ("""{"holiday": {"add": [{"date": "2020-01-01"}]}}""",), + ("""{"monthly_expiry": {"add": [{"date": "2020-01-01"}]}}""",), + ("""{"quarterly_expiry": {"add": [{"date": "2020-01-01"}]}}""",), + # Invalid value key. + ("""{"holiday": {"add": [{"date": "2020-01-01", "value": {"foo": "Holiday"}}]}}""",), + ("""{"monthly_expiry": {"add": [{"date": "2020-01-01", "value": {"foo": "Monthly Expiry"}}]}}""",), + ("""{"quarterly_expiry": {"add": [{"date": "2020-01-01", "value": {"foo": "Quarterly Expiry"}}]}}""",), + # Invalid day type. + ("""{"foo": {"add": [{"date": "2020-01-01", "value": {"name": "Special Open", "time": "10:00"}}]}}""",), + ("""{"foo": {"add": [{"date": "2020-01-01", "value": {"name": "Special Close", "time": "10:00"}}]}}""",), + # Missing date key. + ("""{"special_open": {"add": [{"value": {"name": "Special Open", "time": "10:00"}}]}}""",), + ("""{"special_close": {"add": [{"value": {"name": "Special Close", "time": "10:00"}}]}}""",), + # Invalid date key. + ("""{"special_open": {"add": [{"date": "foo", "value": {"name": "Special Open", "time": "10:00"}}]}}""",), + ("""{"special_close": {"add": [{"date": "foo", "value": {"name": "Special Close", "time": "10:00"}}]}}""",), + # Missing value key. + ("""{"special_open": {"add": [{"date": "2020-01-01"}]}}""",), + ("""{"special_close": {"add": [{"date": "2020-01-01"}]}}""",), + # Invalid value key. + ("""{"special_open": {"add": [{"date": "2020-01-01", "value": {"foo": "Special Open", "time": "10:00"}}]}}""",), + ("""{"special_open": {"add": [{"date": "2020-01-01", "value": {"name": "Special Open", "foo": "10:00"}}]}}""",), + ("""{"special_close": {"add": [{"date": "2020-01-01", "value": {"foo": "Special Close", "time": "10:00"}}]}}""",), + ("""{"special_close": {"add": [{"date": "2020-01-01", "value": {"name": "Special Close", "foo": "10:00"}}]}}""",), + ]) + def test_changeset_from_invalid_dict(self, d_str: str): + d = json.loads(d_str) + with pytest.raises(ValueError): + ChangeSet.from_dict(d) + + @pytest.mark.parametrize(["d_str"], [ + # Same day added twice for different day types. + ("""{ + "holiday": {"add": [{"date": "2020-01-01", "value": {"name": "Holiday"}}]}, + "special_open": {"add": [{"date": "2020-01-01", "value": {"name": "Special Open", "time": "10:00"}}]} + }""",), + ("""{ + "holiday": {"add": [{"date": "2020-01-01", "value": {"name": "Holiday"}}]}, + "special_close": {"add": [{"date": "2020-01-01", "value": {"name": "Special Close", "time": "16:00"}}]} + }""",), + ("""{ + "holiday": {"add": [{"date": "2020-01-01", "value": {"name": "Holiday"}}]}, + "monthly_expiry": {"add": [{"date": "2020-01-01", "value": {"name": "Monthly Expiry"}}]} + }""",), + ("""{ + "holiday": {"add": [{"date": "2020-01-01", "value": {"name": "Holiday"}}]}, + "quarterly_expiry": {"add": [{"date": "2020-01-01", "value": {"name": "Quarterly Expiry"}}]} + }""",), + # Same day added and removed for same day type. + ("""{ + "holiday": {"add": [{"date": "2020-01-01", "value": {"name": "Holiday"}}], "remove": ["2020-01-01"]} + }""",), + ("""{ + "special_open": {"add": [{"date": "2020-01-01", "value": {"name": "Special Open", "time": "10:00"}}], "remove": ["2020-01-01"]} + }""",), + ("""{ + "special_close": {"add": [{"date": "2020-01-01", "value": {"name": "Special Close", "time": "16:00"}}], "remove": ["2020-01-01"]} + }""",), + ("""{ + "monthly_expiry": {"add": [{"date": "2020-01-01", "value": {"name": "Holiday"}}], "remove": ["2020-01-01"]} + }""",), + ("""{ + "quarterly_expiry": {"add": [{"date": "2020-01-01", "value": {"name": "Holiday"}}], "remove": ["2020-01-01"]} + }""",), + ]) + def test_changeset_from_inconsistent_dict(self, d_str: str): + d = json.loads(d_str) + with pytest.raises(ValueError): + ChangeSet.from_dict(d) + + @pytest.mark.parametrize(["cs", "cs_normalized"], [ + (ChangeSet() + .add_day(pd.Timestamp("2020-01-01"), {"name": "Holiday"}, HolidaysAndSpecialSessions.HOLIDAY, strict=True), + ChangeSet() + .remove_day(pd.Timestamp("2020-01-01"), day_type=None, strict=True) + .add_day(pd.Timestamp("2020-01-01"), {"name": "Holiday"}, HolidaysAndSpecialSessions.HOLIDAY, strict=False)), + (ChangeSet() + .add_day(pd.Timestamp("2020-01-01"), {"name": "Special Open", "time": dt.time(10, 0)}, + HolidaysAndSpecialSessions.SPECIAL_OPEN, strict=True), + ChangeSet() + .remove_day(pd.Timestamp("2020-01-01"), day_type=None, strict=True) + .add_day(pd.Timestamp("2020-01-01"), {"name": "Special Open", "time": dt.time(10, 0)}, + HolidaysAndSpecialSessions.SPECIAL_OPEN, strict=False)), + (ChangeSet() + .add_day(pd.Timestamp("2020-01-01"), {"name": "Special Close", "time": dt.time(16, 0)}, + HolidaysAndSpecialSessions.SPECIAL_CLOSE, strict=True), + ChangeSet() + .remove_day(pd.Timestamp("2020-01-01"), day_type=None, strict=True) + .add_day(pd.Timestamp("2020-01-01"), {"name": "Special Close", "time": dt.time(16, 0)}, + HolidaysAndSpecialSessions.SPECIAL_CLOSE, strict=False)), + (ChangeSet() + .add_day(pd.Timestamp("2020-01-01"), {"name": "Monthly Expiry"}, HolidaysAndSpecialSessions.MONTHLY_EXPIRY, + strict=True), + ChangeSet() + .remove_day(pd.Timestamp("2020-01-01"), day_type=None, strict=True) + .add_day(pd.Timestamp("2020-01-01"), {"name": "Monthly Expiry"}, HolidaysAndSpecialSessions.MONTHLY_EXPIRY, + strict=False)), + (ChangeSet() + .add_day(pd.Timestamp("2020-01-01"), {"name": "Quarterly Expiry"}, HolidaysAndSpecialSessions.QUARTERLY_EXPIRY, + strict=True), + ChangeSet() + .remove_day(pd.Timestamp("2020-01-01"), day_type=None, strict=True) + .add_day(pd.Timestamp("2020-01-01"), {"name": "Quarterly Expiry"}, HolidaysAndSpecialSessions.QUARTERLY_EXPIRY, + strict=False)), + (ChangeSet().remove_day(pd.Timestamp("2020-01-01"), HolidaysAndSpecialSessions.HOLIDAY), + ChangeSet().remove_day(pd.Timestamp("2020-01-01"), HolidaysAndSpecialSessions.HOLIDAY)), + (ChangeSet().remove_day(pd.Timestamp("2020-01-01"), HolidaysAndSpecialSessions.SPECIAL_OPEN), + ChangeSet().remove_day(pd.Timestamp("2020-01-01"), HolidaysAndSpecialSessions.SPECIAL_OPEN)), + (ChangeSet().remove_day(pd.Timestamp("2020-01-01"), HolidaysAndSpecialSessions.SPECIAL_CLOSE), + ChangeSet().remove_day(pd.Timestamp("2020-01-01"), HolidaysAndSpecialSessions.SPECIAL_CLOSE)), + (ChangeSet().remove_day(pd.Timestamp("2020-01-01"), HolidaysAndSpecialSessions.MONTHLY_EXPIRY), + ChangeSet().remove_day(pd.Timestamp("2020-01-01"), HolidaysAndSpecialSessions.MONTHLY_EXPIRY)), + (ChangeSet().remove_day(pd.Timestamp("2020-01-01"), HolidaysAndSpecialSessions.QUARTERLY_EXPIRY), + ChangeSet().remove_day(pd.Timestamp("2020-01-01"), HolidaysAndSpecialSessions.QUARTERLY_EXPIRY)), + (ChangeSet() + .add_day(pd.Timestamp("2020-01-01"), {"name": "Holiday"}, HolidaysAndSpecialSessions.HOLIDAY, strict=True) + .remove_day(pd.Timestamp("2020-01-02"), day_type=HolidaysAndSpecialSessions.HOLIDAY, strict=True) + .add_day(pd.Timestamp("2020-02-01"), {"name": "Special Open", "time": dt.time(10, 0)}, + HolidaysAndSpecialSessions.SPECIAL_OPEN, strict=True) + .remove_day(pd.Timestamp("2020-02-02"), day_type=HolidaysAndSpecialSessions.SPECIAL_OPEN, strict=True) + .add_day(pd.Timestamp("2020-03-01"), {"name": "Special Close", "time": dt.time(16, 0)}, + HolidaysAndSpecialSessions.SPECIAL_CLOSE, strict=True) + .remove_day(pd.Timestamp("2020-03-02"), day_type=HolidaysAndSpecialSessions.SPECIAL_CLOSE, strict=True) + .add_day(pd.Timestamp("2020-04-01"), {"name": "Monthly Expiry"}, HolidaysAndSpecialSessions.MONTHLY_EXPIRY, + strict=True) + .remove_day(pd.Timestamp("2020-04-02"), day_type=HolidaysAndSpecialSessions.MONTHLY_EXPIRY, strict=True) + .add_day(pd.Timestamp("2020-05-01"), {"name": "Quarterly Expiry"}, HolidaysAndSpecialSessions.QUARTERLY_EXPIRY, + strict=True) + .remove_day(pd.Timestamp("2020-05-02"), day_type=HolidaysAndSpecialSessions.QUARTERLY_EXPIRY, strict=True), + ChangeSet() + .remove_day(pd.Timestamp("2020-01-01"), day_type=None, strict=True) + .add_day(pd.Timestamp("2020-01-01"), {"name": "Holiday"}, HolidaysAndSpecialSessions.HOLIDAY, strict=False) + .remove_day(pd.Timestamp("2020-01-02"), day_type=HolidaysAndSpecialSessions.HOLIDAY, strict=True) + .remove_day(pd.Timestamp("2020-02-01"), day_type=None, strict=True) + .add_day(pd.Timestamp("2020-02-01"), {"name": "Special Open", "time": dt.time(10, 0)}, + HolidaysAndSpecialSessions.SPECIAL_OPEN, strict=False) + .remove_day(pd.Timestamp("2020-02-02"), day_type=HolidaysAndSpecialSessions.SPECIAL_OPEN, strict=True) + .remove_day(pd.Timestamp("2020-03-01"), day_type=None, strict=True) + .add_day(pd.Timestamp("2020-03-01"), {"name": "Special Close", "time": dt.time(16, 0)}, + HolidaysAndSpecialSessions.SPECIAL_CLOSE, strict=False) + .remove_day(pd.Timestamp("2020-03-02"), day_type=HolidaysAndSpecialSessions.SPECIAL_CLOSE, strict=True) + .remove_day(pd.Timestamp("2020-04-01"), day_type=None, strict=True) + .add_day(pd.Timestamp("2020-04-01"), {"name": "Monthly Expiry"}, HolidaysAndSpecialSessions.MONTHLY_EXPIRY, + strict=False) + .remove_day(pd.Timestamp("2020-04-02"), day_type=HolidaysAndSpecialSessions.MONTHLY_EXPIRY, strict=True) + .remove_day(pd.Timestamp("2020-05-01"), day_type=None, strict=True) + .add_day(pd.Timestamp("2020-05-01"), {"name": "Quarterly Expiry"}, HolidaysAndSpecialSessions.QUARTERLY_EXPIRY, + strict=False) + .remove_day(pd.Timestamp("2020-05-02"), day_type=HolidaysAndSpecialSessions.QUARTERLY_EXPIRY, strict=True)), + ]) + def test_normalize(self, cs: ChangeSet, cs_normalized: ChangeSet): + 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 + # Idempotentcy. + assert cs_normalized0.normalize(inplace=False) == cs_normalized0 diff --git a/tests/test_holiday.py b/tests/test_holiday.py index 24bf553..59535d6 100644 --- a/tests/test_holiday.py +++ b/tests/test_holiday.py @@ -1,9 +1,8 @@ +import pandas as pd from pandas import DatetimeIndex, Series from exchange_calendars_extensions.holiday import get_monthly_expiry_holiday, DayOfWeekPeriodicHoliday -import pandas as pd - def test_get_monthly_expiry_holiday(): holiday = get_monthly_expiry_holiday(name="Holiday", day_of_week=0, month=1, start_date=pd.Timestamp("2019-01-01"), end_date=pd.Timestamp("2021-12-31")) diff --git a/tests/test_holiday_calendar.py b/tests/test_holiday_calendar.py index 3f4aa64..141f8be 100644 --- a/tests/test_holiday_calendar.py +++ b/tests/test_holiday_calendar.py @@ -87,7 +87,7 @@ def test_merge_calendars_with_overlapping_holidays(): calendar2 = get_holiday_calendar_from_timestamps([pd.Timestamp("2019-01-01"), pd.Timestamp("2019-01-03")]) calendar = merge_calendars((calendar1, calendar2)) holidays = calendar.holidays(start=pd.Timestamp("2019-01-01"), end=pd.Timestamp("2019-01-31")) - assert len(holidays == 3) + assert len(holidays) == 3 assert pd.Timestamp("2019-01-01") in holidays assert pd.Timestamp("2019-01-02") in holidays assert pd.Timestamp("2019-01-03") in holidays @@ -160,61 +160,61 @@ def special_closes_adhoc(self): special_closes_calendar = get_special_closes_calendar(calendar) special_closes = special_closes_calendar.holidays(start=pd.Timestamp("2020-01-01"), end=pd.Timestamp("2020-12-31"), return_name=True) expected_special_closes = pd.Series({ - pd.Timestamp("2020-01-06"): "special close day", - pd.Timestamp("2020-01-08"): "ad-hoc special close day", - pd.Timestamp("2020-01-13"): "special close day", - pd.Timestamp("2020-01-20"): "special close day", - pd.Timestamp("2020-01-27"): "special close day", - pd.Timestamp("2020-02-03"): "special close day", - pd.Timestamp("2020-02-10"): "special close day", - pd.Timestamp("2020-02-17"): "special close day", - pd.Timestamp("2020-02-24"): "special close day", - pd.Timestamp("2020-03-02"): "special close day", - pd.Timestamp("2020-03-09"): "special close day", - pd.Timestamp("2020-03-16"): "special close day", - pd.Timestamp("2020-03-23"): "special close day", - pd.Timestamp("2020-03-30"): "special close day", - pd.Timestamp("2020-04-06"): "special close day", - pd.Timestamp("2020-04-13"): "special close day", - pd.Timestamp("2020-04-20"): "special close day", - pd.Timestamp("2020-04-27"): "special close day", - pd.Timestamp("2020-05-04"): "special close day", - pd.Timestamp("2020-05-11"): "special close day", - pd.Timestamp("2020-05-18"): "special close day", - pd.Timestamp("2020-05-25"): "special close day", - pd.Timestamp("2020-06-01"): "special close day", - pd.Timestamp("2020-06-08"): "special close day", - pd.Timestamp("2020-06-15"): "special close day", - pd.Timestamp("2020-06-22"): "special close day", - pd.Timestamp("2020-06-29"): "special close day", - pd.Timestamp("2020-07-06"): "special close day", - pd.Timestamp("2020-07-13"): "special close day", - pd.Timestamp("2020-07-20"): "special close day", - pd.Timestamp("2020-07-27"): "special close day", - pd.Timestamp("2020-08-03"): "special close day", - pd.Timestamp("2020-08-10"): "special close day", - pd.Timestamp("2020-08-12"): "ad-hoc special close day", - pd.Timestamp("2020-08-17"): "special close day", - pd.Timestamp("2020-08-24"): "special close day", - pd.Timestamp("2020-08-31"): "special close day", - pd.Timestamp("2020-09-07"): "special close day", - pd.Timestamp("2020-09-14"): "special close day", - pd.Timestamp("2020-09-21"): "special close day", - pd.Timestamp("2020-09-28"): "special close day", - pd.Timestamp("2020-10-05"): "special close day", - pd.Timestamp("2020-10-12"): "special close day", - pd.Timestamp("2020-10-19"): "special close day", - pd.Timestamp("2020-10-26"): "special close day", - pd.Timestamp("2020-11-02"): "special close day", - pd.Timestamp("2020-11-09"): "special close day", - pd.Timestamp("2020-11-16"): "special close day", - pd.Timestamp("2020-11-23"): "special close day", - pd.Timestamp("2020-11-30"): "special close day", - pd.Timestamp("2020-12-07"): "special close day", - pd.Timestamp("2020-12-14"): "special close day", - pd.Timestamp("2020-12-21"): "special close day", + pd.Timestamp("2020-01-06"): "special close", + pd.Timestamp("2020-01-08"): "ad-hoc special close", + pd.Timestamp("2020-01-13"): "special close", + pd.Timestamp("2020-01-20"): "special close", + pd.Timestamp("2020-01-27"): "special close", + pd.Timestamp("2020-02-03"): "special close", + pd.Timestamp("2020-02-10"): "special close", + pd.Timestamp("2020-02-17"): "special close", + pd.Timestamp("2020-02-24"): "special close", + pd.Timestamp("2020-03-02"): "special close", + pd.Timestamp("2020-03-09"): "special close", + pd.Timestamp("2020-03-16"): "special close", + pd.Timestamp("2020-03-23"): "special close", + pd.Timestamp("2020-03-30"): "special close", + pd.Timestamp("2020-04-06"): "special close", + pd.Timestamp("2020-04-13"): "special close", + pd.Timestamp("2020-04-20"): "special close", + pd.Timestamp("2020-04-27"): "special close", + pd.Timestamp("2020-05-04"): "special close", + pd.Timestamp("2020-05-11"): "special close", + pd.Timestamp("2020-05-18"): "special close", + pd.Timestamp("2020-05-25"): "special close", + pd.Timestamp("2020-06-01"): "special close", + pd.Timestamp("2020-06-08"): "special close", + pd.Timestamp("2020-06-15"): "special close", + pd.Timestamp("2020-06-22"): "special close", + pd.Timestamp("2020-06-29"): "special close", + pd.Timestamp("2020-07-06"): "special close", + pd.Timestamp("2020-07-13"): "special close", + pd.Timestamp("2020-07-20"): "special close", + pd.Timestamp("2020-07-27"): "special close", + pd.Timestamp("2020-08-03"): "special close", + pd.Timestamp("2020-08-10"): "special close", + pd.Timestamp("2020-08-12"): "ad-hoc special close", + pd.Timestamp("2020-08-17"): "special close", + pd.Timestamp("2020-08-24"): "special close", + pd.Timestamp("2020-08-31"): "special close", + pd.Timestamp("2020-09-07"): "special close", + pd.Timestamp("2020-09-14"): "special close", + pd.Timestamp("2020-09-21"): "special close", + pd.Timestamp("2020-09-28"): "special close", + pd.Timestamp("2020-10-05"): "special close", + pd.Timestamp("2020-10-12"): "special close", + pd.Timestamp("2020-10-19"): "special close", + pd.Timestamp("2020-10-26"): "special close", + pd.Timestamp("2020-11-02"): "special close", + pd.Timestamp("2020-11-09"): "special close", + pd.Timestamp("2020-11-16"): "special close", + pd.Timestamp("2020-11-23"): "special close", + pd.Timestamp("2020-11-30"): "special close", + pd.Timestamp("2020-12-07"): "special close", + pd.Timestamp("2020-12-14"): "special close", + pd.Timestamp("2020-12-21"): "special close", pd.Timestamp("2020-12-24"): "Christmas Eve", - pd.Timestamp("2020-12-28"): "special close day", + pd.Timestamp("2020-12-28"): "special close", pd.Timestamp("2020-12-31"): "New Year's Eve", }) assert special_closes.compare(expected_special_closes).empty @@ -268,61 +268,61 @@ def special_opens_adhoc(self): special_opens_calendar = get_special_opens_calendar(calendar) special_opens = special_opens_calendar.holidays(start=pd.Timestamp("2020-01-01"), end=pd.Timestamp("2020-12-31"), return_name=True) expected_special_opens = pd.Series({ - pd.Timestamp("2020-01-06"): "special open day", - pd.Timestamp("2020-01-08"): "ad-hoc special open day", - pd.Timestamp("2020-01-13"): "special open day", - pd.Timestamp("2020-01-20"): "special open day", - pd.Timestamp("2020-01-27"): "special open day", - pd.Timestamp("2020-02-03"): "special open day", - pd.Timestamp("2020-02-10"): "special open day", - pd.Timestamp("2020-02-17"): "special open day", - pd.Timestamp("2020-02-24"): "special open day", - pd.Timestamp("2020-03-02"): "special open day", - pd.Timestamp("2020-03-09"): "special open day", - pd.Timestamp("2020-03-16"): "special open day", - pd.Timestamp("2020-03-23"): "special open day", - pd.Timestamp("2020-03-30"): "special open day", - pd.Timestamp("2020-04-06"): "special open day", - pd.Timestamp("2020-04-13"): "special open day", - pd.Timestamp("2020-04-20"): "special open day", - pd.Timestamp("2020-04-27"): "special open day", - pd.Timestamp("2020-05-04"): "special open day", - pd.Timestamp("2020-05-11"): "special open day", - pd.Timestamp("2020-05-18"): "special open day", - pd.Timestamp("2020-05-25"): "special open day", - pd.Timestamp("2020-06-01"): "special open day", - pd.Timestamp("2020-06-08"): "special open day", - pd.Timestamp("2020-06-15"): "special open day", - pd.Timestamp("2020-06-22"): "special open day", - pd.Timestamp("2020-06-29"): "special open day", - pd.Timestamp("2020-07-06"): "special open day", - pd.Timestamp("2020-07-13"): "special open day", - pd.Timestamp("2020-07-20"): "special open day", - pd.Timestamp("2020-07-27"): "special open day", - pd.Timestamp("2020-08-03"): "special open day", - pd.Timestamp("2020-08-10"): "special open day", - pd.Timestamp("2020-08-12"): "ad-hoc special open day", - pd.Timestamp("2020-08-17"): "special open day", - pd.Timestamp("2020-08-24"): "special open day", - pd.Timestamp("2020-08-31"): "special open day", - pd.Timestamp("2020-09-07"): "special open day", - pd.Timestamp("2020-09-14"): "special open day", - pd.Timestamp("2020-09-21"): "special open day", - pd.Timestamp("2020-09-28"): "special open day", - pd.Timestamp("2020-10-05"): "special open day", - pd.Timestamp("2020-10-12"): "special open day", - pd.Timestamp("2020-10-19"): "special open day", - pd.Timestamp("2020-10-26"): "special open day", - pd.Timestamp("2020-11-02"): "special open day", - pd.Timestamp("2020-11-09"): "special open day", - pd.Timestamp("2020-11-16"): "special open day", - pd.Timestamp("2020-11-23"): "special open day", - pd.Timestamp("2020-11-30"): "special open day", - pd.Timestamp("2020-12-07"): "special open day", - pd.Timestamp("2020-12-14"): "special open day", - pd.Timestamp("2020-12-21"): "special open day", + pd.Timestamp("2020-01-06"): "special open", + pd.Timestamp("2020-01-08"): "ad-hoc special open", + pd.Timestamp("2020-01-13"): "special open", + pd.Timestamp("2020-01-20"): "special open", + pd.Timestamp("2020-01-27"): "special open", + pd.Timestamp("2020-02-03"): "special open", + pd.Timestamp("2020-02-10"): "special open", + pd.Timestamp("2020-02-17"): "special open", + pd.Timestamp("2020-02-24"): "special open", + pd.Timestamp("2020-03-02"): "special open", + pd.Timestamp("2020-03-09"): "special open", + pd.Timestamp("2020-03-16"): "special open", + pd.Timestamp("2020-03-23"): "special open", + pd.Timestamp("2020-03-30"): "special open", + pd.Timestamp("2020-04-06"): "special open", + pd.Timestamp("2020-04-13"): "special open", + pd.Timestamp("2020-04-20"): "special open", + pd.Timestamp("2020-04-27"): "special open", + pd.Timestamp("2020-05-04"): "special open", + pd.Timestamp("2020-05-11"): "special open", + pd.Timestamp("2020-05-18"): "special open", + pd.Timestamp("2020-05-25"): "special open", + pd.Timestamp("2020-06-01"): "special open", + pd.Timestamp("2020-06-08"): "special open", + pd.Timestamp("2020-06-15"): "special open", + pd.Timestamp("2020-06-22"): "special open", + pd.Timestamp("2020-06-29"): "special open", + pd.Timestamp("2020-07-06"): "special open", + pd.Timestamp("2020-07-13"): "special open", + pd.Timestamp("2020-07-20"): "special open", + pd.Timestamp("2020-07-27"): "special open", + pd.Timestamp("2020-08-03"): "special open", + pd.Timestamp("2020-08-10"): "special open", + pd.Timestamp("2020-08-12"): "ad-hoc special open", + pd.Timestamp("2020-08-17"): "special open", + pd.Timestamp("2020-08-24"): "special open", + pd.Timestamp("2020-08-31"): "special open", + pd.Timestamp("2020-09-07"): "special open", + pd.Timestamp("2020-09-14"): "special open", + pd.Timestamp("2020-09-21"): "special open", + pd.Timestamp("2020-09-28"): "special open", + pd.Timestamp("2020-10-05"): "special open", + pd.Timestamp("2020-10-12"): "special open", + pd.Timestamp("2020-10-19"): "special open", + pd.Timestamp("2020-10-26"): "special open", + pd.Timestamp("2020-11-02"): "special open", + pd.Timestamp("2020-11-09"): "special open", + pd.Timestamp("2020-11-16"): "special open", + pd.Timestamp("2020-11-23"): "special open", + pd.Timestamp("2020-11-30"): "special open", + pd.Timestamp("2020-12-07"): "special open", + pd.Timestamp("2020-12-14"): "special open", + pd.Timestamp("2020-12-21"): "special open", pd.Timestamp("2020-12-24"): "Christmas Eve", - pd.Timestamp("2020-12-28"): "special open day", + pd.Timestamp("2020-12-28"): "special open", pd.Timestamp("2020-12-31"): "New Year's Eve", }) assert special_opens.compare(expected_special_opens).empty diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..b9fac23 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,1834 @@ +import datetime +from datetime import time +from typing import Optional, List, Tuple + +import pandas as pd +import pytest +from exchange_calendars.exchange_calendar import HolidayCalendar +from exchange_calendars.pandas_extensions.holiday import Holiday +from pandas.tseries.holiday import next_monday +from pytz import timezone + + +def apply_extensions(): + """ Apply the extensions to the exchange_calendars module. """ + import exchange_calendars_extensions as ece + ece.apply_extensions() + + +def add_test_calendar_and_apply_extensions(holidays: Optional[List[pd.Timestamp]] = [pd.Timestamp("2023-01-01")], + adhoc_holidays: Optional[List[pd.Timestamp]] = [pd.Timestamp("2023-02-01")], + regular_special_close: Optional[time] = time(14, 00), + special_closes: Optional[List[pd.Timestamp]] = [(time(14, 00), [pd.Timestamp("2023-03-01")])], + adhoc_special_closes: Optional[List[Tuple[datetime.time, pd.Timestamp]]] = [(time(14, 00), pd.Timestamp("2023-04-03"))], + regular_special_open: Optional[time] = time(11, 00), + special_opens: Optional[List[pd.Timestamp]] = [(time(11, 00), [pd.Timestamp("2023-05-01")])], + adhoc_special_opens: Optional[List[Tuple[datetime.time, pd.Timestamp]]] = [(time(11, 00), pd.Timestamp("2023-06-01"))], + weekmask: Optional[str] = "1111100", + day_of_week_expiry: Optional[int] = 4): + import exchange_calendars as ec + + # Define a test calendar class, subclassing the ExchangeCalendar class. Within the class body, define the + # holidays, special closes and special opens, as well as the weekmask, based on the parameters passed to the + # factory. + class TestCalendar(ec.ExchangeCalendar): + # Regular open/close times. + open_times = ((None, time(9)),) + close_times = ((None, time(17, 30)),) + + # Special open/close times. + regular_early_close = regular_special_close + regular_late_open = regular_special_open + + # Name. + name = "TEST" + + # Timezone. + tz = timezone("CET") + + # Holidays. + @property + def regular_holidays(self): + return HolidayCalendar([Holiday(name=f"Holiday {i}", month=ts.month, day=ts.day) for i, ts in + enumerate(holidays)] if holidays else []) + + @property + def adhoc_holidays(self): + return adhoc_holidays if adhoc_holidays else [] + + @property + def special_closes(self): + return list(map(lambda x: (x[0], HolidayCalendar( + [Holiday(name=f"Special Close {i}", month=ts.month, day=ts.day, observance=next_monday) for i, ts in + enumerate(x[1])])), special_closes)) if special_closes else [] + + @property + def special_closes_adhoc(self): + return list(map(lambda x: (x[0], pd.DatetimeIndex([x[1]] if not isinstance(x[1], list) else x[1])), + adhoc_special_closes)) if adhoc_special_closes else [] + + @property + def special_opens(self): + return list(map(lambda x: (x[0], HolidayCalendar( + [Holiday(name=f"Special Open {i}", month=ts.month, day=ts.day, observance=next_monday) for i, ts in + enumerate(x[1])])), special_opens)) if special_opens else [] + + @property + def special_opens_adhoc(self): + return list(map(lambda x: (x[0], pd.DatetimeIndex([x[1]] if not isinstance(x[1], list) else x[1])), + adhoc_special_opens)) if adhoc_special_opens else [] + + # Weekmask. + @property + def weekmask(self): + return weekmask + + ec.register_calendar_type("TEST", TestCalendar) + + import exchange_calendars_extensions as ece + + ece.register_extension("TEST", TestCalendar, day_of_week_expiry=day_of_week_expiry) + + ece.apply_extensions() + + +@pytest.mark.isolated +def test_unmodified_calendars(): + """ Test that calendars are unmodified when the module is just imported, without calling apply_extensions() """ + import exchange_calendars_extensions as ece + + import exchange_calendars as ec + c = ec.get_calendar("XETR") + + # Check if returned Calendar is of expected type. + assert isinstance(c, ec.ExchangeCalendar) + + # Check if returned Calendar is not of extended type. + assert not isinstance(c, ece.ExtendedExchangeCalendar) + assert not isinstance(c, ece.holiday_calendar.ExchangeCalendarExtensions) + + +@pytest.mark.isolated +def test_apply_extensions(): + """ Test that calendars are modified when apply_extensions() is called """ + apply_extensions() + import exchange_calendars as ec + import exchange_calendars_extensions as ece + + c = ec.get_calendar("XETR") + + # Check if returned Calendar is of expected types. + assert isinstance(c, ec.ExchangeCalendar) + assert isinstance(c, ece.ExtendedExchangeCalendar) + assert isinstance(c, ece.holiday_calendar.ExchangeCalendarExtensions) + + +@pytest.mark.isolated +def test_extended_calendar_xetr(): + """ Test the additional properties of the extended XETR calendar. """ + apply_extensions() + + import exchange_calendars as ec + + c = ec.get_calendar("XETR") + + # Check if additional properties are present. + assert hasattr(c, "holidays_all") + assert isinstance(c.holidays_all, ec.exchange_calendar.HolidayCalendar) + + assert hasattr(c, "special_opens_all") + assert isinstance(c.special_opens_all, ec.exchange_calendar.HolidayCalendar) + + assert hasattr(c, "special_closes_all") + assert isinstance(c.special_closes_all, ec.exchange_calendar.HolidayCalendar) + + assert hasattr(c, "weekend_days") + assert isinstance(c.weekend_days, ec.exchange_calendar.HolidayCalendar) + + assert hasattr(c, "monthly_expiries") + assert isinstance(c.monthly_expiries, ec.exchange_calendar.HolidayCalendar) + + assert hasattr(c, "quarterly_expiries") + assert isinstance(c.quarterly_expiries, ec.exchange_calendar.HolidayCalendar) + + assert hasattr(c, "last_trading_days_of_months") + assert isinstance(c.last_trading_days_of_months, ec.exchange_calendar.HolidayCalendar) + + assert hasattr(c, "last_regular_trading_days_of_months") + assert isinstance(c.last_regular_trading_days_of_months, ec.exchange_calendar.HolidayCalendar) + + +@pytest.mark.isolated +def test_extended_calendar_test(): + add_test_calendar_and_apply_extensions() + import exchange_calendars as ec + import exchange_calendars_extensions as ece + + c = ec.get_calendar("TEST") + + assert isinstance(c, ece.ExtendedExchangeCalendar) + + start = pd.Timestamp("2022-01-01") + end = pd.Timestamp("2024-12-31") + + # Verify regular holidays for 2022, 2023, and 2024. + 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 + + # Verify adhoc holidays. + assert c.adhoc_holidays == [pd.Timestamp("2023-02-01")] + + # Verify special closes for 2022, 2023, and 2024. + assert len(c.special_closes) == 1 + assert len(c.special_closes[0]) == 2 + assert c.special_closes[0][0] == datetime.time(14, 0) + assert c.special_closes[0][1].holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp('2022-03-01'): 'Special Close 0', + pd.Timestamp('2023-03-01'): 'Special Close 0', + pd.Timestamp('2024-03-01'): 'Special Close 0'})).empty + + # Verify adhoc special closes. + assert c.special_closes_adhoc == [(datetime.time(14, 0), pd.DatetimeIndex([pd.Timestamp("2023-04-03")]))] + + # Verify special opens for 2022, 2023, and 2024. + assert len(c.special_opens) == 1 + assert len(c.special_opens[0]) == 2 + assert c.special_opens[0][0] == datetime.time(11, 0) + assert c.special_opens[0][1].holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp('2022-05-02'): 'Special Open 0', + pd.Timestamp('2023-05-01'): 'Special Open 0', + pd.Timestamp('2024-05-01'): 'Special Open 0'})).empty + + # Verify adhoc special opens. + assert c.special_opens_adhoc == [(datetime.time(11, 0), pd.DatetimeIndex([pd.Timestamp("2023-06-01")]))] + + # Verify additional holiday calendars. + + 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 + + assert c.special_closes_all.holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp('2022-03-01'): 'Special Close 0', + pd.Timestamp('2023-03-01'): 'Special Close 0', + pd.Timestamp('2023-04-03'): "ad-hoc special close", + pd.Timestamp('2024-03-01'): 'Special Close 0'})).empty + + assert c.special_opens_all.holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp('2022-05-02'): 'Special Open 0', + pd.Timestamp('2023-05-01'): 'Special Open 0', + pd.Timestamp('2023-06-01'): "ad-hoc special open", + pd.Timestamp('2024-05-01'): 'Special Open 0'})).empty + + assert c.weekend_days.holidays(start=pd.Timestamp('2023-01-01'), end=pd.Timestamp('2023-01-31'), return_name=True).compare(pd.Series({ + pd.Timestamp('2023-01-01'): 'weekend day', + pd.Timestamp('2023-01-07'): 'weekend day', + pd.Timestamp('2023-01-08'): 'weekend day', + pd.Timestamp('2023-01-14'): 'weekend day', + pd.Timestamp('2023-01-15'): 'weekend day', + pd.Timestamp('2023-01-21'): 'weekend day', + pd.Timestamp('2023-01-22'): 'weekend day', + pd.Timestamp('2023-01-28'): 'weekend day', + pd.Timestamp('2023-01-29'): 'weekend day'})).empty + + assert c.quarterly_expiries.holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp('2022-03-18'): 'quarterly expiry', + pd.Timestamp('2022-06-17'): 'quarterly expiry', + pd.Timestamp('2022-09-16'): 'quarterly expiry', + pd.Timestamp('2022-12-16'): 'quarterly expiry', + pd.Timestamp('2023-03-17'): 'quarterly expiry', + pd.Timestamp('2023-06-16'): 'quarterly expiry', + pd.Timestamp('2023-09-15'): 'quarterly expiry', + pd.Timestamp('2023-12-15'): 'quarterly expiry', + pd.Timestamp('2024-03-15'): 'quarterly expiry', + pd.Timestamp('2024-06-21'): 'quarterly expiry', + pd.Timestamp('2024-09-20'): 'quarterly expiry', + pd.Timestamp('2024-12-20'): 'quarterly expiry'})).empty + + assert c.monthly_expiries.holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp('2022-01-21'): 'monthly expiry', + pd.Timestamp('2022-02-18'): 'monthly expiry', + pd.Timestamp('2022-04-15'): 'monthly expiry', + pd.Timestamp('2022-05-20'): 'monthly expiry', + pd.Timestamp('2022-07-15'): 'monthly expiry', + pd.Timestamp('2022-08-19'): 'monthly expiry', + pd.Timestamp('2022-10-21'): 'monthly expiry', + pd.Timestamp('2022-11-18'): 'monthly expiry', + pd.Timestamp('2023-01-20'): 'monthly expiry', + pd.Timestamp('2023-02-17'): 'monthly expiry', + pd.Timestamp('2023-04-21'): 'monthly expiry', + pd.Timestamp('2023-05-19'): 'monthly expiry', + pd.Timestamp('2023-07-21'): 'monthly expiry', + pd.Timestamp('2023-08-18'): 'monthly expiry', + pd.Timestamp('2023-10-20'): 'monthly expiry', + pd.Timestamp('2023-11-17'): 'monthly expiry', + pd.Timestamp('2024-01-19'): 'monthly expiry', + pd.Timestamp('2024-02-16'): 'monthly expiry', + pd.Timestamp('2024-04-19'): 'monthly expiry', + pd.Timestamp('2024-05-17'): 'monthly expiry', + pd.Timestamp('2024-07-19'): 'monthly expiry', + pd.Timestamp('2024-08-16'): 'monthly expiry', + pd.Timestamp('2024-10-18'): 'monthly expiry', + pd.Timestamp('2024-11-15'): 'monthly expiry'})).empty + + assert c.last_trading_days_of_months.holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp('2022-01-31'): 'last trading day of month', + pd.Timestamp('2022-02-28'): 'last trading day of month', + pd.Timestamp('2022-03-31'): 'last trading day of month', + pd.Timestamp('2022-04-29'): 'last trading day of month', + pd.Timestamp('2022-05-31'): 'last trading day of month', + pd.Timestamp('2022-06-30'): 'last trading day of month', + pd.Timestamp('2022-07-29'): 'last trading day of month', + pd.Timestamp('2022-08-31'): 'last trading day of month', + pd.Timestamp('2022-09-30'): 'last trading day of month', + pd.Timestamp('2022-10-31'): 'last trading day of month', + pd.Timestamp('2022-11-30'): 'last trading day of month', + pd.Timestamp('2022-12-30'): 'last trading day of month', + pd.Timestamp('2023-01-31'): 'last trading day of month', + pd.Timestamp('2023-02-28'): 'last trading day of month', + pd.Timestamp('2023-03-31'): 'last trading day of month', + pd.Timestamp('2023-04-28'): 'last trading day of month', + pd.Timestamp('2023-05-31'): 'last trading day of month', + pd.Timestamp('2023-06-30'): 'last trading day of month', + pd.Timestamp('2023-07-31'): 'last trading day of month', + pd.Timestamp('2023-08-31'): 'last trading day of month', + pd.Timestamp('2023-09-29'): 'last trading day of month', + pd.Timestamp('2023-10-31'): 'last trading day of month', + pd.Timestamp('2023-11-30'): 'last trading day of month', + pd.Timestamp('2023-12-29'): 'last trading day of month', + pd.Timestamp('2024-01-31'): 'last trading day of month', + pd.Timestamp('2024-02-29'): 'last trading day of month', + pd.Timestamp('2024-03-29'): 'last trading day of month', + pd.Timestamp('2024-04-30'): 'last trading day of month', + pd.Timestamp('2024-05-31'): 'last trading day of month', + pd.Timestamp('2024-06-28'): 'last trading day of month', + pd.Timestamp('2024-07-31'): 'last trading day of month', + pd.Timestamp('2024-08-30'): 'last trading day of month', + pd.Timestamp('2024-09-30'): 'last trading day of month', + pd.Timestamp('2024-10-31'): 'last trading day of month', + pd.Timestamp('2024-11-29'): 'last trading day of month', + pd.Timestamp('2024-12-31'): 'last trading day of month'})).empty + + assert c.last_regular_trading_days_of_months.holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp('2022-01-31'): 'last regular trading day of month', + pd.Timestamp('2022-02-28'): 'last regular trading day of month', + pd.Timestamp('2022-03-31'): 'last regular trading day of month', + pd.Timestamp('2022-04-29'): 'last regular trading day of month', + pd.Timestamp('2022-05-31'): 'last regular trading day of month', + pd.Timestamp('2022-06-30'): 'last regular trading day of month', + pd.Timestamp('2022-07-29'): 'last regular trading day of month', + pd.Timestamp('2022-08-31'): 'last regular trading day of month', + pd.Timestamp('2022-09-30'): 'last regular trading day of month', + pd.Timestamp('2022-10-31'): 'last regular trading day of month', + pd.Timestamp('2022-11-30'): 'last regular trading day of month', + pd.Timestamp('2022-12-30'): 'last regular trading day of month', + pd.Timestamp('2023-01-31'): 'last regular trading day of month', + pd.Timestamp('2023-02-28'): 'last regular trading day of month', + pd.Timestamp('2023-03-31'): 'last regular trading day of month', + pd.Timestamp('2023-04-28'): 'last regular trading day of month', + pd.Timestamp('2023-05-31'): 'last regular trading day of month', + pd.Timestamp('2023-06-30'): 'last regular trading day of month', + pd.Timestamp('2023-07-31'): 'last regular trading day of month', + pd.Timestamp('2023-08-31'): 'last regular trading day of month', + pd.Timestamp('2023-09-29'): 'last regular trading day of month', + pd.Timestamp('2023-10-31'): 'last regular trading day of month', + pd.Timestamp('2023-11-30'): 'last regular trading day of month', + pd.Timestamp('2023-12-29'): 'last regular trading day of month', + pd.Timestamp('2024-01-31'): 'last regular trading day of month', + pd.Timestamp('2024-02-29'): 'last regular trading day of month', + pd.Timestamp('2024-03-29'): 'last regular trading day of month', + pd.Timestamp('2024-04-30'): 'last regular trading day of month', + pd.Timestamp('2024-05-31'): 'last regular trading day of month', + pd.Timestamp('2024-06-28'): 'last regular trading day of month', + pd.Timestamp('2024-07-31'): 'last regular trading day of month', + pd.Timestamp('2024-08-30'): 'last regular trading day of month', + pd.Timestamp('2024-09-30'): 'last regular trading day of month', + pd.Timestamp('2024-10-31'): 'last regular trading day of month', + pd.Timestamp('2024-11-29'): 'last regular trading day of month', + pd.Timestamp('2024-12-31'): 'last regular trading day of month'})).empty + + +@pytest.mark.isolated +def test_add_new_holiday(): + add_test_calendar_and_apply_extensions() + import exchange_calendars as ec + import exchange_calendars_extensions as ece + + ece.add_holiday("TEST", pd.Timestamp("2023-07-03"), "Added Holiday") + + 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_overwrite_existing_regular_holiday(): + add_test_calendar_and_apply_extensions() + import exchange_calendars as ec + import exchange_calendars_extensions as ece + + ece.add_holiday("TEST", pd.Timestamp("2023-01-01"), "Added Holiday") + + c = ec.get_calendar("TEST") + + start = pd.Timestamp("2022-01-01") + end = pd.Timestamp("2024-12-31") + + # Added holiday should overwrite existing 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"): "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"): "Added Holiday", + pd.Timestamp("2023-02-01"): "ad-hoc holiday", + pd.Timestamp("2024-01-01"): "Holiday 0"})).empty + + +@pytest.mark.isolated +def test_overwrite_existing_adhoc_holiday(): + add_test_calendar_and_apply_extensions() + import exchange_calendars as ec + import exchange_calendars_extensions as ece + + ece.add_holiday("TEST", pd.Timestamp("2023-02-01"), "Added Holiday") + + c = ec.get_calendar("TEST") + + start = pd.Timestamp("2022-01-01") + end = pd.Timestamp("2024-12-31") + + # Added holiday should be a 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-02-01"): "Added Holiday", + pd.Timestamp("2024-01-01"): "Holiday 0"})).empty + + # Overwritten ad-hoc holiday should be removed from list. + assert c.adhoc_holidays == [] + + # 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"): "Added Holiday", + pd.Timestamp("2024-01-01"): "Holiday 0"})).empty + + +@pytest.mark.isolated +def test_remove_existing_regular_holiday(): + add_test_calendar_and_apply_extensions() + import exchange_calendars as ec + import exchange_calendars_extensions as ece + + ece.remove_holiday("TEST", pd.Timestamp("2023-01-01")) + + c = ec.get_calendar("TEST") + + start = pd.Timestamp("2022-01-01") + end = pd.Timestamp("2024-12-31") + + # Removed day should no longer 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("2024-01-01"): "Holiday 0"})).empty + + # Removed holiday should not affect ad-hoc holidays. + assert c.adhoc_holidays == [pd.Timestamp("2023-02-01")] + + # Removed day should not 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-02-01"): "ad-hoc holiday", + pd.Timestamp("2024-01-01"): "Holiday 0"})).empty + + +@pytest.mark.isolated +def test_remove_existing_adhoc_holiday(): + add_test_calendar_and_apply_extensions() + import exchange_calendars as ec + import exchange_calendars_extensions as ece + + ece.remove_holiday("TEST", pd.Timestamp("2023-02-01")) + + c = ec.get_calendar("TEST") + + start = pd.Timestamp("2022-01-01") + end = pd.Timestamp("2024-12-31") + + # Regular holidays should be untouched. + 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 + + # Removed holiday should no longer be in ad-hoc holidays. + assert c.adhoc_holidays == [] + + # Removed day should not 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("2024-01-01"): "Holiday 0"})).empty + + +@pytest.mark.isolated +def test_remove_non_existent_holiday(): + add_test_calendar_and_apply_extensions() + import exchange_calendars as ec + import exchange_calendars_extensions as ece + + 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 untouched. + 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 untouched. + assert c.adhoc_holidays == [pd.Timestamp("2023-02-01")] + + # Calendar holidays_all should be untouched. + 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 + + +@pytest.mark.isolated +def test_add_and_remove_new_holiday(): + 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 + + +@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. + ece.add_holiday("TEST", pd.Timestamp("2023-01-01"), "Added Holiday") + ece.remove_holiday("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. + assert c.regular_holidays.holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-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")] + + # Day should be removed from 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-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() + import exchange_calendars as ec + 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")) + ece.add_holiday("TEST", pd.Timestamp("2023-07-03"), "Added Holiday") + + 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_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() + import exchange_calendars as ec + 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")) + ece.add_holiday("TEST", pd.Timestamp("2023-01-01"), "Added Holiday") + + c = ec.get_calendar("TEST") + + start = pd.Timestamp("2022-01-01") + end = pd.Timestamp("2024-12-31") + + # Regular holiday should be overwritten. + 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 + + # 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"): "Added Holiday", + pd.Timestamp("2023-02-01"): "ad-hoc 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() + import exchange_calendars as ec + 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")) + ece.add_holiday("TEST", pd.Timestamp("2023-02-01"), "Added Holiday") + + c = ec.get_calendar("TEST") + + start = pd.Timestamp("2022-01-01") + end = pd.Timestamp("2024-12-31") + + # Regular holiday should contain the added 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-02-01"): "Added Holiday", + pd.Timestamp("2024-01-01"): "Holiday 0"})).empty + + # Ad-hoc holidays should be empty. + assert c.adhoc_holidays == [] + + # 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"): "Added 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() + import exchange_calendars as ec + import exchange_calendars_extensions as ece + + ece.add_special_open("TEST", pd.Timestamp("2023-07-03"), time(12, 0), "Added Special Open") + + c = ec.get_calendar("TEST") + + start = pd.Timestamp("2022-01-01") + end = pd.Timestamp("2024-12-31") + + # Check number of distinct special open times. + assert len(c.special_opens) == 2 + + # Special opens for regular special open time should be unchanged. + assert c.special_opens[0][0] == time(11, 0) + assert c.special_opens[0][1].holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-05-02"): "Special Open 0", + pd.Timestamp("2023-05-01"): "Special Open 0", + pd.Timestamp("2024-05-01"): "Special Open 0"})).empty + + # There should be a new calendar for the added special open time. + assert c.special_opens[1][0] == time(12, 0) + assert c.special_opens[1][1].holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2023-07-03"): "Added Special Open"})).empty + + # Added special open should not be in ad-hoc special opens, i.e. this should be unmodified. + assert c.special_opens_adhoc == [(time(11, 00), pd.Timestamp("2023-06-01"))] + + # Added special open should be in consolidated calendar. + assert c.special_opens_all.holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-05-02"): "Special Open 0", + pd.Timestamp("2023-05-01"): "Special Open 0", + pd.Timestamp("2023-06-01"): "ad-hoc special open", + pd.Timestamp("2023-07-03"): "Added Special Open", + pd.Timestamp("2024-05-01"): "Special Open 0"})).empty + + +@pytest.mark.isolated +def test_add_new_special_open_with_existing_time(): + add_test_calendar_and_apply_extensions() + import exchange_calendars as ec + import exchange_calendars_extensions as ece + + ece.add_special_open("TEST", pd.Timestamp("2023-07-03"), time(11, 0), "Added Special Open") + + c = ec.get_calendar("TEST") + + start = pd.Timestamp("2022-01-01") + end = pd.Timestamp("2024-12-31") + + # Check number of distinct special open times. + assert len(c.special_opens) == 1 + + # Special opens for regular special open time should be unchanged. + assert c.special_opens[0][0] == time(11, 0) + assert c.special_opens[0][1].holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-05-02"): "Special Open 0", + pd.Timestamp("2023-05-01"): "Special Open 0", + pd.Timestamp("2023-07-03"): "Added Special Open", + pd.Timestamp("2024-05-01"): "Special Open 0"})).empty + + # Added special open should not be in ad-hoc special opens, i.e. this should be unmodified. + assert c.special_opens_adhoc == [(time(11, 00), pd.Timestamp("2023-06-01"))] + + # Added special open should be in consolidated calendar. + assert c.special_opens_all.holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-05-02"): "Special Open 0", + pd.Timestamp("2023-05-01"): "Special Open 0", + pd.Timestamp("2023-06-01"): "ad-hoc special open", + pd.Timestamp("2023-07-03"): "Added Special Open", + pd.Timestamp("2024-05-01"): "Special Open 0"})).empty + + +@pytest.mark.isolated +def test_overwrite_existing_regular_special_open_with_new_time(): + add_test_calendar_and_apply_extensions() + import exchange_calendars as ec + import exchange_calendars_extensions as ece + + ece.add_special_open("TEST", pd.Timestamp("2023-05-01"), time(12, 0), "Added Special Open") + + c = ec.get_calendar("TEST") + + start = pd.Timestamp("2022-01-01") + end = pd.Timestamp("2024-12-31") + + # Check number of distinct special open times. + assert len(c.special_opens) == 2 + + # Special opens for regular special open time should exclude the overwritten day. + assert c.special_opens[0][0] == time(11, 0) + assert c.special_opens[0][1].holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-05-02"): "Special Open 0", + pd.Timestamp("2024-05-01"): "Special Open 0"})).empty + + # There should be a new calendar for the added special open time. + assert c.special_opens[1][0] == time(12, 0) + assert c.special_opens[1][1].holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2023-05-01"): "Added Special Open"})).empty + + # Added special open should not be in ad-hoc special opens, i.e. this should be unmodified. + assert c.special_opens_adhoc == [(time(11, 00), pd.Timestamp("2023-06-01"))] + + # Added special open should be in consolidated calendar. + assert c.special_opens_all.holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-05-02"): "Special Open 0", + pd.Timestamp("2023-05-01"): "Added Special Open", + pd.Timestamp("2023-06-01"): "ad-hoc special open", + pd.Timestamp("2024-05-01"): "Special Open 0"})).empty + + +@pytest.mark.isolated +def test_overwrite_existing_regular_special_open_with_existing_time(): + add_test_calendar_and_apply_extensions(special_opens=[(time(11, 00), [pd.Timestamp("2023-05-01")]), (time(12, 00), [pd.Timestamp("2023-05-04")])]) + import exchange_calendars as ec + import exchange_calendars_extensions as ece + + c = ec.get_calendar("TEST") + + start = pd.Timestamp("2022-01-01") + end = pd.Timestamp("2024-12-31") + + # Check number of distinct special open times. + assert len(c.special_opens) == 2 + + # Special opens for regular special open time should exclude the overwritten day. + assert c.special_opens[0][0] == time(11, 0) + assert c.special_opens[0][1].holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-05-02"): "Special Open 0", + pd.Timestamp("2023-05-01"): "Special Open 0", + pd.Timestamp("2024-05-01"): "Special Open 0"})).empty + + assert c.special_opens[1][0] == time(12, 0) + assert c.special_opens[1][1].holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-05-04"): "Special Open 0", + pd.Timestamp("2023-05-04"): "Special Open 0", + pd.Timestamp("2024-05-06"): "Special Open 0"})).empty + + # Added special open should not be in ad-hoc special opens, i.e. this should be unmodified. + assert c.special_opens_adhoc == [(time(11, 00), pd.Timestamp("2023-06-01"))] + + # Added special open should be in consolidated calendar. + assert c.special_opens_all.holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-05-02"): "Special Open 0", + pd.Timestamp("2022-05-04"): "Special Open 0", + pd.Timestamp("2023-05-01"): "Special Open 0", + pd.Timestamp("2023-05-04"): "Special Open 0", + pd.Timestamp("2023-06-01"): "ad-hoc special open", + pd.Timestamp("2024-05-01"): "Special Open 0", + pd.Timestamp("2024-05-06"): "Special Open 0"})).empty + + ece.add_special_open("TEST", pd.Timestamp("2023-05-01"), time(12, 0), "Added Special Open") + + c = ec.get_calendar("TEST") + + # Check number of distinct special open times. + assert len(c.special_opens) == 2 + + # Special opens for regular special open time should exclude the overwritten day. + assert c.special_opens[0][0] == time(11, 0) + assert c.special_opens[0][1].holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-05-02"): "Special Open 0", + pd.Timestamp("2024-05-01"): "Special Open 0"})).empty + + assert c.special_opens[1][0] == time(12, 0) + assert c.special_opens[1][1].holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-05-04"): "Special Open 0", + pd.Timestamp("2023-05-01"): "Added Special Open", + pd.Timestamp("2023-05-04"): "Special Open 0", + pd.Timestamp("2024-05-06"): "Special Open 0"})).empty + + # Added special open should not be in ad-hoc special opens, i.e. this should be unmodified. + assert c.special_opens_adhoc == [(time(11, 00), pd.Timestamp("2023-06-01"))] + + # Added special open should be in consolidated calendar. + assert c.special_opens_all.holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-05-02"): "Special Open 0", + pd.Timestamp("2022-05-04"): "Special Open 0", + pd.Timestamp("2023-05-01"): "Added Special Open", + pd.Timestamp("2023-05-04"): "Special Open 0", + pd.Timestamp("2023-06-01"): "ad-hoc special open", + pd.Timestamp("2024-05-01"): "Special Open 0", + pd.Timestamp("2024-05-06"): "Special Open 0"})).empty + + +@pytest.mark.isolated +def test_overwrite_existing_ad_hoc_special_open_with_new_time(): + add_test_calendar_and_apply_extensions() + import exchange_calendars as ec + import exchange_calendars_extensions as ece + + ece.add_special_open("TEST", pd.Timestamp("2023-06-01"), time(12, 0), "Added Special Open") + + c = ec.get_calendar("TEST") + + start = pd.Timestamp("2022-01-01") + end = pd.Timestamp("2024-12-31") + + # Check number of distinct special open times. + assert len(c.special_opens) == 2 + + # Special opens for regular special open time should exclude the overwritten day. + assert c.special_opens[0][0] == time(11, 0) + assert c.special_opens[0][1].holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-05-02"): "Special Open 0", + pd.Timestamp("2023-05-01"): "Special Open 0", + pd.Timestamp("2024-05-01"): "Special Open 0"})).empty + + assert c.special_opens[1][0] == time(12, 0) + assert c.special_opens[1][1].holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2023-06-01"): "Added Special Open"})).empty + + # Ad-hoc special opens should now be empty. + assert c.special_opens_adhoc == [] + + # Added special open should be in consolidated calendar. + assert c.special_opens_all.holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-05-02"): "Special Open 0", + pd.Timestamp("2023-05-01"): "Special Open 0", + pd.Timestamp("2023-06-01"): "Added Special Open", + pd.Timestamp("2024-05-01"): "Special Open 0"})).empty + + +@pytest.mark.isolated +def test_overwrite_existing_ad_hoc_special_open_with_existing_time(): + add_test_calendar_and_apply_extensions() + import exchange_calendars as ec + import exchange_calendars_extensions as ece + + ece.add_special_open("TEST", pd.Timestamp("2023-06-01"), time(11, 0), "Added Special Open") + + c = ec.get_calendar("TEST") + + start = pd.Timestamp("2022-01-01") + end = pd.Timestamp("2024-12-31") + + # Check number of distinct special open times. + assert len(c.special_opens) == 1 + + # Special opens for regular special open time should exclude the overwritten day. + assert c.special_opens[0][0] == time(11, 0) + assert c.special_opens[0][1].holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-05-02"): "Special Open 0", + pd.Timestamp("2023-05-01"): "Special Open 0", + pd.Timestamp("2023-06-01"): "Added Special Open", + pd.Timestamp("2024-05-01"): "Special Open 0"})).empty + + # Ad-hoc special opens should now be empty. + assert c.special_opens_adhoc == [] + + # Added special open should be in consolidated calendar. + assert c.special_opens_all.holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-05-02"): "Special Open 0", + pd.Timestamp("2023-05-01"): "Special Open 0", + pd.Timestamp("2023-06-01"): "Added Special Open", + pd.Timestamp("2024-05-01"): "Special Open 0"})).empty + + +@pytest.mark.isolated +def test_remove_existing_regular_special_open(): + add_test_calendar_and_apply_extensions(special_opens=[(time(11, 00), [pd.Timestamp("2023-05-01")]), (time(12, 00), [pd.Timestamp("2023-05-04")])]) + import exchange_calendars as ec + import exchange_calendars_extensions as ece + + ece.remove_special_open("TEST", pd.Timestamp("2023-05-01")) + + c = ec.get_calendar("TEST") + + start = pd.Timestamp("2022-01-01") + end = pd.Timestamp("2024-12-31") + + # Check number of distinct special open times. + assert len(c.special_opens) == 2 + + # Special opens for regular special open time should exclude the overwritten day. + assert c.special_opens[0][0] == time(11, 0) + assert c.special_opens[0][1].holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-05-02"): "Special Open 0", + pd.Timestamp("2024-05-01"): "Special Open 0"})).empty + + assert c.special_opens[1][0] == time(12, 0) + assert c.special_opens[1][1].holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-05-04"): "Special Open 0", + pd.Timestamp("2023-05-04"): "Special Open 0", + pd.Timestamp("2024-05-06"): "Special Open 0"})).empty + + # Ad-hoc special opens should now be empty. + assert c.special_opens_adhoc == [(time(11, 00), pd.Timestamp("2023-06-01"))] + + # Added special open should be in consolidated calendar. + assert c.special_opens_all.holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-05-02"): "Special Open 0", + pd.Timestamp("2022-05-04"): "Special Open 0", + pd.Timestamp("2023-05-04"): "Special Open 0", + pd.Timestamp("2023-06-01"): "ad-hoc special open", + pd.Timestamp("2024-05-01"): "Special Open 0", + pd.Timestamp("2024-05-06"): "Special Open 0"})).empty + + +@pytest.mark.isolated +def test_remove_existing_ad_hoc_special_open(): + add_test_calendar_and_apply_extensions() + import exchange_calendars as ec + import exchange_calendars_extensions as ece + + ece.remove_special_open("TEST", pd.Timestamp("2023-06-01")) + + c = ec.get_calendar("TEST") + + start = pd.Timestamp("2022-01-01") + end = pd.Timestamp("2024-12-31") + + # Check number of distinct special open times. + assert len(c.special_opens) == 1 + + # Special opens for regular special open time should exclude the overwritten day. + assert c.special_opens[0][0] == time(11, 0) + assert c.special_opens[0][1].holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-05-02"): "Special Open 0", + pd.Timestamp("2023-05-01"): "Special Open 0", + pd.Timestamp("2024-05-01"): "Special Open 0"})).empty + + # Ad-hoc special opens should now be empty. + assert c.special_opens_adhoc == [] + + # Added special open should be in consolidated calendar. + assert c.special_opens_all.holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-05-02"): "Special Open 0", + pd.Timestamp("2023-05-01"): "Special Open 0", + pd.Timestamp("2024-05-01"): "Special Open 0"})).empty + + +@pytest.mark.isolated +def test_remove_non_existent_special_open(): + add_test_calendar_and_apply_extensions() + import exchange_calendars as ec + import exchange_calendars_extensions as ece + + ece.remove_special_open("TEST", pd.Timestamp("2023-07-03")) + + c = ec.get_calendar("TEST") + + start = pd.Timestamp("2022-01-01") + end = pd.Timestamp("2024-12-31") + + # Check number of distinct special open times. + assert len(c.special_opens) == 1 + + # Special opens for regular special open time should exclude the overwritten day. + assert c.special_opens[0][0] == time(11, 0) + assert c.special_opens[0][1].holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-05-02"): "Special Open 0", + pd.Timestamp("2023-05-01"): "Special Open 0", + pd.Timestamp("2024-05-01"): "Special Open 0"})).empty + + # Ad-hoc special opens should now be empty. + assert c.special_opens_adhoc == [(time(11, 00), pd.Timestamp("2023-06-01"))] + + # Added special open should be in consolidated calendar. + assert c.special_opens_all.holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-05-02"): "Special Open 0", + pd.Timestamp("2023-05-01"): "Special Open 0", + pd.Timestamp("2023-06-01"): "ad-hoc special open", + pd.Timestamp("2024-05-01"): "Special Open 0"})).empty + + +@pytest.mark.isolated +def test_add_new_special_close_with_new_time(): + add_test_calendar_and_apply_extensions() + import exchange_calendars as ec + import exchange_calendars_extensions as ece + + ece.add_special_close("TEST", pd.Timestamp("2023-07-03"), time(15, 0), "Added Special Close") + + c = ec.get_calendar("TEST") + + start = pd.Timestamp("2022-01-01") + end = pd.Timestamp("2024-12-31") + + # Check number of distinct special close times. + assert len(c.special_closes) == 2 + + # Special closes for regular special close time should be unchanged. + assert c.special_closes[0][0] == time(14, 0) + assert c.special_closes[0][1].holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-03-01"): "Special Close 0", + pd.Timestamp("2023-03-01"): "Special Close 0", + pd.Timestamp("2024-03-01"): "Special Close 0"})).empty + + # There should be a new calendar for the added special close time. + assert c.special_closes[1][0] == time(15, 0) + assert c.special_closes[1][1].holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2023-07-03"): "Added Special Close"})).empty + + # Added special close should not be in ad-hoc special closes, i.e. this should be unmodified. + assert c.special_closes_adhoc == [(time(14, 00), pd.Timestamp("2023-04-03"))] + + # Added special close should be in consolidated calendar. + assert c.special_closes_all.holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-03-01"): "Special Close 0", + pd.Timestamp("2023-03-01"): "Special Close 0", + pd.Timestamp("2023-04-03"): "ad-hoc special close", + pd.Timestamp("2023-07-03"): "Added Special Close", + pd.Timestamp("2024-03-01"): "Special Close 0"})).empty + + +@pytest.mark.isolated +def test_add_new_special_close_with_existing_time(): + add_test_calendar_and_apply_extensions() + import exchange_calendars as ec + import exchange_calendars_extensions as ece + + ece.add_special_close("TEST", pd.Timestamp("2023-07-03"), time(14, 0), "Added Special Close") + + c = ec.get_calendar("TEST") + + start = pd.Timestamp("2022-01-01") + end = pd.Timestamp("2024-12-31") + + # Check number of distinct special close times. + assert len(c.special_closes) == 1 + + # Special Closes for regular special close time should be unchanged. + assert c.special_closes[0][0] == time(14, 0) + assert c.special_closes[0][1].holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-03-01"): "Special Close 0", + pd.Timestamp("2023-03-01"): "Special Close 0", + pd.Timestamp("2023-07-03"): "Added Special Close", + pd.Timestamp("2024-03-01"): "Special Close 0"})).empty + + # Added special close should not be in ad-hoc special closes, i.e. this should be unmodified. + assert c.special_closes_adhoc == [(time(14, 0), pd.Timestamp("2023-04-03"))] + + # Added special close should be in consolidated calendar. + assert c.special_closes_all.holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-03-01"): "Special Close 0", + pd.Timestamp("2023-03-01"): "Special Close 0", + pd.Timestamp("2023-04-03"): "ad-hoc special close", + pd.Timestamp("2023-07-03"): "Added Special Close", + pd.Timestamp("2024-03-01"): "Special Close 0"})).empty + + +@pytest.mark.isolated +def test_overwrite_existing_regular_special_close_with_new_time(): + add_test_calendar_and_apply_extensions() + import exchange_calendars as ec + import exchange_calendars_extensions as ece + + ece.add_special_close("TEST", pd.Timestamp("2023-03-01"), time(15, 0), "Added Special Close") + + c = ec.get_calendar("TEST") + + start = pd.Timestamp("2022-01-01") + end = pd.Timestamp("2024-12-31") + + # Check number of distinct special close times. + assert len(c.special_closes) == 2 + + # Special Closes for regular special close time should exclude the overwritten day. + assert c.special_closes[0][0] == time(14, 0) + assert c.special_closes[0][1].holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-03-01"): "Special Close 0", + pd.Timestamp("2024-03-01"): "Special Close 0"})).empty + + # There should be a new calendar for the added special close time. + assert c.special_closes[1][0] == time(15, 0) + assert c.special_closes[1][1].holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2023-03-01"): "Added Special Close"})).empty + + # Added special close should not be in ad-hoc special closes, i.e. this should be unmodified. + assert c.special_closes_adhoc == [(time(14, 00), pd.Timestamp("2023-04-03"))] + + # Added special close should be in consolidated calendar. + assert c.special_closes_all.holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-03-01"): "Special Close 0", + pd.Timestamp("2023-03-01"): "Added Special Close", + pd.Timestamp("2023-04-03"): "ad-hoc special close", + pd.Timestamp("2024-03-01"): "Special Close 0"})).empty + + +@pytest.mark.isolated +def test_overwrite_existing_regular_special_close_with_existing_time(): + add_test_calendar_and_apply_extensions(special_closes=[(time(14, 00), [pd.Timestamp("2023-03-01")]), (time(15, 00), [pd.Timestamp("2023-03-04")])]) + import exchange_calendars as ec + import exchange_calendars_extensions as ece + + c = ec.get_calendar("TEST") + + start = pd.Timestamp("2022-01-01") + end = pd.Timestamp("2024-12-31") + + # Check number of distinct special close times. + assert len(c.special_closes) == 2 + + # Special Closes for regular special close time should exclude the overwritten day. + assert c.special_closes[0][0] == time(14, 0) + assert c.special_closes[0][1].holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-03-01"): "Special Close 0", + pd.Timestamp("2023-03-01"): "Special Close 0", + pd.Timestamp("2024-03-01"): "Special Close 0"})).empty + + assert c.special_closes[1][0] == time(15, 0) + assert c.special_closes[1][1].holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-03-04"): "Special Close 0", + pd.Timestamp("2023-03-06"): "Special Close 0", + pd.Timestamp("2024-03-04"): "Special Close 0"})).empty + + # Added special close should not be in ad-hoc special closes, i.e. this should be unmodified. + assert c.special_closes_adhoc == [(time(14, 00), pd.Timestamp("2023-04-03"))] + + # Added special close should be in consolidated calendar. + assert c.special_closes_all.holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-03-01"): "Special Close 0", + pd.Timestamp("2022-03-04"): "Special Close 0", + pd.Timestamp("2023-03-01"): "Special Close 0", + pd.Timestamp("2023-03-06"): "Special Close 0", + pd.Timestamp("2023-04-03"): "ad-hoc special close", + pd.Timestamp("2024-03-01"): "Special Close 0", + pd.Timestamp("2024-03-04"): "Special Close 0"})).empty + + ece.add_special_close("TEST", pd.Timestamp("2023-03-01"), time(15, 0), "Added Special Close") + + c = ec.get_calendar("TEST") + + # Check number of distinct special close times. + assert len(c.special_closes) == 2 + + # Special Closes for regular special close time should exclude the overwritten day. + assert c.special_closes[0][0] == time(14, 0) + assert c.special_closes[0][1].holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-03-01"): "Special Close 0", + pd.Timestamp("2024-03-01"): "Special Close 0"})).empty + + assert c.special_closes[1][0] == time(15, 0) + assert c.special_closes[1][1].holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-03-04"): "Special Close 0", + pd.Timestamp("2023-03-01"): "Added Special Close", + pd.Timestamp("2023-03-06"): "Special Close 0", + pd.Timestamp("2024-03-04"): "Special Close 0"})).empty + + # Added special close should not be in ad-hoc special closes, i.e. this should be unmodified. + assert c.special_closes_adhoc == [(time(14, 00), pd.Timestamp("2023-04-03"))] + + # Added special close should be in consolidated calendar. + assert c.special_closes_all.holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-03-01"): "Special Close 0", + pd.Timestamp("2022-03-04"): "Special Close 0", + pd.Timestamp("2023-03-01"): "Added Special Close", + pd.Timestamp("2023-03-06"): "Special Close 0", + pd.Timestamp("2023-04-03"): "ad-hoc special close", + pd.Timestamp("2024-03-01"): "Special Close 0", + pd.Timestamp("2024-03-04"): "Special Close 0"})).empty + + +@pytest.mark.isolated +def test_overwrite_existing_ad_hoc_special_close_with_new_time(): + add_test_calendar_and_apply_extensions() + import exchange_calendars as ec + import exchange_calendars_extensions as ece + + ece.add_special_close("TEST", pd.Timestamp("2023-04-03"), time(15, 0), "Added Special Close") + + c = ec.get_calendar("TEST") + + start = pd.Timestamp("2022-01-01") + end = pd.Timestamp("2024-12-31") + + # Check number of distinct special close times. + assert len(c.special_closes) == 2 + + # Special Closes for regular special close time should exclude the overwritten day. + assert c.special_closes[0][0] == time(14, 0) + assert c.special_closes[0][1].holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-03-01"): "Special Close 0", + pd.Timestamp("2023-03-01"): "Special Close 0", + pd.Timestamp("2024-03-01"): "Special Close 0"})).empty + + assert c.special_closes[1][0] == time(15, 0) + assert c.special_closes[1][1].holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2023-04-03"): "Added Special Close"})).empty + + # Ad-hoc special closes should now be empty. + assert c.special_closes_adhoc == [] + + # Added special close should be in consolidated calendar. + assert c.special_closes_all.holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-03-01"): "Special Close 0", + pd.Timestamp("2023-03-01"): "Special Close 0", + pd.Timestamp("2023-04-03"): "Added Special Close", + pd.Timestamp("2024-03-01"): "Special Close 0"})).empty + + +@pytest.mark.isolated +def test_overwrite_existing_ad_hoc_special_close_with_existing_time(): + add_test_calendar_and_apply_extensions() + import exchange_calendars as ec + import exchange_calendars_extensions as ece + + ece.add_special_close("TEST", pd.Timestamp("2023-04-03"), time(14, 0), "Added Special Close") + + c = ec.get_calendar("TEST") + + start = pd.Timestamp("2022-01-01") + end = pd.Timestamp("2024-12-31") + + # Check number of distinct special close times. + assert len(c.special_closes) == 1 + + # Special Closes for regular special close time should exclude the overwritten day. + assert c.special_closes[0][0] == time(14, 0) + assert c.special_closes[0][1].holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-03-01"): "Special Close 0", + pd.Timestamp("2023-03-01"): "Special Close 0", + pd.Timestamp("2023-04-03"): "Added Special Close", + pd.Timestamp("2024-03-01"): "Special Close 0"})).empty + + # Ad-hoc special closes should now be empty. + assert c.special_closes_adhoc == [] + + # Added special close should be in consolidated calendar. + assert c.special_closes_all.holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-03-01"): "Special Close 0", + pd.Timestamp("2023-03-01"): "Special Close 0", + pd.Timestamp("2023-04-03"): "Added Special Close", + pd.Timestamp("2024-03-01"): "Special Close 0"})).empty + + +@pytest.mark.isolated +def test_remove_existing_regular_special_close(): + add_test_calendar_and_apply_extensions(special_closes=[(time(14, 00), [pd.Timestamp("2023-03-01")]), (time(15, 00), [pd.Timestamp("2023-03-04")])]) + import exchange_calendars as ec + import exchange_calendars_extensions as ece + + ece.remove_special_close("TEST", pd.Timestamp("2023-03-01")) + + c = ec.get_calendar("TEST") + + start = pd.Timestamp("2022-01-01") + end = pd.Timestamp("2024-12-31") + + # Check number of distinct special close times. + assert len(c.special_closes) == 2 + + # Special Closes for regular special close time should exclude the overwritten day. + assert c.special_closes[0][0] == time(14, 0) + assert c.special_closes[0][1].holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-03-01"): "Special Close 0", + pd.Timestamp("2024-03-01"): "Special Close 0"})).empty + + assert c.special_closes[1][0] == time(15, 0) + assert c.special_closes[1][1].holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-03-04"): "Special Close 0", + pd.Timestamp("2023-03-06"): "Special Close 0", + pd.Timestamp("2024-03-04"): "Special Close 0"})).empty + + # Ad-hoc special closes should now be empty. + assert c.special_closes_adhoc == [(time(14, 00), pd.Timestamp("2023-04-03"))] + + # Added special close should be in consolidated calendar. + assert c.special_closes_all.holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-03-01"): "Special Close 0", + pd.Timestamp("2022-03-04"): "Special Close 0", + pd.Timestamp("2023-03-06"): "Special Close 0", + pd.Timestamp("2023-04-03"): "ad-hoc special close", + pd.Timestamp("2024-03-01"): "Special Close 0", + pd.Timestamp("2024-03-04"): "Special Close 0"})).empty + + +@pytest.mark.isolated +def test_remove_existing_ad_hoc_special_close(): + add_test_calendar_and_apply_extensions() + import exchange_calendars as ec + import exchange_calendars_extensions as ece + + ece.remove_special_close("TEST", pd.Timestamp("2023-04-03")) + + c = ec.get_calendar("TEST") + + start = pd.Timestamp("2022-01-01") + end = pd.Timestamp("2024-12-31") + + # Check number of distinct special close times. + assert len(c.special_closes) == 1 + + # Special Closes for regular special close time should exclude the overwritten day. + assert c.special_closes[0][0] == time(14, 0) + assert c.special_closes[0][1].holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-03-01"): "Special Close 0", + pd.Timestamp("2023-03-01"): "Special Close 0", + pd.Timestamp("2024-03-01"): "Special Close 0"})).empty + + # Ad-hoc special closes should now be empty. + assert c.special_closes_adhoc == [] + + # Added special close should be in consolidated calendar. + assert c.special_closes_all.holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-03-01"): "Special Close 0", + pd.Timestamp("2023-03-01"): "Special Close 0", + pd.Timestamp("2024-03-01"): "Special Close 0"})).empty + + +@pytest.mark.isolated +def test_remove_non_existent_special_close(): + add_test_calendar_and_apply_extensions() + import exchange_calendars as ec + import exchange_calendars_extensions as ece + + ece.remove_special_close("TEST", pd.Timestamp("2023-07-03")) + + c = ec.get_calendar("TEST") + + start = pd.Timestamp("2022-01-01") + end = pd.Timestamp("2024-12-31") + + # Check number of distinct special close times. + assert len(c.special_closes) == 1 + + # Special Closes for regular special close time should exclude the overwritten day. + assert c.special_closes[0][0] == time(14, 0) + assert c.special_closes[0][1].holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-03-01"): "Special Close 0", + pd.Timestamp("2023-03-01"): "Special Close 0", + pd.Timestamp("2024-03-01"): "Special Close 0"})).empty + + # Ad-hoc special closes should now be empty. + assert c.special_closes_adhoc == [(time(14, 00), pd.Timestamp("2023-04-03"))] + + # Added special close should be in consolidated calendar. + assert c.special_closes_all.holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-03-01"): "Special Close 0", + pd.Timestamp("2023-03-01"): "Special Close 0", + pd.Timestamp("2023-04-03"): "ad-hoc special close", + pd.Timestamp("2024-03-01"): "Special Close 0"})).empty + + +@pytest.mark.isolated +def test_add_quarterly_expiry(): + add_test_calendar_and_apply_extensions() + import exchange_calendars as ec + import exchange_calendars_extensions as ece + + # Add quarterly expiry. + ece.add_quarterly_expiry("TEST", pd.Timestamp("2023-06-15"), "Added Quarterly Expiry") + + c = ec.get_calendar("TEST") + + start = pd.Timestamp("2022-01-01") + end = pd.Timestamp("2024-12-31") + + # Quarterly expiry dates should be empty. + assert c.quarterly_expiries.holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-03-18"): "quarterly expiry", + pd.Timestamp("2022-06-17"): "quarterly expiry", + pd.Timestamp("2022-09-16"): "quarterly expiry", + pd.Timestamp("2022-12-16"): "quarterly expiry", + pd.Timestamp("2023-03-17"): "quarterly expiry", + pd.Timestamp("2023-06-15"): "Added Quarterly Expiry", + pd.Timestamp("2023-06-16"): "quarterly expiry", + pd.Timestamp("2023-09-15"): "quarterly expiry", + pd.Timestamp("2023-12-15"): "quarterly expiry", + pd.Timestamp("2024-03-15"): "quarterly expiry", + pd.Timestamp("2024-06-21"): "quarterly expiry", + pd.Timestamp("2024-09-20"): "quarterly expiry", + pd.Timestamp("2024-12-20"): "quarterly expiry"})).empty + + +@pytest.mark.isolated +def test_remove_quarterly_expiry(): + add_test_calendar_and_apply_extensions() + import exchange_calendars as ec + import exchange_calendars_extensions as ece + + # Add quarterly expiry. + ece.remove_quarterly_expiry("TEST", pd.Timestamp("2023-06-16")) + + c = ec.get_calendar("TEST") + + start = pd.Timestamp("2022-01-01") + end = pd.Timestamp("2024-12-31") + + # Quarterly expiry dates should be empty. + assert c.quarterly_expiries.holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-03-18"): "quarterly expiry", + pd.Timestamp("2022-06-17"): "quarterly expiry", + pd.Timestamp("2022-09-16"): "quarterly expiry", + pd.Timestamp("2022-12-16"): "quarterly expiry", + pd.Timestamp("2023-03-17"): "quarterly expiry", + pd.Timestamp("2023-09-15"): "quarterly expiry", + pd.Timestamp("2023-12-15"): "quarterly expiry", + pd.Timestamp("2024-03-15"): "quarterly expiry", + pd.Timestamp("2024-06-21"): "quarterly expiry", + pd.Timestamp("2024-09-20"): "quarterly expiry", + pd.Timestamp("2024-12-20"): "quarterly expiry"})).empty + + +@pytest.mark.isolated +def test_add_monthly_expiry(): + add_test_calendar_and_apply_extensions() + import exchange_calendars as ec + import exchange_calendars_extensions as ece + + # Add quarterly expiry. + ece.add_monthly_expiry("TEST", pd.Timestamp("2023-01-19"), "Added Monthly Expiry") + + c = ec.get_calendar("TEST") + + start = pd.Timestamp("2022-01-01") + end = pd.Timestamp("2024-12-31") + + # Quarterly expiry dates should be empty. + assert c.monthly_expiries.holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-01-21"): "monthly expiry", + pd.Timestamp("2022-02-18"): "monthly expiry", + pd.Timestamp("2022-04-15"): "monthly expiry", + pd.Timestamp("2022-05-20"): "monthly expiry", + pd.Timestamp("2022-07-15"): "monthly expiry", + pd.Timestamp("2022-08-19"): "monthly expiry", + pd.Timestamp("2022-10-21"): "monthly expiry", + pd.Timestamp("2022-11-18"): "monthly expiry", + pd.Timestamp("2023-01-19"): "Added Monthly Expiry", + pd.Timestamp("2023-01-20"): "monthly expiry", + pd.Timestamp("2023-02-17"): "monthly expiry", + pd.Timestamp("2023-04-21"): "monthly expiry", + pd.Timestamp("2023-05-19"): "monthly expiry", + pd.Timestamp("2023-07-21"): "monthly expiry", + pd.Timestamp("2023-08-18"): "monthly expiry", + pd.Timestamp("2023-10-20"): "monthly expiry", + pd.Timestamp("2023-11-17"): "monthly expiry", + pd.Timestamp("2024-01-19"): "monthly expiry", + pd.Timestamp("2024-02-16"): "monthly expiry", + pd.Timestamp("2024-04-19"): "monthly expiry", + pd.Timestamp("2024-05-17"): "monthly expiry", + pd.Timestamp("2024-07-19"): "monthly expiry", + pd.Timestamp("2024-08-16"): "monthly expiry", + pd.Timestamp("2024-10-18"): "monthly expiry", + pd.Timestamp("2024-11-15"): "monthly expiry"})).empty + + +@pytest.mark.isolated +def test_overwrite_regular_holiday_with_special_open(): + add_test_calendar_and_apply_extensions(holidays=[pd.Timestamp("2023-01-02")]) + import exchange_calendars as ec + import exchange_calendars_extensions as ece + + ece.add_special_open("TEST", pd.Timestamp("2023-01-02"), time(11, 0), "Added Special Open") + + c = ec.get_calendar("TEST") + + start = pd.Timestamp("2022-01-01") + end = pd.Timestamp("2024-12-31") + + # Overwritten holiday should no longer be in regular holidays. + assert c.regular_holidays.holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-01-02"): "Holiday 0", + pd.Timestamp("2024-01-02"): "Holiday 0"})).empty + + # Ad-hoc holidays should be unmodified. + assert c.adhoc_holidays == [pd.Timestamp("2023-02-01")] + + # Overwritten holiday should no longer be in holidays_all calendar. + assert c.holidays_all.holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-01-02"): "Holiday 0", + pd.Timestamp("2023-02-01"): "ad-hoc holiday", + pd.Timestamp("2024-01-02"): "Holiday 0"})).empty + + # Check number of distinct special open times. + assert len(c.special_opens) == 1 + + # Added special open should be in special opens for regular time. + assert c.special_opens[0][0] == time(11, 0) + assert c.special_opens[0][1].holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-05-02"): "Special Open 0", + pd.Timestamp("2023-01-02"): "Added Special Open", + pd.Timestamp("2023-05-01"): "Special Open 0", + pd.Timestamp("2024-05-01"): "Special Open 0"})).empty + + # Ad-hoc special opens should be unmodified. + assert c.special_opens_adhoc == [(time(11, 00), pd.Timestamp("2023-06-01"))] + + # Added special open should be in consolidated calendar. + assert c.special_opens_all.holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-05-02"): "Special Open 0", + pd.Timestamp("2023-01-02"): "Added Special Open", + pd.Timestamp("2023-05-01"): "Special Open 0", + pd.Timestamp("2023-06-01"): "ad-hoc special open", + pd.Timestamp("2024-05-01"): "Special Open 0"})).empty + + +@pytest.mark.isolated +def test_apply_changeset(): + add_test_calendar_and_apply_extensions() + import exchange_calendars as ec + import exchange_calendars_extensions as ece + + changes = { + "holiday": {"add": [{"date": "2023-01-02", "value": {"name": "Inserted Holiday"}}], "remove": ["2023-01-01"]}, + "special_open": {"add": [{"date": "2023-05-02", "value": {"name": "Inserted Special Open", "time": "11:00"}}], "remove": ["2023-05-01"]}, + "special_close": {"add": [{"date": "2023-03-02", "value": {"name": "Inserted Special Close", "time": "14:00"}}], "remove": ["2023-03-01"]}, + "monthly_expiry": {"add": [{"date": "2023-08-17", "value": {"name": "Inserted Monthly Expiry"}}], "remove": ["2023-08-18"]}, + "quarterly_expiry": {"add": [{"date": "2023-09-14", "value": {"name": "Inserted Quarterly Expiry"}}], "remove": ["2023-09-15"]}, + } + ece.update_calendar("TEST", changes) + c = ec.get_calendar("TEST") + + assert isinstance(c, ece.ExtendedExchangeCalendar) + + start = pd.Timestamp("2022-01-01") + end = pd.Timestamp("2024-12-31") + + # Verify regular holidays for 2022, 2023, and 2024. + assert c.regular_holidays.holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-01-01"): "Holiday 0", + # removed: pd.Timestamp("2023-01-01"): "Holiday 0", + pd.Timestamp("2023-01-02"): "Inserted Holiday", + pd.Timestamp("2024-01-01"): "Holiday 0"})).empty + + # Verify adhoc holidays. + assert c.adhoc_holidays == [pd.Timestamp("2023-02-01")] + + # Verify special closes for 2022, 2023, and 2024. + assert len(c.special_closes) == 1 + assert len(c.special_closes[0]) == 2 + assert c.special_closes[0][0] == datetime.time(14, 0) + assert c.special_closes[0][1].holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp('2022-03-01'): 'Special Close 0', + # removed: pd.Timestamp('2023-03-01'): 'Special Close 0', + pd.Timestamp('2023-03-02'): 'Inserted Special Close', + pd.Timestamp('2024-03-01'): 'Special Close 0'})).empty + + # Verify adhoc special closes. + assert c.special_closes_adhoc == [(datetime.time(14, 0), pd.DatetimeIndex([pd.Timestamp("2023-04-03")]))] + + # Verify special opens for 2022, 2023, and 2024. + assert len(c.special_opens) == 1 + assert len(c.special_opens[0]) == 2 + assert c.special_opens[0][0] == datetime.time(11, 0) + assert c.special_opens[0][1].holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp('2022-05-02'): 'Special Open 0', + # removed pd.Timestamp('2023-05-01'): 'Special Open 0', + pd.Timestamp('2023-05-02'): 'Inserted Special Open', + pd.Timestamp('2024-05-01'): 'Special Open 0'})).empty + + # Verify adhoc special opens. + assert c.special_opens_adhoc == [(datetime.time(11, 0), pd.DatetimeIndex([pd.Timestamp("2023-06-01")]))] + + # Verify additional holiday calendars. + + assert c.holidays_all.holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp("2022-01-01"): "Holiday 0", + # removed: pd.Timestamp("2023-01-01"): "Holiday 0", + pd.Timestamp("2023-01-02"): "Inserted Holiday", + pd.Timestamp("2023-02-01"): "ad-hoc holiday", + pd.Timestamp("2024-01-01"): "Holiday 0"})).empty + + assert c.special_closes_all.holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp('2022-03-01'): 'Special Close 0', + # removed: pd.Timestamp('2023-03-01'): 'Special Close 0', + pd.Timestamp('2023-03-02'): 'Inserted Special Close', + pd.Timestamp('2023-04-03'): "ad-hoc special close", + pd.Timestamp('2024-03-01'): 'Special Close 0'})).empty + + assert c.special_opens_all.holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp('2022-05-02'): 'Special Open 0', + # removed: pd.Timestamp('2023-05-01'): 'Special Open 0', + pd.Timestamp('2023-05-02'): 'Inserted Special Open', + pd.Timestamp('2023-06-01'): "ad-hoc special open", + pd.Timestamp('2024-05-01'): 'Special Open 0'})).empty + + assert c.quarterly_expiries.holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp('2022-03-18'): 'quarterly expiry', + pd.Timestamp('2022-06-17'): 'quarterly expiry', + pd.Timestamp('2022-09-16'): 'quarterly expiry', + pd.Timestamp('2022-12-16'): 'quarterly expiry', + pd.Timestamp('2023-03-17'): 'quarterly expiry', + pd.Timestamp('2023-06-16'): 'quarterly expiry', + pd.Timestamp('2023-09-14'): 'Inserted Quarterly Expiry', + # removed: pd.Timestamp('2023-09-15'): 'quarterly expiry', + pd.Timestamp('2023-12-15'): 'quarterly expiry', + pd.Timestamp('2024-03-15'): 'quarterly expiry', + pd.Timestamp('2024-06-21'): 'quarterly expiry', + pd.Timestamp('2024-09-20'): 'quarterly expiry', + pd.Timestamp('2024-12-20'): 'quarterly expiry'})).empty + + assert c.monthly_expiries.holidays(start=start, end=end, return_name=True).compare(pd.Series({ + pd.Timestamp('2022-01-21'): 'monthly expiry', + pd.Timestamp('2022-02-18'): 'monthly expiry', + pd.Timestamp('2022-04-15'): 'monthly expiry', + pd.Timestamp('2022-05-20'): 'monthly expiry', + pd.Timestamp('2022-07-15'): 'monthly expiry', + pd.Timestamp('2022-08-19'): 'monthly expiry', + pd.Timestamp('2022-10-21'): 'monthly expiry', + pd.Timestamp('2022-11-18'): 'monthly expiry', + pd.Timestamp('2023-01-20'): 'monthly expiry', + pd.Timestamp('2023-02-17'): 'monthly expiry', + pd.Timestamp('2023-04-21'): 'monthly expiry', + pd.Timestamp('2023-05-19'): 'monthly expiry', + pd.Timestamp('2023-07-21'): 'monthly expiry', + pd.Timestamp('2023-08-17'): 'Inserted Monthly Expiry', + # removed: pd.Timestamp('2023-08-18'): 'monthly expiry', + pd.Timestamp('2023-10-20'): 'monthly expiry', + pd.Timestamp('2023-11-17'): 'monthly expiry', + pd.Timestamp('2024-01-19'): 'monthly expiry', + pd.Timestamp('2024-02-16'): 'monthly expiry', + pd.Timestamp('2024-04-19'): 'monthly expiry', + pd.Timestamp('2024-05-17'): 'monthly expiry', + pd.Timestamp('2024-07-19'): 'monthly expiry', + pd.Timestamp('2024-08-16'): 'monthly expiry', + pd.Timestamp('2024-10-18'): 'monthly expiry', + pd.Timestamp('2024-11-15'): 'monthly expiry'})).empty diff --git a/tests/test_observance.py b/tests/test_observance.py index db87905..19081b3 100644 --- a/tests/test_observance.py +++ b/tests/test_observance.py @@ -1,8 +1,8 @@ -from exchange_calendars_extensions.observance import get_roll_backward_observance +import pandas as pd from exchange_calendars.exchange_calendar import HolidayCalendar from exchange_calendars.pandas_extensions.holiday import Holiday -import pandas as pd +from exchange_calendars_extensions.observance import get_roll_backward_observance def test_get_roll_backward_observance(): diff --git a/tests/test_util.py b/tests/test_util.py index ae438ca..cfba191 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,7 +1,8 @@ -import pytest import pandas as pd +import pytest -from exchange_calendars_extensions.util import get_day_of_week_name, get_month_name, third_day_of_week_in_month, last_day_in_month +from exchange_calendars_extensions.util import get_day_of_week_name, get_month_name, third_day_of_week_in_month, \ + last_day_in_month def test_get_month_name():