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",
+ " open | \n",
+ " break_start | \n",
+ " break_end | \n",
+ " close | \n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " 2022-01-04 | \n",
+ " 2022-01-04 01:30:00+00:00 | \n",
+ " 2022-01-04 04:00:00+00:00 | \n",
+ " 2022-01-04 05:00:00+00:00 | \n",
+ " 2022-01-04 08:00:00+00:00 | \n",
+ "
\n",
+ " \n",
+ "
\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.