From 599bd760269e1051fc290dbde11ef2becfb50894 Mon Sep 17 00:00:00 2001 From: Bryce Hawk Date: Wed, 8 Jan 2025 10:43:44 -0600 Subject: [PATCH 01/11] Split Nyse Holiday Calendar into Pre and Post 1952 Calendars --- pandas_market_calendars/calendars/nyse.py | 55 +++++++++++++++++++---- tests/test_nyse_calendar_early_years.py | 2 +- 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/pandas_market_calendars/calendars/nyse.py b/pandas_market_calendars/calendars/nyse.py index d626a59..8a4e334 100644 --- a/pandas_market_calendars/calendars/nyse.py +++ b/pandas_market_calendars/calendars/nyse.py @@ -18,6 +18,7 @@ import pandas as pd from pandas.tseries.holiday import AbstractHolidayCalendar +from pandas.tseries.offsets import CustomBusinessDay from pytz import timezone from pandas_market_calendars.holidays.nyse import ( @@ -830,7 +831,24 @@ def tz(self): @property def weekmask(self): - return "Mon Tue Wed Thu Fri Sat" # Market open on Saturdays thru 5/24/1952 + return "Mon Tue Wed Thu Fri" + + def holidays_pre_1952(self): + """ + NYSE Market open on Saturdays pre 5/24/1952. + CustomBusinessDay object that can be used inplace of holidays() for dates prior to crossover + + :return: CustomBusinessDay object of holidays + """ + if hasattr(self, "_holidays_hist"): + return self._holidays_hist + + self._holidays_hist = CustomBusinessDay( + holidays=self.adhoc_holidays, + calendar=self.regular_holidays, + weekmask="Mon Tue Wed Thu Fri Sat", + ) + return self._holidays_hist @property def regular_holidays(self): @@ -1275,7 +1293,7 @@ def special_opens_adhoc(self): ), ] - # Override market_calendar.py + # Override market_calendar.py to split calc between pre & post 1952 Saturday Close def valid_days(self, start_date, end_date, tz="UTC"): """ Get a DatetimeIndex of valid open business days. @@ -1285,7 +1303,8 @@ def valid_days(self, start_date, end_date, tz="UTC"): :param tz: time zone in either string or pytz.timezone :return: DatetimeIndex of valid business days """ - trading_days = super().valid_days(start_date, end_date, tz=tz) + start_date = pd.Timestamp(start_date, tz=tz) + end_date = pd.Timestamp(end_date, tz=tz) # Starting Monday Sept. 29, 1952, no more saturday trading days if tz is None: @@ -1293,12 +1312,32 @@ def valid_days(self, start_date, end_date, tz="UTC"): else: saturday_end = self._saturday_end - above_cut_off = trading_days >= saturday_end - if above_cut_off.any(): - above_and_saturday = (trading_days.weekday == 5) & above_cut_off - trading_days = trading_days[~above_and_saturday] + # Don't care about Saturdays. Call super. + if start_date > saturday_end: + return super().valid_days(start_date, end_date, tz=tz) + + # Full Date Range is pre 1952. Augment the Super call + if end_date <= saturday_end: + return pd.date_range( + start_date, + end_date, + freq=self.holidays_pre_1952(), + normalize=True, + tz=tz, + ) - return trading_days + # Range is split across 1952. Concatenate Two different Date_Range calls + days_pre = pd.date_range( + start_date, + saturday_end, + freq=self.holidays_pre_1952(), + normalize=True, + tz=tz, + ) + days_post = pd.date_range( + saturday_end, end_date, freq=self.holidays(), normalize=True, tz=tz + ) + return days_pre.union(days_post) def days_at_time(self, days, market_time, day_offset=0): days = super().days_at_time(days, market_time, day_offset=day_offset) diff --git a/tests/test_nyse_calendar_early_years.py b/tests/test_nyse_calendar_early_years.py index cd31a80..7a421a7 100644 --- a/tests/test_nyse_calendar_early_years.py +++ b/tests/test_nyse_calendar_early_years.py @@ -22,7 +22,7 @@ def test_close_time_tz(): def test_weekmask(): - assert nyse.weekmask == "Mon Tue Wed Thu Fri Sat" + assert nyse.holidays_pre_1952().weekmask == "Mon Tue Wed Thu Fri Sat" def _test_holidays(holidays, start, end): From c2265f912937ccfbd81f90a2dc466809dd4439de Mon Sep 17 00:00:00 2001 From: Bryce Hawk Date: Wed, 8 Jan 2025 10:44:31 -0600 Subject: [PATCH 02/11] Small IEX Performance Update --- pandas_market_calendars/calendars/iex.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/pandas_market_calendars/calendars/iex.py b/pandas_market_calendars/calendars/iex.py index edba7fe..88eb673 100644 --- a/pandas_market_calendars/calendars/iex.py +++ b/pandas_market_calendars/calendars/iex.py @@ -1,6 +1,7 @@ from datetime import time from itertools import chain +from pandas import Timestamp from pandas.tseries.holiday import AbstractHolidayCalendar from pytz import timezone @@ -106,7 +107,11 @@ def special_opens(self): return [] def valid_days(self, start_date, end_date, tz="UTC"): - trading_days = super().valid_days( - start_date, end_date, tz=tz - ) # all NYSE valid days - return trading_days[~(trading_days <= "2013-08-25")] + start_date = Timestamp(start_date) + if start_date.tz is not None: + # Ensure valid Comparison to "2013-08-25" is possible + start_date.tz_convert(self.tz).tz_localize(None) + + # Limit Start_date to the Exchange's Open + start_date = max(start_date, Timestamp("2013-08-25")) + return super().valid_days(start_date, end_date, tz=tz) From 844192f629c79ca533fa8a0a522630cf090fb486 Mon Sep 17 00:00:00 2001 From: Bryce Hawk Date: Fri, 10 Jan 2025 19:38:41 -0600 Subject: [PATCH 03/11] Date_Range_HTF Util Function --- pandas_market_calendars/calendar_utils.py | 379 ++++++++++++++++++++-- 1 file changed, 355 insertions(+), 24 deletions(-) diff --git a/pandas_market_calendars/calendar_utils.py b/pandas_market_calendars/calendar_utils.py index 22a7eb7..3368d01 100644 --- a/pandas_market_calendars/calendar_utils.py +++ b/pandas_market_calendars/calendar_utils.py @@ -11,6 +11,8 @@ import numpy as np import pandas as pd +from pandas.tseries.offsets import CustomBusinessDay + def merge_schedules(schedules, how="outer"): """ @@ -66,8 +68,20 @@ def convert_freq(index, frequency): return pd.DataFrame(index=index).asfreq(frequency).index -def date_range_htf(): - "Returns a Datetime Index from the start-date to End-Date for Timeperiods of 1D and Higher" +SESSIONS = Literal[ + "pre", + "post", + "RTH", + "pre_break", + "post_break", + "ETH", + "break", + "closed", + "closed_masked", +] +MKT_TIMES = Literal[ + "pre", "post", "market_open", "market_close", "break_start", "break_end" +] # region ---- ---- ---- Date Range Warning Types ---- ---- ---- @@ -131,9 +145,6 @@ class InsufficientScheduleWarning(DateRangeWarning): """ -# endregion - - def filter_date_range_warnings( action: Literal["error", "ignore", "always", "default", "once"], source: Union[ @@ -163,22 +174,6 @@ def filter_date_range_warnings( warnings.filterwarnings(action, category=src) -SESSIONS = Literal[ - "pre", - "post", - "RTH", - "pre_break", - "post_break", - "ETH", - "break", - "closed", - "closed_masked", -] -MKT_TIMES = Literal[ - "pre", "post", "market_open", "market_close", "break_start", "break_end" -] - - def parse_missing_session_warning( err: MissingSessionWarning, ) -> Tuple[set[SESSIONS], set[MKT_TIMES]]: @@ -218,6 +213,9 @@ def parse_insufficient_schedule_warning( return (b, t1, t2) if t1 <= t2 else (b, t2, t1) +# endregion + + def date_range( schedule: pd.DataFrame, frequency: Union[str, pd.Timedelta, int, float], @@ -230,7 +228,12 @@ def date_range( periods: Union[int, None] = None, ) -> pd.DatetimeIndex: """ - Returns a DatetimeIndex from the Start-Date to End-Date of the schedule at the desired frequency. + Interpolates a Market's Schedule at the desired frequency and returns the result as a DatetimeIndex. + This function is only valid for periods less than 1 Day, for longer periods use date_range_htf(). + + Note: The slowest part of this function is by far generating the necessary schedule (which in + turn is limited by pandas' date_range() function). If speed is a concern, store and update the + schedule as needed instead of generating it every time. WARNINGS SYSTEM: *There are multiple edge-case warnings that are thrown by this function. See the Docstrings @@ -246,7 +249,7 @@ def date_range( PARAMETERS: :param schedule: Schedule of a calendar which includes all the columns necessary - for the desired sessions + for the desired sessions. :param frequency: String, Int/float (seconds) or pd.Timedelta that represents the desired interval of the date_range. Intervals larger than 1D are not supported. @@ -363,7 +366,7 @@ def date_range( return pd.DatetimeIndex(time_series, tz=tz, dtype=dtype) -# region ------------------ Date Range Subroutines ------------------ +# region ------------------ Date Range LTF Subroutines ------------------ def _make_session_list( @@ -714,3 +717,331 @@ def _calc_time_series( # endregion + + +PeriodCode = Literal["D", "W", "M", "Q", "Y"] +Day_Anchor = Literal["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"] +Month_Anchor = Literal[ + "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC" +] + +# These are needed because the pandas Period Object is stupid. +days_rolled = list(Day_Anchor.__args__) +days_rolled.append(days_rolled.pop(0)) +weekly_grouping_map = dict(zip(Day_Anchor.__args__, days_rolled)) + +months_rolled = list(Month_Anchor.__args__) +months_rolled.insert(0, months_rolled.pop()) +yearly_grouping_map = dict(zip(Month_Anchor.__args__, months_rolled)) + + +def date_range_htf( + cal: CustomBusinessDay, + frequency: Union[str, pd.Timedelta, int, float], + start: Union[str, pd.Timestamp, int, float, None] = None, + end: Union[str, pd.Timestamp, int, float, None] = None, + periods: Union[int, None] = None, + closed: Union[Literal["left", "right"], None] = "right", + *, + day_anchor: Day_Anchor = "SUN", + month_anchor: Month_Anchor = "DEC", +): + """ + Returns a Normalized DatetimeIndex from the start-date to End-Date for Time periods of 1D and Higher. + + Unless using a custom calendar, it is advised to call the date_range_htf() method of the desired calendar. + This is because default_anchors may change, or a single calendar may not be sufficient to model a market. + + For example, NYSE has two calendars: The first covers pre-1952 where saturdays were trading days. The second + covers post-1952 where saturdays are closed. + + PARAMETERS: + + :param cal: CustomBuisnessDay Calendar associated with a MarketCalendar. This can be retieved by + calling the holidays() method of a MarketCalendar. + + :param frequency: String, Int/float (POSIX seconds) or pd.Timedelta of the desired frequency. + :Must be Greater than '1D' and an integer multiple of the base frequency (D, W, M, Q, or Y) + :Important Note: Ints/Floats & Timedeltas are always considered as 'Open Business Days', + '2D' == Every Other Buisness Day, '3D' == Every 3rd B.Day, '7D' == Every 7th B.Day + :Higher periods (passed as strings) align to the beginning or end of the relevant period + :i.e. '1W' == First/[Last] Trading Day of each Week, '1Q' == First/[Last] Day of every Quarter + + :param start: String, Int/float (POSIX seconds) or pd.Timestamp of the desired start time. + :The Time & Timezone information is ignored. Only the Normalized Day is considered. + + :param end: String, Int/float (POSIX seconds) or pd.Timestamp of the desired start time. + :The Time & Timezone information is ignored. Only the Normalized Day is considered. + + :param periods: Optional Integer number of periods to return. If a Period count, Start time, + and End time are given the period count is ignored. + + :param closed: Literal['left', 'right']. Method used to close each range. + :Left: First open trading day of the Session is returned (e.g. First Open Day of The Month) + :right: Last open trading day of the Session is returned (e.g. Last Open Day of The Month) + :Note, This has no effect when the desired frequency is a number of days. + + :param day_anchor: Day of the week to Anchor the Weekly timeframes to. Default 'SUN'. + : To get the First/Last Days of the trading Week then the Anchor needs to be on a day the relevant + market is closed. + : This can be set so that a specific day each week is returned. + : freq='1W' & day_anchor='WED' Will return Every 'WED' when the market is open, and nearest day + to the left or right (based on 'closed') when the market is closed. + + :param month_anchor: Month of the year to Anchor the Quarter and yearly timeframes to. + : Default 'DEC' for Calendar Quarters/Years. Can be set to 'JUN' to return Fiscal Years + """ + + start, end, periods = _error_check_htf_range(start, end, periods) + mult, _period_code = _standardize_htf_freq(frequency) + + if _period_code == "D": + if mult == 1: + # When desiring a frequency of '1D' default to pd.date_range. It will give the same + # answer but it is more performant than the method in _cal_day_range. + return pd.date_range(start, end, periods, freq=cal) + else: + return _cal_day_range(cal, start, end, periods, mult) + + elif _period_code == "W": + freq = str(mult) + "W-" + day_anchor.upper() + grouping_period = "W-" + weekly_grouping_map[day_anchor.upper()] + + return _cal_WMQY_range(cal, start, end, periods, freq, grouping_period, closed) + + elif _period_code == "M": + freq = str(mult) + "M" + ("S" if closed == "left" else "E") + return _cal_WMQY_range(cal, start, end, periods, freq, "M", closed) + + else: # Yearly & Quarterly Period + freq = str(mult) + _period_code + ("S" if closed == "left" else "E") + freq = freq + "-" + month_anchor.upper() + grouping_period = _period_code + "-" + yearly_grouping_map[month_anchor.upper()] + + return _cal_WMQY_range(cal, start, end, periods, freq, grouping_period, closed) + + +# region ---- ---- ---- Date Range HTF SubRoutines ---- ---- ---- + + +def _error_check_htf_range( + start, end, periods: Union[int, None] +) -> Tuple[Union[pd.Timestamp, None], Union[pd.Timestamp, None], Union[int, None]]: + "Standardize and Error Check Start, End, and period params" + if all((start, end, periods)): + periods = None # Ignore Periods if passed too many params + + if periods is not None and periods < 0: + raise ValueError("Date_range_HTF Periods must be Positive.") + + if isinstance(start, (int, float)): + start = int(start * 1_000_000_000) + if isinstance(end, (int, float)): + end = int(end * 1_000_000_000) + + if start is not None: + start = pd.Timestamp(start).normalize().tz_localize(None) + if end is not None: + end = pd.Timestamp(end).normalize().tz_localize(None) + + if len([param for param in (start, end, periods) if param is not None]) < 2: + raise ValueError( + "Date_Range_HTF must be given two of the three following params: (start, end, periods)" + ) + + if start is not None and end is not None and end < start: + raise ValueError("Date_Range_HTF() Start-Date must be before the End-Date") + + return start, end, periods + + +def _standardize_htf_freq( + frequency: Union[str, pd.Timedelta, int, float] +) -> Tuple[int, PeriodCode]: + "Standardize the frequency multiplier and Code, throwing errors as needed." + if isinstance(frequency, str): + if len(frequency) == 0: + raise ValueError("Date_Range_HTF Frequency is an empty string.") + if len(frequency) == 1: + frequency = "1" + frequency # Turn 'D' into '1D' for all period codes + if frequency[-1].upper() in {"W", "M", "Q", "Y"}: + try: + if (mult := int(frequency[0:-1])) <= 0: + raise ValueError() + return mult, frequency[-1].upper() # type: ignore + except ValueError as e: + raise ValueError( + "Date_Range_HTF() Week, Month, Quarter and Year frequency must " + "have a positive integer multiplier" + ) from e + + # All remaining frequencies (int, float, strs, & Timedeltas) are parsed as business days. + if isinstance(frequency, (int, float)): # Convert To Seconds + frequency = int(frequency * 1_000_000_000) + + frequency = pd.Timedelta(frequency) + if frequency < pd.Timedelta("1D"): + raise ValueError("Date_Range_HTF() Frequency must be '1D' or Higher.") + if frequency % pd.Timedelta("1D") != pd.Timedelta(0): + raise ValueError( + "Date_Range_HTF() Week and Day frequency must be an integer multiple of Days" + ) + + return frequency.days, "D" + + +def _days_per_week(weekmask: Union[Iterable, str]) -> int: + "Used to get a more accurate estimate of the number of days per week" + # Return any 'Array Like' Representation + if not isinstance(weekmask, str): + return len([day for day in weekmask if bool(day)]) + + if len(weekmask) == 0: + raise ValueError("Weekmask cannot be blank") + + day_abbrs = {day for day in WEEKMASK_ABBR.values() if day in weekmask} + if len(day_abbrs) != 0: + return len(day_abbrs) + + # Weekmask Something like '0111110' + return len([day for day in weekmask if bool(day)]) + + +def _cal_day_range( + cb_day: CustomBusinessDay, start, end, periods, mult +) -> pd.DatetimeIndex: + """ + Returns a Normalized DateTimeIndex of Open Buisness Days. + Exactly two of the (start, end, periods) arguments must be given. + + ** Arguments should be Type/Error Checked before calling this function ** + + :param cb_day: CustomBusinessDay Object from the respective calendar + :param start: Optional Start-Date. Must be a Normalized, TZ-Naive pd.Timestamp + :param end: Optional End-Date. Must be a Normalized, TZ-Naive pd.Timestamp + :param periods: Optional Number of periods to return + :param mult: Integer Multiple of buisness days between data-points. + e.g: 1 == Every Business Day, 2 == Every Other B.Day, 3 == Every Third B.Day, etc. + :returns: DateRangeIndex[datetime64[ns]] + """ + + # ---- Start-Date to End-Date ---- + if isinstance(start, pd.Timestamp) and isinstance(end, pd.Timestamp): + num_days = (end - start) / mult + # Get a better estimate of the number of open days since date_range calc is slow + est_open_days = ( + (num_days // 7) * _days_per_week(cb_day.weekmask) + ) + num_days % pd.Timedelta("1W") + + # Should always produce a small overestimate since Holidays aren't accounted for. + est_open_days = ceil(est_open_days / pd.Timedelta("1D")) + _range = pd.RangeIndex(0, est_open_days * mult, mult) + + dt_index = pd.DatetimeIndex(start + _range * cb_day, dtype="datetime64[ns]") + return dt_index[dt_index <= end] + + # ---- Periods from Start-Date ---- + elif isinstance(start, pd.Timestamp): + _range = pd.RangeIndex(0, periods * mult, mult) + return pd.DatetimeIndex(start + _range * cb_day, dtype="datetime64[ns]") + + # ---- Periods from End-Date ---- + else: + # Ensure the end-date is the first valid Trading Day <= given end-date + end = cb_day.rollback(end) + _range = pd.RangeIndex(0, -1 * periods * mult, -1 * mult) + + return pd.DatetimeIndex(end + _range * cb_day, dtype="datetime64[ns]") + + +def _cal_WMQY_range( + cb_day: CustomBusinessDay, + start: Union[pd.Timestamp, None], + end: Union[pd.Timestamp, None], + periods: Union[int, None], + freq: str, + grouping_period: str, + closed: Union[Literal["left", "right"], None] = "right", +): + """ + Return A DateRangeIndex of the Weekdays that mark either the start or end of each + buisness week based on the 'closed' parameter. + + ** Arguments should be Type/Error Checked before calling this function ** + + :param cb_day: CustomBusinessDay Object from the respective calendar + :param start: Optional Start-Date. Must be a Normalized, TZ-Naive pd.Timestamp + :param end: Optional End-Date. Must be a Normalized, TZ-Naive pd.Timestamp + :param periods: Optional Number of periods to return + :param freq: Formatted frequency of '1W' and Higher with desired multiple, S/E Chars, + and Anchoring code. + :param grouping_period: Period_Code with anchor that matches the given period Code. + i.e. 'W-[DAY]', 'M', 'Q-[MONTH]', 'Y-[MONTH]' + :param closed: Union['left', Any]. + 'left': The normalized start-day of the relative period is returned + Everything else: The normalized last-day of the relative period is returned + :returns: DateRangeIndex[datetime64[ns]] + """ + + # Need to Adjust the Start/End Dates given to pandas since Rolling forward or backward can shift + # the calculated date range out of the desired [start, end] range adding or ignoring desired valiues. + + # For Example, say we want NYSE-Month-Starts between [2020-01-02, 2020-02-02]. W/O Adjusting dates + # we call pd.date_range('2020-01-02, '2020-02-02', 'MS') => ['2020-02-01'] Rolled to ['2020-02-03']. + # '02-03' date is then trimmed off returning an empty Index. despite '2020-01-02' being a valid Month Start + # By Adjusting the Dates we call pd.date_range('2020-01-01, '2020-02-02') => ['2020-01-01, '2020-02-01'] + # That's then Rolled into [2020-01-02, 2020-02-03] & Trimmed to [2020-01-02] as desired. + + _dr_start, _dr_end = None, None + + if closed == "left": + roll_func = cb_day.rollforward + if start is not None: + normalized_start = start.to_period(grouping_period).start_time + _dr_start = ( + normalized_start if start <= roll_func(normalized_start) else start + ) + + if end is not None and periods is not None: + normalized_end = end.to_period(grouping_period).start_time + _dr_end = ( + normalized_end - pd.Timedelta("1D") # Shift into the prior grouping + if end < roll_func(normalized_end) + else end + ) + else: + _dr_end = end + + else: + roll_func = cb_day.rollback + if start is not None and periods is not None: + normalized_start = start.to_period(grouping_period).end_time.normalize() + _dr_start = ( + normalized_start + pd.Timedelta("1D") # Shift into the next grouping + if start > roll_func(normalized_start) + else start + ) + else: + _dr_start = start + + if end is not None: + normalized_end = end.to_period(grouping_period).end_time.normalize() + _dr_end = normalized_end if end >= roll_func(normalized_end) else end + + _range = ( + pd.date_range(_dr_start, _dr_end, periods, freq).to_series().apply(roll_func) + ) + + # Ensure that Rolled Timestamps are in the desired range When given both Start and End + if start is not None and end is not None: + if len(_range) > 0 and _range.iloc[0] < start: + # Trims off the first 'WMQY End' that might have been Rolled before start + _range = _range[1:] + if len(_range) > 0 and _range.iloc[-1] > end: + # Trims off the last 'WMQY Start' the might have been Rolled after end + _range = _range[0:-1] + + return pd.DatetimeIndex(_range, dtype="datetime64[ns]") + + +# endregion From 9f9e01a1de928f4c23393c0b0b615781b44982f3 Mon Sep 17 00:00:00 2001 From: Bryce Hawk Date: Sat, 11 Jan 2025 14:04:05 -0600 Subject: [PATCH 04/11] Date_Range_HTF tests and bug fixes --- pandas_market_calendars/calendar_utils.py | 90 ++-- tests/test_date_range.py | 598 ++++++++++++++++++++++ 2 files changed, 652 insertions(+), 36 deletions(-) diff --git a/pandas_market_calendars/calendar_utils.py b/pandas_market_calendars/calendar_utils.py index 3368d01..994b710 100644 --- a/pandas_market_calendars/calendar_utils.py +++ b/pandas_market_calendars/calendar_utils.py @@ -13,6 +13,8 @@ from pandas.tseries.offsets import CustomBusinessDay +from pandas_market_calendars.market_calendar import WEEKMASK_ABBR + def merge_schedules(schedules, how="outer"): """ @@ -725,14 +727,16 @@ def _calc_time_series( "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC" ] -# These are needed because the pandas Period Object is stupid. +# These needed because the pandas Period Object is stupid and not consistant w/ date_range. +# pd.date_range(s,e, freq = 'W-SUN') == [DatetimeIndex of all sundays] (as Expected) +# but, pd.Timestamp([A Sunday]).to_period('W-SUN').start_time == [The Monday Prior???] days_rolled = list(Day_Anchor.__args__) -days_rolled.append(days_rolled.pop(0)) -weekly_grouping_map = dict(zip(Day_Anchor.__args__, days_rolled)) +days_rolled.insert(0, days_rolled.pop()) +weekly_roll_map = dict(zip(Day_Anchor.__args__, days_rolled)) months_rolled = list(Month_Anchor.__args__) months_rolled.insert(0, months_rolled.pop()) -yearly_grouping_map = dict(zip(Month_Anchor.__args__, months_rolled)) +yearly_roll_map = dict(zip(Month_Anchor.__args__, months_rolled)) def date_range_htf( @@ -744,7 +748,7 @@ def date_range_htf( closed: Union[Literal["left", "right"], None] = "right", *, day_anchor: Day_Anchor = "SUN", - month_anchor: Month_Anchor = "DEC", + month_anchor: Month_Anchor = "JAN", ): """ Returns a Normalized DatetimeIndex from the start-date to End-Date for Time periods of 1D and Higher. @@ -781,15 +785,15 @@ def date_range_htf( :right: Last open trading day of the Session is returned (e.g. Last Open Day of The Month) :Note, This has no effect when the desired frequency is a number of days. - :param day_anchor: Day of the week to Anchor the Weekly timeframes to. Default 'SUN'. + :param day_anchor: Day to Anchor the start of the Weekly timeframes to. Default 'SUN'. : To get the First/Last Days of the trading Week then the Anchor needs to be on a day the relevant market is closed. : This can be set so that a specific day each week is returned. : freq='1W' & day_anchor='WED' Will return Every 'WED' when the market is open, and nearest day to the left or right (based on 'closed') when the market is closed. - :param month_anchor: Month of the year to Anchor the Quarter and yearly timeframes to. - : Default 'DEC' for Calendar Quarters/Years. Can be set to 'JUN' to return Fiscal Years + :param month_anchor: Month to Anchor the start of the year to for Quarter and yearly timeframes. + : Default 'JAN' for Calendar Quarters/Years. Can be set to 'JUL' to return Fiscal Years """ start, end, periods = _error_check_htf_range(start, end, periods) @@ -805,7 +809,7 @@ def date_range_htf( elif _period_code == "W": freq = str(mult) + "W-" + day_anchor.upper() - grouping_period = "W-" + weekly_grouping_map[day_anchor.upper()] + grouping_period = "W-" + weekly_roll_map[day_anchor.upper()] return _cal_WMQY_range(cal, start, end, periods, freq, grouping_period, closed) @@ -814,9 +818,13 @@ def date_range_htf( return _cal_WMQY_range(cal, start, end, periods, freq, "M", closed) else: # Yearly & Quarterly Period - freq = str(mult) + _period_code + ("S" if closed == "left" else "E") - freq = freq + "-" + month_anchor.upper() - grouping_period = _period_code + "-" + yearly_grouping_map[month_anchor.upper()] + freq = str(mult) + _period_code + freq += ( + "S-" + month_anchor.upper() + if closed == "left" # *Insert Angry Tom Meme Here* + else "E-" + yearly_roll_map[month_anchor.upper()] + ) + grouping_period = _period_code + "-" + yearly_roll_map[month_anchor.upper()] return _cal_WMQY_range(cal, start, end, periods, freq, grouping_period, closed) @@ -828,11 +836,13 @@ def _error_check_htf_range( start, end, periods: Union[int, None] ) -> Tuple[Union[pd.Timestamp, None], Union[pd.Timestamp, None], Union[int, None]]: "Standardize and Error Check Start, End, and period params" - if all((start, end, periods)): - periods = None # Ignore Periods if passed too many params - - if periods is not None and periods < 0: - raise ValueError("Date_range_HTF Periods must be Positive.") + if periods is not None: + if not isinstance(periods, int): + raise ValueError( + f"Date_Range_HTF Must be either an int or None. Given {type(periods)}" + ) + if periods < 0: + raise ValueError("Date_range_HTF Periods must be Positive.") if isinstance(start, (int, float)): start = int(start * 1_000_000_000) @@ -844,6 +854,8 @@ def _error_check_htf_range( if end is not None: end = pd.Timestamp(end).normalize().tz_localize(None) + if all((start, end, periods)): + periods = None # Ignore Periods if passed too many params if len([param for param in (start, end, periods) if param is not None]) < 2: raise ValueError( "Date_Range_HTF must be given two of the three following params: (start, end, periods)" @@ -925,6 +937,10 @@ def _cal_day_range( :returns: DateRangeIndex[datetime64[ns]] """ + # Ensure Start and End are open Business days in the desired range + start = cb_day.rollforward(start) + end = cb_day.rollback(end) + # ---- Start-Date to End-Date ---- if isinstance(start, pd.Timestamp) and isinstance(end, pd.Timestamp): num_days = (end - start) / mult @@ -984,7 +1000,7 @@ def _cal_WMQY_range( """ # Need to Adjust the Start/End Dates given to pandas since Rolling forward or backward can shift - # the calculated date range out of the desired [start, end] range adding or ignoring desired valiues. + # the calculated date range out of the desired [start, end] range adding or ignoring desired values. # For Example, say we want NYSE-Month-Starts between [2020-01-02, 2020-02-02]. W/O Adjusting dates # we call pd.date_range('2020-01-02, '2020-02-02', 'MS') => ['2020-02-01'] Rolled to ['2020-02-03']. @@ -1002,27 +1018,29 @@ def _cal_WMQY_range( normalized_start if start <= roll_func(normalized_start) else start ) - if end is not None and periods is not None: - normalized_end = end.to_period(grouping_period).start_time - _dr_end = ( - normalized_end - pd.Timedelta("1D") # Shift into the prior grouping - if end < roll_func(normalized_end) - else end - ) - else: - _dr_end = end + if end is not None: + if periods is not None: + normalized_end = end.to_period(grouping_period).start_time + _dr_end = ( + normalized_end - pd.Timedelta("1D") # Shift into preceding group + if end < roll_func(normalized_end) + else cb_day.rollback(end) + ) + else: + _dr_end = cb_day.rollback(end) else: roll_func = cb_day.rollback - if start is not None and periods is not None: - normalized_start = start.to_period(grouping_period).end_time.normalize() - _dr_start = ( - normalized_start + pd.Timedelta("1D") # Shift into the next grouping - if start > roll_func(normalized_start) - else start - ) - else: - _dr_start = start + if start is not None: + if periods is not None: + normalized_start = start.to_period(grouping_period).end_time.normalize() + _dr_start = ( + normalized_start + pd.Timedelta("1D") # Shift into trailing group + if start > roll_func(normalized_start) + else cb_day.rollforward(start) + ) + else: + _dr_start = cb_day.rollforward(start) if end is not None: normalized_end = end.to_period(grouping_period).end_time.normalize() diff --git a/tests/test_date_range.py b/tests/test_date_range.py index d5bd330..28366c2 100644 --- a/tests/test_date_range.py +++ b/tests/test_date_range.py @@ -13,11 +13,14 @@ _make_session_list, DisappearingSessionWarning, OverlappingSessionWarning, + date_range_htf, filter_date_range_warnings, parse_missing_session_warning, parse_insufficient_schedule_warning, ) +# region ---- ---- ---- Date Range LTF ---- ---- ---- + def test_date_range_exceptions(): cal = FakeCalendar(open_time=datetime.time(9), close_time=datetime.time(11, 30)) @@ -1442,3 +1445,598 @@ def test_date_range_closed(): tz=cal.tz, ), ) + + +# endregion + +# region ---- ---- ---- Date Range HTF ---- ---- ---- + + +def test_date_range_htf_exceptions(): + cal = FakeCalendar().holidays() + + with pytest.raises(ValueError): + date_range_htf(cal, "1D", "2020-01-01", "2025-01-01", "left") + with pytest.raises(ValueError): + date_range_htf(cal, "1D", "2020-01-01", 1e9) + with pytest.raises(ValueError): + date_range_htf(cal, "1D", "2025-01-01", "2020-01-01") + with pytest.raises(ValueError): + date_range_htf(cal, "1h", "2020-01-01", "2025-01-01") + with pytest.raises(ValueError): + date_range_htf(cal, "1.6D", "2020-01-01", "2025-01-01") + with pytest.raises(ValueError): + date_range_htf(cal, "1D", "2020-01-01") + with pytest.raises(ValueError): + date_range_htf(cal, "-1D", "2020-01-01", "2025-01-01") + with pytest.raises(ValueError): + date_range_htf(cal, "1D", "2020-01-01", None, -10) + with pytest.raises(ValueError): + date_range_htf(cal, "", "2020-01-01", None, 10) + + assert len(date_range_htf(cal, "1W", "2020-01-01", None, 10)) == 10 + assert len(date_range_htf(cal, "2W", "2020-01-01", None, 10)) == 10 + assert len(date_range_htf(cal, "2M", "2020-01-01", None, 10)) == 10 + assert len(date_range_htf(cal, "4Q", "2020-01-01", None, 10)) == 10 + assert len(date_range_htf(cal, "2Y", "2020-01-01", None, 10)) == 10 + + +def test_date_range_htf_days(): + cal = FakeCalendar().holidays() + reference = pd.date_range("2020-01-01", "2021-01-01", freq=cal) + + assert_index_equal(reference, date_range_htf(cal, "1D", "2020-01-01", "2021-01-01")) + + assert_index_equal( + reference[::2], date_range_htf(cal, "2D", "2020-01-01", "2021-01-01") + ) + assert_index_equal( + reference[::5], date_range_htf(cal, "5D", "2020-01-01", "2021-01-01") + ) + assert_index_equal( + reference[:20], date_range_htf(cal, "1D", "2020-01-01", None, 20) + ) + assert_index_equal( + reference[-20:], date_range_htf(cal, "1D", None, "2021-01-01", 20) + ) + assert_index_equal( + pd.DatetimeIndex([]), date_range_htf(cal, "D", "2020-01-01", "2020-01-01") + ) + + # Following should test that _days_per_week doesn't underestimate? i think? + assert_index_equal( + reference[0:1], date_range_htf(cal, "D", "2020-01-01", "2020-01-02") + ) + assert_index_equal( + reference[0:5], date_range_htf(cal, "D", "2020-01-01", "2020-01-08") + ) + assert_index_equal( + reference[0:5:2], date_range_htf(cal, "2D", "2020-01-01", "2020-01-08") + ) + + +def test_date_range_htf_weeks(): + cal = FakeCalendar().holidays() + + # region closed == 'right' + reference = pd.DatetimeIndex( + [ + "2025-01-03", + "2025-01-10", + "2025-01-17", + "2025-01-24", + "2025-01-31", + "2025-02-07", + "2025-02-14", + "2025-02-21", + "2025-02-28", + "2025-03-07", + "2025-03-14", + "2025-03-21", + "2025-03-28", + "2025-04-04", + "2025-04-11", + "2025-04-18", + "2025-04-25", + "2025-05-02", + "2025-05-09", + "2025-05-16", + "2025-05-23", + "2025-05-30", + "2025-06-06", + "2025-06-13", + "2025-06-20", + "2025-06-27", + "2025-07-04", + "2025-07-11", + "2025-07-18", + "2025-07-25", + "2025-08-01", + "2025-08-08", + "2025-08-15", + "2025-08-22", + "2025-08-29", + "2025-09-05", + "2025-09-12", + "2025-09-19", + "2025-09-26", + "2025-10-03", + "2025-10-10", + "2025-10-17", + "2025-10-24", + "2025-10-31", + "2025-11-07", + "2025-11-14", + "2025-11-21", + "2025-11-28", + "2025-12-05", + "2025-12-12", + "2025-12-19", + "2025-12-26", + ], + dtype="datetime64[ns]", + freq=None, + ) + + assert_index_equal(reference, date_range_htf(cal, "1W", "2025-01-01", "2026-01-01")) + + assert_index_equal( + reference[::2], date_range_htf(cal, "2W", "2025-01-01", "2026-01-01") + ) + assert_index_equal( + reference[::5], date_range_htf(cal, "5W", "2025-01-03", "2026-01-01") + ) + assert_index_equal( + reference[1::5], date_range_htf(cal, "5W", "2025-01-04", "2026-01-01") + ) + assert_index_equal(reference[:20], date_range_htf(cal, "W", "2025-01-01", None, 20)) + assert_index_equal( + reference[-20:], date_range_htf(cal, "W", None, "2026-01-01", 20) + ) + assert_index_equal( + pd.DatetimeIndex([]), date_range_htf(cal, "W", "2020-01-01", "2020-01-02") + ) + + # endregion + # region closed == 'left' + reference = pd.DatetimeIndex( + [ + "2025-01-06", + "2025-01-13", + "2025-01-20", + "2025-01-27", + "2025-02-03", + "2025-02-10", + "2025-02-17", + "2025-02-24", + "2025-03-03", + "2025-03-10", + "2025-03-17", + "2025-03-24", + "2025-03-31", + "2025-04-07", + "2025-04-14", + "2025-04-21", + "2025-04-28", + "2025-05-05", + "2025-05-12", + "2025-05-19", + "2025-05-26", + "2025-06-02", + "2025-06-09", + "2025-06-16", + "2025-06-23", + "2025-06-30", + "2025-07-07", + "2025-07-14", + "2025-07-21", + "2025-07-28", + "2025-08-04", + "2025-08-11", + "2025-08-18", + "2025-08-25", + "2025-09-01", + "2025-09-08", + "2025-09-15", + "2025-09-22", + "2025-09-29", + "2025-10-06", + "2025-10-13", + "2025-10-20", + "2025-10-27", + "2025-11-03", + "2025-11-10", + "2025-11-17", + "2025-11-24", + "2025-12-01", + "2025-12-08", + "2025-12-15", + "2025-12-22", + "2025-12-29", + ], + dtype="datetime64[ns]", + freq=None, + ) + + assert_index_equal( + reference, date_range_htf(cal, "1W", "2025-01-01", "2026-01-01", closed="left") + ) + + assert_index_equal( + reference[::2], + date_range_htf(cal, "2W", "2025-01-01", "2026-01-01", closed="left"), + ) + assert_index_equal( + reference[::5], + date_range_htf(cal, "5W", "2025-01-05", "2026-01-01", closed="left"), + ) + assert_index_equal( + reference[::5], + date_range_htf(cal, "5W", "2025-01-06", "2026-01-01", closed="left"), + ) + assert_index_equal( + reference[1::5], + date_range_htf(cal, "5W", "2025-01-07", "2026-01-01", closed="left"), + ) + assert_index_equal( + reference[:20], date_range_htf(cal, "W", "2025-01-01", None, 20, closed="left") + ) + assert_index_equal( + reference[-20:], date_range_htf(cal, "W", None, "2026-01-01", 20, closed="left") + ) + assert_index_equal( + pd.DatetimeIndex([]), + date_range_htf(cal, "W", "2020-01-01", "2020-01-05", closed="left"), + ) + # endregion + + assert_index_equal( # Checks that 'WED' Anchor Rolls Closed 2025-01-01 'WED' to 2025-01-02 + pd.DatetimeIndex( + ["2025-01-02", "2025-01-08", "2025-01-15", "2025-01-22", "2025-01-29"], + dtype="datetime64[ns]", + freq=None, + ), + date_range_htf( + cal, "1W", "2025-01-01", "2025-02-01", closed="left", day_anchor="WED" + ), + ) + assert_index_equal( # Checks that 'WED' Anchor Trims off Closed 2025-01-01 'WED' + pd.DatetimeIndex( + ["2025-01-08", "2025-01-15", "2025-01-22", "2025-01-29"], + dtype="datetime64[ns]", + freq=None, + ), + date_range_htf( + cal, "1W", "2025-01-01", "2025-02-01", closed="right", day_anchor="WED" + ), + ) + + +def test_date_range_htf_months(): + cal = FakeCalendar().holidays() + + # region closed == 'right' + reference = pd.DatetimeIndex( + [ + "2025-01-31", + "2025-02-28", + "2025-03-31", + "2025-04-30", + "2025-05-30", + "2025-06-30", + "2025-07-31", + "2025-08-29", + "2025-09-30", + "2025-10-31", + "2025-11-28", + "2025-12-31", + ], + dtype="datetime64[ns]", + freq=None, + ) + + assert_index_equal(reference, date_range_htf(cal, "M", "2025-01-01", "2026-01-01")) + + assert_index_equal( + reference[::2], date_range_htf(cal, "2M", "2025-01-01", "2026-01-01") + ) + assert_index_equal( + reference[4::5], date_range_htf(cal, "5M", "2025-05-30", "2026-01-01") + ) + assert_index_equal( + reference[5::5], date_range_htf(cal, "5M", "2025-05-31", "2026-01-01") + ) + assert_index_equal(reference[:10], date_range_htf(cal, "M", "2025-01-01", None, 10)) + assert_index_equal( + reference[-10:], date_range_htf(cal, "M", None, "2026-01-01", 10) + ) + assert_index_equal( + reference[3::2], date_range_htf(cal, "2M", None, "2026-01-01", 5) + ) + assert_index_equal( + reference[2:-1:2], date_range_htf(cal, "2M", None, "2025-12-30", 5) + ) + assert_index_equal( + pd.DatetimeIndex([]), date_range_htf(cal, "M", "2020-01-01", "2020-01-02") + ) + # endregion + + # region closed == 'left' + reference = pd.DatetimeIndex( + [ + "2025-01-02", + "2025-02-03", + "2025-03-03", + "2025-04-01", + "2025-05-01", + "2025-06-02", + "2025-07-01", + "2025-08-01", + "2025-09-01", + "2025-10-01", + "2025-11-03", + "2025-12-01", + ], + dtype="datetime64[ns]", + freq=None, + ) + + assert_index_equal( + reference, date_range_htf(cal, "1M", "2025-01-01", "2026-01-01", closed="left") + ) + + assert_index_equal( + reference[::2], + date_range_htf(cal, "2M", "2025-01-01", "2026-01-01", closed="left"), + ) + assert_index_equal( + reference[::5], + date_range_htf(cal, "5M", "2025-01-02", "2026-01-01", closed="left"), + ) + assert_index_equal( + reference[1::5], + date_range_htf(cal, "5M", "2025-01-05", "2026-01-01", closed="left"), + ) + assert_index_equal( + reference[::5], + date_range_htf(cal, "5M", "2025-01-02", "2026-01-01", closed="left"), + ) + assert_index_equal( + reference[1::5], + date_range_htf(cal, "5M", "2025-01-05", "2026-01-01", closed="left"), + ) + assert_index_equal( + reference[:10], date_range_htf(cal, "M", "2025-01-01", None, 10, closed="left") + ) + assert_index_equal( + reference[-10:], date_range_htf(cal, "M", None, "2026-01-01", 10, closed="left") + ) + assert_index_equal( + reference[3::2], date_range_htf(cal, "2M", None, "2026-01-01", 5, closed="left") + ) + assert_index_equal( + pd.DatetimeIndex([]), + date_range_htf(cal, "M", "2020-01-01", "2020-01-01", closed="left"), + ) + # endregion + + +def test_date_range_htf_quarters(): + cal = FakeCalendar().holidays() + + # region closed == 'right' + reference = pd.DatetimeIndex( + [ + "2025-03-31", + "2025-06-30", + "2025-09-30", + "2025-12-31", + "2026-03-31", + "2026-06-30", + "2026-09-30", + "2026-12-31", + ], + dtype="datetime64[ns]", + freq=None, + ) + + assert_index_equal(reference, date_range_htf(cal, "Q", "2025-01-01", "2027-01-01")) + + assert_index_equal( + reference[::2], date_range_htf(cal, "2Q", "2025-01-01", "2027-01-01") + ) + assert_index_equal(reference[:5], date_range_htf(cal, "Q", "2025-01-01", None, 5)) + assert_index_equal(reference[-5:], date_range_htf(cal, "Q", None, "2027-01-01", 5)) + assert_index_equal( + reference[1::2], date_range_htf(cal, "2Q", None, "2027-01-01", 4) + ) + assert_index_equal( + reference[1::2], date_range_htf(cal, "2Q", None, "2026-12-31", 4) + ) + assert_index_equal( + pd.DatetimeIndex([]), date_range_htf(cal, "Q", "2020-01-01", "2020-01-02") + ) + # endregion + + # region closed == 'left' + reference = pd.DatetimeIndex( + [ + "2025-01-02", + "2025-04-01", + "2025-07-01", + "2025-10-01", + "2026-01-02", + "2026-04-01", + "2026-07-01", + "2026-10-01", + ], + dtype="datetime64[ns]", + freq=None, + ) + + assert_index_equal( + reference, date_range_htf(cal, "1Q", "2025-01-01", "2027-01-01", closed="left") + ) + assert_index_equal( + reference[::2], + date_range_htf(cal, "2Q", "2025-01-01", "2027-01-01", closed="left"), + ) + assert_index_equal( + reference[::5], + date_range_htf(cal, "5Q", "2025-01-02", "2027-01-01", closed="left"), + ) + assert_index_equal( + reference[1::5], + date_range_htf(cal, "5Q", "2025-01-05", "2027-01-01", closed="left"), + ) + assert_index_equal( + reference[1::5], + date_range_htf(cal, "5Q", "2025-01-05", "2027-01-01", closed="left"), + ) + assert_index_equal( + reference[:5], date_range_htf(cal, "Q", "2025-01-01", None, 5, closed="left") + ) + assert_index_equal( + reference[-5:], date_range_htf(cal, "Q", None, "2027-01-01", 5, closed="left") + ) + assert_index_equal( + reference[1::2], date_range_htf(cal, "2Q", None, "2027-01-01", 4, closed="left") + ) + assert_index_equal( + pd.DatetimeIndex([]), + date_range_htf(cal, "Q", "2020-01-01", "2020-01-01", closed="left"), + ) + # endregion + + assert_index_equal( + pd.DatetimeIndex( + ["2025-01-31", "2025-04-30", "2025-07-31", "2025-10-31"], + dtype="datetime64[ns]", + freq=None, + ), + date_range_htf(cal, "Q", "2025-01-01", "2026-01-01", month_anchor="FEB"), + ) + assert_index_equal( + pd.DatetimeIndex( + ["2025-02-03", "2025-05-01", "2025-08-01", "2025-11-03"], + dtype="datetime64[ns]", + freq=None, + ), + date_range_htf( + cal, "Q", "2025-01-01", "2026-01-01", closed="left", month_anchor="FEB" + ), + ) + + +def test_date_range_htf_years(): + cal = FakeCalendar().holidays() + + # region closed == 'right' + reference = pd.DatetimeIndex( + [ + "2025-12-31", + "2026-12-31", + "2027-12-31", + "2028-12-29", + "2029-12-31", + "2030-12-31", + "2031-12-31", + "2032-12-31", + "2033-12-30", + "2034-12-29", + "2035-12-31", + ], + dtype="datetime64[ns]", + freq=None, + ) + + assert_index_equal(reference, date_range_htf(cal, "Y", "2025-01-01", "2036-01-01")) + + assert_index_equal( + reference[::2], date_range_htf(cal, "2Y", "2025-01-01", "2036-01-01") + ) + assert_index_equal(reference[:5], date_range_htf(cal, "Y", "2025-01-01", None, 5)) + assert_index_equal(reference[-5:], date_range_htf(cal, "Y", None, "2036-01-01", 5)) + assert_index_equal(reference[::2], date_range_htf(cal, "2Y", None, "2036-01-01", 6)) + assert_index_equal( + reference[1::2], date_range_htf(cal, "2Y", None, "2035-12-30", 5) + ) + assert_index_equal( + pd.DatetimeIndex([]), date_range_htf(cal, "Y", "2020-01-01", "2020-01-02") + ) + # endregion + + # region closed == 'left' + reference = pd.DatetimeIndex( + [ + "2025-01-02", + "2026-01-02", + "2027-01-04", + "2028-01-03", + "2029-01-02", + "2030-01-02", + "2031-01-02", + "2032-01-02", + "2033-01-03", + "2034-01-03", + "2035-01-02", + ], + dtype="datetime64[ns]", + freq=None, + ) + + assert_index_equal( + reference, date_range_htf(cal, "1Y", "2025-01-01", "2036-01-01", closed="left") + ) + assert_index_equal( + reference[::2], + date_range_htf(cal, "2Y", "2025-01-01", "2036-01-01", closed="left"), + ) + assert_index_equal( + reference[::5], + date_range_htf(cal, "5Y", "2025-01-02", "2036-01-01", closed="left"), + ) + assert_index_equal( + reference[1::5], + date_range_htf(cal, "5Y", "2025-01-05", "2036-01-01", closed="left"), + ) + assert_index_equal( + reference[1::5], + date_range_htf(cal, "5Y", "2025-01-05", "2036-01-01", closed="left"), + ) + assert_index_equal( + reference[:5], date_range_htf(cal, "Y", "2025-01-01", None, 5, closed="left") + ) + assert_index_equal( + reference[-6:], date_range_htf(cal, "Y", None, "2036-01-01", 6, closed="left") + ) + assert_index_equal( + reference[2::2], date_range_htf(cal, "2Y", None, "2036-01-01", 5, closed="left") + ) + assert_index_equal( + pd.DatetimeIndex([]), + date_range_htf(cal, "Y", "2020-01-01", "2020-01-01", closed="left"), + ) + + assert_index_equal( + pd.DatetimeIndex( + ["2025-01-31", "2026-01-30", "2027-01-29"], + dtype="datetime64[ns]", + freq=None, + ), + date_range_htf(cal, "Y", "2025-01-01", "2028-01-01", month_anchor="FEB"), + ) + assert_index_equal( + pd.DatetimeIndex( + ["2025-02-03", "2026-02-02", "2027-02-01"], + dtype="datetime64[ns]", + freq=None, + ), + date_range_htf( + cal, "Y", "2025-01-01", "2028-01-01", closed="left", month_anchor="FEB" + ), + ) + # endregion + + +# endregion From f22d8c9938c797465d080e020b163ac4461c8809 Mon Sep 17 00:00:00 2001 From: Bryce Hawk Date: Sun, 12 Jan 2025 14:44:57 -0600 Subject: [PATCH 05/11] Circular Import Error Fix & edge-case bug fixes --- pandas_market_calendars/calendar_utils.py | 42 ++++++++++++++++------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/pandas_market_calendars/calendar_utils.py b/pandas_market_calendars/calendar_utils.py index 994b710..ff1c4c5 100644 --- a/pandas_market_calendars/calendar_utils.py +++ b/pandas_market_calendars/calendar_utils.py @@ -4,16 +4,16 @@ import itertools from math import ceil, floor -from typing import Iterable, Literal, Tuple, Union +from typing import TYPE_CHECKING, Iterable, Literal, Tuple, Union import warnings from re import finditer, split import numpy as np import pandas as pd -from pandas.tseries.offsets import CustomBusinessDay - -from pandas_market_calendars.market_calendar import WEEKMASK_ABBR +if TYPE_CHECKING: + from pandas.tseries.offsets import CustomBusinessDay + from pandas.tseries.holiday import AbstractHolidayCalendar, Holiday def merge_schedules(schedules, how="outer"): @@ -59,6 +59,17 @@ def merge_schedules(schedules, how="outer"): return result +def is_single_observance(holiday: "Holiday"): + "Returns the Date of the Holiday if it is only observed once, None otherwise." + return holiday.start_date if holiday.start_date == holiday.end_date else None # type: ignore ?? + + +def all_single_observance_rules(calendar: "AbstractHolidayCalendar"): + "Returns a list of timestamps if the Calendar's Rules are all single observance holidays, None Otherwise" + observances = [is_single_observance(rule) for rule in calendar.rules] + return observances if all(observances) else None + + def convert_freq(index, frequency): """ Converts a DateTimeIndex to a new lower frequency @@ -740,7 +751,7 @@ def _calc_time_series( def date_range_htf( - cal: CustomBusinessDay, + cal: "CustomBusinessDay", frequency: Union[str, pd.Timedelta, int, float], start: Union[str, pd.Timestamp, int, float, None] = None, end: Union[str, pd.Timestamp, int, float, None] = None, @@ -749,7 +760,7 @@ def date_range_htf( *, day_anchor: Day_Anchor = "SUN", month_anchor: Month_Anchor = "JAN", -): +) -> pd.DatetimeIndex: """ Returns a Normalized DatetimeIndex from the start-date to End-Date for Time periods of 1D and Higher. @@ -791,9 +802,11 @@ def date_range_htf( : This can be set so that a specific day each week is returned. : freq='1W' & day_anchor='WED' Will return Every 'WED' when the market is open, and nearest day to the left or right (based on 'closed') when the market is closed. + Options: ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"] :param month_anchor: Month to Anchor the start of the year to for Quarter and yearly timeframes. : Default 'JAN' for Calendar Quarters/Years. Can be set to 'JUL' to return Fiscal Years + Options: ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"] """ start, end, periods = _error_check_htf_range(start, end, periods) @@ -829,7 +842,7 @@ def date_range_htf( return _cal_WMQY_range(cal, start, end, periods, freq, grouping_period, closed) -# region ---- ---- ---- Date Range HTF SubRoutines ---- ---- ---- +# region ---- ---- ---- Date Range HTF Subroutines ---- ---- ---- def _error_check_htf_range( @@ -911,7 +924,8 @@ def _days_per_week(weekmask: Union[Iterable, str]) -> int: if len(weekmask) == 0: raise ValueError("Weekmask cannot be blank") - day_abbrs = {day for day in WEEKMASK_ABBR.values() if day in weekmask} + weekmask = weekmask.upper() + day_abbrs = {day for day in weekly_roll_map.values() if day in weekmask} if len(day_abbrs) != 0: return len(day_abbrs) @@ -920,7 +934,7 @@ def _days_per_week(weekmask: Union[Iterable, str]) -> int: def _cal_day_range( - cb_day: CustomBusinessDay, start, end, periods, mult + cb_day: "CustomBusinessDay", start, end, periods, mult ) -> pd.DatetimeIndex: """ Returns a Normalized DateTimeIndex of Open Buisness Days. @@ -938,8 +952,10 @@ def _cal_day_range( """ # Ensure Start and End are open Business days in the desired range - start = cb_day.rollforward(start) - end = cb_day.rollback(end) + if start is not None: + start = cb_day.rollforward(start) + if end is not None: + end = cb_day.rollback(end) # ---- Start-Date to End-Date ---- if isinstance(start, pd.Timestamp) and isinstance(end, pd.Timestamp): @@ -967,11 +983,11 @@ def _cal_day_range( end = cb_day.rollback(end) _range = pd.RangeIndex(0, -1 * periods * mult, -1 * mult) - return pd.DatetimeIndex(end + _range * cb_day, dtype="datetime64[ns]") + return pd.DatetimeIndex(end + _range * cb_day, dtype="datetime64[ns]")[::-1] def _cal_WMQY_range( - cb_day: CustomBusinessDay, + cb_day: "CustomBusinessDay", start: Union[pd.Timestamp, None], end: Union[pd.Timestamp, None], periods: Union[int, None], From 8ce993e781db06557abd692d98ab77ea76a44c3c Mon Sep 17 00:00:00 2001 From: Bryce Hawk Date: Sun, 12 Jan 2025 14:48:33 -0600 Subject: [PATCH 06/11] Added MarketCalendar Methods: Schedule_from_days() & date_range_htf() --- pandas_market_calendars/market_calendar.py | 145 ++++++++++++++++++--- tests/test_market_calendar.py | 2 +- 2 files changed, 126 insertions(+), 21 deletions(-) diff --git a/pandas_market_calendars/market_calendar.py b/pandas_market_calendars/market_calendar.py index 1a3c73d..f295573 100644 --- a/pandas_market_calendars/market_calendar.py +++ b/pandas_market_calendars/market_calendar.py @@ -16,12 +16,15 @@ import warnings from abc import ABCMeta, abstractmethod from datetime import time +from typing import Literal, Union import pandas as pd from pandas.tseries.offsets import CustomBusinessDay from .class_registry import RegisteryMeta, ProtectedDict +from . import calendar_utils as u + MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY = range(7) WEEKMASK_ABBR = { @@ -550,7 +553,7 @@ def interruptions_df(self): return intr.apply(self._convert).sort_index() - def holidays(self): + def holidays(self) -> pd.tseries.offsets.CustomBusinessDay: """ Returns the complete CustomBusinessDay object of holidays that can be used in any Pandas function that take that input. @@ -567,7 +570,7 @@ def holidays(self): ) return self._holidays - def valid_days(self, start_date, end_date, tz="UTC"): + def valid_days(self, start_date, end_date, tz="UTC") -> pd.DatetimeIndex: """ Get a DatetimeIndex of valid open business days. @@ -627,7 +630,7 @@ def _tryholidays(self, cal, s, e): try: # If the Calendar is all single Observance Holidays then it is far # more efficient to extract and return those dates - observed_dates = _all_single_observance_rules(cal) + observed_dates = u.all_single_observance_rules(cal) if observed_dates is not None: return pd.DatetimeIndex( [date for date in observed_dates if s <= date <= e] @@ -700,7 +703,7 @@ def schedule( force_special_times=True, market_times=None, interruptions=False, - ): + ) -> pd.DataFrame: """ Generates the schedule DataFrame. The resulting DataFrame will have all the valid business days as the index and columns for the requested market times. The columns can be determined either by setting a range (inclusive @@ -732,30 +735,83 @@ def schedule( if not (start_date <= end_date): raise ValueError("start_date must be before or equal to end_date.") - # Setup all valid trading days and the requested market_times _all_days = self.valid_days(start_date, end_date) + + # Setup all valid trading days and the requested market_times if market_times is None: market_times = self._get_market_times(start, end) elif market_times == "all": market_times = self._market_times - # If no valid days return an empty DataFrame - if not _all_days.size: + if not _all_days.size: # If no valid days return an empty DataFrame return pd.DataFrame( columns=market_times, index=pd.DatetimeIndex([], freq="C") ) + return self.schedule_from_days( + _all_days, tz, start, end, force_special_times, market_times, interruptions + ) + + def schedule_from_days( + self, + days: pd.DatetimeIndex, + tz="UTC", + start="market_open", + end="market_close", + force_special_times=True, + market_times=None, + interruptions=False, + ) -> pd.DataFrame: + """ + Generates a schedule DataFrame for the days provided. The days are assumed to be valid trading days. + + The columns can be determined either by setting a range (inclusive on both sides), using `start` and `end`, + or by passing a list to `market_times'. A range of market_times is derived from a list of market_times that + are available to the instance, which are sorted based on the current regular time. + See examples/usage.ipynb for demonstrations. + + All time zones are set to UTC by default. Setting the tz parameter will convert the columns to the desired + timezone, such as 'America/New_York'. + + :param days: pd.DatetimeIndex of all the desired days in ascending order. This function does not double check + that these are valid trading days, it is assumed they are. It is intended that this parameter is generated + by either the .valid_days() or .date_range_htf() methods. Time & Timezone Information is ignored. + :param tz: timezone that the columns of the returned schedule are in, default: "UTC" + :param start: the first market_time to include as a column, default: "market_open" + :param end: the last market_time to include as a column, default: "market_close" + :param force_special_times: how to handle special times. + True: overwrite regular times of the column itself, conform other columns to special times of + market_open/market_close if those are requested. + False: only overwrite regular times of the column itself, leave others alone + None: completely ignore special times + :param market_times: alternative to start/end, list of market_times that are in self.regular_market_times + :param interruptions: bool, whether to add interruptions to the schedule, default: False + These will be added as columns to the right of the DataFrame. Any interruption on a day between + start_date and end_date will be included, regardless of the market_times requested. + Also, `force_special_times` does not take these into consideration. + :return: schedule DataFrame + """ + + if days.dtype != "datetime64[ns]": + days = pd.DatetimeIndex(days).normalize().tz_localize(None) + + # Setup all valid trading days and the requested market_times + if market_times is None: + market_times = self._get_market_times(start, end) + elif market_times == "all": + market_times = self._market_times + _adj_others = force_special_times is True _adj_col = force_special_times is not None _open_adj = _close_adj = [] schedule = pd.DataFrame() for market_time in market_times: - temp = self.days_at_time(_all_days, market_time).copy() # standard times + temp = self.days_at_time(days, market_time).copy() # standard times if _adj_col: # create an array of special times special = self.special_dates( - market_time, start_date, end_date, filter_holidays=False + market_time, days[0], days[-1], filter_holidays=False ) # overwrite standard times specialix = special.index[ @@ -803,6 +859,66 @@ def adjust_closes(x): return schedule + def date_range_htf( + self, + frequency: Union[str, pd.Timedelta, int, float], + start: Union[str, pd.Timestamp, int, float, None] = None, + end: Union[str, pd.Timestamp, int, float, None] = None, + periods: Union[int, None] = None, + closed: Union[Literal["left", "right"], None] = "right", + *, + day_anchor: u.Day_Anchor = "SUN", + month_anchor: u.Month_Anchor = "JAN", + ) -> pd.DatetimeIndex: + """ + Returns a Normalized DatetimeIndex from the start-date to End-Date for Time periods of 1D and Higher. + + PARAMETERS: + + :param frequency: String, Int/float (POSIX seconds) or pd.Timedelta of the desired frequency. + :Must be Greater than '1D' and an integer multiple of the base frequency (D, W, M, Q, or Y) + :Important Note: Ints/Floats & Timedeltas are always considered as 'Open Business Days', + '2D' == Every Other Buisness Day, '3D' == Every 3rd B.Day, '7D' == Every 7th B.Day + :Higher periods (passed as strings) align to the beginning or end of the relevant period + :i.e. '1W' == First/[Last] Trading Day of each Week, '1Q' == First/[Last] Day of every Quarter + + :param start: String, Int/float (POSIX seconds) or pd.Timestamp of the desired start time. + :The Time & Timezone information is ignored. Only the Normalized Day is considered. + + :param end: String, Int/float (POSIX seconds) or pd.Timestamp of the desired start time. + :The Time & Timezone information is ignored. Only the Normalized Day is considered. + + :param periods: Optional Integer number of periods to return. If a Period count, Start time, + and End time are given the period count is ignored. + + :param closed: Literal['left', 'right']. Method used to close each range. + :Left: First open trading day of the Session is returned (e.g. First Open Day of The Month) + :right: Last open trading day of the Session is returned (e.g. Last Open Day of The Month) + :Note, This has no effect when the desired frequency is a number of days. + + :param day_anchor: Day to Anchor the start of the Weekly timeframes to. Default 'SUN'. + : To get the First/Last Days of the trading Week then the Anchor needs to be on a day the relevant + market is closed. + : This can be set so that a specific day each week is returned. + : freq='1W' & day_anchor='WED' Will return Every 'WED' when the market is open, and nearest day + to the left or right (based on 'closed') when the market is closed. + Options: ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"] + + :param month_anchor: Month to Anchor the start of the year to for Quarter and yearly timeframes. + : Default 'JAN' for Calendar Quarters/Years. Can be set to 'JUL' to return Fiscal Years + Options: ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"] + """ + return u.date_range_htf( + self.holidays(), + frequency, + start, + end, + periods, + closed, + day_anchor=day_anchor, + month_anchor=month_anchor, + ) + def open_at_time(self, schedule, timestamp, include_close=False, only_rth=False): """ Determine if a given timestamp is during an open time for the market. If the timestamp is @@ -939,14 +1055,3 @@ def __setitem__(self, key, value): def __delitem__(self, key): return self.remove_time(key) - - -def _is_single_observance(holiday): - "Returns the Date of the Holiday if it is only observed once, None otherwise." - return holiday.start_date if holiday.start_date == holiday.end_date else None - - -def _all_single_observance_rules(calendar): - "Returns a list of timestamps if the Calendar's Rules are all single observance holidays, None Otherwise" - observances = [_is_single_observance(rule) for rule in calendar.rules] - return observances if all(observances) else None diff --git a/tests/test_market_calendar.py b/tests/test_market_calendar.py index 9810683..46c551d 100644 --- a/tests/test_market_calendar.py +++ b/tests/test_market_calendar.py @@ -493,7 +493,7 @@ def tz(self): def dat(day, day_offset, time_offset, cal, expected): days = pd.DatetimeIndex([pd.Timestamp(day, tz=cal.tz)]) - result = cal.days_at_time(days, time_offset, day_offset)[0] + result = cal.days_at_time(days, time_offset, day_offset).iloc[0] expected = pd.Timestamp(expected, tz=cal.tz).tz_convert("UTC") assert result == expected From c3a9b2d774ea5ca653bdd5e31a5cb31c80b57264 Mon Sep 17 00:00:00 2001 From: Bryce Hawk Date: Sun, 12 Jan 2025 14:49:47 -0600 Subject: [PATCH 07/11] Date_Range_HTF() Sub-Class Overrides & Relevant Tests. --- pandas_market_calendars/calendars/iex.py | 36 +++++- pandas_market_calendars/calendars/nyse.py | 110 +++++++++++++++++- pandas_market_calendars/calendars/tase.py | 25 ++++- tests/test_iex_calendar.py | 6 + tests/test_nyse_calendar_early_years.py | 131 ++++++++++++++++++++++ 5 files changed, 301 insertions(+), 7 deletions(-) diff --git a/pandas_market_calendars/calendars/iex.py b/pandas_market_calendars/calendars/iex.py index 88eb673..54e7939 100644 --- a/pandas_market_calendars/calendars/iex.py +++ b/pandas_market_calendars/calendars/iex.py @@ -1,10 +1,13 @@ from datetime import time from itertools import chain -from pandas import Timestamp +from pandas import Timestamp, DatetimeIndex, Timedelta from pandas.tseries.holiday import AbstractHolidayCalendar from pytz import timezone +from typing import Literal, Union +from pandas_market_calendars import calendar_utils as u + from pandas_market_calendars.holidays.nyse import ( USPresidentsDay, GoodFriday, @@ -115,3 +118,34 @@ def valid_days(self, start_date, end_date, tz="UTC"): # Limit Start_date to the Exchange's Open start_date = max(start_date, Timestamp("2013-08-25")) return super().valid_days(start_date, end_date, tz=tz) + + def date_range_htf( + self, + frequency: Union[str, Timedelta, int, float], + start: Union[str, Timestamp, int, float, None] = None, + end: Union[str, Timestamp, int, float, None] = None, + periods: Union[int, None] = None, + closed: Union[Literal["left", "right"], None] = "right", + *, + day_anchor: u.Day_Anchor = "SUN", + month_anchor: u.Month_Anchor = "JAN", + ) -> DatetimeIndex: + + start, end, periods = u._error_check_htf_range(start, end, periods) + + # Cap Beginning and end dates to the opening date of IEX + if start is not None: + start = max(start, Timestamp("2013-08-25")) + if end is not None: + end = max(end, Timestamp("2013-08-25")) + + return u.date_range_htf( + self.holidays(), + frequency, + start, + end, + periods, + closed, + day_anchor=day_anchor, + month_anchor=month_anchor, + ) diff --git a/pandas_market_calendars/calendars/nyse.py b/pandas_market_calendars/calendars/nyse.py index 8a4e334..b566c9c 100644 --- a/pandas_market_calendars/calendars/nyse.py +++ b/pandas_market_calendars/calendars/nyse.py @@ -21,6 +21,9 @@ from pandas.tseries.offsets import CustomBusinessDay from pytz import timezone +from typing import Literal, Union +from pandas_market_calendars import calendar_utils as u + from pandas_market_calendars.holidays.nyse import ( # Always Celebrated Holidays USNewYearsDayNYSEpost1952, @@ -833,6 +836,10 @@ def tz(self): def weekmask(self): return "Mon Tue Wed Thu Fri" + @property + def weekmask_pre_1952(self): + return "Mon Tue Wed Thu Fri Sat" + def holidays_pre_1952(self): """ NYSE Market open on Saturdays pre 5/24/1952. @@ -846,7 +853,7 @@ def holidays_pre_1952(self): self._holidays_hist = CustomBusinessDay( holidays=self.adhoc_holidays, calendar=self.regular_holidays, - weekmask="Mon Tue Wed Thu Fri Sat", + weekmask=self.weekmask_pre_1952, ) return self._holidays_hist @@ -985,7 +992,6 @@ def adhoc_holidays(self): ) ) - # @property def special_closes(self): return [ @@ -1106,7 +1112,6 @@ def special_closes(self): ), ] - # @property def special_closes_adhoc(self): def _union_many(indexes): @@ -1161,7 +1166,6 @@ def _union_many(indexes): ), ] - # @property def special_opens(self): return [ @@ -1275,7 +1279,6 @@ def special_opens(self): ), ] - # @property def special_opens_adhoc(self): return [ @@ -1351,6 +1354,103 @@ def days_at_time(self, days, market_time, day_offset=0): days = days.dt.tz_convert("UTC") return days + def date_range_htf( + self, + frequency: Union[str, pd.Timedelta, int, float], + start: Union[str, pd.Timestamp, int, float, None] = None, + end: Union[str, pd.Timestamp, int, float, None] = None, + periods: Union[int, None] = None, + closed: Union[Literal["left", "right"], None] = "right", + *, + day_anchor: u.Day_Anchor = "SUN", + month_anchor: u.Month_Anchor = "JAN", + ) -> pd.DatetimeIndex: + # __doc__ = MarketCalendar.date_range_htf.__doc__ + + start, end, periods = u._error_check_htf_range(start, end, periods) + + args = { + "frequency": frequency, + "start": start, + "end": end, + "periods": periods, + "closed": closed, + "day_anchor": day_anchor, + "month_anchor": month_anchor, + } + + saturday_end = self._saturday_end.tz_localize(None) + + # All Dates post 1952 This is the most common use case so return it first + if start is not None and start > saturday_end: + return u.date_range_htf(self.holidays(), **args) + + # ---- Start-Date to End-Date w/ pre-1952 ---- + if start is not None and end is not None: + if end <= saturday_end: + # All pre 1952 Dates + return u.date_range_htf(self.holidays_pre_1952(), **args) + else: + # Split Range Across 1952 + pre = u.date_range_htf( # Only Generate to the last saturday + self.holidays_pre_1952(), **(args | {"end": saturday_end}) + ) + post = u.date_range_htf( # start generating from the last date of 'pre' + self.holidays(), **(args | {"start": pre[-1]}) + ) + return pd.DatetimeIndex(pre.union(post), dtype="datetime64[ns]") + + # ---- Periods from Start-Date w/ pre-1952 ---- + elif start is not None and periods is not None: + # Start prior to 1952 & Number of periods given + rtn_dt = u.date_range_htf(self.holidays_pre_1952(), **args) + if rtn_dt[-1] <= saturday_end: + return rtn_dt # never passed 1952, good to return + + # Date Range Split. + pre = rtn_dt[rtn_dt <= saturday_end] + post = u.date_range_htf( + self.holidays(), + **(args | {"start": pre[-1], "periods": periods - len(pre) + 1}), + ) + return pd.DatetimeIndex(pre.union(post)[:periods], dtype="datetime64[ns]") + + # ---- Periods from End-Date ---- + elif end is not None and periods is not None: + if end <= saturday_end: + # All Dates pre-1952, Good to return the normal call + return u.date_range_htf(self.holidays_pre_1952(), **args) + else: + rtn_dt = u.date_range_htf(self.holidays(), **args) + + if rtn_dt[0] > saturday_end: + return rtn_dt # never passed 1952, good to return + + # Date Range Split + post = rtn_dt[rtn_dt > saturday_end] + _, period_code = u._standardize_htf_freq(frequency) + altered_args = { + # This nonsense is to realign the schedules as best as possible. This + # essentially creates the 'pre-1952' equivalent date to the last generated 'post-1952' + # date. Start the Range from there, then pre[0:-1] trims off that extra date where we + # started from + "end": post[0].to_period(period_code).end_time.normalize(), + "periods": periods - len(post) + 2, + } + pre = u.date_range_htf( + self.holidays_pre_1952(), + **(args | altered_args), + ) + + return pd.DatetimeIndex( + pre[:-1].union(post)[-periods:], dtype="datetime64[ns]" + ) + else: + _, _ = u._standardize_htf_freq(frequency) + raise ValueError( + "This should never be raised, the above call should error first" + ) + def early_closes(self, schedule): """ Get a DataFrame of the dates that are an early close. diff --git a/pandas_market_calendars/calendars/tase.py b/pandas_market_calendars/calendars/tase.py index 1bb34e9..c618920 100644 --- a/pandas_market_calendars/calendars/tase.py +++ b/pandas_market_calendars/calendars/tase.py @@ -1,9 +1,11 @@ from datetime import time -from pandas import Timestamp +from typing import Literal, Union +from pandas import Timestamp, Timedelta, DatetimeIndex from pytz import timezone from pandas_market_calendars.market_calendar import MarketCalendar +from pandas_market_calendars.calendar_utils import Day_Anchor, Month_Anchor TASEClosedDay = [ # 2019 @@ -195,3 +197,24 @@ def adhoc_holidays(self): @property def weekmask(self): return "Sun Mon Tue Wed Thu" + + def date_range_htf( + self, + frequency: Union[str, Timedelta, int, float], + start: Union[str, Timestamp, int, float, None] = None, + end: Union[str, Timestamp, int, float, None] = None, + periods: Union[int, None] = None, + closed: Union[Literal["left", "right"], None] = "right", + *, + day_anchor: Day_Anchor = "SAT", # Change the default day anchor + month_anchor: Month_Anchor = "JAN", + ) -> DatetimeIndex: + return super().date_range_htf( + frequency, + start, + end, + periods, + closed, + day_anchor=day_anchor, + month_anchor=month_anchor, + ) diff --git a/tests/test_iex_calendar.py b/tests/test_iex_calendar.py index d713e65..0ce14f5 100644 --- a/tests/test_iex_calendar.py +++ b/tests/test_iex_calendar.py @@ -38,3 +38,9 @@ def test_calendar_utility(): def test_trading_days_before_operation(): trading_days = iex.valid_days(start_date="2000-01-01", end_date="2022-02-23") assert np.array([~(trading_days <= "2013-08-25")]).any() + + trading_days = iex.date_range_htf("1D", "2000-01-01", "2022-02-23") + assert np.array([~(trading_days <= "2013-08-25")]).any() + + trading_days = iex.date_range_htf("1D", "2000-01-01", "2010-02-23") + assert len(trading_days) == 0 diff --git a/tests/test_nyse_calendar_early_years.py b/tests/test_nyse_calendar_early_years.py index 7a421a7..b4ce5e0 100644 --- a/tests/test_nyse_calendar_early_years.py +++ b/tests/test_nyse_calendar_early_years.py @@ -2048,6 +2048,137 @@ def test_1952_no_saturdays(): assert len(df) == 0 +def test_1952_date_range_htf_crossover(): + # Test that Date_Range_HTF can produce the correct range when having + # to merge ranges from two different holiday objects. This is only + # A massive pain in the ass when closed='right' + nyse = NYSEExchangeCalendar() + + actual_week_ends = pd.DatetimeIndex( + [ + "1952-01-05", # Saturday + "1952-01-12", # Saturday + "1952-01-19", # Saturday + "1952-01-26", # Saturday + "1952-02-02", # Saturday + "1952-02-09", # Saturday + "1952-02-16", # Saturday + "1952-02-23", # Saturday + "1952-03-01", # Saturday + "1952-03-08", # Saturday + "1952-03-15", # Saturday + "1952-03-22", # Saturday + "1952-03-29", # Saturday + "1952-04-05", # Saturday + "1952-04-12", # Saturday + "1952-04-19", # Saturday + "1952-04-26", # Saturday + "1952-05-03", # Saturday + "1952-05-10", # Saturday + "1952-05-17", # Saturday + "1952-05-24", # Saturday ## Last Trading Saturday. ## + "1952-05-29", # Thursday + "1952-06-06", # Friday + "1952-06-13", # Friday + "1952-06-20", # Friday + "1952-06-27", # Friday All Saturdays, and the + "1952-07-03", # Thursday two fridays, in this + "1952-07-11", # Friday range are omitted since + "1952-07-18", # Friday they are labeled as holidays. + "1952-07-25", # Friday + "1952-08-01", # Friday + "1952-08-08", # Friday + "1952-08-15", # Friday + "1952-08-22", # Friday + "1952-08-29", # Friday + "1952-09-05", # Friday + "1952-09-12", # Friday + "1952-09-19", # Friday + "1952-09-26", # Friday ## 1952-09-29 is Crossover ## + "1952-10-03", # Friday + "1952-10-10", # Friday + "1952-10-17", # Friday + "1952-10-24", # Friday + "1952-10-31", # Friday + "1952-11-07", # Friday + "1952-11-14", # Friday + "1952-11-21", # Friday + "1952-11-28", # Friday + "1952-12-05", # Friday + "1952-12-12", # Friday + "1952-12-19", # Friday + "1952-12-26", # Friday + ], + dtype="datetime64[ns]", + freq=None, + ) + + # Ensure all three different ways produce the same range. + assert_index_equal( + actual_week_ends, + nyse.date_range_htf("1W", "1952-01-01", "1953-01-01", closed="right"), + ) + assert_index_equal( + actual_week_ends, + nyse.date_range_htf("1W", "1952-01-01", periods=52, closed="right"), + ) + assert_index_equal( + actual_week_ends, + nyse.date_range_htf("1W", end="1953-01-01", periods=52, closed="right"), + ) + + # Ensure all three different ways produce the same range. + assert_index_equal( + actual_week_ends[::3], + nyse.date_range_htf("3W", "1952-01-01", "1953-01-01", closed="right"), + ) + assert_index_equal( + actual_week_ends[::3], + nyse.date_range_htf("3W", "1952-01-01", periods=18, closed="right"), + ) + assert_index_equal( + actual_week_ends[::-1][::3][::-1], + nyse.date_range_htf("3W", end="1953-01-01", periods=18, closed="right"), + ) + assert_index_equal( + actual_week_ends[::-1][::7][::-1], + nyse.date_range_htf("7W", end="1953-01-01", periods=8, closed="right"), + ) + + # Results Should Agree between the two methods in this critical range + actual_days = nyse.valid_days("1952-05-01", "1952-11-01").tz_localize(None) + + assert_index_equal( + actual_days, + nyse.date_range_htf("D", "1952-05-01", "1952-11-01"), + ) + assert_index_equal( + actual_days, + nyse.date_range_htf("D", "1952-05-01", periods=132), + ) + assert_index_equal( + actual_days, + nyse.date_range_htf("D", end="1952-11-01", periods=132), + ) + + assert_index_equal( + actual_days[::3], + nyse.date_range_htf("3D", "1952-05-01", "1952-11-01"), + ) + assert_index_equal( + actual_days[::3], + nyse.date_range_htf("3D", "1952-05-01", periods=44), + ) + assert_index_equal( + actual_days[::-1][::3][::-1], + nyse.date_range_htf("3D", end="1952-11-01", periods=44), + ) + assert_index_equal( + actual_days[::-1][::7][::-1], + nyse.date_range_htf("7D", end="1952-11-01", periods=19), + ) + + def test_1953(): start = "1953-01-01" end = "1953-12-31" From d8840c610e5a57d3543f415b464dbc4a43706a29 Mon Sep 17 00:00:00 2001 From: Bryce Hawk Date: Tue, 14 Jan 2025 18:16:58 -0600 Subject: [PATCH 08/11] Added mark_session() Util Function --- pandas_market_calendars/__init__.py | 3 +- pandas_market_calendars/calendar_utils.py | 115 +++++++++++++++++++++- 2 files changed, 116 insertions(+), 2 deletions(-) diff --git a/pandas_market_calendars/__init__.py b/pandas_market_calendars/__init__.py index 3a588c9..a551af5 100644 --- a/pandas_market_calendars/__init__.py +++ b/pandas_market_calendars/__init__.py @@ -17,7 +17,7 @@ from importlib import metadata from .calendar_registry import get_calendar, get_calendar_names -from .calendar_utils import convert_freq, date_range, merge_schedules +from .calendar_utils import convert_freq, date_range, merge_schedules, mark_session # TODO: is the below needed? Can I replace all the imports on the calendars with ".market_calendar" from .market_calendar import MarketCalendar @@ -34,5 +34,6 @@ "get_calendar_names", "merge_schedules", "date_range", + "mark_session", "convert_freq", ] diff --git a/pandas_market_calendars/calendar_utils.py b/pandas_market_calendars/calendar_utils.py index ff1c4c5..3085dbc 100644 --- a/pandas_market_calendars/calendar_utils.py +++ b/pandas_market_calendars/calendar_utils.py @@ -4,7 +4,7 @@ import itertools from math import ceil, floor -from typing import TYPE_CHECKING, Iterable, Literal, Tuple, Union +from typing import TYPE_CHECKING, Any, Dict, Iterable, Literal, Tuple, Union import warnings from re import finditer, split @@ -15,6 +15,119 @@ from pandas.tseries.offsets import CustomBusinessDay from pandas.tseries.holiday import AbstractHolidayCalendar, Holiday +DEFAULT_NAME_MAP = { + "pre": "pre", + "rth_pre_break": "rth", + "rth": "rth", + "break": "break", + "rth_post_break": "rth", + "post": "post", + "closed": "closed", +} + + +def mark_session( + schedule: pd.DataFrame, + timestamps: pd.DatetimeIndex, + label_map: Dict[str, Any] = {}, + *, + closed: Literal["left", "right"] = "right", +) -> pd.Series: + """ + Return a Series that denotes the trading session of each timestamp in a DatetimeIndex. + The returned Series's Index is the provided Datetime Index, the Series's values + are the timestamps' corresponding session. + + PARAMETERS: + + :param schedule: The market schedule to check the timestamps against. This Schedule must include + all of the trading days that are in the provided DatetimeIndex of timestamps. + Note: The columns need to be sorted into ascending order, if not, then an error will be + raised saying the bins must be in ascending order. + + :param timestamps: A DatetimeIndex of Timestamps to check. Must be sorted in ascending order. + + :param label_map: Optional mapping of Dict[str, Any] to change the values returned in the + series. The keys of the given mapping should match the keys of the default dict, but the + values can be anything. A subset of mappings may also be provided, e.g. {'closed':-1} will + only change the label of the 'closed' session. All others will remain the default label. + + >>> Default Mapping == { + "pre": "pre", + "rth_pre_break": "rth", # When the Schedule has a break + "rth": "rth", # When the Schedule doesn't have a break + "break": "break", # When the Schedule has a break + "rth_post_break": "rth", # When the Schedule has a break + "post": "post", + "closed": "closed", + } + + :param closed: Which side of each interval should be closed (inclusive) + left: == [start, end) + right: == (start, end] + + """ + # ---- ---- ---- Determine which columns need to be dropped ---- ---- ---- + session_labels = ["closed"] + columns = set(schedule.columns) + needed_cols = set() + + def _extend_statement(session: str, parts: set): + if parts.issubset(columns): + needed_cols.update(parts) + session_labels.append(session) + + _extend_statement("pre", {"pre", "market_open"}) + if {"break_start", "break_end"}.issubset(columns): + _extend_statement("open_pre_break", {"market_open", "break_start"}) + _extend_statement("break", {"break_start", "break_end"}) + _extend_statement("open_pre_break", {"break_end", "market_close"}) + else: + _extend_statement("rth", {"market_open", "market_close"}) + _extend_statement("post", {"market_close", "post"}) + + # ---- ---- ---- Error Check ---- ---- ---- + if len(extra_cols := columns - needed_cols) > 0: + schedule = schedule.drop(columns=[*extra_cols]) + warnings.warn( + f"Attempting to mark trading sessions and the schedule ({columns = }) contains the " + f"extra columns: {extra_cols}. Returned sessions may not be labeled as desired." + ) + + start = timestamps[0] + end = timestamps[-1] + if start < schedule.iloc[0, 0]: # type: ignore + raise ValueError( + f"Insufficient Schedule. Needed Start-Time: {start.normalize().tz_localize(None)}. " + f"Schedule starts at: {schedule.iloc[0, 0]}" + ) + if end > schedule.iloc[-1, -1]: # type: ignore + raise ValueError( + f"Insufficient Schedule. Needed End-Time: {end.normalize().tz_localize(None)}. " + f"Schedule ends at: {schedule.iloc[-1, -1]}" + ) + + # Trim the schedule to match the timeframe covered by the given timeseries + schedule = schedule[ + (schedule.index >= start.normalize().tz_localize(None)) + & (schedule.index <= end.normalize().tz_localize(None)) + ] + + backfilled_map = DEFAULT_NAME_MAP | label_map + mapped_labels = [backfilled_map[label] for label in session_labels] + labels = pd.Series([mapped_labels]).repeat(len(schedule)).explode() + labels = pd.concat([labels, pd.Series([backfilled_map["closed"]])]) + + # Append on additional Edge-Case Bins so result doesn't include NaNs + bins = schedule.to_numpy().flatten() + bins = np.insert(bins, 0, bins[0].normalize()) + bins = np.append(bins, bins[-1].normalize() + pd.Timedelta("1D")) + + return pd.Series( + pd.cut(timestamps, bins, closed != "left", labels=labels, ordered=False), # type: ignore + index=timestamps, + ) + def merge_schedules(schedules, how="outer"): """ From 463b9982c0698386382fff663290a0362b646892 Mon Sep 17 00:00:00 2001 From: Bryce Hawk Date: Wed, 15 Jan 2025 11:03:36 -0600 Subject: [PATCH 09/11] mark_session() Tests and Bug Fixes --- pandas_market_calendars/calendar_utils.py | 16 ++- tests/test_utils.py | 167 +++++++++++++++++++++- 2 files changed, 176 insertions(+), 7 deletions(-) diff --git a/pandas_market_calendars/calendar_utils.py b/pandas_market_calendars/calendar_utils.py index 3085dbc..c3fa887 100644 --- a/pandas_market_calendars/calendar_utils.py +++ b/pandas_market_calendars/calendar_utils.py @@ -15,7 +15,7 @@ from pandas.tseries.offsets import CustomBusinessDay from pandas.tseries.holiday import AbstractHolidayCalendar, Holiday -DEFAULT_NAME_MAP = { +DEFAULT_LABEL_MAP = { "pre": "pre", "rth_pre_break": "rth", "rth": "rth", @@ -65,7 +65,6 @@ def mark_session( :param closed: Which side of each interval should be closed (inclusive) left: == [start, end) right: == (start, end] - """ # ---- ---- ---- Determine which columns need to be dropped ---- ---- ---- session_labels = ["closed"] @@ -79,9 +78,9 @@ def _extend_statement(session: str, parts: set): _extend_statement("pre", {"pre", "market_open"}) if {"break_start", "break_end"}.issubset(columns): - _extend_statement("open_pre_break", {"market_open", "break_start"}) + _extend_statement("rth_pre_break", {"market_open", "break_start"}) _extend_statement("break", {"break_start", "break_end"}) - _extend_statement("open_pre_break", {"break_end", "market_close"}) + _extend_statement("rth_post_break", {"break_end", "market_close"}) else: _extend_statement("rth", {"market_open", "market_close"}) _extend_statement("post", {"market_close", "post"}) @@ -113,7 +112,7 @@ def _extend_statement(session: str, parts: set): & (schedule.index <= end.normalize().tz_localize(None)) ] - backfilled_map = DEFAULT_NAME_MAP | label_map + backfilled_map = DEFAULT_LABEL_MAP | label_map mapped_labels = [backfilled_map[label] for label in session_labels] labels = pd.Series([mapped_labels]).repeat(len(schedule)).explode() labels = pd.concat([labels, pd.Series([backfilled_map["closed"]])]) @@ -123,6 +122,13 @@ def _extend_statement(session: str, parts: set): bins = np.insert(bins, 0, bins[0].normalize()) bins = np.append(bins, bins[-1].normalize() + pd.Timedelta("1D")) + bins, _ind, _counts = np.unique(bins, return_index=True, return_counts=True) + + if len(bins) - 1 != len(labels): + # np.Unique Dropped some bins, need to drop the associated labels + label_inds = (_ind + _counts - 1)[:-1] + labels = labels.iloc[label_inds] + return pd.Series( pd.cut(timestamps, bins, closed != "left", labels=labels, ordered=False), # type: ignore index=timestamps, diff --git a/tests/test_utils.py b/tests/test_utils.py index 6c111b7..e4de40a 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,11 +2,11 @@ import pandas as pd import pytest -from pandas.testing import assert_frame_equal +from pandas.testing import assert_frame_equal, assert_series_equal import pandas_market_calendars as mcal from pandas_market_calendars.calendars.nyse import NYSEExchangeCalendar -from tests.test_market_calendar import FakeCalendar, FakeBreakCalendar +from tests.test_market_calendar import FakeCalendar, FakeBreakCalendar, FakeETHCalendar def test_get_calendar(): @@ -105,3 +105,166 @@ def test_merge_schedules_w_break(): assert "break_start" not in result.columns assert "break_end" not in result.columns + + +def test_mark_session(): + cal = FakeETHCalendar() + sched = cal.schedule("2020-01-01", "2020-02-01", market_times="all", tz=cal.tz) + + dt = mcal.date_range( + sched, + "1h", + closed="left", + periods=8, + session={"RTH", "ETH"}, + merge_adjacent=False, + ) + + assert_series_equal( + pd.Series( + [ + "closed", + "pre", + "pre", + "rth", + "rth", + "post", + "post", + "closed", + ], + index=pd.DatetimeIndex( + [ + "2020-01-02 08:00:00-05:00", + "2020-01-02 09:00:00-05:00", + "2020-01-02 09:30:00-05:00", + "2020-01-02 10:30:00-05:00", + "2020-01-02 11:30:00-05:00", + "2020-01-02 12:30:00-05:00", + "2020-01-02 13:00:00-05:00", + "2020-01-03 08:00:00-05:00", + ], + dtype="datetime64[ns, America/New_York]", + ), + dtype=pd.CategoricalDtype(["closed", "post", "pre", "rth"], ordered=False), + ), + mcal.mark_session(sched, dt), + ) + + assert_series_equal( + pd.Series( + [ + "pre", + "pre", + "rth", + "rth", + "post", + "post", + "closed", + "pre", + ], + index=pd.DatetimeIndex( + [ + "2020-01-02 08:00:00-05:00", + "2020-01-02 09:00:00-05:00", + "2020-01-02 09:30:00-05:00", + "2020-01-02 10:30:00-05:00", + "2020-01-02 11:30:00-05:00", + "2020-01-02 12:30:00-05:00", + "2020-01-02 13:00:00-05:00", + "2020-01-03 08:00:00-05:00", + ], + dtype="datetime64[ns, America/New_York]", + ), + dtype=pd.CategoricalDtype(["closed", "post", "pre", "rth"], ordered=False), + ), + mcal.mark_session(sched, dt, closed="left"), + ) + + # Test Label Mapping + mapping = {"pre": 1, "rth": 2, "post": "_post"} + assert_series_equal( + pd.Series( + [ + 1, + 1, + 2, + 2, + "_post", + "_post", + "closed", + 1, + ], + index=pd.DatetimeIndex( + [ + "2020-01-02 08:00:00-05:00", + "2020-01-02 09:00:00-05:00", + "2020-01-02 09:30:00-05:00", + "2020-01-02 10:30:00-05:00", + "2020-01-02 11:30:00-05:00", + "2020-01-02 12:30:00-05:00", + "2020-01-02 13:00:00-05:00", + "2020-01-03 08:00:00-05:00", + ], + dtype="datetime64[ns, America/New_York]", + ), + dtype=pd.CategoricalDtype([1, 2, "_post", "closed"], ordered=False), + ), + mcal.mark_session(sched, dt, closed="left", label_map=mapping), + ) + + CME = mcal.get_calendar("CME_Equity") + sched = CME.schedule("2020-01-17", "2020-01-20", market_times="all") + + # Ensure the early close on the 20th gets labeled correctly + assert_series_equal( + pd.Series( + ["rth", "rth", "rth", "closed"], + index=pd.DatetimeIndex( + [ + "2020-01-20 17:15:00+00:00", + "2020-01-20 17:30:00+00:00", + "2020-01-20 17:45:00+00:00", + "2020-01-20 18:00:00+00:00", + ], + dtype="datetime64[ns, UTC]", + ), + dtype=pd.CategoricalDtype(categories=["closed", "rth"], ordered=False), + ), + mcal.mark_session( + sched, + mcal.date_range( + sched, "15m", start="2020-01-20 17:00", end="2020-01-20 18:00" + ), + closed="left", + ), + ) + + sched = CME.schedule("2020-01-20", "2020-01-21", market_times="all") + assert_series_equal( + pd.Series( + ["rth", "rth", "rth", "closed", "rth", "rth", "rth", "rth"], + index=pd.DatetimeIndex( + [ + "2020-01-20 17:15:00+00:00", + "2020-01-20 17:30:00+00:00", + "2020-01-20 17:45:00+00:00", + "2020-01-20 18:00:00+00:00", + "2020-01-20 23:15:00+00:00", + "2020-01-20 23:30:00+00:00", + "2020-01-20 23:45:00+00:00", + "2020-01-21 00:00:00+00:00", + ], + dtype="datetime64[ns, UTC]", + ), + dtype=pd.CategoricalDtype( + categories=["break", "closed", "rth"], ordered=False + ), + ), + mcal.mark_session( + sched, + mcal.date_range( + sched, "15m", start="2020-01-20 17:00", end="2020-01-21 00:00" + ), + closed="left", + ), + ) From aeac25747ace437a1d590cd70479f845df24f7be Mon Sep 17 00:00:00 2001 From: Bryce Hawk Date: Wed, 15 Jan 2025 13:38:19 -0600 Subject: [PATCH 10/11] Usage Notebook Updates --- examples/usage.ipynb | 1518 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 1368 insertions(+), 150 deletions(-) diff --git a/examples/usage.ipynb b/examples/usage.ipynb index f4c2d59..b62061b 100644 --- a/examples/usage.ipynb +++ b/examples/usage.ipynb @@ -81,7 +81,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 104, "metadata": {}, "outputs": [ { @@ -140,7 +140,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### Exchange open valid business days" + "### Exchange open valid business days: Valid_Days()" ] }, { @@ -166,10 +166,10 @@ " '2017-01-03 00:00:00+00:00', '2017-01-04 00:00:00+00:00',\n", " '2017-01-05 00:00:00+00:00', '2017-01-06 00:00:00+00:00',\n", " '2017-01-09 00:00:00+00:00', '2017-01-10 00:00:00+00:00'],\n", - " dtype='datetime64[ns, UTC]', freq=None)" + " dtype='datetime64[ns, UTC]', freq='C')" ] }, - "execution_count": 6, + "execution_count": 106, "metadata": {}, "output_type": "execute_result" } @@ -178,6 +178,218 @@ "nyse.valid_days(start_date='2016-12-20', end_date='2017-01-10')" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Exchange open valid business days: Date_Range_HTF()" + ] + }, + { + "cell_type": "code", + "execution_count": 107, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "DatetimeIndex(['2016-12-20', '2016-12-21', '2016-12-22', '2016-12-23',\n", + " '2016-12-27', '2016-12-28', '2016-12-29', '2016-12-30',\n", + " '2017-01-03', '2017-01-04', '2017-01-05', '2017-01-06',\n", + " '2017-01-09', '2017-01-10'],\n", + " dtype='datetime64[ns]', freq='C')" + ] + }, + "execution_count": 107, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# date_range_htf() is functionally identical to valid_days() when given a frequency of '1D', a Start, and End Date\n", + "nyse.date_range_htf('1D', start='2016-12-20', end='2017-01-10')" + ] + }, + { + "cell_type": "code", + "execution_count": 108, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "DatetimeIndex(['2017-01-03', '2017-01-04', '2017-01-05', '2017-01-06'], dtype='datetime64[ns]', freq='C')" + ] + }, + "execution_count": 108, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# However, it can be used in a similar manner to pandas.date_range()\n", + "# Request a number of open days from a given start date\n", + "nyse.date_range_htf('1D', start='2017-01-01', periods=4)" + ] + }, + { + "cell_type": "code", + "execution_count": 109, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "DatetimeIndex(['2016-12-30', '2017-01-03', '2017-01-04', '2017-01-05'], dtype='datetime64[ns]', freq='C')" + ] + }, + "execution_count": 109, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Request a number of open days prior to a given end date\n", + "nyse.date_range_htf('1D', end='2017-01-05', periods=4)" + ] + }, + { + "cell_type": "code", + "execution_count": 110, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "DatetimeIndex(['2016-12-20', '2016-12-22', '2016-12-27', '2016-12-29',\n", + " '2017-01-03', '2017-01-05', '2017-01-09'],\n", + " dtype='datetime64[ns]', freq=None)" + ] + }, + "execution_count": 110, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Request every other open business day in the given range\n", + "nyse.date_range_htf('2D', start='2016-12-20', end='2017-01-10')" + ] + }, + { + "cell_type": "code", + "execution_count": 111, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "DatetimeIndex(['2016-12-23', '2016-12-30', '2017-01-06'], dtype='datetime64[ns]', freq=None)" + ] + }, + "execution_count": 111, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# The Last Trading Day of every Week in the range\n", + "nyse.date_range_htf('1W', start='2016-12-20', end='2017-01-10')" + ] + }, + { + "cell_type": "code", + "execution_count": 112, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "DatetimeIndex(['2016-12-27', '2017-01-03', '2017-01-09'], dtype='datetime64[ns]', freq=None)" + ] + }, + "execution_count": 112, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# The First Trading Day of every Week in the range\n", + "nyse.date_range_htf('1W', start='2016-12-20', end='2017-01-10', closed='left')" + ] + }, + { + "cell_type": "code", + "execution_count": 113, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "DatetimeIndex(['2017-01-03', '2017-02-01', '2017-03-01', '2017-04-03',\n", + " '2017-05-01', '2017-06-01', '2017-07-03', '2017-08-01',\n", + " '2017-09-01', '2017-10-02', '2017-11-01', '2017-12-01'],\n", + " dtype='datetime64[ns]', freq=None)" + ] + }, + "execution_count": 113, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# The First Trading Day of every Month in the range\n", + "nyse.date_range_htf('1M', start='2017-01-01', end='2018-01-01', closed='left')" + ] + }, + { + "cell_type": "code", + "execution_count": 114, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "DatetimeIndex(['2017-01-03', '2017-10-02', '2018-07-02', '2019-04-01',\n", + " '2020-01-02', '2020-10-01', '2021-07-01', '2022-04-01',\n", + " '2023-01-03', '2023-10-02', '2024-07-01'],\n", + " dtype='datetime64[ns]', freq=None)" + ] + }, + "execution_count": 114, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# The First Trading Day of every Third Quarter in the range\n", + "nyse.date_range_htf('3Q', start='2017-01-01', end='2025-01-01', closed='left')" + ] + }, + { + "cell_type": "code", + "execution_count": 115, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "DatetimeIndex(['2017-07-03', '2018-07-02', '2019-07-01', '2020-07-01',\n", + " '2021-07-01', '2022-07-01', '2023-07-03', '2024-07-01',\n", + " '2025-07-01', '2026-07-01'],\n", + " dtype='datetime64[ns]', freq=None)" + ] + }, + "execution_count": 115, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# The First Trading Day of the next 10 Fiscal Years\n", + "nyse.date_range_htf('Y', start='2017-01-01', periods=10, closed='left', month_anchor='JUL')" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -187,7 +399,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 116, "metadata": {}, "outputs": [ { @@ -278,7 +490,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 117, "metadata": {}, "outputs": [ { @@ -351,7 +563,7 @@ "2012-07-10 2012-07-10 13:30:00+00:00 2012-07-10 20:00:00+00:00" ] }, - "execution_count": 8, + "execution_count": 117, "metadata": {}, "output_type": "execute_result" } @@ -364,7 +576,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 118, "metadata": {}, "outputs": [ { @@ -459,7 +671,7 @@ "2012-07-10 2012-07-10 20:00:00+00:00 2012-07-11 00:00:00+00:00 " ] }, - "execution_count": 9, + "execution_count": 118, "metadata": {}, "output_type": "execute_result" } @@ -472,7 +684,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 119, "metadata": { "scrolled": true }, @@ -547,7 +759,7 @@ "2012-07-10 2012-07-11 00:00:00+00:00 2012-07-10 13:30:00+00:00" ] }, - "execution_count": 10, + "execution_count": 119, "metadata": {}, "output_type": "execute_result" } @@ -564,15 +776,14 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### Get early closes" + "### Schedule_from_days\n", + "*CAVEAT: This function assumes all the days passed to it are valid trading days. If random dates are passed, it will not be filtered down to only valid trading days." ] }, { "cell_type": "code", - "execution_count": 11, - "metadata": { - "scrolled": true - }, + "execution_count": 120, + "metadata": {}, "outputs": [ { "data": { @@ -601,9 +812,39 @@ " \n", " \n", " \n", - " 2012-07-03\n", - " 2012-07-03 13:30:00+00:00\n", - " 2012-07-03 17:00:00+00:00\n", + " 2016-12-30\n", + " 2016-12-30 14:30:00+00:00\n", + " 2016-12-30 21:00:00+00:00\n", + " \n", + " \n", + " 2017-01-03\n", + " 2017-01-03 14:30:00+00:00\n", + " 2017-01-03 21:00:00+00:00\n", + " \n", + " \n", + " 2017-01-04\n", + " 2017-01-04 14:30:00+00:00\n", + " 2017-01-04 21:00:00+00:00\n", + " \n", + " \n", + " 2017-01-05\n", + " 2017-01-05 14:30:00+00:00\n", + " 2017-01-05 21:00:00+00:00\n", + " \n", + " \n", + " 2017-01-06\n", + " 2017-01-06 14:30:00+00:00\n", + " 2017-01-06 21:00:00+00:00\n", + " \n", + " \n", + " 2017-01-09\n", + " 2017-01-09 14:30:00+00:00\n", + " 2017-01-09 21:00:00+00:00\n", + " \n", + " \n", + " 2017-01-10\n", + " 2017-01-10 14:30:00+00:00\n", + " 2017-01-10 21:00:00+00:00\n", " \n", " \n", "\n", @@ -611,24 +852,30 @@ ], "text/plain": [ " market_open market_close\n", - "2012-07-03 2012-07-03 13:30:00+00:00 2012-07-03 17:00:00+00:00" + "2016-12-30 2016-12-30 14:30:00+00:00 2016-12-30 21:00:00+00:00\n", + "2017-01-03 2017-01-03 14:30:00+00:00 2017-01-03 21:00:00+00:00\n", + "2017-01-04 2017-01-04 14:30:00+00:00 2017-01-04 21:00:00+00:00\n", + "2017-01-05 2017-01-05 14:30:00+00:00 2017-01-05 21:00:00+00:00\n", + "2017-01-06 2017-01-06 14:30:00+00:00 2017-01-06 21:00:00+00:00\n", + "2017-01-09 2017-01-09 14:30:00+00:00 2017-01-09 21:00:00+00:00\n", + "2017-01-10 2017-01-10 14:30:00+00:00 2017-01-10 21:00:00+00:00" ] }, - "execution_count": 11, + "execution_count": 120, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "nyse.early_closes(schedule=early)" + "# This is Equivalent to calling nyse.schedule('2016-12-30', '2017-01-10')\n", + "dates = nyse.date_range_htf('1D', start='2016-12-30', end='2017-01-10')\n", + "nyse.schedule_from_days(dates)" ] }, { "cell_type": "code", - "execution_count": 12, - "metadata": { - "scrolled": false - }, + "execution_count": 121, + "metadata": {}, "outputs": [ { "data": { @@ -651,81 +898,347 @@ " \n", " \n", " \n", - " pre\n", " market_open\n", " market_close\n", - " post\n", " \n", " \n", " \n", " \n", - " 2012-07-03\n", - " 2012-07-03 08:00:00+00:00\n", - " 2012-07-03 13:30:00+00:00\n", - " 2012-07-03 17:00:00+00:00\n", - " 2012-07-03 17:00:00+00:00\n", + " 2017-03-31\n", + " 2017-03-31 13:30:00+00:00\n", + " 2017-03-31 20:00:00+00:00\n", + " \n", + " \n", + " 2017-06-30\n", + " 2017-06-30 13:30:00+00:00\n", + " 2017-06-30 20:00:00+00:00\n", + " \n", + " \n", + " 2017-09-29\n", + " 2017-09-29 13:30:00+00:00\n", + " 2017-09-29 20:00:00+00:00\n", + " \n", + " \n", + " 2017-12-29\n", + " 2017-12-29 14:30:00+00:00\n", + " 2017-12-29 21:00:00+00:00\n", " \n", " \n", "\n", "" ], "text/plain": [ - " pre market_open \\\n", - "2012-07-03 2012-07-03 08:00:00+00:00 2012-07-03 13:30:00+00:00 \n", - "\n", - " market_close post \n", - "2012-07-03 2012-07-03 17:00:00+00:00 2012-07-03 17:00:00+00:00 " + " market_open market_close\n", + "2017-03-31 2017-03-31 13:30:00+00:00 2017-03-31 20:00:00+00:00\n", + "2017-06-30 2017-06-30 13:30:00+00:00 2017-06-30 20:00:00+00:00\n", + "2017-09-29 2017-09-29 13:30:00+00:00 2017-09-29 20:00:00+00:00\n", + "2017-12-29 2017-12-29 14:30:00+00:00 2017-12-29 21:00:00+00:00" ] }, - "execution_count": 12, + "execution_count": 121, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "nyse.early_closes(schedule=extended)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Open at time\n", - "Test to see if a given timestamp is during market open hours. (You can find more on this under the 'Advanced open_at_time' header)" + "# But This method can produce a schedule from any range produced by date_range_htf()\n", + "dates = nyse.date_range_htf('Q', start='2017-01-01', end='2018-01-10')\n", + "nyse.schedule_from_days(dates)" ] }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 122, "metadata": {}, "outputs": [ { "data": { - "text/plain": [ - "True" - ] - }, - "execution_count": 13, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "nyse.open_at_time(early, pd.Timestamp('2012-07-03 12:00', tz='America/New_York'))" - ] - }, - { - "cell_type": "code", - "execution_count": 14, - "metadata": {}, - "outputs": [ + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
premarket_openmarket_closepost
2017-01-032017-01-03 04:00:00-05:002017-01-03 09:30:00-05:002017-01-03 16:00:00-05:002017-01-03 20:00:00-05:00
2017-03-312017-03-31 04:00:00-04:002017-03-31 09:30:00-04:002017-03-31 16:00:00-04:002017-03-31 20:00:00-04:00
2017-04-032017-04-03 04:00:00-04:002017-04-03 09:30:00-04:002017-04-03 16:00:00-04:002017-04-03 20:00:00-04:00
2017-06-302017-06-30 04:00:00-04:002017-06-30 09:30:00-04:002017-06-30 16:00:00-04:002017-06-30 20:00:00-04:00
2017-07-032017-07-03 04:00:00-04:002017-07-03 09:30:00-04:002017-07-03 13:00:00-04:002017-07-03 13:00:00-04:00
2017-09-292017-09-29 04:00:00-04:002017-09-29 09:30:00-04:002017-09-29 16:00:00-04:002017-09-29 20:00:00-04:00
2017-10-022017-10-02 04:00:00-04:002017-10-02 09:30:00-04:002017-10-02 16:00:00-04:002017-10-02 20:00:00-04:00
2017-12-292017-12-29 04:00:00-05:002017-12-29 09:30:00-05:002017-12-29 16:00:00-05:002017-12-29 20:00:00-05:00
\n", + "
" + ], + "text/plain": [ + " pre market_open \\\n", + "2017-01-03 2017-01-03 04:00:00-05:00 2017-01-03 09:30:00-05:00 \n", + "2017-03-31 2017-03-31 04:00:00-04:00 2017-03-31 09:30:00-04:00 \n", + "2017-04-03 2017-04-03 04:00:00-04:00 2017-04-03 09:30:00-04:00 \n", + "2017-06-30 2017-06-30 04:00:00-04:00 2017-06-30 09:30:00-04:00 \n", + "2017-07-03 2017-07-03 04:00:00-04:00 2017-07-03 09:30:00-04:00 \n", + "2017-09-29 2017-09-29 04:00:00-04:00 2017-09-29 09:30:00-04:00 \n", + "2017-10-02 2017-10-02 04:00:00-04:00 2017-10-02 09:30:00-04:00 \n", + "2017-12-29 2017-12-29 04:00:00-05:00 2017-12-29 09:30:00-05:00 \n", + "\n", + " market_close post \n", + "2017-01-03 2017-01-03 16:00:00-05:00 2017-01-03 20:00:00-05:00 \n", + "2017-03-31 2017-03-31 16:00:00-04:00 2017-03-31 20:00:00-04:00 \n", + "2017-04-03 2017-04-03 16:00:00-04:00 2017-04-03 20:00:00-04:00 \n", + "2017-06-30 2017-06-30 16:00:00-04:00 2017-06-30 20:00:00-04:00 \n", + "2017-07-03 2017-07-03 13:00:00-04:00 2017-07-03 13:00:00-04:00 \n", + "2017-09-29 2017-09-29 16:00:00-04:00 2017-09-29 20:00:00-04:00 \n", + "2017-10-02 2017-10-02 16:00:00-04:00 2017-10-02 20:00:00-04:00 \n", + "2017-12-29 2017-12-29 16:00:00-05:00 2017-12-29 20:00:00-05:00 " + ] + }, + "execution_count": 122, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Example of how to get the first and last Trading Day of each Quarter in 2017\n", + "dates_end = nyse.date_range_htf('Q', start='2017-01-01', periods=4)\n", + "dates_start = nyse.date_range_htf('Q', start='2017-01-01', periods=4, closed='left')\n", + "nyse.schedule_from_days(dates_start.union(dates_end).sort_values(), market_times='all', tz=nyse.tz)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Get early closes" + ] + }, + { + "cell_type": "code", + "execution_count": 123, + "metadata": { + "scrolled": true + }, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
market_openmarket_close
2012-07-032012-07-03 13:30:00+00:002012-07-03 17:00:00+00:00
\n", + "
" + ], + "text/plain": [ + " market_open market_close\n", + "2012-07-03 2012-07-03 13:30:00+00:00 2012-07-03 17:00:00+00:00" + ] + }, + "execution_count": 123, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "nyse.early_closes(schedule=early)" + ] + }, + { + "cell_type": "code", + "execution_count": 124, + "metadata": { + "scrolled": false + }, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
premarket_openmarket_closepost
2012-07-032012-07-03 08:00:00+00:002012-07-03 13:30:00+00:002012-07-03 17:00:00+00:002012-07-03 17:00:00+00:00
\n", + "
" + ], + "text/plain": [ + " pre market_open \\\n", + "2012-07-03 2012-07-03 08:00:00+00:00 2012-07-03 13:30:00+00:00 \n", + "\n", + " market_close post \n", + "2012-07-03 2012-07-03 17:00:00+00:00 2012-07-03 17:00:00+00:00 " + ] + }, + "execution_count": 124, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "nyse.early_closes(schedule=extended)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Open at time\n", + "Test to see if a given timestamp is during market open hours. (You can find more on this under the 'Advanced open_at_time' header)" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "nyse.open_at_time(early, pd.Timestamp('2012-07-03 12:00', tz='America/New_York'))" + ] + }, + { + "cell_type": "code", + "execution_count": 126, + "metadata": {}, + "outputs": [ { "data": { "text/plain": [ "False" ] }, - "execution_count": 14, + "execution_count": 126, "metadata": {}, "output_type": "execute_result" } @@ -743,7 +1256,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 127, "metadata": {}, "outputs": [ { @@ -752,7 +1265,7 @@ "True" ] }, - "execution_count": 15, + "execution_count": 127, "metadata": {}, "output_type": "execute_result" } @@ -770,7 +1283,7 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": 128, "metadata": {}, "outputs": [ { @@ -779,7 +1292,7 @@ "False" ] }, - "execution_count": 16, + "execution_count": 128, "metadata": {}, "output_type": "execute_result" } @@ -804,7 +1317,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 129, "metadata": { "tags": [] }, @@ -839,7 +1352,7 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": 130, "metadata": {}, "outputs": [ { @@ -891,7 +1404,7 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": 131, "metadata": {}, "outputs": [ { @@ -927,7 +1440,7 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 132, "metadata": {}, "outputs": [ { @@ -957,7 +1470,7 @@ }, { "cell_type": "code", - "execution_count": 21, + "execution_count": 133, "metadata": { "scrolled": true }, @@ -985,7 +1498,7 @@ }, { "cell_type": "code", - "execution_count": 22, + "execution_count": 134, "metadata": {}, "outputs": [], "source": [ @@ -1002,7 +1515,7 @@ }, { "cell_type": "code", - "execution_count": 23, + "execution_count": 135, "metadata": {}, "outputs": [ { @@ -1042,7 +1555,7 @@ }, { "cell_type": "code", - "execution_count": 24, + "execution_count": 136, "metadata": {}, "outputs": [ { @@ -1130,7 +1643,7 @@ "2009-12-29 2009-12-29 21:00:00+00:00 " ] }, - "execution_count": 24, + "execution_count": 136, "metadata": {}, "output_type": "execute_result" } @@ -1155,7 +1668,7 @@ }, { "cell_type": "code", - "execution_count": 25, + "execution_count": 137, "metadata": {}, "outputs": [ { @@ -1232,7 +1745,7 @@ "2009-12-29 2009-12-29 21:00:00+00:00 2009-12-27 16:00:00+00:00 " ] }, - "execution_count": 25, + "execution_count": 137, "metadata": {}, "output_type": "execute_result" } @@ -1255,7 +1768,7 @@ }, { "cell_type": "code", - "execution_count": 26, + "execution_count": 138, "metadata": {}, "outputs": [ { @@ -1310,7 +1823,7 @@ "2009-12-28 2009-12-26 16:00:00+00:00 2009-12-28 21:00:00+00:00" ] }, - "execution_count": 26, + "execution_count": 138, "metadata": {}, "output_type": "execute_result" } @@ -1322,7 +1835,7 @@ }, { "cell_type": "code", - "execution_count": 27, + "execution_count": 139, "metadata": { "scrolled": false }, @@ -1379,7 +1892,7 @@ "2009-12-28 2009-12-26 16:00:00+00:00 2009-12-28 21:00:00+00:00" ] }, - "execution_count": 27, + "execution_count": 139, "metadata": {}, "output_type": "execute_result" } @@ -1419,9 +1932,21 @@ }, { "cell_type": "code", - "execution_count": 28, + "execution_count": 140, "metadata": {}, - "outputs": [], + "outputs": [ + { + "ename": "ModuleNotFoundError", + "evalue": "No module named 'pandas_market_calendars.exchange_calendar'", + "output_type": "error", + "traceback": [ + "\u001b[1;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[1;31mModuleNotFoundError\u001b[0m Traceback (most recent call last)", + "Cell \u001b[1;32mIn[140], line 5\u001b[0m\n\u001b[0;32m 1\u001b[0m \u001b[38;5;66;03m# For example, CFEExchangeCalendar only has the regular trading hours for the futures exchange (8:30 - 15:15).\u001b[39;00m\n\u001b[0;32m 2\u001b[0m \u001b[38;5;66;03m# If you want to use the equity options exchange (8:30 - 15:00), including the order acceptance time at 7:30, and\u001b[39;00m\n\u001b[0;32m 3\u001b[0m \u001b[38;5;66;03m# some special cases when the order acceptance time was different, do this:\u001b[39;00m\n\u001b[1;32m----> 5\u001b[0m \u001b[38;5;28;01mfrom\u001b[39;00m \u001b[38;5;21;01mpandas_market_calendars\u001b[39;00m\u001b[38;5;21;01m.\u001b[39;00m\u001b[38;5;21;01mexchange_calendar\u001b[39;00m\u001b[38;5;21;01m.\u001b[39;00m\u001b[38;5;21;01mcboe\u001b[39;00m \u001b[38;5;28;01mimport\u001b[39;00m CFEExchangeCalendar \n\u001b[0;32m 7\u001b[0m \u001b[38;5;28;01mclass\u001b[39;00m \u001b[38;5;21;01mDemoOptionsCalendar\u001b[39;00m(CFEExchangeCalendar): \u001b[38;5;66;03m# Inherit what doesn't need to change\u001b[39;00m\n\u001b[0;32m 8\u001b[0m name \u001b[38;5;241m=\u001b[39m \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mDemo_Options\u001b[39m\u001b[38;5;124m\"\u001b[39m\n", + "\u001b[1;31mModuleNotFoundError\u001b[0m: No module named 'pandas_market_calendars.exchange_calendar'" + ] + } + ], "source": [ "# For example, CFEExchangeCalendar only has the regular trading hours for the futures exchange (8:30 - 15:15).\n", "# If you want to use the equity options exchange (8:30 - 15:00), including the order acceptance time at 7:30, and\n", @@ -2958,8 +3483,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Helpers\n", - "*schedules with columns other than market_open, break_start, break_end or market_close are not yet supported by the following functions*" + "# Helpers\n" ] }, { @@ -2973,72 +3497,766 @@ }, { "cell_type": "code", - "execution_count": 65, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "DatetimeIndex(['2012-07-02 20:00:00+00:00', '2012-07-03 17:00:00+00:00',\n", - " '2012-07-05 20:00:00+00:00', '2012-07-06 20:00:00+00:00',\n", - " '2012-07-09 20:00:00+00:00', '2012-07-10 20:00:00+00:00'],\n", - " dtype='datetime64[ns, UTC]', freq=None)" - ] - }, - "execution_count": 65, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "mcal.date_range(early, frequency='1D')" - ] - }, - { - "cell_type": "code", - "execution_count": 66, + "execution_count": 54, "metadata": {}, "outputs": [ { "data": { - "text/plain": [ - "DatetimeIndex(['2012-07-02 14:30:00+00:00', '2012-07-02 15:30:00+00:00',\n", - " '2012-07-02 16:30:00+00:00', '2012-07-02 17:30:00+00:00',\n", - " '2012-07-02 18:30:00+00:00', '2012-07-02 19:30:00+00:00',\n", - " '2012-07-02 20:00:00+00:00', '2012-07-03 14:30:00+00:00',\n", - " '2012-07-03 15:30:00+00:00', '2012-07-03 16:30:00+00:00',\n", - " '2012-07-03 17:00:00+00:00', '2012-07-05 14:30:00+00:00',\n", - " '2012-07-05 15:30:00+00:00', '2012-07-05 16:30:00+00:00',\n", - " '2012-07-05 17:30:00+00:00', '2012-07-05 18:30:00+00:00',\n", - " '2012-07-05 19:30:00+00:00', '2012-07-05 20:00:00+00:00',\n", - " '2012-07-06 14:30:00+00:00', '2012-07-06 15:30:00+00:00',\n", - " '2012-07-06 16:30:00+00:00', '2012-07-06 17:30:00+00:00',\n", - " '2012-07-06 18:30:00+00:00', '2012-07-06 19:30:00+00:00',\n", - " '2012-07-06 20:00:00+00:00', '2012-07-09 14:30:00+00:00',\n", - " '2012-07-09 15:30:00+00:00', '2012-07-09 16:30:00+00:00',\n", - " '2012-07-09 17:30:00+00:00', '2012-07-09 18:30:00+00:00',\n", - " '2012-07-09 19:30:00+00:00', '2012-07-09 20:00:00+00:00',\n", - " '2012-07-10 14:30:00+00:00', '2012-07-10 15:30:00+00:00',\n", - " '2012-07-10 16:30:00+00:00', '2012-07-10 17:30:00+00:00',\n", - " '2012-07-10 18:30:00+00:00', '2012-07-10 19:30:00+00:00',\n", - " '2012-07-10 20:00:00+00:00'],\n", - " dtype='datetime64[ns, UTC]', freq=None)" - ] - }, - "execution_count": 66, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "mcal.date_range(early, frequency='1H')" - ] - }, - { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
premarket_openmarket_closepost
2012-07-022012-07-02 04:00:00-04:002012-07-02 09:30:00-04:002012-07-02 16:00:00-04:002012-07-02 20:00:00-04:00
2012-07-032012-07-03 04:00:00-04:002012-07-03 09:30:00-04:002012-07-03 13:00:00-04:002012-07-03 13:00:00-04:00
2012-07-052012-07-05 04:00:00-04:002012-07-05 09:30:00-04:002012-07-05 16:00:00-04:002012-07-05 20:00:00-04:00
\n", + "
" + ], + "text/plain": [ + " pre market_open \\\n", + "2012-07-02 2012-07-02 04:00:00-04:00 2012-07-02 09:30:00-04:00 \n", + "2012-07-03 2012-07-03 04:00:00-04:00 2012-07-03 09:30:00-04:00 \n", + "2012-07-05 2012-07-05 04:00:00-04:00 2012-07-05 09:30:00-04:00 \n", + "\n", + " market_close post \n", + "2012-07-02 2012-07-02 16:00:00-04:00 2012-07-02 20:00:00-04:00 \n", + "2012-07-03 2012-07-03 13:00:00-04:00 2012-07-03 13:00:00-04:00 \n", + "2012-07-05 2012-07-05 16:00:00-04:00 2012-07-05 20:00:00-04:00 " + ] + }, + "execution_count": 54, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import pandas_market_calendars as mcal\n", + "NYSE = mcal.get_calendar('NYSE')\n", + "nyse_schedule = NYSE.schedule('2012-07-02', '2012-07-05', market_times='all', tz=NYSE.tz)\n", + "nyse_schedule" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "DatetimeIndex(['2012-07-02 16:00:00-04:00', '2012-07-03 13:00:00-04:00',\n", + " '2012-07-05 16:00:00-04:00'],\n", + " dtype='datetime64[ns, America/New_York]', freq=None)" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# default is closed='right' & force_close=True => End-of-Period is returned\n", + "mcal.date_range(nyse_schedule, frequency='1D')" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "DatetimeIndex(['2012-07-02 09:30:00-04:00', '2012-07-03 09:30:00-04:00',\n", + " '2012-07-05 09:30:00-04:00'],\n", + " dtype='datetime64[ns, America/New_York]', freq=None)" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# closed='left' & force_close=False => start-of-Day is returned \n", + "# See function docstr for more info on these parameters\n", + "mcal.date_range(nyse_schedule, frequency='1D', closed='left', force_close=False)" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "DatetimeIndex(['2012-07-02 09:30:00-04:00', '2012-07-02 10:30:00-04:00',\n", + " '2012-07-02 11:30:00-04:00', '2012-07-02 12:30:00-04:00',\n", + " '2012-07-02 13:30:00-04:00', '2012-07-02 14:30:00-04:00',\n", + " '2012-07-02 15:30:00-04:00', '2012-07-03 09:30:00-04:00',\n", + " '2012-07-03 10:30:00-04:00', '2012-07-03 11:30:00-04:00',\n", + " '2012-07-03 12:30:00-04:00', '2012-07-05 09:30:00-04:00',\n", + " '2012-07-05 10:30:00-04:00', '2012-07-05 11:30:00-04:00',\n", + " '2012-07-05 12:30:00-04:00', '2012-07-05 13:30:00-04:00',\n", + " '2012-07-05 14:30:00-04:00', '2012-07-05 15:30:00-04:00'],\n", + " dtype='datetime64[ns, America/New_York]', freq=None)" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "mcal.date_range(nyse_schedule, frequency='1h', closed='left', force_close=False)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Advanced Date_Range() Arguments\n", + "\n", + "Date_Range returns Regular Trading Hours ('rth') by default, but all standard sessions (pre, rth, break, post, & closed) can be selected either individually or by listing them in an iterable." + ] + }, + { + "cell_type": "code", + "execution_count": 55, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "DatetimeIndex(['2012-07-02 04:00:00-04:00', '2012-07-02 05:00:00-04:00',\n", + " '2012-07-02 06:00:00-04:00', '2012-07-02 07:00:00-04:00',\n", + " '2012-07-02 08:00:00-04:00', '2012-07-02 09:00:00-04:00',\n", + " '2012-07-02 16:00:00-04:00', '2012-07-02 17:00:00-04:00',\n", + " '2012-07-02 18:00:00-04:00', '2012-07-02 19:00:00-04:00',\n", + " '2012-07-03 04:00:00-04:00', '2012-07-03 05:00:00-04:00',\n", + " '2012-07-03 06:00:00-04:00', '2012-07-03 07:00:00-04:00',\n", + " '2012-07-03 08:00:00-04:00', '2012-07-03 09:00:00-04:00',\n", + " '2012-07-05 04:00:00-04:00', '2012-07-05 05:00:00-04:00',\n", + " '2012-07-05 06:00:00-04:00', '2012-07-05 07:00:00-04:00',\n", + " '2012-07-05 08:00:00-04:00', '2012-07-05 09:00:00-04:00',\n", + " '2012-07-05 16:00:00-04:00', '2012-07-05 17:00:00-04:00',\n", + " '2012-07-05 18:00:00-04:00', '2012-07-05 19:00:00-04:00'],\n", + " dtype='datetime64[ns, America/New_York]', freq=None)" + ] + }, + "execution_count": 55, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "mcal.date_range(nyse_schedule, frequency='1h', session='ETH', closed='left', force_close=None)" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "DatetimeIndex(['2012-07-02 04:00:00-04:00', '2012-07-02 05:00:00-04:00',\n", + " '2012-07-02 06:00:00-04:00', '2012-07-02 07:00:00-04:00',\n", + " '2012-07-02 08:00:00-04:00', '2012-07-02 09:00:00-04:00',\n", + " '2012-07-02 10:00:00-04:00', '2012-07-02 11:00:00-04:00',\n", + " '2012-07-02 12:00:00-04:00', '2012-07-02 13:00:00-04:00',\n", + " '2012-07-02 14:00:00-04:00', '2012-07-02 15:00:00-04:00',\n", + " '2012-07-02 16:00:00-04:00', '2012-07-02 17:00:00-04:00',\n", + " '2012-07-02 18:00:00-04:00', '2012-07-02 19:00:00-04:00',\n", + " '2012-07-03 04:00:00-04:00', '2012-07-03 05:00:00-04:00',\n", + " '2012-07-03 06:00:00-04:00', '2012-07-03 07:00:00-04:00',\n", + " '2012-07-03 08:00:00-04:00', '2012-07-03 09:00:00-04:00',\n", + " '2012-07-03 10:00:00-04:00', '2012-07-03 11:00:00-04:00',\n", + " '2012-07-03 12:00:00-04:00', '2012-07-05 04:00:00-04:00',\n", + " '2012-07-05 05:00:00-04:00', '2012-07-05 06:00:00-04:00',\n", + " '2012-07-05 07:00:00-04:00', '2012-07-05 08:00:00-04:00',\n", + " '2012-07-05 09:00:00-04:00', '2012-07-05 10:00:00-04:00',\n", + " '2012-07-05 11:00:00-04:00', '2012-07-05 12:00:00-04:00',\n", + " '2012-07-05 13:00:00-04:00', '2012-07-05 14:00:00-04:00',\n", + " '2012-07-05 15:00:00-04:00', '2012-07-05 16:00:00-04:00',\n", + " '2012-07-05 17:00:00-04:00', '2012-07-05 18:00:00-04:00',\n", + " '2012-07-05 19:00:00-04:00'],\n", + " dtype='datetime64[ns, America/New_York]', freq=None)" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "mcal.date_range(nyse_schedule, frequency='1h', session={'ETH', 'RTH'}, closed='left', force_close=False)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This last example poses a bit of an issue, the pre-market session starts a 4AM, but the market opens at 9:30AM. When requesting an Interval of '1h' there will be a period every day where one of the intervals is in both the pre-market and rth sessions. This is the default since it maintains a constant time delta between each timestamp.\n", + "\n", + "However, the argument merge_adjacent can be set to False to keep adjacent sessions separated. This re-aligns the timestamps to the underlying session at the cost of having a time delta that isn't constant." + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "DatetimeIndex(['2012-07-02 04:00:00-04:00', '2012-07-02 05:00:00-04:00',\n", + " '2012-07-02 06:00:00-04:00', '2012-07-02 07:00:00-04:00',\n", + " '2012-07-02 08:00:00-04:00', '2012-07-02 09:00:00-04:00',\n", + " '2012-07-02 09:30:00-04:00', '2012-07-02 10:30:00-04:00',\n", + " '2012-07-02 11:30:00-04:00', '2012-07-02 12:30:00-04:00',\n", + " '2012-07-02 13:30:00-04:00', '2012-07-02 14:30:00-04:00',\n", + " '2012-07-02 15:30:00-04:00', '2012-07-02 16:00:00-04:00',\n", + " '2012-07-02 17:00:00-04:00', '2012-07-02 18:00:00-04:00',\n", + " '2012-07-02 19:00:00-04:00', '2012-07-03 04:00:00-04:00',\n", + " '2012-07-03 05:00:00-04:00', '2012-07-03 06:00:00-04:00',\n", + " '2012-07-03 07:00:00-04:00', '2012-07-03 08:00:00-04:00',\n", + " '2012-07-03 09:00:00-04:00', '2012-07-03 09:30:00-04:00',\n", + " '2012-07-03 10:30:00-04:00', '2012-07-03 11:30:00-04:00',\n", + " '2012-07-03 12:30:00-04:00', '2012-07-05 04:00:00-04:00',\n", + " '2012-07-05 05:00:00-04:00', '2012-07-05 06:00:00-04:00',\n", + " '2012-07-05 07:00:00-04:00', '2012-07-05 08:00:00-04:00',\n", + " '2012-07-05 09:00:00-04:00', '2012-07-05 09:30:00-04:00',\n", + " '2012-07-05 10:30:00-04:00', '2012-07-05 11:30:00-04:00',\n", + " '2012-07-05 12:30:00-04:00', '2012-07-05 13:30:00-04:00',\n", + " '2012-07-05 14:30:00-04:00', '2012-07-05 15:30:00-04:00',\n", + " '2012-07-05 16:00:00-04:00', '2012-07-05 17:00:00-04:00',\n", + " '2012-07-05 18:00:00-04:00', '2012-07-05 19:00:00-04:00'],\n", + " dtype='datetime64[ns, America/New_York]', freq=None)" + ] + }, + "execution_count": 17, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "mcal.date_range(nyse_schedule, frequency='1h', session={'ETH', 'RTH'}, closed='left', force_close=False, merge_adjacent=False)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "As long as the Dates are Covered by the given schedule, Date Range also Supports Custom Start, End and Period Parameters in the exact same manner that pandas' date_range() function does. \n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "DatetimeIndex(['2012-07-02 13:30:00-04:00', '2012-07-02 14:30:00-04:00',\n", + " '2012-07-02 15:30:00-04:00', '2012-07-03 09:30:00-04:00',\n", + " '2012-07-03 10:30:00-04:00', '2012-07-03 11:30:00-04:00',\n", + " '2012-07-03 12:30:00-04:00', '2012-07-05 09:30:00-04:00'],\n", + " dtype='datetime64[ns, America/New_York]', freq=None)" + ] + }, + "execution_count": 21, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "mcal.date_range(nyse_schedule, frequency='1h', start='2012-07-02 13:30:00-04:00', periods=8, closed='left', force_close=False)" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "DatetimeIndex(['2012-07-02 14:30:00-04:00', '2012-07-02 15:30:00-04:00',\n", + " '2012-07-03 09:30:00-04:00', '2012-07-03 10:30:00-04:00',\n", + " '2012-07-03 11:30:00-04:00', '2012-07-03 12:30:00-04:00',\n", + " '2012-07-05 09:30:00-04:00', '2012-07-05 10:30:00-04:00'],\n", + " dtype='datetime64[ns, America/New_York]', freq=None)" + ] + }, + "execution_count": 22, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Note: When requesting a custom start or end timestamp the returned result will always align\n", + "# with the underlying session and frequency given. \n", + "mcal.date_range(nyse_schedule, frequency='1h', start='2012-07-02 13:34:39-04:00', periods=8, closed='left', force_close=False)" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "DatetimeIndex(['2012-07-02 13:30:00-04:00', '2012-07-02 14:30:00-04:00',\n", + " '2012-07-02 15:30:00-04:00', '2012-07-03 09:30:00-04:00',\n", + " '2012-07-03 10:30:00-04:00', '2012-07-03 11:30:00-04:00',\n", + " '2012-07-03 12:30:00-04:00', '2012-07-05 09:30:00-04:00'],\n", + " dtype='datetime64[ns, America/New_York]', freq=None)" + ] + }, + "execution_count": 23, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Start/End Timestamps without TZ information are interpreted in the timezone given by the schedule.\n", + "mcal.date_range(nyse_schedule, frequency='1h', start='2012-07-02 13:30', periods=8, closed='left', force_close=False)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Escalating / Ignoring Date_Range Warnings\n", + "\n", + "There Are a few different warnings thrown by date_range:\n", + "- DateRangeWarning (Super Class to other warnings)\n", + "- InsufficientScheduleWarning,\n", + "- MissingSessionWarning, \n", + "- OverlappingSessionWarning\n", + "- DisappearingSessionWarning\n", + "\n", + "For Example, Below the given start, # of periods, and frequency result in a date range that extends beyond the last date in the schedule. A Warning is thrown and the portion of the date_range that was generated is returned." + ] + }, + { + "cell_type": "code", + "execution_count": 44, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "C:\\Users\\bryce\\Documents\\GitHub\\pandas_market_calendars\\pandas_market_calendars\\calendar_utils.py:765: InsufficientScheduleWarning: Insufficient Schedule. Requested Approx End-Time: 2012-07-13 09:30:00-04:00. Schedule ends at: 2012-07-05 00:00:00\n", + " warnings.warn(\n" + ] + }, + { + "data": { + "text/plain": [ + "DatetimeIndex(['2012-07-05 15:30:00-04:00'], dtype='datetime64[ns, America/New_York]', freq=None)" + ] + }, + "execution_count": 44, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "nyse_schedule = NYSE.schedule('2012-07-02', '2012-07-05', market_times='all', tz=NYSE.tz)\n", + "problematic_date_range_args = lambda: mcal.date_range(nyse_schedule, frequency='1h', start='2012-07-05 15:30', periods=8, closed='left', force_close=False)\n", + "problematic_date_range_args()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "DatetimeIndex(['2012-07-05 15:30:00-04:00'], dtype='datetime64[ns, America/New_York]', freq=None)" + ] + }, + "execution_count": 45, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import pandas_market_calendars.calendar_utils as mcal_util\n", + "\n", + "# This warning can be ignored\n", + "mcal_util.filter_date_range_warnings('ignore', mcal_util.InsufficientScheduleWarning)\n", + "nyse_schedule = NYSE.schedule('2012-07-02', '2012-07-05', market_times='all', tz=NYSE.tz)\n", + "problematic_date_range_args()" + ] + }, + { + "cell_type": "code", + "execution_count": 51, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Error Thrown: Insufficient Schedule. Requested Approx End-Time: 2012-07-13 09:30:00-04:00. Schedule ends at: 2012-07-05 00:00:00\n" + ] + } + ], + "source": [ + "# Or It can be escalated into an Error\n", + "mcal_util.filter_date_range_warnings('error', mcal_util.InsufficientScheduleWarning)\n", + "nyse_schedule = NYSE.schedule('2012-07-02', '2012-07-05', market_times='all', tz=NYSE.tz)\n", + "try:\n", + " problematic_date_range_args()\n", + "except Exception as e:\n", + " print(f'Error Thrown: {e}')" + ] + }, + { + "cell_type": "code", + "execution_count": 62, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "DatetimeIndex(['2012-07-05 15:30:00-04:00', '2012-07-06 09:30:00-04:00',\n", + " '2012-07-06 10:30:00-04:00', '2012-07-06 11:30:00-04:00',\n", + " '2012-07-06 12:30:00-04:00', '2012-07-06 13:30:00-04:00',\n", + " '2012-07-06 14:30:00-04:00', '2012-07-06 15:30:00-04:00'],\n", + " dtype='datetime64[ns, America/New_York]', freq=None)" + ] + }, + "execution_count": 62, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import pandas as pd\n", + "from pytest import mark\n", + "# When Escalated into an Error, it can be caught and processed\n", + "try:\n", + " nyse_schedule = NYSE.schedule('2012-07-02', '2012-07-05', market_times='all', tz=NYSE.tz)\n", + " dt = problematic_date_range_args()\n", + "except mcal_util.InsufficientScheduleWarning as w:\n", + " # When this Warning is thrown from a date_range call that requests a # of periods\n", + " # it over estimates the needed date by about 1 week to ensure that a second warning\n", + " # is not thrown when doing this.\n", + " beginning, start, end = mcal_util.parse_insufficient_schedule_warning(w)\n", + " extra_dates_needed = NYSE.schedule(start, end, tz=NYSE.tz, market_times='all')\n", + " if beginning:\n", + " nyse_schedule = pd.concat([extra_dates_needed, nyse_schedule])\n", + " else:\n", + " nyse_schedule = pd.concat([nyse_schedule, extra_dates_needed])\n", + "\n", + " # Call the Function again to get the desired result\n", + " dt = problematic_date_range_args()\n", + "dt" + ] + }, + { + "cell_type": "code", + "execution_count": 63, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
premarket_openmarket_closepost
2012-07-022012-07-02 04:00:00-04:002012-07-02 09:30:00-04:002012-07-02 16:00:00-04:002012-07-02 20:00:00-04:00
2012-07-032012-07-03 04:00:00-04:002012-07-03 09:30:00-04:002012-07-03 13:00:00-04:002012-07-03 13:00:00-04:00
2012-07-052012-07-05 04:00:00-04:002012-07-05 09:30:00-04:002012-07-05 16:00:00-04:002012-07-05 20:00:00-04:00
2012-07-062012-07-06 04:00:00-04:002012-07-06 09:30:00-04:002012-07-06 16:00:00-04:002012-07-06 20:00:00-04:00
2012-07-092012-07-09 04:00:00-04:002012-07-09 09:30:00-04:002012-07-09 16:00:00-04:002012-07-09 20:00:00-04:00
2012-07-102012-07-10 04:00:00-04:002012-07-10 09:30:00-04:002012-07-10 16:00:00-04:002012-07-10 20:00:00-04:00
2012-07-112012-07-11 04:00:00-04:002012-07-11 09:30:00-04:002012-07-11 16:00:00-04:002012-07-11 20:00:00-04:00
2012-07-122012-07-12 04:00:00-04:002012-07-12 09:30:00-04:002012-07-12 16:00:00-04:002012-07-12 20:00:00-04:00
2012-07-132012-07-13 04:00:00-04:002012-07-13 09:30:00-04:002012-07-13 16:00:00-04:002012-07-13 20:00:00-04:00
\n", + "
" + ], + "text/plain": [ + " pre market_open \\\n", + "2012-07-02 2012-07-02 04:00:00-04:00 2012-07-02 09:30:00-04:00 \n", + "2012-07-03 2012-07-03 04:00:00-04:00 2012-07-03 09:30:00-04:00 \n", + "2012-07-05 2012-07-05 04:00:00-04:00 2012-07-05 09:30:00-04:00 \n", + "2012-07-06 2012-07-06 04:00:00-04:00 2012-07-06 09:30:00-04:00 \n", + "2012-07-09 2012-07-09 04:00:00-04:00 2012-07-09 09:30:00-04:00 \n", + "2012-07-10 2012-07-10 04:00:00-04:00 2012-07-10 09:30:00-04:00 \n", + "2012-07-11 2012-07-11 04:00:00-04:00 2012-07-11 09:30:00-04:00 \n", + "2012-07-12 2012-07-12 04:00:00-04:00 2012-07-12 09:30:00-04:00 \n", + "2012-07-13 2012-07-13 04:00:00-04:00 2012-07-13 09:30:00-04:00 \n", + "\n", + " market_close post \n", + "2012-07-02 2012-07-02 16:00:00-04:00 2012-07-02 20:00:00-04:00 \n", + "2012-07-03 2012-07-03 13:00:00-04:00 2012-07-03 13:00:00-04:00 \n", + "2012-07-05 2012-07-05 16:00:00-04:00 2012-07-05 20:00:00-04:00 \n", + "2012-07-06 2012-07-06 16:00:00-04:00 2012-07-06 20:00:00-04:00 \n", + "2012-07-09 2012-07-09 16:00:00-04:00 2012-07-09 20:00:00-04:00 \n", + "2012-07-10 2012-07-10 16:00:00-04:00 2012-07-10 20:00:00-04:00 \n", + "2012-07-11 2012-07-11 16:00:00-04:00 2012-07-11 20:00:00-04:00 \n", + "2012-07-12 2012-07-12 16:00:00-04:00 2012-07-12 20:00:00-04:00 \n", + "2012-07-13 2012-07-13 16:00:00-04:00 2012-07-13 20:00:00-04:00 " + ] + }, + "execution_count": 63, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "nyse_schedule # The Updated Nyse_Schedule" + ] + }, + { "cell_type": "markdown", "metadata": {}, "source": [ - "## Merge schedules" + "## Mark Session\n", + "\n", + "Returns a series identifying a given DatetimeIndex with the trading session it belongs in.\n", + "The series values are the timestamp's session and the Index is the original DatetimeIndex passed." + ] + }, + { + "cell_type": "code", + "execution_count": 144, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "2012-07-05 04:00:00-04:00 pre\n", + "2012-07-05 06:00:00-04:00 pre\n", + "2012-07-05 08:00:00-04:00 pre\n", + "2012-07-05 10:00:00-04:00 rth\n", + "2012-07-05 12:00:00-04:00 rth\n", + "2012-07-05 14:00:00-04:00 rth\n", + "2012-07-05 16:00:00-04:00 post\n", + "2012-07-05 18:00:00-04:00 post\n", + "2012-07-06 04:00:00-04:00 pre\n", + "2012-07-06 06:00:00-04:00 pre\n", + "dtype: category\n", + "Categories (4, object): ['closed', 'post', 'pre', 'rth']" + ] + }, + "execution_count": 144, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "dt = mcal.date_range(nyse_schedule, '2h', start='2012-07-05 04:00:00', periods=10, session={'RTH','ETH'}, closed='left', force_close=None)\n", + "mcal.mark_session(nyse_schedule, dt, closed='left')" + ] + }, + { + "cell_type": "code", + "execution_count": 147, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "2012-07-05 04:00:00-04:00 pre\n", + "2012-07-05 06:00:00-04:00 pre\n", + "2012-07-05 08:00:00-04:00 pre\n", + "2012-07-05 09:30:00-04:00 rth\n", + "2012-07-05 11:30:00-04:00 rth\n", + "2012-07-05 13:30:00-04:00 rth\n", + "2012-07-05 15:30:00-04:00 rth\n", + "2012-07-05 16:00:00-04:00 post\n", + "2012-07-05 18:00:00-04:00 post\n", + "2012-07-06 04:00:00-04:00 pre\n", + "dtype: category\n", + "Categories (4, object): ['closed', 'post', 'pre', 'rth']" + ] + }, + "execution_count": 147, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# This makes it much easier to identify nuances in date_range() results like the \n", + "# one described above in the date_range(merge_adjacent=True/False) discussion\n", + "dt = mcal.date_range(nyse_schedule, '2h', start='2012-07-05 04:00:00', periods=10, merge_adjacent=False, session={'RTH','ETH'}, closed='left', force_close=None)\n", + "mcal.mark_session(nyse_schedule, dt, closed='left')" + ] + }, + { + "cell_type": "code", + "execution_count": 142, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "2012-07-05 04:00:00-04:00 eth\n", + "2012-07-05 06:00:00-04:00 eth\n", + "2012-07-05 08:00:00-04:00 eth\n", + "2012-07-05 09:30:00-04:00 rth\n", + "2012-07-05 11:30:00-04:00 rth\n", + "2012-07-05 13:30:00-04:00 rth\n", + "2012-07-05 15:30:00-04:00 rth\n", + "2012-07-05 16:00:00-04:00 eth\n", + "2012-07-05 18:00:00-04:00 eth\n", + "2012-07-06 04:00:00-04:00 eth\n", + "dtype: category\n", + "Categories (3, object): ['closed', 'eth', 'rth']" + ] + }, + "execution_count": 142, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# User Defined Names for each session can be passed in an optional dictionary\n", + "# While only strings are shown, the dictionary values can be any basic type.\n", + "mcal.mark_session(nyse_schedule, dt, closed='left', label_map={'pre':'eth', 'post':'eth'})" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Merge schedules\n", + "Merge two schedule producing either the Union ('outer') or Intersection ('inner') of the given market dates.\n", + "\n", + "*This function does not yet support schedules with columns other than market_open, break_start, break_end or market_close*" ] }, { From 137e42ca81c1083bdb314d639d5fe53b9314ccb1 Mon Sep 17 00:00:00 2001 From: Bryce Hawk Date: Wed, 15 Jan 2025 13:38:49 -0600 Subject: [PATCH 11/11] Updated change_log.rst --- docs/change_log.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/change_log.rst b/docs/change_log.rst index 67e9d78..c803320 100644 --- a/docs/change_log.rst +++ b/docs/change_log.rst @@ -5,6 +5,13 @@ Updates ------- 4.6.0 (XX/XX/2025) ~~~~~~~~~~~~~~~~~~ +- Updated useage.ipynb with information on added features +- Added mark_session() Util Function +- Added MarketCalendar.date_range_htf() Method +- Added MarketCalendar.schedule_from_days() Method +- Split NYSE Holiday Calendar into Two Calendars to support Date_Range_HTF() + - NYSE.weekmask now returns "Mon Tue Wed Thur Fri" instead of "Mon Tue Wed Thur Fri Sat" +- Start, End, Periods, and Session Arguments added to Date_Range() from PR #358 - Speed enhancements from PR #358 4.5.1 (01/01/2025)