From f2ab02f6858109dfbd4f6ffe36527ba6c78b8631 Mon Sep 17 00:00:00 2001 From: Marcus Read Date: Tue, 21 Jun 2022 17:25:15 +0100 Subject: [PATCH] Add `ExchangeCalendar.is_open_at_time` Adds new `is_open_at_time` calendar method, adds tests and adds example to calendar_methods.ipynb Also - corrects `minutes.ipynb` text that identifies which session break bounds are considered as trading minutes for a given side. --- README.md | 3 +- docs/tutorials/calendar_methods.ipynb | 296 +++++++++++++++++++++--- docs/tutorials/minutes.ipynb | 8 +- exchange_calendars/exchange_calendar.py | 98 +++++++- tests/test_exchange_calendar.py | 105 +++++++++ 5 files changed, 473 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 5638689f..46d9dfc6 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,9 @@ Version 4.0 completes the transition to a more consistent interface across the p * The following schedule columns were renamed: * 'market_open' renamed as 'open'. * 'market_close' renamed as 'close'. -* Default calendar 'side' for all calendars is now "left" (previously "right" for 24-hour calendars and "both" for all others). +* Default calendar 'side' for all calendars is now "left" (previously "right" for 24-hour calendars and "both" for all others). This **changes the minutes that are considered trading minutes by default** (see [minutes tutorial](docs/tutorials/minutes.ipynb) for an explanation of trading minutes). * The 'count' parameter of `sessions_window` and `minutes_window` methods now reflects the window length (previously window length + 1). +* New `is_open_at_time` calendar method to evaluate if an exchange is open as at a specific instance (as opposed to over an evaluated minute). * The minimum Python version supported is now 3.8 (previously 3.7). * Parameters have been renamed for some methods (list [here](#Methods-with-a-parameter-renamed-in-40)) * The following methods have been deprecated: diff --git a/docs/tutorials/calendar_methods.ipynb b/docs/tutorials/calendar_methods.ipynb index c8e43410..f0111810 100644 --- a/docs/tutorials/calendar_methods.ipynb +++ b/docs/tutorials/calendar_methods.ipynb @@ -14,6 +14,7 @@ "* [Methods that query a Minute](#Methods-that-query-a-Minute) \n", "* [Methods that query multiple TradingMinute](#Methods-that-query-multiple-TradingMinute) \n", "* [Methods that query a range of dates](#Methods-that-query-a-range-of-dates)\n", + "* [Methods that query a time](#Methods-that-query-a-time)\n", "\n", "The following sections cover methods that evaluate an index of trading minutes or sessions:\n", "* [Methods that evaluate an index of contiguous trading minutes](#Methods-that-evaluate-an-index-of-contiguous-trading-minutes)\n", @@ -2488,11 +2489,11 @@ { "data": { "text/plain": [ - "2022-08-24 2022-08-24 13:35:00+00:00\n", - "2022-08-25 2022-08-25 13:35:00+00:00\n", - "2022-08-26 2022-08-26 13:35:00+00:00\n", - "2022-08-29 2022-08-29 13:35:00+00:00\n", - "2022-08-30 2022-08-30 13:35:00+00:00\n", + "2022-09-02 2022-09-02 13:35:00+00:00\n", + "2022-09-06 2022-09-06 13:35:00+00:00\n", + "2022-09-07 2022-09-07 13:35:00+00:00\n", + "2022-09-08 2022-09-08 13:35:00+00:00\n", + "2022-09-09 2022-09-09 13:35:00+00:00\n", "Freq: C, Name: first_minutes, dtype: datetime64[ns, UTC]" ] }, @@ -2515,8 +2516,8 @@ { "data": { "text/plain": [ - "DatetimeIndex(['2022-08-24 13:35:00+00:00', '2022-08-29 13:35:00+00:00',\n", - " '2022-08-30 13:35:00+00:00'],\n", + "DatetimeIndex(['2022-09-02 13:35:00+00:00', '2022-09-08 13:35:00+00:00',\n", + " '2022-09-09 13:35:00+00:00'],\n", " dtype='datetime64[ns, UTC]', name='first_minutes', freq=None)" ] }, @@ -2540,7 +2541,7 @@ { "data": { "text/plain": [ - "DatetimeIndex(['2022-08-24', '2022-08-29', '2022-08-30'], dtype='datetime64[ns]', freq=None)" + "DatetimeIndex(['2022-09-02', '2022-09-08', '2022-09-09'], dtype='datetime64[ns]', freq=None)" ] }, "execution_count": 87, @@ -2560,8 +2561,8 @@ { "data": { "text/plain": [ - "DatetimeIndex(['2022-08-24', '2022-08-25', '2022-08-26', '2022-08-29',\n", - " '2022-08-30'],\n", + "DatetimeIndex(['2022-09-02', '2022-09-06', '2022-09-07', '2022-09-08',\n", + " '2022-09-09'],\n", " dtype='datetime64[ns]', freq=None)" ] }, @@ -2667,13 +2668,8 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### Methods that query a range of dates" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ + "### Methods that query a range of dates\n", + "\n", "The methods in this section query sessions that fall within the range of dates from `start` through `end` (inclusive of both). Both parameters take a `Date` (i.e the passed values can but do not have to represent an actual session)." ] }, @@ -2935,6 +2931,248 @@ "nys.closes[start:end]" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Methods that query a time\n", + "\n", + "`is_open_at_time` is rather unique in that it is not concerned with any specific `Minute` or `Date` but rather only if the exchange is considered open as at a given instance. The calendar's side has no effect on the return. Consequently, \n", + "even if the calendar's side is \"both\" it's possible to query if the market is open as at a time specified with second or greater accuracy." + ] + }, + { + "cell_type": "code", + "execution_count": 99, + "metadata": {}, + "outputs": [], + "source": [ + "hkg_both = xcals.get_calendar(\"XHKG\", side=\"both\")" + ] + }, + { + "cell_type": "code", + "execution_count": 100, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
openbreak_startbreak_endclose
2022-01-042022-01-04 01:30:00+00:002022-01-04 04:00:00+00:002022-01-04 05:00:00+00:002022-01-04 08:00:00+00:00
\n", + "
" + ], + "text/plain": [ + " open break_start \\\n", + "2022-01-04 2022-01-04 01:30:00+00:00 2022-01-04 04:00:00+00:00 \n", + "\n", + " break_end close \n", + "2022-01-04 2022-01-04 05:00:00+00:00 2022-01-04 08:00:00+00:00 " + ] + }, + "execution_count": 100, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# recalling...\n", + "hkg.schedule.loc[[\"2022-01-04\"]]" + ] + }, + { + "cell_type": "code", + "execution_count": 101, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 101, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "timestamp = pd.Timestamp(\"2022-01-04 07:59:59\")\n", + "hkg_both.is_open_at_time(timestamp)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Note that unlike other methods, the input has to be an instance of `pd.Timestamp`. If passed as timezone-naive (as here) it will be assumed to represent UTC.\n", + "\n", + "`is_open_at_time` can take a 'side' option to determine if the exchange will be considered open or closed on a session's bounds:\n", + "* **\"left\"** (default) - treat exchange as open on session open and any break-end, treat as closed on session close and any break-start.\n", + "* **\"right\"** - treat exchange as open on session close and any break-start, treat as closed on session open and any break-end.\n", + "* **\"both\"** - treat exchange as open on all of session open, close and any break-start and break-end.\n", + "* **\"neither\"** - treat exchange as closed on all of session open, close and any break-start and break-end.\n", + "\n", + "It can also take an `ignore_breaks` options which, as for `is_open_on_minute`, will treat the exchange as open during any break." + ] + }, + { + "cell_type": "code", + "execution_count": 102, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[True, False, True, False]" + ] + }, + "execution_count": 102, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "sides = (\"left\", \"right\", \"both\", \"neither\")\n", + "timestamp = pd.Timestamp(\"2022-01-04 01:30:00\")\n", + "[ hkg.is_open_at_time(timestamp, side=side) for side in sides ]" + ] + }, + { + "cell_type": "code", + "execution_count": 103, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[False, True, True, False]" + ] + }, + "execution_count": 103, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "timestamp = pd.Timestamp(\"2022-01-04 04:00:00\")\n", + "[ hkg.is_open_at_time(timestamp, side=side) for side in sides ]" + ] + }, + { + "cell_type": "code", + "execution_count": 104, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[True, True, True, True]" + ] + }, + "execution_count": 104, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "[ hkg.is_open_at_time(timestamp, side, ignore_breaks=True) for side in sides ]" + ] + }, + { + "cell_type": "code", + "execution_count": 105, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[True, True, True, True]" + ] + }, + "execution_count": 105, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "timestamp = pd.Timestamp(\"2022-01-04 03:59:59\")\n", + "[ hkg.is_open_at_time(timestamp, side=side) for side in sides ]" + ] + }, + { + "cell_type": "code", + "execution_count": 106, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[False, False, False, False]" + ] + }, + "execution_count": 106, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "timestamp = pd.Timestamp(\"2022-01-04 04:00:01\")\n", + "[ hkg.is_open_at_time(timestamp, side=side) for side in sides ]" + ] + }, + { + "cell_type": "code", + "execution_count": 107, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[False, False, False, False]" + ] + }, + "execution_count": 107, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "[ hkg.is_open_at_time(timestamp, side, ignore_breaks=False) for side in sides ]" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -2947,7 +3185,7 @@ }, { "cell_type": "code", - "execution_count": 99, + "execution_count": 108, "metadata": {}, "outputs": [ { @@ -2956,7 +3194,7 @@ "('2021-12-23', '2021-12-29')" ] }, - "execution_count": 99, + "execution_count": 108, "metadata": {}, "output_type": "execute_result" } @@ -2968,7 +3206,7 @@ }, { "cell_type": "code", - "execution_count": 100, + "execution_count": 109, "metadata": {}, "outputs": [ { @@ -2988,7 +3226,7 @@ " dtype='datetime64[ns, UTC]', length=104, freq=None)" ] }, - "execution_count": 100, + "execution_count": 109, "metadata": {}, "output_type": "execute_result" } @@ -2999,7 +3237,7 @@ }, { "cell_type": "code", - "execution_count": 101, + "execution_count": 110, "metadata": {}, "outputs": [ { @@ -3017,7 +3255,7 @@ " dtype='datetime64[ns, UTC]', freq=None)" ] }, - "execution_count": 101, + "execution_count": 110, "metadata": {}, "output_type": "execute_result" } @@ -3028,7 +3266,7 @@ }, { "cell_type": "code", - "execution_count": 102, + "execution_count": 111, "metadata": {}, "outputs": [ { @@ -3046,7 +3284,7 @@ " dtype='datetime64[ns, UTC]', freq=None)" ] }, - "execution_count": 102, + "execution_count": 111, "metadata": {}, "output_type": "execute_result" } @@ -3057,7 +3295,7 @@ }, { "cell_type": "code", - "execution_count": 103, + "execution_count": 112, "metadata": {}, "outputs": [ { @@ -3066,7 +3304,7 @@ "IntervalIndex([[2021-12-23 14:30:00, 2021-12-23 14:50:00), [2021-12-23 14:50:00, 2021-12-23 15:10:00), [2021-12-23 15:10:00, 2021-12-23 15:30:00), [2021-12-23 15:30:00, 2021-12-23 15:50:00), [2021-12-23 15:50:00, 2021-12-23 16:10:00) ... [2021-12-29 19:30:00, 2021-12-29 19:50:00), [2021-12-29 19:50:00, 2021-12-29 20:10:00), [2021-12-29 20:10:00, 2021-12-29 20:30:00), [2021-12-29 20:30:00, 2021-12-29 20:50:00), [2021-12-29 20:50:00, 2021-12-29 21:10:00)], dtype='interval[datetime64[ns, UTC], left]')" ] }, - "execution_count": 103, + "execution_count": 112, "metadata": {}, "output_type": "execute_result" } @@ -3078,7 +3316,7 @@ }, { "cell_type": "code", - "execution_count": 104, + "execution_count": 113, "metadata": {}, "outputs": [ { @@ -3197,7 +3435,7 @@ "[80 rows x 2 columns]" ] }, - "execution_count": 104, + "execution_count": 113, "metadata": {}, "output_type": "execute_result" } diff --git a/docs/tutorials/minutes.ipynb b/docs/tutorials/minutes.ipynb index d28ad439..fc4f3460 100644 --- a/docs/tutorials/minutes.ipynb +++ b/docs/tutorials/minutes.ipynb @@ -162,10 +162,10 @@ "source": [ "Any minute that represents a time when an exchange is open is referred to as a 'trading minute'. At a session's bounds, which of a session's open/close and break start/end are considered as trading minutes is determined by the calendar's `side` parameter:\n", "\n", - "* **\"left\"** - treat session open and break-start as trading minutes,\n", - " do not treat session close or break-end as trading minutes.\n", - "* **\"right\"** - treat session close and break-end as trading minutes,\n", - " do not treat session open or break-start as tradng minutes.\n", + "* **\"left\"** - treat session open and break-end as trading minutes,\n", + " do not treat session close or break-start as trading minutes.\n", + "* **\"right\"** - treat session close and break-start as trading minutes,\n", + " do not treat session open or break-end as tradng minutes.\n", "* **\"both\"** - treat all of session open, session close, break-start\n", " and break-end as trading minutes.\n", "* **\"neither\"** - treat none of session open, session close,\n", diff --git a/exchange_calendars/exchange_calendar.py b/exchange_calendars/exchange_calendar.py index 8a4f0a1e..1674f28b 100644 --- a/exchange_calendars/exchange_calendar.py +++ b/exchange_calendars/exchange_calendar.py @@ -13,13 +13,14 @@ # limitations under the License. from __future__ import annotations -import datetime -import functools -import warnings from abc import ABC, abstractmethod import collections from collections.abc import Sequence, Callable +import datetime +import functools +import operator from typing import TYPE_CHECKING, Literal +import warnings import numpy as np import pandas as pd @@ -1308,6 +1309,7 @@ def is_trading_minute(self, minute: Minute, _parse: bool = True) -> bool: See Also -------- is_open_on_minute + is_open_at_time """ if _parse: minute = parse_timestamp(minute, calendar=self) @@ -1367,6 +1369,7 @@ def is_open_on_minute( See Also -------- is_trading_minute + is_open_at_time """ if _parse: minute = parse_timestamp(minute, "minute", self) @@ -1378,6 +1381,95 @@ def is_open_on_minute( # not a trading minute although should return True if in break return self.is_break_minute(minute, _parse=False) + def is_open_at_time( + self, + timestamp: pd.Timestamp, + side: Literal["left", "right", "both", "neither"] = "left", + ignore_breaks: bool = False, + ) -> bool: + """Query if exchange is open at a given timestamp. + + Note: method differs from `is_trading_minute` and + `is_open_on_minute` in that it does not consider if the market is + open over an evaluated minute, but rather as at a specific + instance that can be of any resolution. + + Parameters + ---------- + timestamp + Timestamp being queried. + + Can have any resolution (i.e. can be defined with second and + more accurate components). + + If timezone naive then will be assumed as representing UTC. + + side + Determines whether the exchange will be considered open or + closed on a session's open, close, break-start and break-end: + + "left" - treat exchange as open on session open and + any break-end, treat as closed on session close and any + break-start. + + "right" - treat exchange as open on session close and + any break-start, treat as closed on session open and any + break-end. + + "both" (default) - treat exchange as open on all of session + open, close and any break-start and break-end. + + "neither" - treat exchange as closed on all of session + open, close and any break-start and break-end. + + ignore_breaks + Should exchange be considered open during any break? + True - treat exchange as open during any break. + False - treat exchange as closed during any break. + + Returns + ------- + bool + Boolean indicting if exchange is open at time. + + See Also + -------- + is_trading_minute + is_open_on_minute + """ + ts = timestamp + if not isinstance(ts, pd.Timestamp): + raise TypeError( + "`timestamp` expected to receive type pd.Timestamp although" + f" got type {type(ts)}." + ) + + if ts.tz is not pytz.UTC: + ts = ts.tz_localize("UTC") if ts.tz is None else ts.tz_convert("UTC") + + if self._minute_oob(ts): + raise errors.MinuteOutOfBounds(self, ts, "timestamp") + + op_left = operator.le if side in self._LEFT_SIDES else operator.lt + op_right = operator.le if side in self._RIGHT_SIDES else operator.lt + + nano = ts.value + if not self.has_break or ignore_breaks: + # only one check requried + bv = op_left(self.opens_nanos, nano) & op_right(nano, self.closes_nanos) + return bv.any() + + break_starts_nanos = self.break_starts_nanos.copy() + bv_missing = self.break_starts.isna() + close_replacement = self.closes_nanos[bv_missing] + break_starts_nanos[bv_missing] = close_replacement + break_ends_nanos = self.break_ends_nanos.copy() + break_ends_nanos[bv_missing] = close_replacement + + bv_am = op_left(self.opens_nanos, nano) & op_right(nano, break_starts_nanos) + bv_pm = op_left(break_ends_nanos, nano) & op_right(nano, self.closes_nanos) + return (bv_am | bv_pm).any() + def next_open(self, minute: Minute, _parse: bool = True) -> pd.Timestamp: """Return next open that follows a given minute. diff --git a/tests/test_exchange_calendar.py b/tests/test_exchange_calendar.py index 2a864d1c..2679e5cb 100644 --- a/tests/test_exchange_calendar.py +++ b/tests/test_exchange_calendar.py @@ -2810,6 +2810,111 @@ def test_is_open_on_minute(self, all_calendars_with_answers): rtrn = f(break_min) assert rtrn is False + def test_is_open_at_time(self, all_calendars_with_answers, one_minute): + cal, ans = all_calendars_with_answers + + one_min = one_minute + one_sec = pd.Timedelta(1, "S") + + sides = ("left", "both", "right", "neither") + + # verify raises expected errors + oob_time = ans.first_minute - one_sec + for side in sides: + with pytest.raises(errors.MinuteOutOfBounds): + cal.is_open_at_time(oob_time, side, ignore_breaks=True) + + match = ( + "`timestamp` expected to receive type pd.Timestamp although got type" + " ." + ) + with pytest.raises(TypeError, match=match): + cal.is_open_at_time("2022-06-21 14:22", "left", ignore_breaks=True) + + # verify expected returns + bools = (True, False) + + def get_returns( + ts: pd.Timestamp, + ignore_breaks: bool, + ) -> list[bool]: + return [cal.is_open_at_time(ts, side, ignore_breaks) for side in sides] + + gap_before = ans.sessions_with_gap_before + gap_after = ans.sessions_with_gap_after + + for session in ans.sessions_sample: + ts = ans.opens[session] + expected = [True, True, False, False] + expected_no_gap = [True, True, True, False] + if ts > ans.first_minute: + for ignore in bools: + expected_ = expected if session in gap_before else expected_no_gap + assert get_returns(ts, ignore) == expected_ + + for ignore, ts_ in itertools.product( + bools, (ts - one_sec, ts - one_min) + ): + if session in gap_before: + assert not any(get_returns(ts_, ignore)) + else: + assert all(get_returns(ts_, ignore)) + + for ignore, ts_ in itertools.product( + bools, (ts + one_sec, ts + one_min) + ): + assert all(get_returns(ts_, ignore)) + + if ans.session_has_break(session): + ts = ans.break_ends[session] + assert get_returns(ts, ignore_breaks=False) == expected + assert all(get_returns(ts, ignore_breaks=True)) + + for ignore, ts_ in itertools.product( + bools, (ts + one_sec, ts + one_min) + ): + assert all(get_returns(ts_, ignore)) + + for ts_ in (ts - one_sec, ts - one_min): + assert not any(get_returns(ts_, ignore_breaks=False)) + assert all(get_returns(ts_, ignore_breaks=True)) + + ts = ans.closes[session] + expected = [False, True, True, False] + expected_no_gap = [True, True, True, False] + if ts < ans.last_minute: + for ignore in bools: + expected_ = expected if session in gap_after else expected_no_gap + # check interprets tz-naive timestamp as UTC + assert get_returns(ts.astimezone(None), ignore) == expected_ + + for ignore, ts_ in itertools.product( + bools, (ts - one_sec, ts - one_min) + ): + assert all(get_returns(ts_, ignore)) + + for ignore, ts_ in itertools.product( + bools, (ts + one_sec, ts + one_min) + ): + if session in gap_after: + assert not any(get_returns(ts_, ignore)) + else: + assert all(get_returns(ts_.astimezone(None), ignore)) + + if ans.session_has_break(session): + ts = ans.break_starts[session] + assert get_returns(ts, ignore_breaks=False) == expected + assert all(get_returns(ts, ignore_breaks=True)) + + for ignore, ts_ in itertools.product( + bools, (ts - one_sec, ts - one_min) + ): + assert all(get_returns(ts_, ignore)) + + for ts_ in (ts + one_sec, ts + one_min): + assert not any(get_returns(ts_, ignore_breaks=False)) + assert all(get_returns(ts_, ignore_breaks=True)) + def test_prev_next_open_close(self, default_calendar_with_answers): """Test methods that return previous/next open/close.