Repository: pandas-dev/pandas
Pull request: BUG: bug in date_range with custom business hours and given periods
- [x] closes #30593
- [x] tests added / passed
- [x] passes `black pandas`
- [x] passes `git diff upstream/master -u -- "*.py" | flake8 --diff`
- [x] whatsnew entry


Problem statement:
Pandas date_range does not work when using periods and adding holiday
This code works fine

````
pd.date_range(start='2020-11-25 10:00',periods=14,
              freq=pd.offsets.CustomBusinessHour(start='10:00'))
````

but if I add holidays then it produces more than 14 periods


````
pd.date_range(start='2020-11-25 10:00',periods=14,
              freq=pd.offsets.CustomBusinessHour(start='10:00',holidays=['2020-11-26']))
````

Output:

````
DatetimeIndex(['2020-11-25 10:00:00', '2020-11-25 11:00:00',
               '2020-11-25 12:00:00', '2020-11-25 13:00:00',
               '2020-11-25 14:00:00', '2020-11-25 15:00:00',
               '2020-11-25 16:00:00', '2020-11-27 10:00:00',
               '2020-11-27 11:00:00', '2020-11-27 12:00:00',
               '2020-11-27 13:00:00', '2020-11-27 14:00:00',
               '2020-11-27 15:00:00', '2020-11-27 16:00:00',
               '2020-11-30 10:00:00', '2020-11-30 11:00:00',
               '2020-11-30 12:00:00', '2020-11-30 13:00:00',
               '2020-11-30 14:00:00', '2020-11-30 15:00:00',
               '2020-11-30 16:00:00', '2020-12-01 10:00:00',
               '2020-12-01 11:00:00', '2020-12-01 12:00:00',
               '2020-12-01 13:00:00', '2020-12-01 14:00:00',
               '2020-12-01 15:00:00', '2020-12-01 16:00:00',
               '2020-12-02 10:00:00', '2020-12-02 11:00:00',
               '2020-12-02 12:00:00'],
              dtype='datetime64[ns]', freq='CBH')
````

If I replace `periods` with the corresponding `end` then everything works fine.

````
pd.date_range(start='2020-11-25 10:00',end='2020-11-27 16:00:00',
              freq=pd.offsets.CustomBusinessHour(start='10:00',holidays=['2020-11-26']))
````

I am not sure why date_range has this weird behavior. Appreciate any help.

Hints from issue discussion:
This does not explain the behavior but just subseting the output gets the job done.

````
pd.date_range(start='2020-11-25 10:00',periods=14,
              freq=pd.offsets.CustomBusinessHour(start='10:00',holidays=['2020-11-26']))[:14]
````

Edit: Actually this does not work because the code below only produces 13 records, not more than 20

````
pd.date_range(start='2020-11-25 10:00',periods=20,
              freq=pd.offsets.CustomBusinessHour(start='10:00',holidays=['2020-11-26']))
````
This is a bug in applying a custom-business-hour offset onto a datetime.

Will get this fixed.

Commit message: Merge branch 'master' into bug_in_date_range_with_custom_business_hours

Patch to review:
diff --git a/doc/source/whatsnew/v1.0.0.rst b/doc/source/whatsnew/v1.0.0.rst
index b9cc1dad53674..98ef62b557afd 100755
--- a/doc/source/whatsnew/v1.0.0.rst
+++ b/doc/source/whatsnew/v1.0.0.rst
@@ -833,6 +833,7 @@ Datetimelike
 - Bug in :meth:`Series.cummin` and :meth:`Series.cummax` with timezone-aware dtype incorrectly dropping its timezone (:issue:`15553`)
 - Bug in :class:`DatetimeArray`, :class:`TimedeltaArray`, and :class:`PeriodArray` where inplace addition and subtraction did not actually operate inplace (:issue:`24115`)
 - Bug in :func:`pandas.to_datetime` when called with ``Series`` storing ``IntegerArray`` raising ``TypeError`` instead of returning ``Series`` (:issue:`30050`)
+- Bug in :func:`date_range` with custom business hours as ``freq`` and given number of ``periods`` (:issue:`30593`)
 
 Timedelta
 ^^^^^^^^^
diff --git a/pandas/tests/indexes/datetimes/test_date_range.py b/pandas/tests/indexes/datetimes/test_date_range.py
index f9df295284806..4d0beecbbf5d3 100644
--- a/pandas/tests/indexes/datetimes/test_date_range.py
+++ b/pandas/tests/indexes/datetimes/test_date_range.py
@@ -945,3 +945,19 @@ def test_range_with_millisecond_resolution(self, start_end):
         result = pd.date_range(start=start, end=end, periods=2, closed="left")
         expected = DatetimeIndex([start])
         tm.assert_index_equal(result, expected)
+
+
+def test_date_range_with_custom_holidays():
+    # GH 30593
+    freq = pd.offsets.CustomBusinessHour(start="15:00", holidays=["2020-11-26"])
+    result = pd.date_range(start="2020-11-25 15:00", periods=4, freq=freq)
+    expected = pd.DatetimeIndex(
+        [
+            "2020-11-25 15:00:00",
+            "2020-11-25 16:00:00",
+            "2020-11-27 15:00:00",
+            "2020-11-27 16:00:00",
+        ],
+        freq=freq,
+    )
+    tm.assert_index_equal(result, expected)
diff --git a/pandas/tseries/offsets.py b/pandas/tseries/offsets.py
index f20d385ffbbce..8b8d0788ed761 100644
--- a/pandas/tseries/offsets.py
+++ b/pandas/tseries/offsets.py
@@ -896,7 +896,15 @@ def apply(self, other):
 
             # adjust by business days first
             if bd != 0:
-                skip_bd = BusinessDay(n=bd)
+                if isinstance(self, _CustomMixin):  # GH 30593
+                    skip_bd = CustomBusinessDay(
+                        n=bd,
+                        weekmask=getattr(self, "weekmask"),
+                        holidays=getattr(self, "holidays"),
+                        calendar=getattr(self, "calendar"),
+                    )
+                else:
+                    skip_bd = BusinessDay(n=bd)
                 # midnight business hour may not on BusinessDay
                 if not self.next_bday.is_on_offset(other):
                     prev_open = self._prev_opening_time(other)


Code review comments:
diff_hunk: @@ -896,7 +896,15 @@ def apply(self, other):
 
             # adjust by business days first
             if bd != 0:
-                skip_bd = BusinessDay(n=bd)
+                if isinstance(self, _CustomMixin):  # GH 30593
+                    skip_bd = CustomBusinessDay(
+                        n=bd,
+                        weekmask=getattr(self, "weekmask"),
path: pandas/tseries/offsets.py
text: @user1:
Do you need the getattr if this is true? Couldn't we just called `self.weekmask`, `self.holidays`, etc... instead?

@author:
Actually I was asking the exact same question when I wrote this. I saw you guys use object.__setattr__ in the __init__ method of _CustomMixin so I thought that was the paradigm here.

Anyway I have updated the code to use self.xxx directly.