diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index 99a6be03c84d3..52d0e8b4f7384 100644 --- a/doc/source/whatsnew/v3.0.0.rst +++ b/doc/source/whatsnew/v3.0.0.rst @@ -981,7 +981,8 @@ Timezones ^^^^^^^^^ - Bug in :meth:`DatetimeIndex.union`, :meth:`DatetimeIndex.intersection`, and :meth:`DatetimeIndex.symmetric_difference` changing timezone to UTC when merging two DatetimeIndex objects with the same timezone but different units (:issue:`60080`) - Bug in :meth:`Series.dt.tz_localize` with a timezone-aware :class:`ArrowDtype` incorrectly converting to UTC when ``tz=None`` (:issue:`61780`) -- +- Fixed bug in :func:`date_range` where tz-aware endpoints with calendar offsets (e.g. ``"MS"``) failed on DST fall-back. These now respect ``ambiguous``/ ``nonexistent``. (:issue:`52908`) + Numeric ^^^^^^^ diff --git a/pandas/core/arrays/datetimes.py b/pandas/core/arrays/datetimes.py index 7e0b91365ce02..d17ffbbfa5b4d 100644 --- a/pandas/core/arrays/datetimes.py +++ b/pandas/core/arrays/datetimes.py @@ -456,13 +456,14 @@ def _generate_range( end = _maybe_localize_point(end, freq, tz, ambiguous, nonexistent) if freq is not None: - # We break Day arithmetic (fixed 24 hour) here and opt for - # Day to mean calendar day (23/24/25 hour). Therefore, strip - # tz info from start and day to avoid DST arithmetic - if isinstance(freq, Day): - if start is not None: + # Offset handling: + # Ticks (fixed-duration like hours/minutes): keep tz; do absolute-time math. + # Other calendar offsets: drop tz; do naive wall time; localize once later + # so `ambiguous`/`nonexistent` are applied correctly. + if not isinstance(freq, Tick): + if start is not None and start.tz is not None: start = start.tz_localize(None) - if end is not None: + if end is not None and end.tz is not None: end = end.tz_localize(None) if isinstance(freq, (Tick, Day)): diff --git a/pandas/tests/indexes/datetimes/test_date_range.py b/pandas/tests/indexes/datetimes/test_date_range.py index 83e1a7a276875..85e2f6a8070e0 100644 --- a/pandas/tests/indexes/datetimes/test_date_range.py +++ b/pandas/tests/indexes/datetimes/test_date_range.py @@ -1740,3 +1740,28 @@ def test_date_range_negative_freq_year_end_inbounds(self, unit): freq="-1YE", ) tm.assert_index_equal(rng, exp) + + def test_date_range_tzaware_endpoints_accept_ambiguous(self): + # https://github.com/pandas-dev/pandas/issues/52908 + start = Timestamp("1916-08-01", tz="Europe/Oslo") + end = Timestamp("1916-12-01", tz="Europe/Oslo") + res = date_range(start, end, freq="MS", ambiguous=True) + exp = date_range( + "1916-08-01", "1916-12-01", freq="MS", tz="Europe/Oslo", ambiguous=True + ) + tm.assert_index_equal(res, exp) + + def test_date_range_tzaware_endpoints_accept_nonexistent(self): + # Europe/London spring-forward: 2015-03-29 01:30 does not exist. + tz = "Europe/London" + start = Timestamp("2015-03-28 01:30", tz=tz) + end = Timestamp("2015-03-30 01:30", tz=tz) + + result = date_range(start, end, freq="D", nonexistent="shift_forward") + + # Build expected by generating naive daily times, then tz_localize so + # the nonexistent handling is applied during localization. + expected = date_range( + "2015-03-28 01:30", "2015-03-30 01:30", freq="D" + ).tz_localize(tz, nonexistent="shift_forward") + tm.assert_index_equal(result, expected)