fa45d8aa5f
- health_checklist.json: 192.168.1.122→node122
- ocr_client.py: docstring IP→node122
- docs/market-data-requirements.md: IP→node122
- 所有API调用通过ProxyHandler({})绕过系统代理
Privoxy对node122:18003返回500,直连正常
2967 lines
104 KiB
Python
2967 lines
104 KiB
Python
# Copyright 2018 Quantopian, Inc.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
from __future__ import annotations
|
|
|
|
from abc import ABC, abstractmethod
|
|
from calendar import day_name
|
|
import collections
|
|
import functools
|
|
import operator
|
|
from typing import TYPE_CHECKING, Literal, Any
|
|
import warnings
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
import toolz
|
|
from pandas.tseries.holiday import AbstractHolidayCalendar
|
|
from pandas.tseries.offsets import CustomBusinessDay
|
|
|
|
from exchange_calendars import errors
|
|
from .calendar_helpers import (
|
|
UTC,
|
|
NANOSECONDS_PER_MINUTE,
|
|
NP_NAT,
|
|
Date,
|
|
Minute,
|
|
Session,
|
|
TradingMinute,
|
|
_TradingIndex,
|
|
compute_minutes,
|
|
next_divider_idx,
|
|
one_minute_earlier,
|
|
one_minute_later,
|
|
parse_date,
|
|
parse_session,
|
|
parse_timestamp,
|
|
parse_trading_minute,
|
|
previous_divider_idx,
|
|
)
|
|
from .utils.pandas_utils import days_at_time
|
|
|
|
if TYPE_CHECKING:
|
|
from zoneinfo import ZoneInfo
|
|
import datetime
|
|
from collections.abc import Sequence, Callable
|
|
from pandas._libs.tslibs.nattype import NaTType
|
|
|
|
|
|
GLOBAL_DEFAULT_START = pd.Timestamp.now().floor("D") - pd.DateOffset(years=20)
|
|
# Give an aggressive buffer for logic that needs to use the next trading
|
|
# day or minute.
|
|
GLOBAL_DEFAULT_END = pd.Timestamp.now().floor("D") + pd.DateOffset(years=1)
|
|
|
|
ONE_MINUTE = pd.Timedelta(1, "min")
|
|
ONE_HOUR = pd.Timedelta("1h")
|
|
NANOS_IN_MINUTE = 60000000000
|
|
MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY = range(7)
|
|
WEEKDAYS = (MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY)
|
|
WEEKENDS = (SATURDAY, SUNDAY)
|
|
|
|
|
|
def selection(
|
|
arr: pd.DatetimeIndex, start: pd.Timestamp, end: pd.Timestamp
|
|
) -> pd.DatetimeIndex:
|
|
predicates = []
|
|
if start is not None:
|
|
predicates.append(start <= arr)
|
|
if end is not None:
|
|
predicates.append(arr < end)
|
|
|
|
if not predicates:
|
|
return arr
|
|
|
|
return arr[np.all(predicates, axis=0)]
|
|
|
|
|
|
def _group_times(
|
|
sessions: pd.DatetimeIndex,
|
|
times: None | Sequence[tuple[pd.Timestamp | None, datetime.time]],
|
|
tz: ZoneInfo,
|
|
offset: int = 0,
|
|
) -> pd.DatetimeIndex | None:
|
|
"""Evaluate standard times for a specific session bound.
|
|
|
|
For example, if `times` passed as standard times for session opens then
|
|
will return a DatetimeIndex describing standard open times for each
|
|
session.
|
|
"""
|
|
if times is None:
|
|
return None
|
|
elements = [
|
|
days_at_time(selection(sessions, start, end), time, tz, offset)
|
|
for (start, time), (end, _) in toolz.sliding_window(
|
|
2, toolz.concatv(times, [(None, None)])
|
|
)
|
|
]
|
|
return elements[0].append(elements[1:])
|
|
|
|
|
|
class deprecate: # noqa: N801
|
|
"""Decorator for deprecated ExchangeCalendar methods."""
|
|
|
|
def __init__(
|
|
self,
|
|
deprecated_release: str = "4.0",
|
|
message: str | None = None,
|
|
):
|
|
self.deprecated_release = "release " + deprecated_release
|
|
self.message = message
|
|
|
|
def __call__(self, f: Callable) -> Callable:
|
|
@functools.wraps(f)
|
|
def wrapped_f(*args, **kwargs):
|
|
warnings.warn(self._message(f), FutureWarning) # noqa: B028
|
|
return f(*args, **kwargs)
|
|
|
|
return wrapped_f
|
|
|
|
def _message(self, f: Callable) -> str:
|
|
msg = (
|
|
f"`{f.__name__}` was deprecated in {self.deprecated_release}"
|
|
f" and will be removed in a future release."
|
|
)
|
|
if self.message is not None:
|
|
msg += " " + self.message
|
|
return msg
|
|
|
|
|
|
class HolidayCalendar(AbstractHolidayCalendar):
|
|
def __init__(self, rules):
|
|
super().__init__(rules=rules)
|
|
|
|
|
|
class ExchangeCalendar(ABC):
|
|
"""Representation of timing information of a single market exchange.
|
|
|
|
The timing information comprises sessions, open/close times and, for
|
|
exchanges that observe an intraday break, break_start/break_end times.
|
|
|
|
For exchanges that do not observe an intraday break a session
|
|
represents a contiguous set of minutes. Where an exchange observes
|
|
an intraday break a session represents two contiguous sets of minutes
|
|
separated by the intraday break.
|
|
|
|
Each session is labeled as the date that the session represents.
|
|
|
|
For each session, we store the open and close time together with, for
|
|
those exchanges with breaks, the break start and break end. All times
|
|
are defined as UTC.
|
|
|
|
Note that a session may start on the day prior to the session label or
|
|
end on the day following the session label. Such behaviour is common
|
|
for calendars that represent futures exchanges.
|
|
|
|
Parameters
|
|
----------
|
|
start : default: later of 20 years ago or first supported start date.
|
|
First calendar session will be `start`, if `start` is a session, or
|
|
first session after `start`. Cannot be earlier than any date
|
|
returned by class method `bound_min`.
|
|
|
|
end : default: earliest of 1 year from 'today' or last supported end
|
|
date. Last calendar session will be `end`, if `end` is a session,
|
|
or last session before `end`. Cannot be later than any date
|
|
returned by class method `bound_max`.
|
|
|
|
side : default: "left"
|
|
Define which of session open/close and break start/end should
|
|
be treated as a trading minute:
|
|
"left" - treat session open and break_start as trading minutes,
|
|
do not treat session close or break_end as trading minutes.
|
|
"right" - treat session close and break_end as trading minutes,
|
|
do not treat session open or break_start as tradng minutes.
|
|
"both" - treat all of session open, session close, break_start
|
|
and break_end as trading minutes.
|
|
"neither" - treat none of session open, session close,
|
|
break_start or break_end as trading minutes.
|
|
|
|
Raises
|
|
------
|
|
ValueError
|
|
If `start` is earlier than the earliest supported start date.
|
|
If `end` is later than the latest supported end date.
|
|
If `start` parses to a later date than `end`.
|
|
|
|
Notes
|
|
-----
|
|
Exchange calendars were originally defined for the Zipline package from
|
|
Quantopian under the package 'trading_calendars'. Since 2021 they have
|
|
been maintained under the 'exchange_calendars' package (a fork of
|
|
'trading_calendars') by an active community of contributing users.
|
|
|
|
Some calendars have defined start and end bounds within which
|
|
contributors have endeavoured to ensure the calendar's accuracy and
|
|
outside of which the calendar would not be accurate. These bounds
|
|
are enforced such that passing `start` or `end` as dates that are
|
|
out-of-bounds will raise a ValueError. The bounds of each calendar are
|
|
exposed via the `bound_min` and `bound_max` class methods.
|
|
|
|
Many calendars do not have bounds defined (in these cases `bound_min`
|
|
and/or `bound_max` return None). These calendars can be created through
|
|
any date range although it should be noted that the earlier the start
|
|
date, the greater the potential for inaccuracies.
|
|
|
|
In all cases, no guarantees are offered as to the accuracy of any
|
|
calendar.
|
|
|
|
|
|
-- Internal method parameters --
|
|
|
|
_parse: bool
|
|
Determines if a `minute` or `session` parameter should be
|
|
parsed (default True). Passed as False:
|
|
- internally to prevent double parsing.
|
|
- by tests for efficiency.
|
|
"""
|
|
|
|
_LEFT_SIDES = ["left", "both"]
|
|
_RIGHT_SIDES = ["right", "both"]
|
|
|
|
@classmethod
|
|
def bound_min(cls) -> pd.Timestamp | None:
|
|
"""Earliest date from which calendar can be constructed.
|
|
|
|
Returns
|
|
-------
|
|
pd.Timestamp or None
|
|
Earliest date from which calendar can be constructed. Must be
|
|
timezone naive. None if no limit.
|
|
|
|
Notes
|
|
-----
|
|
To impose a constraint on the earliest date from which a calendar
|
|
can be constructed subclass should override this method and
|
|
optionally override `_bound_min_error_msg`.
|
|
"""
|
|
return None
|
|
|
|
@classmethod
|
|
def bound_max(cls) -> pd.Timestamp | None:
|
|
"""Latest date to which calendar can be constructed.
|
|
|
|
Returns
|
|
-------
|
|
pd.Timestamp or None
|
|
Latest date to which calendar can be constructed. Must be
|
|
timezone naive. None if no limit.
|
|
|
|
Notes
|
|
-----
|
|
To impose a constraint on the latest date to which a calendar can
|
|
be constructed subclass should override this method and optionally
|
|
override `_bound_max_error_msg`.
|
|
"""
|
|
return None
|
|
|
|
@classmethod
|
|
def default_start(cls) -> pd.Timestamp:
|
|
"""Return default calendar start date.
|
|
|
|
Calendar will start from this date if 'start' is not otherwise
|
|
passed to the constructor.
|
|
"""
|
|
bound_min = cls.bound_min()
|
|
if bound_min is None:
|
|
return GLOBAL_DEFAULT_START
|
|
return max(GLOBAL_DEFAULT_START, bound_min)
|
|
|
|
@classmethod
|
|
def default_end(cls) -> pd.Timestamp:
|
|
"""Return default calendar end date.
|
|
|
|
Calendar will end at this date if 'end' is not otherwise passed to
|
|
the constructor.
|
|
"""
|
|
bound_max = cls.bound_max()
|
|
if bound_max is None:
|
|
return GLOBAL_DEFAULT_END
|
|
return min(GLOBAL_DEFAULT_END, bound_max)
|
|
|
|
def __init__( # noqa: PLR0915 # can lose noqa post changes when min pandas bumps to 2.0
|
|
self,
|
|
start: Date | None = None,
|
|
end: Date | None = None,
|
|
side: Literal["left", "right", "both", "neither"] = "left",
|
|
):
|
|
if side not in self.valid_sides():
|
|
raise ValueError(
|
|
f"`side` must be in {self.valid_sides()} although received as {side}."
|
|
)
|
|
self._side = side
|
|
|
|
if start is None:
|
|
start = self.default_start()
|
|
else:
|
|
start = parse_date(start, "start", raise_oob=False)
|
|
bound_min = self.bound_min()
|
|
if bound_min is not None and start < bound_min:
|
|
raise ValueError(self._bound_min_error_msg(start))
|
|
|
|
if end is None:
|
|
end = self.default_end()
|
|
else:
|
|
end = parse_date(end, "end", raise_oob=False)
|
|
bound_max = self.bound_max()
|
|
if bound_max is not None and end > bound_max:
|
|
raise ValueError(self._bound_max_error_msg(end))
|
|
|
|
if start >= end:
|
|
raise ValueError(
|
|
"`start` must be earlier than `end` although `start` parsed as"
|
|
f" '{start}' and `end` as '{end}'."
|
|
)
|
|
|
|
_all_days = pd.date_range(start, end, freq=self.day) # session labels
|
|
if _all_days.empty:
|
|
raise errors.NoSessionsError(calendar_name=self.name, start=start, end=end)
|
|
|
|
# DatetimeIndex of standard times for each day.
|
|
self._opens = _group_times(
|
|
_all_days,
|
|
self.open_times,
|
|
self.tz,
|
|
self.open_offset,
|
|
)
|
|
self._break_starts = _group_times(
|
|
_all_days,
|
|
self.break_start_times,
|
|
self.tz,
|
|
)
|
|
self._break_ends = _group_times(
|
|
_all_days,
|
|
self.break_end_times,
|
|
self.tz,
|
|
)
|
|
self._closes = _group_times(
|
|
_all_days,
|
|
self.close_times,
|
|
self.tz,
|
|
self.close_offset,
|
|
)
|
|
|
|
# Apply any special offsets first
|
|
self.apply_special_offsets(_all_days, start, end)
|
|
|
|
# Series mapping sessions with non-standard opens/closes.
|
|
_special_opens = self._calculate_special_opens(start, end)
|
|
_special_closes = self._calculate_special_closes(start, end)
|
|
|
|
# Adjust for special opens and closes.
|
|
self._opens = _adjust_special_dates(_all_days, self._opens, _special_opens)
|
|
self._closes = _adjust_special_dates(_all_days, self._closes, _special_closes)
|
|
self._break_starts = _remove_breaks_for_special_dates(
|
|
_all_days, self._break_starts, _special_closes
|
|
)
|
|
self._break_ends = _remove_breaks_for_special_dates(
|
|
_all_days, self._break_ends, _special_closes
|
|
)
|
|
|
|
break_starts = (
|
|
self._break_starts
|
|
if self._break_starts is not None
|
|
else pd.DatetimeIndex([np.nan] * len(_all_days), tz=UTC)
|
|
)
|
|
break_ends = (
|
|
self._break_ends
|
|
if self._break_ends is not None
|
|
else pd.DatetimeIndex([np.nan] * len(_all_days), tz=UTC)
|
|
)
|
|
|
|
# NOTE can lose this if line when min supported version of pandas bumps to
|
|
# 2.0 (`as_unit` introduced in pandas 2.0) and just dedent the content.
|
|
# NB pre v3.0 pandas infers resolution here as "ns", not so in v3 which would
|
|
# otherwise infer as "us".
|
|
if pd.__version__ >= "3.0.0":
|
|
_all_days = _all_days.as_unit("ns")
|
|
self._opens = self._opens.as_unit("ns")
|
|
self._closes = self._closes.as_unit("ns")
|
|
break_starts = break_starts.as_unit("ns")
|
|
break_ends = break_ends.as_unit("ns")
|
|
self.schedule = pd.DataFrame(
|
|
index=_all_days,
|
|
data=collections.OrderedDict(
|
|
[
|
|
("open", self._opens),
|
|
("break_start", break_starts),
|
|
("break_end", break_ends),
|
|
("close", self._closes),
|
|
]
|
|
),
|
|
)
|
|
|
|
self.opens_nanos = self.schedule.open.values.astype(np.int64)
|
|
self.break_starts_nanos = self.schedule.break_start.values.astype(np.int64)
|
|
self.break_ends_nanos = self.schedule.break_end.values.astype(np.int64)
|
|
self.closes_nanos = self.schedule.close.values.astype(np.int64)
|
|
|
|
_check_breaks_match(self.break_starts_nanos, self.break_ends_nanos)
|
|
|
|
# NOTE If / when min pandas bumps to 2.0 can reduce all the following to just
|
|
# the content of the if clause. (`as_unit`` introduced in pandas 2.0).
|
|
# NB pre v3.0 pandas infers resolution here as "ns", not so in v3.
|
|
if pd.__version__ >= "3.0.0":
|
|
self._late_opens = _special_opens.index.as_unit("ns")
|
|
self._early_closes = _special_closes.index.as_unit("ns")
|
|
else:
|
|
self._late_opens = _special_opens.index
|
|
self._early_closes = _special_closes.index
|
|
|
|
# --------------- Calendar definition methods/properties --------------
|
|
# Methods and properties in this section should be overriden or
|
|
# extended by subclass if and as required.
|
|
|
|
@property
|
|
@abstractmethod
|
|
def name(self) -> str:
|
|
"""Calendar name."""
|
|
raise NotImplementedError
|
|
|
|
def _bound_min_error_msg(self, start: pd.Timestamp) -> str:
|
|
"""Return error message to handle `start` being out-of-bounds.
|
|
|
|
See Also
|
|
--------
|
|
bound_min
|
|
"""
|
|
return (
|
|
f"The earliest date from which calendar {self.name} can be"
|
|
f" evaluated is {self.bound_min()}, although received `start` as"
|
|
f" {start}."
|
|
)
|
|
|
|
def _bound_max_error_msg(self, end: pd.Timestamp) -> str:
|
|
"""Return error message to handle `end` being out-of-bounds.
|
|
|
|
See Also
|
|
--------
|
|
bound_max
|
|
"""
|
|
return (
|
|
f"The latest date to which calendar {self.name} can be evaluated"
|
|
f" is {self.bound_max()}, although received `end` as {end}."
|
|
)
|
|
|
|
@property
|
|
@abstractmethod
|
|
def tz(self) -> ZoneInfo:
|
|
"""Calendar timezone."""
|
|
raise NotImplementedError
|
|
|
|
@property
|
|
@abstractmethod
|
|
def open_times(self) -> Sequence[tuple[pd.Timestamp | None, datetime.time]]:
|
|
"""Local open time(s).
|
|
|
|
Returns
|
|
-------
|
|
Sequence[tuple[pd.Timestamp | None, datetime.time]]:
|
|
Sequence of tuples representing (start_date, open_time) where:
|
|
start_date: date from which `open_time` applies. Must be
|
|
timezone-naive. None for first item.
|
|
open_time: exchange's local open time.
|
|
|
|
Notes
|
|
-----
|
|
Examples for concreting `open_times` on a subclass.
|
|
|
|
Example where open time is constant throughout period covered by
|
|
calendar:
|
|
open_times = ((None, datetime.time(9)),)
|
|
|
|
Example where open times have varied over period covered by
|
|
calendar:
|
|
open_times = (
|
|
(None, time(9, 30)),
|
|
(pd.Timestamp("1978-04-01"), datetime.time(10, 0)),
|
|
(pd.Timestamp("1986-04-01"), datetime.time(9, 40)),
|
|
(pd.Timestamp("1995-01-01"), datetime.time(9, 30)),
|
|
(pd.Timestamp("1998-12-07"), datetime.time(9, 0)),
|
|
)
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
@property
|
|
def break_start_times(
|
|
self,
|
|
) -> None | Sequence[tuple[pd.Timestamp | None, datetime.time]]:
|
|
"""Local break start time(s).
|
|
|
|
As `close_times` although times represent the close of the morning
|
|
subsession. None if exchange does not observe a break.
|
|
"""
|
|
return None
|
|
|
|
@property
|
|
def break_end_times(
|
|
self,
|
|
) -> None | Sequence[tuple[pd.Timestamp | None, datetime.time]]:
|
|
"""Local break end time(s).
|
|
|
|
As `open_times` although times represent the open of the afternoon
|
|
subsession. None if exchange does not observe a break.
|
|
"""
|
|
return None
|
|
|
|
@property
|
|
@abstractmethod
|
|
def close_times(self) -> Sequence[tuple[pd.Timestamp | None, datetime.time]]:
|
|
"""Local close time(s).
|
|
|
|
Returns
|
|
-------
|
|
Sequence[tuple[pd.Timestamp | None, datetime.time]]:
|
|
Sequence of tuples representing (start_date, close_time) where:
|
|
start_date: date from which `close_time` applies. Must be
|
|
timezone naive. None for first item.
|
|
close_time: exchange's local close time.
|
|
|
|
Notes
|
|
-----
|
|
Examples for concreting `close_times` on a subclass.
|
|
|
|
Example where close time is constant throughout period covered by
|
|
calendar:
|
|
close_times = ((None, time(17, 30)),)
|
|
|
|
Example where close times have varied over period covered by
|
|
calendar:
|
|
close_times = (
|
|
(None, datetime.time(17, 30)),
|
|
(pd.Timestamp("1986-04-01"), datetime.time(17, 20)),
|
|
(pd.Timestamp("1995-01-01"), datetime.time(17, 0)),
|
|
(pd.Timestamp("2016-08-01"), datetime.time(17, 30)),
|
|
)
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
@property
|
|
def weekmask(self) -> str:
|
|
"""Indicator of weekdays on which the exchange is open.
|
|
|
|
Default is '1111100' (i.e. Monday-Friday).
|
|
|
|
See Also
|
|
--------
|
|
numpy.busdaycalendar
|
|
"""
|
|
return "1111100"
|
|
|
|
@property
|
|
def open_offset(self) -> int:
|
|
"""Day offset of open time(s) relative to session.
|
|
|
|
Returns
|
|
-------
|
|
int
|
|
0 if the date components of local open times are as the
|
|
corresponding session labels.
|
|
|
|
-1 if the date components of local open times are the day
|
|
before the corresponding session labels.
|
|
"""
|
|
return 0
|
|
|
|
@property
|
|
def close_offset(self) -> int:
|
|
"""Day offset of close time(s) relative to session.
|
|
|
|
Returns
|
|
-------
|
|
int
|
|
0 if the date components of local close times are as the
|
|
corresponding session labels.
|
|
|
|
1 if the date components of local close times are the day
|
|
after the corresponding session labels.
|
|
"""
|
|
return 0
|
|
|
|
@property
|
|
def regular_holidays(self) -> HolidayCalendar | None:
|
|
"""Holiday calendar representing calendar's regular holidays."""
|
|
return None
|
|
|
|
@property
|
|
def adhoc_holidays(self) -> list[pd.Timestamp]:
|
|
"""List of non-regular holidays.
|
|
|
|
Returns
|
|
-------
|
|
list[pd.Timestamp]
|
|
List of tz-naive timestamps representing non-regular holidays.
|
|
"""
|
|
return []
|
|
|
|
@property
|
|
def special_opens(self) -> list[tuple[datetime.time, HolidayCalendar] | int]:
|
|
"""Regular non-standard open times.
|
|
|
|
Example of what would be defined as a special open:
|
|
"EVERY YEAR on national lie-in day the exchange opens
|
|
at 13:00 rather than the standard 09:00".
|
|
|
|
"Every Monday the exchange opens late, at 10:30 rather than
|
|
the standard 09:00".
|
|
|
|
Returns
|
|
-------
|
|
list[tuple[datetime.time, HolidayCalendar | int]]:
|
|
list of tuples each describing a regular non-standard open:
|
|
[0] datetime.time: regular non-standard open time.
|
|
|
|
[1] Describes dates with regular non-standard open time
|
|
as [0]. As either:
|
|
HolidayCalendar: defines annual dates by rules.
|
|
|
|
int : integer defines a weekday with a regular
|
|
non-standard open (0 - Monday, ..., 6 - Sunday).
|
|
|
|
The same date may be described by more than one tuple, for
|
|
example, if a late open on an annual holiday coincides with
|
|
a weekday late open. In this case the time assigned to the
|
|
date will be that defined by the tuple with the lowest index
|
|
in the returned list.
|
|
"""
|
|
return []
|
|
|
|
@property
|
|
def special_opens_adhoc(
|
|
self,
|
|
) -> list[tuple[datetime.time, pd.DatetimeIndex]]:
|
|
"""Adhoc non-standard open times.
|
|
|
|
Defines non-standard open times that cannot be otherwise codified
|
|
within within `special_opens`.
|
|
|
|
Example of an event to define as an adhoc special open:
|
|
"On 2022-02-14 due to a typhoon the exchange opened at 13:00,
|
|
rather than the standard 09:00".
|
|
|
|
Returns
|
|
-------
|
|
list[tuple[datetime.time, pd.DatetimeIndex]]:
|
|
List of tuples each describing an adhoc non-standard open time:
|
|
[0] datetime.time: non-standard open time.
|
|
|
|
[1] pd.DatetimeIndex: date or dates corresponding with the
|
|
non-standard open time. (Must be timezone-naive.)
|
|
"""
|
|
return []
|
|
|
|
@property
|
|
def special_closes(self) -> list[tuple[datetime.time, HolidayCalendar | int]]:
|
|
"""Regular non-standard close times.
|
|
|
|
Examples of what would be defined as a special close:
|
|
"On christmas eve the exchange closes at 14:00 rather than
|
|
the standard 17:00".
|
|
|
|
"Every Friday the exchange closes early, at 14:00 rather than
|
|
the standard 17:00".
|
|
|
|
Returns
|
|
-------
|
|
list[tuple[datetime.time, HolidayCalendar | int]]:
|
|
list of tuples each describing a regular non-standard close:
|
|
[0] datetime.time: regular non-standard close time.
|
|
|
|
[1] Describes dates with regular non-standard close time
|
|
as [0]. As either:
|
|
HolidayCalendar: defines annual dates by rules.
|
|
|
|
int : integer defines a weekday with a regular
|
|
non-standard close (0 - Monday, ..., 6 - Sunday).
|
|
|
|
The same date may be described by more than one tuple, for
|
|
example, if an early close on an annual holiday coincides with
|
|
a weekday early close. In this case the time assigned to the
|
|
date will be that defined by the tuple with the lowest index
|
|
in the returned list.
|
|
"""
|
|
return []
|
|
|
|
@property
|
|
def special_closes_adhoc(
|
|
self,
|
|
) -> list[tuple[datetime.time, pd.DatetimeIndex]]:
|
|
"""Adhoc non-standard close times.
|
|
|
|
Defines non-standard close times that cannot be otherwise codified
|
|
within within `special_closes`.
|
|
|
|
Example of an event to define as an adhoc special close:
|
|
"On 2022-02-19 due to a typhoon the exchange closed at 12:00,
|
|
rather than the standard 16:00".
|
|
|
|
Returns
|
|
-------
|
|
list[tuple[datetime.time, pd.DatetimeIndex]]:
|
|
List of tuples each describing an adhoc non-standard close
|
|
time:
|
|
[0] datetime.time: non-standard close time.
|
|
|
|
[1] pd.DatetimeIndex: date or dates corresponding with the
|
|
non-standard close time. (Must be timezone-naive.)
|
|
"""
|
|
return []
|
|
|
|
def apply_special_offsets(
|
|
self,
|
|
sessions: pd.DatetimeIndex, # noqa: ARG002
|
|
start: pd.Timestamp, # noqa: ARG002
|
|
end: pd.Timestamp, # noqa: ARG002
|
|
) -> None:
|
|
"""Hook for subclass to apply changes.
|
|
|
|
Method executed by constructor prior to overwritting special dates.
|
|
|
|
Parameters
|
|
----------
|
|
sessions
|
|
All calendar sessions.
|
|
|
|
start
|
|
Date from which special offsets to be applied.
|
|
|
|
end
|
|
Date through which special offsets to be applied.
|
|
|
|
Notes
|
|
-----
|
|
Incorporated to provide hook to `exchange_calendar_xkrx`.
|
|
"""
|
|
return
|
|
|
|
# ------------------------------------------------------------------
|
|
# -- NO method below this line should be overriden on a subclass! --
|
|
# ------------------------------------------------------------------
|
|
|
|
# Methods and properties that define calendar (continued...).
|
|
|
|
@functools.cached_property
|
|
def day(self) -> CustomBusinessDay:
|
|
"""CustomBusinessDay instance representing calendar sessions."""
|
|
return CustomBusinessDay(
|
|
holidays=self.adhoc_holidays,
|
|
calendar=self.regular_holidays,
|
|
weekmask=self.weekmask,
|
|
)
|
|
|
|
@classmethod
|
|
def valid_sides(cls) -> list[str]:
|
|
"""List of valid `side` options."""
|
|
if cls.close_times == cls.open_times:
|
|
return ["left", "right"]
|
|
return ["both", "left", "right", "neither"]
|
|
|
|
@property
|
|
def side(self) -> Literal["left", "right", "both", "neither"]:
|
|
"""Side on which sessions are closed.
|
|
|
|
Returns
|
|
-------
|
|
str
|
|
"left" - Session open and break_start are trading minutes.
|
|
Session close and break_end are not trading minutes.
|
|
"right" - Session close and break_end are trading minutes,
|
|
Session open and break_start are not tradng minutes.
|
|
"both" - Session open, session close, break_start and
|
|
break_end are all trading minutes.
|
|
"neither" - Session open, session close, break_start and
|
|
break_end are all not trading minutes.
|
|
|
|
Notes
|
|
-----
|
|
Subclasses should NOT override this method.
|
|
"""
|
|
return self._side
|
|
|
|
# Properties covering all sessions.
|
|
|
|
@property
|
|
def sessions(self) -> pd.DatetimeIndex:
|
|
"""All calendar sessions."""
|
|
return self.schedule.index
|
|
|
|
@functools.cached_property
|
|
def sessions_nanos(self) -> np.ndarray:
|
|
"""All calendar sessions as nano seconds."""
|
|
return self.sessions.values.astype("int64")
|
|
|
|
@property
|
|
def opens(self) -> pd.Series:
|
|
"""Open time of each session.
|
|
|
|
Returns
|
|
-------
|
|
pd.Series
|
|
index : pd.DatetimeIndex
|
|
All sessions.
|
|
dtype : datetime64[ns, UTC]
|
|
UTC open time of corresponding session.
|
|
"""
|
|
return self.schedule.open
|
|
|
|
@property
|
|
def closes(self) -> pd.Series:
|
|
"""Close time of each session.
|
|
|
|
Returns
|
|
-------
|
|
pd.Series
|
|
index : pd.DatetimeIndex
|
|
All sessions.
|
|
dtype : datetime64[ns, UTC]
|
|
UTC close time of corresponding session.
|
|
"""
|
|
return self.schedule.close
|
|
|
|
@property
|
|
def break_starts(self) -> pd.Series:
|
|
"""Break start time of each session.
|
|
|
|
Returns
|
|
-------
|
|
pd.Series
|
|
index : pd.DatetimeIndex
|
|
All sessions.
|
|
dtype : datetime64[ns, UTC]
|
|
UTC break-start time of corresponding session. Value is
|
|
missing (pd.NaT) for any session that does not have a
|
|
break.
|
|
"""
|
|
return self.schedule.break_start
|
|
|
|
@property
|
|
def break_ends(self) -> pd.Series:
|
|
"""Break end time of each session.
|
|
|
|
Returns
|
|
-------
|
|
pd.Series
|
|
index : pd.DatetimeIndex
|
|
All sessions.
|
|
dtype : datetime64[ns, UTC]
|
|
UTC break-end time of corresponding session.Value is
|
|
missing (pd.NaT) for any session that does not have a
|
|
break.
|
|
"""
|
|
return self.schedule.break_end
|
|
|
|
@functools.cached_property
|
|
def first_minutes_nanos(self) -> np.ndarray:
|
|
"""Each session's first minute as an integer."""
|
|
if self.side in self._LEFT_SIDES:
|
|
return self.opens_nanos
|
|
return one_minute_later(self.opens_nanos)
|
|
|
|
@functools.cached_property
|
|
def last_minutes_nanos(self) -> np.ndarray:
|
|
"""Each session's last minute as an integer."""
|
|
if self.side in self._RIGHT_SIDES:
|
|
return self.closes_nanos
|
|
return one_minute_earlier(self.closes_nanos)
|
|
|
|
@functools.cached_property
|
|
def last_am_minutes_nanos(self) -> np.ndarray:
|
|
"""Each morning subsessions's last minute as an integer."""
|
|
if self.side in self._RIGHT_SIDES:
|
|
return self.break_starts_nanos
|
|
return one_minute_earlier(self.break_starts_nanos)
|
|
|
|
@functools.cached_property
|
|
def first_pm_minutes_nanos(self) -> np.ndarray:
|
|
"""Each afternoon subsessions's first minute as an integer."""
|
|
if self.side in self._LEFT_SIDES:
|
|
return self.break_ends_nanos
|
|
return one_minute_later(self.break_ends_nanos)
|
|
|
|
def _minutes_as_series(self, nanos: np.ndarray, name: str) -> pd.Series:
|
|
"""Convert trading minute nanos to pd.Series."""
|
|
ser = pd.Series(pd.DatetimeIndex(nanos, tz=UTC), index=self.sessions)
|
|
ser.name = name
|
|
return ser
|
|
|
|
@property
|
|
def first_minutes(self) -> pd.Series:
|
|
"""First trading minute of each session."""
|
|
return self._minutes_as_series(self.first_minutes_nanos, "first_minutes")
|
|
|
|
@property
|
|
def last_minutes(self) -> pd.Series:
|
|
"""Last trading minute of each session."""
|
|
return self._minutes_as_series(self.last_minutes_nanos, "last_minutes")
|
|
|
|
@property
|
|
def last_am_minutes(self) -> pd.Series:
|
|
"""Last am trading minute of each session."""
|
|
return self._minutes_as_series(self.last_am_minutes_nanos, "last_am_minutes")
|
|
|
|
@property
|
|
def first_pm_minutes(self) -> pd.Series:
|
|
"""First pm trading minute of each session."""
|
|
return self._minutes_as_series(self.first_pm_minutes_nanos, "first_pm_minutes")
|
|
|
|
# Properties covering all minutes.
|
|
|
|
def _minutes(
|
|
self, side: Literal["left", "right", "both", "neither"]
|
|
) -> pd.DatetimeIndex:
|
|
return pd.DatetimeIndex(
|
|
compute_minutes(
|
|
self.opens_nanos,
|
|
self.break_starts_nanos,
|
|
self.break_ends_nanos,
|
|
self.closes_nanos,
|
|
side,
|
|
),
|
|
tz=UTC,
|
|
)
|
|
|
|
@functools.cached_property
|
|
def minutes(self) -> pd.DatetimeIndex:
|
|
"""All trading minutes."""
|
|
return self._minutes(self.side)
|
|
|
|
@functools.cached_property
|
|
def minutes_nanos(self) -> np.ndarray:
|
|
"""All trading minutes as nanoseconds."""
|
|
return self.minutes.values.astype(np.int64)
|
|
|
|
# Calendar properties.
|
|
|
|
@property
|
|
def first_session(self) -> pd.Timestamp:
|
|
"""First calendar session."""
|
|
return self.sessions[0]
|
|
|
|
@property
|
|
def last_session(self) -> pd.Timestamp:
|
|
"""Last calendar session."""
|
|
return self.sessions[-1]
|
|
|
|
@property
|
|
def first_session_open(self) -> pd.Timestamp:
|
|
"""Open time of calendar's first session."""
|
|
return self.opens.iloc[0]
|
|
|
|
@property
|
|
def last_session_close(self) -> pd.Timestamp:
|
|
"""Close time of calendar's last session."""
|
|
return self.closes.iloc[-1]
|
|
|
|
@property
|
|
def first_minute(self) -> pd.Timestamp:
|
|
"""Calendar's first trading minute."""
|
|
return pd.Timestamp(self.minutes_nanos[0], tz=UTC)
|
|
|
|
@property
|
|
def last_minute(self) -> pd.Timestamp:
|
|
"""Calendar's last trading minute."""
|
|
return pd.Timestamp(self.minutes_nanos[-1], tz=UTC)
|
|
|
|
@property
|
|
def has_break(self) -> bool:
|
|
"""Query if any calendar session has a break."""
|
|
return self.sessions_has_break(
|
|
self.first_session, self.last_session, _parse=False
|
|
)
|
|
|
|
@property
|
|
def late_opens(self) -> pd.DatetimeIndex:
|
|
"""Sessions that open later than the prevailing normal open.
|
|
|
|
NB. Prevailing normal open as defined by `open_times`.
|
|
"""
|
|
return self._late_opens
|
|
|
|
@property
|
|
def early_closes(self) -> pd.DatetimeIndex:
|
|
"""Sessions that close earlier than the prevailing normal close.
|
|
|
|
NB. Prevailing normal close as defined by `close_times`.
|
|
"""
|
|
return self._early_closes
|
|
|
|
# Methods that interrogate a given session.
|
|
|
|
def _get_session_idx(self, session: Date, _parse=True) -> int:
|
|
"""Index position of a session."""
|
|
session_ = parse_session(self, session) if _parse else session
|
|
if TYPE_CHECKING:
|
|
assert isinstance(session_, pd.Timestamp)
|
|
return self.sessions_nanos.searchsorted(session_.value, side="left")
|
|
|
|
def session_open(self, session: Session, _parse: bool = True) -> pd.Timestamp:
|
|
"""Return open time for a given session."""
|
|
if _parse:
|
|
session = parse_session(self, session, "session")
|
|
return self.schedule.loc[session, "open"]
|
|
|
|
def session_close(self, session: Session, _parse: bool = True) -> pd.Timestamp:
|
|
"""Return close time for a given session."""
|
|
if _parse:
|
|
session = parse_session(self, session, "session")
|
|
return self.schedule.loc[session, "close"]
|
|
|
|
def session_break_start(
|
|
self, session: Session, _parse: bool = True
|
|
) -> pd.Timestamp | NaTType:
|
|
"""Return break-start time for a given session.
|
|
|
|
Returns pd.NaT if no break.
|
|
"""
|
|
if _parse:
|
|
session = parse_session(self, session, "session")
|
|
return self.schedule.loc[session, "break_start"]
|
|
|
|
def session_break_end(
|
|
self, session: Session, _parse: bool = True
|
|
) -> pd.Timestamp | NaTType:
|
|
"""Return break-end time for a given session.
|
|
|
|
Returns pd.NaT if no break.
|
|
"""
|
|
if _parse:
|
|
session = parse_session(self, session, "session")
|
|
return self.schedule.loc[session, "break_end"]
|
|
|
|
def session_open_close(
|
|
self, session: Session, _parse: bool = True
|
|
) -> tuple[pd.Timestamp, pd.Timestamp]:
|
|
"""Return open and close times for a given session.
|
|
|
|
Parameters
|
|
----------
|
|
session
|
|
Session for which require open and close.
|
|
|
|
Returns
|
|
-------
|
|
tuple[pd.Timestamp, pd.Timestamp]
|
|
[0] Open time of `session`.
|
|
[1] Close time of `session`.
|
|
"""
|
|
if _parse:
|
|
session = parse_session(self, session)
|
|
return self.session_open(session), self.session_close(session)
|
|
|
|
def session_break_start_end(
|
|
self, session: Session, _parse: bool = True
|
|
) -> tuple[pd.Timestamp | NaTType, pd.Timestamp | NaTType]:
|
|
"""Return break-start and break-end times for a given session.
|
|
|
|
Parameters
|
|
----------
|
|
session
|
|
Session for which require break-start and break-end.
|
|
|
|
Returns
|
|
-------
|
|
tuple[pd.Timestamp | NaTType, pd.Timestamp | NaTType]
|
|
[0] Break-start time of `session`, or pd.NaT if no break.
|
|
[1] Close time of `session`, or pd.NaT if no break.
|
|
"""
|
|
if _parse:
|
|
session = parse_session(self, session)
|
|
return self.session_break_start(session), self.session_break_end(session)
|
|
|
|
def _get_session_minute_from_nanos(
|
|
self, session: Session, nanos: np.ndarray, _parse: bool
|
|
) -> pd.Timestamp:
|
|
idx = self._get_session_idx(session, _parse=_parse)
|
|
return pd.Timestamp(nanos[idx], tz=UTC)
|
|
|
|
def session_first_minute(
|
|
self, session: Session, _parse: bool = True
|
|
) -> pd.Timestamp:
|
|
"""Return first trading minute of a given session."""
|
|
nanos = self.first_minutes_nanos
|
|
return self._get_session_minute_from_nanos(session, nanos, _parse)
|
|
|
|
def session_last_minute(
|
|
self, session: Session, _parse: bool = True
|
|
) -> pd.Timestamp:
|
|
"""Return last trading minute of a given session."""
|
|
nanos = self.last_minutes_nanos
|
|
return self._get_session_minute_from_nanos(session, nanos, _parse)
|
|
|
|
def session_last_am_minute(
|
|
self, session: Session, _parse: bool = True
|
|
) -> pd.Timestamp | pd.NaT:
|
|
"""Return last trading minute of am subsession of a given session."""
|
|
nanos = self.last_am_minutes_nanos
|
|
return self._get_session_minute_from_nanos(session, nanos, _parse)
|
|
|
|
def session_first_pm_minute(
|
|
self, session: Session, _parse: bool = True
|
|
) -> pd.Timestamp | pd.NaT:
|
|
"""Return first trading minute of pm subsession of a given session."""
|
|
nanos = self.first_pm_minutes_nanos
|
|
return self._get_session_minute_from_nanos(session, nanos, _parse)
|
|
|
|
def session_first_last_minute(
|
|
self,
|
|
session: Session,
|
|
_parse: bool = True,
|
|
) -> tuple[pd.Timestamp, pd.Timestamp]:
|
|
"""Return first and last trading minutes of a given session."""
|
|
idx = self._get_session_idx(session, _parse=_parse)
|
|
first = pd.Timestamp(self.first_minutes_nanos[idx], tz=UTC)
|
|
last = pd.Timestamp(self.last_minutes_nanos[idx], tz=UTC)
|
|
return (first, last)
|
|
|
|
def session_has_break(self, session: Session, _parse: bool = True) -> bool:
|
|
"""Query if a given session has a break.
|
|
|
|
Parameters
|
|
----------
|
|
session
|
|
Session to query.
|
|
|
|
Returns
|
|
-------
|
|
bool
|
|
True if `session` has a break, false otherwise.
|
|
"""
|
|
if _parse:
|
|
session = parse_session(self, session)
|
|
return bool(pd.notna(self.session_break_start(session)))
|
|
|
|
def next_session(self, session: Session, _parse: bool = True) -> pd.Timestamp:
|
|
"""Return session that immediately follows a given session.
|
|
|
|
Parameters
|
|
----------
|
|
session
|
|
Session whose next session is desired.
|
|
|
|
Raises
|
|
------
|
|
errors.RequestedSessionOutOfBounds
|
|
If `session` is the last calendar session.
|
|
|
|
See Also
|
|
--------
|
|
date_to_session
|
|
"""
|
|
idx = self._get_session_idx(session, _parse=_parse)
|
|
try:
|
|
return self.schedule.index[idx + 1]
|
|
except IndexError:
|
|
if idx == len(self.schedule.index) - 1:
|
|
raise errors.RequestedSessionOutOfBounds(
|
|
self, too_early=False
|
|
) from None
|
|
raise
|
|
|
|
def previous_session(self, session: Session, _parse: bool = True) -> pd.Timestamp:
|
|
"""Return session that immediately preceeds a given session.
|
|
|
|
Parameters
|
|
----------
|
|
session
|
|
Session whose previous session is desired.
|
|
|
|
Raises
|
|
------
|
|
errors.RequestedSessionOutOfBounds
|
|
If `session` is the first calendar session.
|
|
|
|
See Also
|
|
--------
|
|
date_to_session
|
|
"""
|
|
idx = self._get_session_idx(session, _parse=_parse)
|
|
if not idx:
|
|
raise errors.RequestedSessionOutOfBounds(self, too_early=True)
|
|
return self.schedule.index[idx - 1]
|
|
|
|
def session_minutes(
|
|
self, session: Session, _parse: bool = True
|
|
) -> pd.DatetimeIndex:
|
|
"""Return trading minutes corresponding to a given session.
|
|
|
|
Parameters
|
|
----------
|
|
session
|
|
Session for which require trading minutes.
|
|
|
|
Returns
|
|
-------
|
|
pd.DateTimeIndex
|
|
Trading minutes for `session`.
|
|
"""
|
|
first, last = self.session_first_last_minute(session, _parse=_parse)
|
|
return self.minutes_in_range(start=first, end=last)
|
|
|
|
def session_offset(
|
|
self, session: Session, count: int, _parse: bool = True
|
|
) -> pd.Timestamp:
|
|
"""Offset a given session by a number of sessions.
|
|
|
|
Parameters
|
|
----------
|
|
session
|
|
Session from which to offset.
|
|
|
|
count
|
|
Number of sessions to offset `session`. Positive to offset
|
|
forwards, negative to offset backwards.
|
|
|
|
Returns
|
|
-------
|
|
pd.Timestamp
|
|
Offset session.
|
|
|
|
Raises
|
|
------
|
|
exchange_calendars.errors.RequestedSessionOutOfBounds
|
|
If offset session would be either before the calendar's first
|
|
session or after the calendar's last session.
|
|
"""
|
|
idx = self._get_session_idx(session, _parse=_parse) + count
|
|
if idx >= len(self.sessions):
|
|
raise errors.RequestedSessionOutOfBounds(self, too_early=False)
|
|
if idx < 0:
|
|
raise errors.RequestedSessionOutOfBounds(self, too_early=True)
|
|
return self.sessions[idx]
|
|
|
|
# Methods that interrogate a date.
|
|
|
|
def _get_date_idx(self, date: Date, _parse=True) -> int:
|
|
"""Index position of a date.
|
|
|
|
Returns
|
|
-------
|
|
Index position of session if `date` represents a session,
|
|
otherwise index position of session that immediately
|
|
follows `date`.
|
|
"""
|
|
date_ = parse_date(date, "date", self) if _parse else date
|
|
if TYPE_CHECKING:
|
|
assert isinstance(date_, pd.Timestamp)
|
|
return self.sessions_nanos.searchsorted(date_.value, side="left")
|
|
|
|
def _date_oob(self, date: pd.Timestamp) -> bool:
|
|
"""Is `date` out-of-bounds."""
|
|
return (
|
|
date.value < self.sessions_nanos[0] or date.value > self.sessions_nanos[-1]
|
|
)
|
|
|
|
def is_session(self, date: Date, _parse: bool = True) -> bool:
|
|
"""Query if a date is a valid session.
|
|
|
|
Parameters
|
|
----------
|
|
date
|
|
Date to be queried.
|
|
|
|
Return
|
|
------
|
|
bool
|
|
True if `date` is a session, False otherwise.
|
|
"""
|
|
if _parse:
|
|
date = parse_date(date, "date", self)
|
|
idx = self._get_date_idx(date, _parse=False)
|
|
return bool(self.sessions_nanos[idx] == date.value) # convert from np.bool_
|
|
|
|
def date_to_session(
|
|
self,
|
|
date: Date,
|
|
direction: Literal["next", "previous", "none"] = "none",
|
|
_parse: bool = True,
|
|
) -> pd.Timestamp:
|
|
"""Return a session corresponding to a given date.
|
|
|
|
Parameters
|
|
----------
|
|
date
|
|
Date for which require session. Can be a date that does not
|
|
represent an actual session (see `direction`).
|
|
|
|
direction : default: "none"
|
|
Defines behaviour if `date` does not represent a session:
|
|
"next" - return first session following `date`.
|
|
"previous" - return first session prior to `date`.
|
|
"none" - raise ValueError.
|
|
|
|
See Also
|
|
--------
|
|
next_session
|
|
previous_session
|
|
"""
|
|
if _parse:
|
|
date = parse_date(date, calendar=self)
|
|
if self.is_session(date, _parse=False):
|
|
return date
|
|
if direction in ["next", "previous"]:
|
|
idx = self._get_date_idx(date, _parse=False)
|
|
if direction == "previous":
|
|
idx -= 1
|
|
return self.sessions[idx]
|
|
if direction == "none":
|
|
raise ValueError(
|
|
f"`date` '{date}' does not represent a session. Consider passing"
|
|
" a `direction`."
|
|
)
|
|
raise ValueError(
|
|
f"'{direction}' is not a valid `direction`. Valid `direction`"
|
|
' values are "next", "previous" and "none".'
|
|
)
|
|
|
|
# Methods that interrogate a given minute (trading or non-trading).
|
|
|
|
def _get_minute_idx(self, minute: Minute, _parse=True) -> int:
|
|
"""Index position of a minute.
|
|
|
|
Returns
|
|
-------
|
|
Index position of trading minute if `minute` represents a
|
|
trading minute, otherwise index position of trading
|
|
minute that immediately follows `minute`.
|
|
"""
|
|
if _parse:
|
|
minute = parse_timestamp(minute, "minute", self)
|
|
return self.minutes_nanos.searchsorted(minute.value, side="left")
|
|
|
|
def _minute_oob(self, minute: Minute) -> bool:
|
|
"""Is `minute` out-of-bounds."""
|
|
return (
|
|
minute.value < self.minutes_nanos[0]
|
|
or minute.value > self.minutes_nanos[-1]
|
|
)
|
|
|
|
def is_trading_minute(self, minute: Minute, _parse: bool = True) -> bool:
|
|
"""Query if a given minute is a trading minute.
|
|
|
|
Minutes during breaks are not considered trading minutes.
|
|
|
|
Note: `self.side` determines whether exchange will be considered
|
|
open or closed on session open, session close, break start and
|
|
break end.
|
|
|
|
Parameters
|
|
----------
|
|
minute
|
|
Minute being queried.
|
|
|
|
Returns
|
|
-------
|
|
bool
|
|
Boolean indicting if `minute` is a trading minute.
|
|
|
|
See Also
|
|
--------
|
|
is_open_on_minute
|
|
is_open_at_time
|
|
"""
|
|
if _parse:
|
|
minute = parse_timestamp(minute, calendar=self)
|
|
idx = self._get_minute_idx(minute, _parse=False)
|
|
# convert from np.bool_
|
|
return bool(self.minutes_nanos[idx] == minute.value)
|
|
|
|
def is_break_minute(self, minute: Minute, _parse: bool = True) -> bool:
|
|
"""Query if a given minute is within a break.
|
|
|
|
Note: `self.side` determines whether either, both or one of break
|
|
start and break end are treated as break minutes.
|
|
|
|
Parameters
|
|
----------
|
|
minute
|
|
Minute being queried.
|
|
|
|
Returns
|
|
-------
|
|
bool
|
|
Boolean indicting if `minute` is a break minute.
|
|
"""
|
|
if _parse:
|
|
minute = parse_timestamp(minute, calendar=self)
|
|
session_idx = np.searchsorted(self.first_minutes_nanos, minute.value) - 1
|
|
break_start = self.last_am_minutes_nanos[session_idx]
|
|
break_end = self.first_pm_minutes_nanos[session_idx]
|
|
# NaT comparisions evalute as False
|
|
numpy_bool = break_start < minute.value < break_end
|
|
return bool(numpy_bool)
|
|
|
|
def is_open_on_minute(
|
|
self, minute: Minute, ignore_breaks: bool = False, _parse: bool = True
|
|
) -> bool:
|
|
"""Query if exchange is open on a given minute.
|
|
|
|
Note: `self.side` determines whether exchange will be considered
|
|
open or closed on session open, session close, break start and
|
|
break end.
|
|
|
|
Parameters
|
|
----------
|
|
minute
|
|
Minute being queried.
|
|
|
|
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 on `minute`.
|
|
|
|
See Also
|
|
--------
|
|
is_trading_minute
|
|
is_open_at_time
|
|
"""
|
|
if _parse:
|
|
minute = parse_timestamp(minute, "minute", self)
|
|
|
|
is_trading_minute = self.is_trading_minute(minute, _parse=False)
|
|
if is_trading_minute or not ignore_breaks:
|
|
return is_trading_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 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 bool(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 bool((bv_am | bv_pm).any())
|
|
|
|
def next_open(self, minute: Minute, _parse: bool = True) -> pd.Timestamp:
|
|
"""Return next open that follows a given minute.
|
|
|
|
If `minute` is a session open, the next session's open will be
|
|
returned.
|
|
|
|
Parameters
|
|
----------
|
|
minute
|
|
Minute for which to get the next open.
|
|
|
|
Returns
|
|
-------
|
|
pd.Timestamp
|
|
UTC timestamp of the next open.
|
|
"""
|
|
if _parse:
|
|
minute = parse_timestamp(minute, "minute", self)
|
|
try:
|
|
idx = next_divider_idx(self.opens_nanos, minute.value)
|
|
except IndexError:
|
|
if minute >= self.opens.iloc[-1]:
|
|
raise ValueError(
|
|
"Minute cannot be the last open or later (received `minute`"
|
|
f" parsed as '{minute}'.)"
|
|
) from None
|
|
raise
|
|
|
|
return pd.Timestamp(self.opens_nanos[idx], tz=UTC)
|
|
|
|
def next_close(self, minute: Minute, _parse: bool = True) -> pd.Timestamp:
|
|
"""Return next close that follows a given minute.
|
|
|
|
If `minute` is a session close, the next session's close will be
|
|
returned.
|
|
|
|
Parameters
|
|
----------
|
|
minute
|
|
Minute for which to get the next close.
|
|
|
|
Returns
|
|
-------
|
|
pd.Timestamp
|
|
UTC timestamp of the next close.
|
|
"""
|
|
if _parse:
|
|
minute = parse_timestamp(minute, "minute", self)
|
|
try:
|
|
idx = next_divider_idx(self.closes_nanos, minute.value)
|
|
except IndexError:
|
|
if minute == self.closes.iloc[-1]:
|
|
raise ValueError(
|
|
"Minute cannot be the last close (received `minute` parsed as"
|
|
f" '{minute}'.)"
|
|
) from None
|
|
raise
|
|
return pd.Timestamp(self.closes_nanos[idx], tz=UTC)
|
|
|
|
def previous_open(self, minute: Minute, _parse: bool = True) -> pd.Timestamp:
|
|
"""Return previous open that preceeds a given minute.
|
|
|
|
If `minute` is a session open, the previous session's open will be
|
|
returned.
|
|
|
|
Parameters
|
|
----------
|
|
minute
|
|
Minute for which to get the previous open.
|
|
|
|
Returns
|
|
-------
|
|
pd.Timestamp
|
|
UTC timestamp of the previous open.
|
|
"""
|
|
if _parse:
|
|
minute = parse_timestamp(minute, "minute", self)
|
|
try:
|
|
idx = previous_divider_idx(self.opens_nanos, minute.value)
|
|
except ValueError:
|
|
if minute == self.opens.iloc[0]:
|
|
raise ValueError(
|
|
"Minute cannot be the first open (received `minute` parsed as"
|
|
f" '{minute}'.)"
|
|
) from None
|
|
raise
|
|
|
|
return pd.Timestamp(self.opens_nanos[idx], tz=UTC)
|
|
|
|
def previous_close(self, minute: Minute, _parse: bool = True) -> pd.Timestamp:
|
|
"""Return previous close that preceeds a given minute.
|
|
|
|
If `minute` is a session close, the previous session's close will be
|
|
returned.
|
|
|
|
Parameters
|
|
----------
|
|
minute
|
|
Minute for which to get the previous close.
|
|
|
|
Returns
|
|
-------
|
|
pd.Timestamp
|
|
UTC timestamp of the previous close.
|
|
"""
|
|
if _parse:
|
|
minute = parse_timestamp(minute, "minute", self)
|
|
try:
|
|
idx = previous_divider_idx(self.closes_nanos, minute.value)
|
|
except ValueError:
|
|
if minute <= self.closes.iloc[0]:
|
|
raise ValueError(
|
|
"Minute cannot be the first close or earlier (received"
|
|
f" `minute` parsed as '{minute}'.)"
|
|
) from None
|
|
raise
|
|
|
|
return pd.Timestamp(self.closes_nanos[idx], tz=UTC)
|
|
|
|
def next_minute(self, minute: Minute, _parse: bool = True) -> pd.Timestamp:
|
|
"""Return trading minute that immediately follows a given minute.
|
|
|
|
Parameters
|
|
----------
|
|
minute
|
|
Minute for which to get next trading minute. Minute can be a
|
|
trading or a non-trading minute.
|
|
|
|
Returns
|
|
-------
|
|
pd.Timestamp
|
|
UTC timestamp of the next minute.
|
|
|
|
Raises
|
|
------
|
|
errors.RequestedSessionOutOfBounds
|
|
If `minute` is the last calendar minute.
|
|
"""
|
|
if _parse:
|
|
minute = parse_timestamp(minute, "minute", self)
|
|
try:
|
|
idx = next_divider_idx(self.minutes_nanos, minute.value)
|
|
except IndexError:
|
|
# dt > last_minute handled via parsing
|
|
if minute == self.last_minute:
|
|
raise errors.RequestedMinuteOutOfBounds(self, too_early=False) from None
|
|
return self.minutes[idx]
|
|
|
|
def previous_minute(self, minute: Minute, _parse: bool = True) -> pd.Timestamp:
|
|
"""Return trading minute that immediately preceeds a given minute.
|
|
|
|
Parameters
|
|
----------
|
|
minute
|
|
Minute for which to get previous trading minute. Minute can be
|
|
a trading or a non-trading minute.
|
|
|
|
Returns
|
|
-------
|
|
pd.Timestamp
|
|
UTC timestamp of the previous minute.
|
|
|
|
Raises
|
|
------
|
|
errors.RequestedSessionOutOfBounds
|
|
If `minute` is the first calendar minute.
|
|
"""
|
|
if _parse:
|
|
minute = parse_timestamp(minute, "minute", self)
|
|
try:
|
|
idx = previous_divider_idx(self.minutes_nanos, minute.value)
|
|
except ValueError:
|
|
# dt < first_minute handled via parsing
|
|
if minute == self.first_minute:
|
|
raise errors.RequestedMinuteOutOfBounds(self, too_early=True) from None
|
|
return self.minutes[idx]
|
|
|
|
def minute_to_session( # noqa: C901
|
|
self,
|
|
minute: Minute,
|
|
direction: Literal["next", "previous", "none"] = "next",
|
|
_parse: bool = True,
|
|
) -> pd.Timestamp:
|
|
"""Get session corresponding with a trading or break minute.
|
|
|
|
Parameters
|
|
----------
|
|
minute
|
|
Minute for which require corresponding session.
|
|
|
|
direction
|
|
How to resolve session in event that `minute` is not a trading
|
|
or break minute:
|
|
"next" (default) - return first session subsequent to
|
|
`minute`.
|
|
"previous" - return first session prior to `minute`.
|
|
"none" - raise ValueError.
|
|
|
|
Returns
|
|
-------
|
|
pd.Timestamp
|
|
Corresponding session label.
|
|
|
|
Raises
|
|
------
|
|
ValueError
|
|
If `minute` is not a trading minute and `direction` is "none".
|
|
|
|
See Also
|
|
--------
|
|
minute_to_past_session
|
|
minute_to_future_session
|
|
session_offset
|
|
"""
|
|
if _parse:
|
|
minute = parse_timestamp(minute, calendar=self)
|
|
|
|
if minute.value < self.minutes_nanos[0]:
|
|
# Resolve call here.
|
|
if direction == "next":
|
|
return self.first_session
|
|
raise ValueError(
|
|
f"Received `minute` as '{minute}' although this is earlier than the"
|
|
f" calendar's first trading minute ({self.first_minute}). Consider"
|
|
" passing `direction` as 'next' to get first session."
|
|
)
|
|
|
|
if minute.value > self.minutes_nanos[-1]:
|
|
# Resolve call here.
|
|
if direction == "previous":
|
|
return self.last_session
|
|
raise ValueError(
|
|
f"Received `minute` as '{minute}' although this is later than the"
|
|
f" calendar's last trading minute ({self.last_minute}). Consider"
|
|
" passing `direction` as 'previous' to get last session."
|
|
)
|
|
|
|
idx = np.searchsorted(self.last_minutes_nanos, minute.value)
|
|
current_or_next_session = self.schedule.index[idx]
|
|
|
|
if direction == "next":
|
|
return current_or_next_session
|
|
if direction == "previous":
|
|
if not self.is_open_on_minute(minute, ignore_breaks=True, _parse=False):
|
|
return self.schedule.index[idx - 1]
|
|
elif direction == "none":
|
|
if not self.is_open_on_minute(minute, ignore_breaks=True, _parse=False):
|
|
# if the exchange is closed, blow up
|
|
raise ValueError(
|
|
f"`minute` '{minute}' is not a trading minute. Consider passing"
|
|
" `direction` as 'next' or 'previous'."
|
|
)
|
|
else:
|
|
# invalid direction
|
|
raise ValueError(f"Invalid direction parameter: {direction}")
|
|
|
|
return current_or_next_session
|
|
|
|
def minute_to_past_session(
|
|
self, minute: Minute, count: int = 1, _parse: bool = True
|
|
) -> pd.Timestamp:
|
|
"""Get a session that closed before a given minute.
|
|
|
|
Parameters
|
|
----------
|
|
minute
|
|
Minute for which to return a previous session. Can be a
|
|
trading minute or non-trading minute.
|
|
Note: if `minute` is a trading minute then returned session
|
|
will not be the session of which `minute` is a trading minute,
|
|
but rather a session that closed before `minute`.
|
|
|
|
count : default: 1
|
|
Number of sessions prior to `minute` for which require session.
|
|
|
|
Returns
|
|
-------
|
|
pd.Timstamp
|
|
Session that is `count` full sessions before `minute`.
|
|
|
|
See Also
|
|
--------
|
|
minute_to_session
|
|
minute_to_future_session
|
|
session_offset
|
|
"""
|
|
if _parse:
|
|
minute = parse_timestamp(minute, calendar=self)
|
|
if count <= 0:
|
|
raise ValueError("`count` must be higher than 0.")
|
|
if self.is_open_on_minute(minute, ignore_breaks=True, _parse=False):
|
|
current_session = self.minute_to_session(minute, _parse=False)
|
|
base_session = self.previous_session(current_session, _parse=False)
|
|
else:
|
|
base_session = self.minute_to_session(minute, "previous", _parse=False)
|
|
count -= 1
|
|
return self.session_offset(base_session, -count, _parse=False)
|
|
|
|
def minute_to_future_session(
|
|
self,
|
|
minute: Minute,
|
|
count: int = 1,
|
|
_parse: bool = True,
|
|
) -> pd.Timestamp:
|
|
"""Get a session that opens after a given minute.
|
|
|
|
Parameters
|
|
----------
|
|
minute
|
|
Minute for which to return a future session. Can be a trading
|
|
minute or non-trading minute.
|
|
Note: if `minute` is a trading minute then returned session
|
|
will not be the session of which `minute` is a trading minute,
|
|
but rather a session that opens after `minute`.
|
|
|
|
count : default: 1
|
|
Number of sessions following `minute` for which require
|
|
session.
|
|
|
|
Returns
|
|
-------
|
|
pd.Timstamp
|
|
Session that is `count` full sessions after `minute`.
|
|
|
|
See Also
|
|
--------
|
|
minute_to_session
|
|
minute_to_past_session
|
|
session_offset
|
|
"""
|
|
if _parse:
|
|
minute = parse_timestamp(minute, calendar=self)
|
|
if count <= 0:
|
|
raise ValueError("`count` must be higher than 0.")
|
|
if self.is_open_on_minute(minute, ignore_breaks=True, _parse=False):
|
|
current_session = self.minute_to_session(minute, _parse=False)
|
|
base_session = self.next_session(current_session, _parse=False)
|
|
else:
|
|
base_session = self.minute_to_session(minute, "next", _parse=False)
|
|
count -= 1
|
|
return self.session_offset(base_session, count, _parse=False)
|
|
|
|
def minute_to_trading_minute(
|
|
self,
|
|
minute: Minute,
|
|
direction: Literal["next", "previous", "none"] = "none",
|
|
_parse: bool = True,
|
|
) -> pd.Timestamp:
|
|
"""Resolve a minute to a trading minute.
|
|
|
|
Differs from `previous_minute` and `next_minute` by returning
|
|
`minute` unchanged if `minute` is a trading minute.
|
|
|
|
Parameters
|
|
----------
|
|
minute
|
|
Timestamp to be resolved to a trading minute.
|
|
|
|
direction:
|
|
How to resolve `minute` if does not represent a trading minute:
|
|
'next' - return trading minute that immediately follows
|
|
`minute`.
|
|
'previous' - return trading minute that immediately
|
|
preceeds `minute`.
|
|
'none' - raise KeyError
|
|
|
|
Returns
|
|
-------
|
|
pd.Timestamp
|
|
Returns `minute` if `minute` is a trading minute otherwise
|
|
first trading minute that, in accordance with `direction`,
|
|
either immediately follows or preceeds `minute`.
|
|
|
|
Raises
|
|
------
|
|
ValueError
|
|
If `minute` is not a trading minute and `direction` is None.
|
|
|
|
See Also
|
|
--------
|
|
next_mintue
|
|
previous_minute
|
|
"""
|
|
if _parse:
|
|
minute = parse_timestamp(minute, calendar=self)
|
|
if self.is_trading_minute(minute, _parse=False):
|
|
return minute
|
|
if direction == "next":
|
|
return self.next_minute(minute, _parse=False)
|
|
if direction == "previous":
|
|
return self.previous_minute(minute, _parse=False)
|
|
raise ValueError(
|
|
f"`minute` '{minute}' is not a trading minute. Consider passing"
|
|
" `direction` as 'next' or 'previous'."
|
|
)
|
|
|
|
def minute_offset(
|
|
self, minute: TradingMinute, count: int, _parse: bool = True
|
|
) -> pd.Timestamp:
|
|
"""Offset a given trading minute by a number of trading minutes.
|
|
|
|
Parameters
|
|
----------
|
|
minute
|
|
Trading minute from which to offset.
|
|
|
|
count
|
|
Number of trading minutes to offset `minute`. Positive to
|
|
offset forwards, negative to offset backwards.
|
|
|
|
Returns
|
|
-------
|
|
pd.Timstamp
|
|
Offset trading minute.
|
|
|
|
Raises
|
|
------
|
|
ValueError
|
|
If offset minute would be either before the calendar's first
|
|
trading minute or after the calendar's last trading minute.
|
|
"""
|
|
if _parse:
|
|
minute = parse_trading_minute(self, minute)
|
|
idx = self._get_minute_idx(minute) + count
|
|
if idx >= len(self.minutes_nanos):
|
|
raise errors.RequestedMinuteOutOfBounds(self, too_early=False)
|
|
if idx < 0:
|
|
raise errors.RequestedMinuteOutOfBounds(self, too_early=True)
|
|
return self.minutes[idx]
|
|
|
|
def minute_offset_by_sessions( # noqa: C901, PLR0912
|
|
self,
|
|
minute: TradingMinute,
|
|
count: int = 1,
|
|
_parse: bool = True,
|
|
) -> pd.Timestamp:
|
|
"""Offset trading minute by a given number of sessions.
|
|
|
|
If trading minute is not represented in target session (due to a late
|
|
open for example) then offset minute will be rolled (with respect to
|
|
the target session):
|
|
- forwards to first session minute, if offset minute otherwise
|
|
falls earlier than first session minute.
|
|
- back to last session minute, if offset minute otherwise falls
|
|
later than last session minute.
|
|
- back to last minute before break, if offset otherwise
|
|
falls in session break.
|
|
|
|
Parameters
|
|
----------
|
|
minute
|
|
Trading minute to be offset.
|
|
|
|
count
|
|
Number of sessions by which to offset trading minute. Negative
|
|
to offset to an earlier session.
|
|
"""
|
|
if _parse:
|
|
minute = parse_trading_minute(self, minute)
|
|
if not count:
|
|
return minute
|
|
|
|
if count > 0:
|
|
try:
|
|
target_session = self.minute_to_future_session(minute, abs(count))
|
|
except errors.RequestedSessionOutOfBounds:
|
|
raise errors.RequestedMinuteOutOfBounds(self, too_early=False) from None
|
|
else:
|
|
try:
|
|
target_session = self.minute_to_past_session(minute, abs(count))
|
|
except errors.RequestedSessionOutOfBounds:
|
|
raise errors.RequestedMinuteOutOfBounds(self, too_early=True) from None
|
|
|
|
base_session = self.minute_to_session(minute)
|
|
day_offset = (minute.normalize() - base_session.tz_localize(UTC)).days
|
|
|
|
minute = target_session.replace(hour=minute.hour, minute=minute.minute)
|
|
minute = minute.tz_localize(UTC)
|
|
minute += pd.Timedelta(days=day_offset)
|
|
|
|
if self._minute_oob(minute):
|
|
if minute.value < self.minutes_nanos[0]:
|
|
errors.RequestedMinuteOutOfBounds(self, too_early=True)
|
|
if minute.value > self.minutes_nanos[-1]:
|
|
raise errors.RequestedMinuteOutOfBounds(self, too_early=False)
|
|
|
|
if self.is_trading_minute(minute, _parse=False) and (
|
|
self.minute_to_session(minute, _parse=False) == target_session
|
|
):
|
|
# this guard is necessary as minute can be for a different session than the
|
|
# intended if the gap between sessions is less than any difference in the
|
|
# open or close times (i.e. only relevant if base and target sessions have
|
|
# different open/close times.
|
|
return minute
|
|
first_minute = self.session_first_minute(target_session, _parse=False)
|
|
if minute < first_minute:
|
|
return first_minute
|
|
last_minute = self.session_last_minute(target_session, _parse=False)
|
|
if minute > last_minute:
|
|
return last_minute
|
|
if self.is_break_minute(minute, _parse=False):
|
|
return self.session_last_am_minute(target_session, _parse=False)
|
|
raise AssertionError("offset minute should have resolved!")
|
|
|
|
# Methods that evaluate or interrogate a range of minutes.
|
|
|
|
def _get_minutes_slice(self, start: Minute, end: Minute, _parse=True) -> slice:
|
|
"""Slice representing a range of trading minutes."""
|
|
if _parse:
|
|
start = parse_timestamp(start, "start", self)
|
|
end = parse_timestamp(end, "end", self)
|
|
slice_start = self.minutes_nanos.searchsorted(start.value, side="left")
|
|
slice_end = self.minutes_nanos.searchsorted(end.value, side="right")
|
|
return slice(slice_start, slice_end)
|
|
|
|
def minutes_in_range(
|
|
self, start: Minute, end: Minute, _parse: bool = True
|
|
) -> pd.DatetimeIndex:
|
|
"""Return all trading minutes between given minutes.
|
|
|
|
Parameters
|
|
----------
|
|
start
|
|
Minute representing start of desired range. Can be a trading
|
|
minute or non-trading minute.
|
|
|
|
end
|
|
Minute representing end of desired range. Can be a trading
|
|
minute or non-trading minute.
|
|
"""
|
|
slc = self._get_minutes_slice(start, end, _parse)
|
|
return self.minutes[slc]
|
|
|
|
def minutes_window(
|
|
self, minute: TradingMinute, count: int, _parse: bool = True
|
|
) -> pd.DatetimeIndex:
|
|
"""Return block of given size of consecutive trading minutes.
|
|
|
|
Parameters
|
|
----------
|
|
minute
|
|
Minute representing the first (if `count` positive) or last
|
|
(if `count` negative) minute of minutes window.
|
|
|
|
count
|
|
Number of mintues to include in window.
|
|
Positive to return a block of minutes from `minute`
|
|
Negative to return a block of minutes to `minute`.
|
|
"""
|
|
if not count:
|
|
raise ValueError("`count` cannot be 0.")
|
|
if _parse:
|
|
minute = parse_trading_minute(self, minute, "minute")
|
|
|
|
start_idx = self._get_minute_idx(minute, _parse=False)
|
|
end_idx = start_idx + count + (-1 if count > 0 else 1)
|
|
|
|
if end_idx < 0:
|
|
raise ValueError(
|
|
f"Minutes window cannot begin before the calendar's first minute"
|
|
f" ({self.first_minute}). `count` cannot be lower than"
|
|
f" {count - end_idx} for `minute` '{minute}'."
|
|
)
|
|
if end_idx >= len(self.minutes_nanos):
|
|
raise ValueError(
|
|
f"Minutes window cannot end after the calendar's last minute"
|
|
f" ({self.last_minute}). `count` cannot be higher than"
|
|
f" {count - (end_idx - len(self.minutes_nanos) + 1)} for `minute`"
|
|
f" '{minute}'."
|
|
)
|
|
return self.minutes[min(start_idx, end_idx) : max(start_idx, end_idx) + 1]
|
|
|
|
def minutes_distance(self, start: Minute, end: Minute, _parse: bool = True) -> int:
|
|
"""Return the number of minutes in a range.
|
|
|
|
Parameters
|
|
----------
|
|
start
|
|
Start of minute range (range inclusive of `start`).
|
|
|
|
end
|
|
End of minute range (range inclusive of `end`).
|
|
|
|
Returns
|
|
-------
|
|
int
|
|
Number of minutes in minute range, If `start` is later than
|
|
`end` then return will be negated.
|
|
"""
|
|
if _parse:
|
|
start = parse_timestamp(start, "start", self)
|
|
end = parse_timestamp(end, "end", self)
|
|
negate = end < start
|
|
if negate:
|
|
start, end = end, start
|
|
slc = self._get_minutes_slice(start, end, _parse=False)
|
|
return slc.start - slc.stop if negate else slc.stop - slc.start
|
|
|
|
def minutes_to_sessions(self, minutes: pd.DatetimeIndex) -> pd.DatetimeIndex:
|
|
"""Return sessions corresponding to multiple trading minutes.
|
|
|
|
For the purpose of this method trading minutes are considered as:
|
|
- Trading minutes as determined by `self.side`.
|
|
- All minutes of any breaks.
|
|
|
|
Parameters
|
|
----------
|
|
minutes
|
|
Sorted DatetimeIndex representing market minutes for which to get
|
|
corresponding sessions.
|
|
|
|
Returns
|
|
-------
|
|
pd.DatetimeIndex
|
|
Sessions corresponding to `minutes`.
|
|
|
|
Raises
|
|
------
|
|
ValueError
|
|
If any indice of `minute` is not a trading minute.
|
|
"""
|
|
if not minutes.is_monotonic_increasing:
|
|
raise ValueError("`index` must be ordered.")
|
|
# Find the indices of the previous first session minute and the next
|
|
# last session minute for each minute.
|
|
index_nanos = minutes.values.astype(np.int64)
|
|
first_min_nanos = self.first_minutes_nanos
|
|
last_min_nanos = self.last_minutes_nanos
|
|
prev_first_mins_idxs = (
|
|
first_min_nanos.searchsorted(index_nanos, side="right") - 1
|
|
)
|
|
next_last_mins_idxs = last_min_nanos.searchsorted(index_nanos, side="left")
|
|
|
|
# If they don't match, the minute is outside the trading day. Barf.
|
|
mismatches = prev_first_mins_idxs != next_last_mins_idxs
|
|
if mismatches.any():
|
|
# Show the first bad minute in the error message.
|
|
bad_ix = np.flatnonzero(mismatches)[0]
|
|
example = minutes[bad_ix]
|
|
|
|
prev_session_idx = prev_first_mins_idxs[bad_ix]
|
|
prev_first_min = pd.Timestamp(first_min_nanos[prev_session_idx], tz=UTC)
|
|
prev_last_min = pd.Timestamp(last_min_nanos[prev_session_idx], tz=UTC)
|
|
next_first_min = pd.Timestamp(first_min_nanos[prev_session_idx + 1], tz=UTC)
|
|
next_last_min = pd.Timestamp(last_min_nanos[prev_session_idx + 1], tz=UTC)
|
|
|
|
raise ValueError(
|
|
f"{mismatches.sum()} non-trading minutes in"
|
|
f" minutes_to_sessions:\nFirst Bad Minute: {example}\n"
|
|
f"Previous Session: {prev_first_min} -> {prev_last_min}\n"
|
|
f"Next Session: {next_first_min} -> {next_last_min}"
|
|
)
|
|
|
|
return self.schedule.index[prev_first_mins_idxs]
|
|
|
|
# Methods that evaluate or interrogate a range of sessions.
|
|
|
|
def _parse_start_end_dates(
|
|
self, start: Date, end: Date, _parse: bool
|
|
) -> tuple[pd.Timestamp, pd.Timestamp]:
|
|
if not _parse:
|
|
return start, end
|
|
return parse_date(start, "start", self), parse_date(end, "end", self)
|
|
|
|
def _get_sessions_slice(self, start: Date, end: Date, _parse=True) -> slice:
|
|
"""Slice representing a range of sessions."""
|
|
start, end = self._parse_start_end_dates(start, end, _parse)
|
|
slice_start = self.sessions_nanos.searchsorted(start.value, side="left")
|
|
slice_end = self.sessions_nanos.searchsorted(end.value, side="right")
|
|
return slice(slice_start, slice_end)
|
|
|
|
def sessions_in_range(
|
|
self, start: Date, end: Date, _parse: bool = True
|
|
) -> pd.DatetimeIndex:
|
|
"""Return sessions within a given range.
|
|
|
|
Parameters
|
|
----------
|
|
start
|
|
Start of session range (range inclusive of `start`).
|
|
|
|
end
|
|
End of session range (range inclusive of `end`).
|
|
|
|
Returns
|
|
-------
|
|
pd.DatetimeIndex
|
|
Sessions from `start` through `end`.
|
|
"""
|
|
slc = self._get_sessions_slice(start, end, _parse)
|
|
return self.sessions[slc]
|
|
|
|
def sessions_has_break(self, start: Date, end: Date, _parse: bool = True) -> bool:
|
|
"""Query if at least one session in a session range has a break.
|
|
|
|
Parameters
|
|
----------
|
|
start
|
|
Start of session range (range inclusive of `start`).
|
|
|
|
end
|
|
End of session range (range inclusive of `end`).
|
|
|
|
Returns
|
|
-------
|
|
bool
|
|
True if any session in session range has a break, False otherwise.
|
|
"""
|
|
slc = self._get_sessions_slice(start, end, _parse)
|
|
return self.break_starts[slc].notna().any()
|
|
|
|
def sessions_window(
|
|
self, session: Session, count: int, _parse: bool = True
|
|
) -> pd.DatetimeIndex:
|
|
"""Return block of given size of consecutive sessions.
|
|
|
|
Parameters
|
|
----------
|
|
session
|
|
Session representing the first (if `count` positive) or last
|
|
(if `count` negative) session of session window.
|
|
|
|
count
|
|
Number of sessions to include in window.
|
|
Positive to return window of sessions from `session`
|
|
Negative to return window of sessions to `session`.
|
|
"""
|
|
if not count:
|
|
raise ValueError("`count` cannot be 0.")
|
|
if _parse:
|
|
session = parse_session(self, session, "session")
|
|
start_idx = self._get_session_idx(session, _parse=False)
|
|
end_idx = start_idx + count + (-1 if count > 0 else 1)
|
|
if end_idx < 0:
|
|
raise ValueError(
|
|
f"Sessions window cannot begin before the first calendar session"
|
|
f" ({self.first_session}). `count` cannot be lower than"
|
|
f" {count - end_idx} for `session` '{session}'."
|
|
)
|
|
if end_idx >= len(self.sessions):
|
|
raise ValueError(
|
|
f"Sessions window cannot end after the last calendar session"
|
|
f" ({self.last_session}). `count` cannot be higher than"
|
|
f" {count - (end_idx - len(self.sessions) + 1)} for"
|
|
f" `session` '{session}'."
|
|
)
|
|
return self.sessions[min(start_idx, end_idx) : max(start_idx, end_idx) + 1]
|
|
|
|
def sessions_distance(self, start: Date, end: Date, _parse: bool = True) -> int:
|
|
"""Return the number of sessions in a range.
|
|
|
|
Parameters
|
|
----------
|
|
start
|
|
Start of session range (range inclusive of `start`).
|
|
|
|
end
|
|
End of session range (range inclusive of `end`).
|
|
|
|
Returns
|
|
-------
|
|
int
|
|
Number of sessions in session range, If `start` is later than
|
|
`end` then return will be negated.
|
|
"""
|
|
start, end = self._parse_start_end_dates(start, end, _parse)
|
|
negate = end < start
|
|
if negate:
|
|
start, end = end, start
|
|
slc = self._get_sessions_slice(start, end, _parse=False)
|
|
dist = slc.start - slc.stop if negate else slc.stop - slc.start
|
|
return int(dist) # otherwise returned as `numpy.int64`
|
|
|
|
def sessions_minutes(
|
|
self, start: Date, end: Date, _parse: bool = True
|
|
) -> pd.DatetimeIndex:
|
|
"""Return trading minutes over a sessions range.
|
|
|
|
Parameters
|
|
----------
|
|
start
|
|
Start of session range (range inclusive of `start`).
|
|
|
|
end
|
|
End of session range (range inclusive of `end`).
|
|
|
|
Returns
|
|
-------
|
|
pd.DatetimeIndex
|
|
Trading minutes for sessions in range.
|
|
"""
|
|
start, end = self._parse_start_end_dates(start, end, _parse)
|
|
start = self.date_to_session(start, "next", _parse=False)
|
|
end = self.date_to_session(end, "previous", _parse=False)
|
|
first_minute = self.session_first_minute(start)
|
|
last_minute = self.session_last_minute(end)
|
|
return self.minutes_in_range(first_minute, last_minute)
|
|
|
|
def sessions_minutes_count(
|
|
self, start: Date, end: Date, _parse: bool = True
|
|
) -> int:
|
|
"""Return number of trading minutes in a range of sessions.
|
|
|
|
Parameters
|
|
----------
|
|
start
|
|
Start of session range (range inclusive of `start`).
|
|
|
|
end
|
|
End of session range (range inclusive of `end`).
|
|
|
|
Returns
|
|
-------
|
|
int
|
|
Total number of trading minutes in sessions range.
|
|
"""
|
|
slc = self._get_sessions_slice(start, end, _parse)
|
|
session_diff = self.last_minutes_nanos[slc] - self.first_minutes_nanos[slc]
|
|
session_diff += NANOSECONDS_PER_MINUTE
|
|
break_diff = self.first_pm_minutes_nanos[slc] - self.last_am_minutes_nanos[slc]
|
|
break_diff[break_diff != 0] -= NANOSECONDS_PER_MINUTE
|
|
nanos = session_diff - break_diff
|
|
return (nanos // NANOSECONDS_PER_MINUTE).sum()
|
|
|
|
def trading_index( # noqa: C901
|
|
self,
|
|
start: Date | Minute,
|
|
end: Date | Minute,
|
|
period: pd.Timedelta | str,
|
|
intervals: bool = True,
|
|
closed: Literal["left", "right", "both", "neither"] = "left",
|
|
force_close: bool = False,
|
|
force_break_close: bool = False,
|
|
force: bool | None = None,
|
|
curtail_overlaps: bool = False,
|
|
ignore_breaks: bool = False,
|
|
align: pd.Timedelta | str = ONE_MINUTE,
|
|
align_pm: pd.Timedelta | bool = True,
|
|
parse: bool = True,
|
|
) -> pd.DatetimeIndex | pd.IntervalIndex:
|
|
"""Create a trading index.
|
|
|
|
Create a trading index of given `period` over a given range of
|
|
dates.
|
|
|
|
NB. Which minutes the calendar treats as trading minutes, according
|
|
to `self.side`, is irrelevant in the evaluation of the trading
|
|
index.
|
|
|
|
NB. Execution time is related to the number of indices created. The
|
|
longer the range of dates covered and/or the shorter the period
|
|
(i.e. higher the frequency), the longer the execution. Whilst an
|
|
index with 4000 indices might be created in a couple of
|
|
miliseconds, a high frequency index with 2 million indices might
|
|
take a second or two.
|
|
|
|
Parameters
|
|
----------
|
|
start
|
|
Timestamp representing start of index.
|
|
|
|
If `start` is passed as a date then the first indice will be:
|
|
if `start` is a session, then the first indice of that
|
|
session (i.e. the left side of the first indice will be
|
|
the session open).
|
|
otherwise, the first indice of the nearest session
|
|
following `start`.
|
|
|
|
If `start` is passed as a minute then the first indice will be:
|
|
if `start` coincides with (the left side of*) an indice,
|
|
then that indice.
|
|
otherwise the nearest indice to `start` (with a left side*)
|
|
that is later than `start`.
|
|
* if `intervals` is True (default)
|
|
|
|
`start` will be interpreted as a date if it is timezone-naive
|
|
and does not have a time component (or any time component is
|
|
00:00). Otherwise `start` will be interpreted as a time.
|
|
|
|
If `period` is one day ("1D") then `start` must be passed as
|
|
a date. The first indice will be either `start`, if `start` is
|
|
a session, or otherwise the nearest session following `start`.
|
|
|
|
end
|
|
Timestamp representing end of index.
|
|
|
|
If `end` is passed as a date then the last indice will be:
|
|
if `end` is a session, then the last indice of that
|
|
session (i.e. either the right side of the final indice
|
|
will be the session close or the final indice will
|
|
contain the session close).
|
|
otherwise, the last indice of the nearest session
|
|
preceeding `end`.
|
|
|
|
If `end` is passed as a minute then the last indice will be:
|
|
if `end` coincides with (the right side of*) an indice,
|
|
then that indice.
|
|
otherwise the nearest indice to `end` (with a right side*)
|
|
that is earlier than `end`.
|
|
* if `intervals` is True (default)
|
|
|
|
`end` will be interpreted as a date if it is timezone-naive
|
|
and does not have a time component (or any time component is
|
|
00:00). Otherwise `start` will be interpreted as a time.
|
|
|
|
If `period` is one day ("1d") then `end` must be passed as
|
|
a date. The last indice will be either `end`, if `end` is
|
|
a session, or otherwise the nearest session prceeding `end`.
|
|
|
|
period
|
|
If `intervals` is True, the length of each interval. If
|
|
`intervals` is False, the distance between indices. Period
|
|
should be passed as a pd.Timedelta or a str that's acceptable
|
|
as a single input to pd.Timedelta. `period` cannot be greater
|
|
than 1 day.
|
|
|
|
Examples of valid `period` input:
|
|
pd.Timedelta(minutes=15), pd.Timedelta(minutes=15, hours=2)
|
|
'15min', '15T', '1H', '4h', '1D', '30s', '2s', '500ms'.
|
|
Examples of invalid `period` input:
|
|
'15minutes', '2D'.
|
|
|
|
intervals : default: True
|
|
True to return trading index as a pd.IntervalIndex with indices
|
|
representing explicit intervals.
|
|
|
|
False to return trading index as a pd.DatetimeIndex with
|
|
indices that implicitely represent a period according to
|
|
`closed`.
|
|
|
|
If `period` is '1D' then trading index will be returned as a
|
|
pd.DatetimeIndex.
|
|
|
|
closed : {"left", "right", "both", "neither"}
|
|
(ignored if `period` is '1D'.)
|
|
|
|
If `intervals` is True, the side that intervals should be
|
|
closed on. Must be either "left" or "right" (any time during a
|
|
session must belong to one interval and one interval only).
|
|
|
|
If `intervals` is False, the side of each period that an
|
|
indice should be defined. The first and last indices of each
|
|
(sub)session will be defined according to:
|
|
"left" - include left side of first period, do not include
|
|
right side of last period.
|
|
"right" - do not include left side of first period, include
|
|
right side of last period.
|
|
"both" - include both left side of first period and right
|
|
side of last period.
|
|
"neither" - do not include either left side of first period
|
|
or right side of last period.
|
|
NB if `period` is not a factor of the (sub)session length then
|
|
"right" or "both" will result in an indice being defined after
|
|
the (sub)session close. See `force_close` and
|
|
`force_break_close`.
|
|
|
|
force_close : default: False
|
|
(ignored if `force` is passed.)
|
|
(ignored if `period` is '1D')
|
|
(irrelevant if `intervals` is False and `closed` is "left" or
|
|
"neither")
|
|
|
|
Defines behaviour if right side of a session's last period
|
|
falls after the session close.
|
|
|
|
If True, defines right side of this period as session close.
|
|
|
|
If False, defines right side of this period after the session
|
|
close. In this case the represented period will include a
|
|
non-trading period.
|
|
|
|
force_break_close : default: False
|
|
(ignored if `force` is passed.)
|
|
(ignored if `period` is '1D'.)
|
|
(irrelevant if `intervals` is False and `closed` is "left" or
|
|
"neither.)
|
|
|
|
Defines behaviour if right side of last pre-break period falls
|
|
after the start of the break.
|
|
|
|
If True, defines right side of this period as break start.
|
|
|
|
If False, defines right side of this period after the break
|
|
start. In this case the represented period will include a
|
|
non-trading period.
|
|
|
|
force : optional
|
|
(ignored if `period` is '1D'.)
|
|
(irrelevant if `intervals` is False and `closed` is "left" or
|
|
"neither.)
|
|
|
|
Convenience option to set both `force_close` and
|
|
`force_break_close`. If passed then values passsed to
|
|
`force_close` and `force_break_close` will be ignored.
|
|
|
|
curtail_overlaps : default: False
|
|
(ignored if `period` is '1D')
|
|
(irrelevant if (`intervals` is False) or (`force_close` and
|
|
`force_break_close` are both True).)
|
|
|
|
Defines action to take if a period ends after the start of the
|
|
next period. (This can occur if `period` is longer
|
|
than a break or the gap between one session's close and the
|
|
next session's open.)
|
|
|
|
If True, the right of the earlier of two overlapping
|
|
periods will be curtailed to the left of the latter period.
|
|
(NB consequently the period length will not be constant for
|
|
all periods.)
|
|
|
|
If False, will raise IntervalsOverlapError.
|
|
|
|
ignore_breaks : default: False
|
|
(ignored if `period` is '1D')
|
|
(irrelevant if no session has a break)
|
|
|
|
Defines whether trading index should respect session breaks.
|
|
|
|
If False, treat sessions with breaks as comprising independent
|
|
morning and afternoon subsessions.
|
|
|
|
If True, treat all sessions as continuous, ignoring any
|
|
breaks.
|
|
|
|
parse : default: True
|
|
Determines if `start` and `end` values are parsed. If these
|
|
arguments are passed as tz-naive pd.Timestamp with no time
|
|
component then can pass `parse` as False to save around
|
|
500µs on the execution.
|
|
|
|
align : default: pd.Timedelta(1, "min")
|
|
Anchor the first indice of each session such that it aligns
|
|
with the nearest occurrence of a specific fraction of an hour.
|
|
|
|
Pass as a pd.Timedelta or a str that's acceptable as a single
|
|
input to pd.Timedelta. Pass +ve values to shift indices
|
|
forwards, -ve values to shift indices backwards.
|
|
|
|
Valid values are (or equivalent):
|
|
"2min", "3min", "4min", "5min", "6min", "10min", "12min",
|
|
"15min", "20min", "30min", "-2min", "-4min", "-5min",
|
|
"-6min", "-10min", "-12min", "-15min", "-20min", "-30min"
|
|
|
|
For example, if `intervals` is True and `period` is '5T' then
|
|
the first interval of a session with open time as 07:59 would
|
|
be:
|
|
07:59 - 08:04 if `align` is pd.Timedelta(1, "min") (default)
|
|
08:00 - 08:05 if `align` is '5T'
|
|
07:55 - 08:00 if `align` is '-5T'
|
|
|
|
Subsequent indices will be similarly shifted.
|
|
|
|
Note: A session's indices will not be shifted if the session
|
|
open already aligns with `align`. For example, if the open time
|
|
were 08:00 then the first interval will always have a left side
|
|
as 08:00 regardless of `align`.
|
|
|
|
align_pm : default: True
|
|
(ignored if `ignore_break` is True)
|
|
(irrelevant if no session has a break)
|
|
|
|
Anchor the first indice of each afternoon subsession such that
|
|
it aligns with the nearest occurrence of a specific fraction of
|
|
an hour.
|
|
|
|
True: (default) Treat as `align`.
|
|
|
|
False: Do not shift post-break indices.
|
|
|
|
pd.Timedelta or str: Align post-break indices to the
|
|
nearest occurence of this fraction of an hour. Valid values
|
|
as for `align`.
|
|
|
|
Returns
|
|
-------
|
|
pd.IntervalIndex or pd.DatetimeIndex
|
|
Trading index.
|
|
|
|
If `intervals` is False or `period` is '1D' then returned as a
|
|
pd.DatetimeIndex.
|
|
If `intervals` is True (default) returned as pd.IntervalIndex.
|
|
|
|
Raises
|
|
------
|
|
exchange_calendars.errors.IntervalsOverlapError
|
|
If `intervals` is True and right side of one or more indices
|
|
would fall after the left of the subsequent indice. This can
|
|
occur if `period` is longer than a break or the gap between one
|
|
session's close and the next session's open.
|
|
|
|
exchange_calendars.errors.IntervalsOverlapError
|
|
If `intervals` is False and an indice would otherwise fall to
|
|
the right of the subsequent indice. This can occur if `period`
|
|
is longer than a break or the gap between one session's close
|
|
and the next session's open.
|
|
|
|
Credit to @Stryder-Git at pandas_market_calendars for showing the
|
|
way with a vectorised solution to creating trading indices (a
|
|
variation of which is employed within the underlying _TradingIndex
|
|
class).
|
|
"""
|
|
if not isinstance(period, pd.Timedelta):
|
|
try:
|
|
period = pd.Timedelta(period)
|
|
except ValueError:
|
|
msg = (
|
|
f"`period` receieved as '{period}' although takes type"
|
|
" 'pd.Timedelta' or a 'str' that is valid as a single input"
|
|
" to 'pd.Timedelta'. Examples of valid input: pd.Timestamp('15T'),"
|
|
" '15min', '15T', '1H', '4h', '1D', '5s', 500ms'."
|
|
)
|
|
raise ValueError(msg) from None
|
|
|
|
if period > pd.Timedelta(1, "D"):
|
|
msg = (
|
|
"`period` cannot be greater than one day although received as"
|
|
f" '{period}'."
|
|
)
|
|
raise ValueError(msg)
|
|
|
|
if period == pd.Timedelta(1, "D"):
|
|
start, end = self._parse_start_end_dates(start, end, parse)
|
|
return self.sessions_in_range(start, end)
|
|
|
|
if intervals and closed in ["both", "neither"]:
|
|
raise ValueError(
|
|
f"If `intervals` is True then `closed` cannot be '{closed}'."
|
|
)
|
|
|
|
def get_align(name: Literal["align", "align_pm"], value: Any) -> pd.Timedelta:
|
|
"""Convert value received for an align parameter to Timestamp.
|
|
|
|
Raises `ValueError` if value is invalid or not of a valid type.
|
|
|
|
Parameters
|
|
----------
|
|
name
|
|
Parameter name.
|
|
|
|
value
|
|
Value assigned to parameter.
|
|
"""
|
|
try:
|
|
value = pd.Timedelta(value)
|
|
except ValueError:
|
|
insert = " bool," if name == "align_pm" else ""
|
|
msg = (
|
|
f"`{name}` receieved as '{value}' although takes type{insert}"
|
|
f" 'pd.Timedelta' or a 'str' that is valid as a single input"
|
|
" to 'pd.Timedelta'. Examples of valid input: pd.Timestamp('5T'),"
|
|
" '5min', '5T', pd.Timedelta('-5T'), '-5min', '-5T'."
|
|
)
|
|
raise ValueError(msg) from None
|
|
|
|
if value > ONE_HOUR or value < -ONE_HOUR or not value or (ONE_HOUR % value):
|
|
raise ValueError(
|
|
f"`{name}` must be factor of 1H although received '{value}'."
|
|
)
|
|
|
|
if value % pd.Timedelta(1, "min"):
|
|
raise ValueError(
|
|
f"`{name}` cannot include a fraction of a minute although received"
|
|
f" '{value}'."
|
|
)
|
|
return value
|
|
|
|
align = get_align("align", align)
|
|
|
|
if align_pm is False:
|
|
align_pm = pd.Timedelta(1, "min")
|
|
else:
|
|
align_pm = align if align_pm is True else get_align("align_pm", align_pm)
|
|
|
|
if force is not None:
|
|
force_close = force_break_close = force
|
|
|
|
# method exposes public methods of _TradingIndex.
|
|
_trading_index = _TradingIndex(
|
|
self,
|
|
start,
|
|
end,
|
|
period,
|
|
closed,
|
|
force_close,
|
|
force_break_close,
|
|
curtail_overlaps,
|
|
ignore_breaks,
|
|
align,
|
|
align_pm,
|
|
)
|
|
|
|
if not intervals:
|
|
return _trading_index.trading_index()
|
|
return _trading_index.trading_index_intervals()
|
|
|
|
# Internal methods called by constructor.
|
|
|
|
def _special_dates(
|
|
self,
|
|
regular_dates: list[tuple[datetime.time, HolidayCalendar | int]],
|
|
ad_hoc_dates: list[tuple[datetime.time, pd.DatetimeIndex]],
|
|
start_date: pd.Timestamp,
|
|
end_date: pd.Timestamp,
|
|
) -> pd.Series:
|
|
"""Evaluate times associated with special dates.
|
|
|
|
Parameters
|
|
----------
|
|
regular_dates
|
|
Regular non-standard times and corresponding HolidayCalendars
|
|
or Int day-of-week.
|
|
|
|
ad_hoc_dates
|
|
Adhoc non-standard times and corresponding sessions.
|
|
|
|
start_date
|
|
Start of the range over which to evaluate special dates. Must
|
|
be timezone naive.
|
|
|
|
end_date
|
|
End of the range over which to evaluate special dates. Must be
|
|
timezone naive.
|
|
|
|
Returns
|
|
-------
|
|
special_dates: pd.Series
|
|
Series mapping trading sessions with special times.
|
|
|
|
Index is timezone naive.
|
|
dtype is datetime64[ns, UTC].
|
|
"""
|
|
# List of Series for regularly-scheduled times.
|
|
regular = [
|
|
scheduled_special_times(
|
|
holiday_calendar,
|
|
start_date,
|
|
end_date,
|
|
time_,
|
|
self.tz,
|
|
)
|
|
for time_, holiday_calendar in regular_dates
|
|
]
|
|
|
|
# List of Series for ad-hoc times.
|
|
ad_hoc = []
|
|
for time_, dti in ad_hoc_dates:
|
|
dti = dti[(dti >= start_date) & (dti <= end_date)] # noqa: PLW2901
|
|
srs = pd.Series(index=dti, data=days_at_time(dti, time_, self.tz, 0))
|
|
ad_hoc.append(srs)
|
|
|
|
merged = ad_hoc + regular
|
|
if not merged:
|
|
# Concat barfs if the input has length 0.
|
|
return pd.Series(
|
|
[], index=pd.DatetimeIndex([]), dtype="datetime64[ns, UTC]"
|
|
)
|
|
|
|
result = pd.concat(merged)
|
|
# where there are multiple occurrences of the same date, keep only the first
|
|
result = result[~result.index.duplicated(keep="first")]
|
|
result = result.sort_index()
|
|
# exclude any special date that coincides with a holiday
|
|
adhoc_holidays = pd.DatetimeIndex(self.adhoc_holidays)
|
|
result = result[~result.index.isin(adhoc_holidays)]
|
|
regular_holidays = self.regular_holidays
|
|
if regular_holidays is not None:
|
|
reg_holidays = regular_holidays.holidays(start_date, end_date)
|
|
if not reg_holidays.empty:
|
|
result = result[~result.index.isin(reg_holidays)]
|
|
return result
|
|
|
|
def _calculate_special_opens(
|
|
self, start: pd.Timestamp, end: pd.Timestamp
|
|
) -> pd.Series:
|
|
return self._special_dates(
|
|
self.special_opens,
|
|
self.special_opens_adhoc,
|
|
start,
|
|
end,
|
|
)
|
|
|
|
def _calculate_special_closes(
|
|
self, start: pd.Timestamp, end: pd.Timestamp
|
|
) -> pd.Series:
|
|
return self._special_dates(
|
|
self.special_closes,
|
|
self.special_closes_adhoc,
|
|
start,
|
|
end,
|
|
)
|
|
|
|
|
|
def _check_breaks_match(break_starts_nanos: np.ndarray, break_ends_nanos: np.ndarray):
|
|
"""Checks that break_starts_nanos and break_ends_nanos match."""
|
|
nats_match = np.equal(break_starts_nanos == NP_NAT, break_ends_nanos == NP_NAT)
|
|
if not nats_match.all():
|
|
raise ValueError(
|
|
f"""
|
|
Mismatched market breaks
|
|
Break starts:
|
|
{break_starts_nanos[~nats_match]}
|
|
Break ends:
|
|
{break_ends_nanos[~nats_match]}
|
|
"""
|
|
)
|
|
|
|
|
|
def scheduled_special_times(
|
|
special_days: HolidayCalendar | int,
|
|
start: pd.Timestamp,
|
|
end: pd.Timestamp,
|
|
time: datetime.time,
|
|
tz: ZoneInfo,
|
|
) -> pd.Series:
|
|
"""Return map of dates to special times.
|
|
|
|
Parameters
|
|
----------
|
|
special_days
|
|
Describes dates with a special time. Pass as either:
|
|
HolidayCalendar : calendar with rules describing the dates on
|
|
which special times apply
|
|
|
|
int : integer describing a weekday with a regular special
|
|
time (0 - Monday, ..., 6 - Sunday).
|
|
|
|
start
|
|
Date from which to evaluate mapping (inclusive of `start`).
|
|
|
|
end
|
|
Date to which to evaluate mapping (inclusive of `end`).
|
|
|
|
time
|
|
Special time for dates described by `special_days`.
|
|
|
|
tz
|
|
The timezone in which to interpret `time`.
|
|
|
|
Returns
|
|
-------
|
|
pd.Series
|
|
Series mapping dates to special times.
|
|
|
|
Index is timezone naive.
|
|
dtype is datetime64[ns, UTC].
|
|
"""
|
|
if isinstance(special_days, int):
|
|
day_str = "W-" + day_name[special_days].upper()[0:3]
|
|
days = pd.date_range(start, end, freq=day_str)
|
|
else:
|
|
days = special_days.holidays(start, end)
|
|
if not isinstance(days, pd.DatetimeIndex):
|
|
# days will be pd.Index if empty
|
|
days = pd.DatetimeIndex(days)
|
|
return pd.Series(
|
|
index=days,
|
|
data=days_at_time(days, time, tz=tz, day_offset=0),
|
|
)
|
|
|
|
|
|
def _adjust_special_dates(
|
|
session_labels: pd.DatetimeIndex,
|
|
standard_times: pd.DatetimeIndex,
|
|
special_times: pd.Series,
|
|
) -> pd.DatetimeIndex:
|
|
"""Adjust standard times of a session bound with special times.
|
|
|
|
`session_labels` required for alignment.
|
|
"""
|
|
# Short circuit when nothing to apply.
|
|
if special_times.empty:
|
|
return standard_times
|
|
|
|
len_m, len_oc = len(session_labels), len(standard_times)
|
|
if len_m != len_oc:
|
|
raise ValueError(
|
|
"Found misaligned dates while building calendar.\nExpected"
|
|
" session_labels to be the same length as open_or_closes but,\n"
|
|
f"len(session_labels)={len_m}, len(open_or_closes)={len_oc}"
|
|
)
|
|
|
|
# Find the array indices corresponding to each special date.
|
|
indexer = session_labels.get_indexer(special_times.index)
|
|
|
|
# -1 indicates that no corresponding entry was found. If any -1s are
|
|
# present, then we have special dates that doesn't correspond to any
|
|
# trading day.
|
|
if -1 in indexer:
|
|
bad_dates = list(special_times[indexer == -1])
|
|
raise ValueError(f"Special dates {bad_dates} are not sessions.")
|
|
|
|
srs = standard_times.to_series()
|
|
srs.iloc[indexer] = special_times
|
|
return pd.DatetimeIndex(srs)
|
|
|
|
|
|
def _remove_breaks_for_special_dates(
|
|
session_labels: pd.DatetimeIndex,
|
|
standard_break_times: pd.DatetimeIndex | None,
|
|
special_times: pd.Series,
|
|
) -> pd.DatetimeIndex | None:
|
|
"""Remove standard break times for sessions with special times."
|
|
|
|
Replaces standard break times with NaT for sessions with speical
|
|
times. Anticipated that `special_times` will be special times for
|
|
'opens' or 'closes'.
|
|
|
|
`session_labels` required for alignment.
|
|
"""
|
|
# Short circuit when we have no breaks
|
|
if standard_break_times is None:
|
|
return None
|
|
|
|
# Short circuit when nothing to apply.
|
|
if special_times.empty:
|
|
return standard_break_times
|
|
|
|
len_m, len_oc = len(session_labels), len(standard_break_times)
|
|
if len_m != len_oc:
|
|
raise ValueError(
|
|
"Found misaligned dates while building calendar.\n"
|
|
"Expected session_labels to be the same length as break_starts,\n"
|
|
f"but len(session_labels)={len_m}, len(break_start_or_end)={len_oc}"
|
|
)
|
|
|
|
# Find the array indices corresponding to each special date.
|
|
indexer = session_labels.get_indexer(special_times.index)
|
|
|
|
# -1 indicates that no corresponding entry was found. If any -1s are
|
|
# present, then we have special dates that doesn't correspond to any
|
|
# trading day.
|
|
if -1 in indexer:
|
|
bad_dates = list(special_times[indexer == -1])
|
|
raise ValueError(f"Special dates {bad_dates} are not trading days.")
|
|
|
|
srs = standard_break_times.to_series()
|
|
srs.iloc[indexer] = np.nan
|
|
return pd.DatetimeIndex(srs)
|