Coverage for /home/martinb/.local/share/virtualenvs/camcops/lib/python3.6/site-packages/pandas/tseries/offsets.py : 28%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1from datetime import date, datetime, timedelta
2import functools
3import operator
4from typing import Any, Optional
5import warnings
7from dateutil.easter import easter
8import numpy as np
10from pandas._libs.tslibs import (
11 NaT,
12 OutOfBoundsDatetime,
13 Period,
14 Timedelta,
15 Timestamp,
16 ccalendar,
17 conversion,
18 delta_to_nanoseconds,
19 frequencies as libfrequencies,
20 normalize_date,
21 offsets as liboffsets,
22 timezones,
23)
24from pandas._libs.tslibs.offsets import (
25 ApplyTypeError,
26 BaseOffset,
27 _get_calendar,
28 _is_normalized,
29 _to_dt64,
30 apply_index_wraps,
31 as_datetime,
32 roll_yearday,
33 shift_month,
34)
35from pandas.errors import AbstractMethodError
36from pandas.util._decorators import Appender, Substitution, cache_readonly
38from pandas.core.dtypes.inference import is_list_like
40__all__ = [
41 "Day",
42 "BusinessDay",
43 "BDay",
44 "CustomBusinessDay",
45 "CDay",
46 "CBMonthEnd",
47 "CBMonthBegin",
48 "MonthBegin",
49 "BMonthBegin",
50 "MonthEnd",
51 "BMonthEnd",
52 "SemiMonthEnd",
53 "SemiMonthBegin",
54 "BusinessHour",
55 "CustomBusinessHour",
56 "YearBegin",
57 "BYearBegin",
58 "YearEnd",
59 "BYearEnd",
60 "QuarterBegin",
61 "BQuarterBegin",
62 "QuarterEnd",
63 "BQuarterEnd",
64 "LastWeekOfMonth",
65 "FY5253Quarter",
66 "FY5253",
67 "Week",
68 "WeekOfMonth",
69 "Easter",
70 "Hour",
71 "Minute",
72 "Second",
73 "Milli",
74 "Micro",
75 "Nano",
76 "DateOffset",
77]
79# convert to/from datetime/timestamp to allow invalid Timestamp ranges to
80# pass thru
83def as_timestamp(obj):
84 if isinstance(obj, Timestamp):
85 return obj
86 try:
87 return Timestamp(obj)
88 except (OutOfBoundsDatetime):
89 pass
90 return obj
93def apply_wraps(func):
94 @functools.wraps(func)
95 def wrapper(self, other):
96 if other is NaT:
97 return NaT
98 elif isinstance(other, (timedelta, Tick, DateOffset)):
99 # timedelta path
100 return func(self, other)
101 elif isinstance(other, (np.datetime64, datetime, date)):
102 other = as_timestamp(other)
104 tz = getattr(other, "tzinfo", None)
105 nano = getattr(other, "nanosecond", 0)
107 try:
108 if self._adjust_dst and isinstance(other, Timestamp):
109 other = other.tz_localize(None)
111 result = func(self, other)
113 if self._adjust_dst:
114 result = conversion.localize_pydatetime(result, tz)
116 result = Timestamp(result)
117 if self.normalize:
118 result = result.normalize()
120 # nanosecond may be deleted depending on offset process
121 if not self.normalize and nano != 0:
122 if not isinstance(self, Nano) and result.nanosecond != nano:
123 if result.tz is not None:
124 # convert to UTC
125 value = conversion.tz_convert_single(
126 result.value, timezones.UTC, result.tz
127 )
128 else:
129 value = result.value
130 result = Timestamp(value + nano)
132 if tz is not None and result.tzinfo is None:
133 result = conversion.localize_pydatetime(result, tz)
135 except OutOfBoundsDatetime:
136 result = func(self, as_datetime(other))
138 if self.normalize:
139 # normalize_date returns normal datetime
140 result = normalize_date(result)
142 if tz is not None and result.tzinfo is None:
143 result = conversion.localize_pydatetime(result, tz)
145 result = Timestamp(result)
147 return result
149 return wrapper
152# ---------------------------------------------------------------------
153# DateOffset
156class DateOffset(BaseOffset):
157 """
158 Standard kind of date increment used for a date range.
160 Works exactly like relativedelta in terms of the keyword args you
161 pass in, use of the keyword n is discouraged-- you would be better
162 off specifying n in the keywords you use, but regardless it is
163 there for you. n is needed for DateOffset subclasses.
165 DateOffset work as follows. Each offset specify a set of dates
166 that conform to the DateOffset. For example, Bday defines this
167 set to be the set of dates that are weekdays (M-F). To test if a
168 date is in the set of a DateOffset dateOffset we can use the
169 is_on_offset method: dateOffset.is_on_offset(date).
171 If a date is not on a valid date, the rollback and rollforward
172 methods can be used to roll the date to the nearest valid date
173 before/after the date.
175 DateOffsets can be created to move dates forward a given number of
176 valid dates. For example, Bday(2) can be added to a date to move
177 it two business days forward. If the date does not start on a
178 valid date, first it is moved to a valid date. Thus pseudo code
179 is:
181 def __add__(date):
182 date = rollback(date) # does nothing if date is valid
183 return date + <n number of periods>
185 When a date offset is created for a negative number of periods,
186 the date is first rolled forward. The pseudo code is:
188 def __add__(date):
189 date = rollforward(date) # does nothing is date is valid
190 return date + <n number of periods>
192 Zero presents a problem. Should it roll forward or back? We
193 arbitrarily have it rollforward:
195 date + BDay(0) == BDay.rollforward(date)
197 Since 0 is a bit weird, we suggest avoiding its use.
199 Parameters
200 ----------
201 n : int, default 1
202 The number of time periods the offset represents.
203 normalize : bool, default False
204 Whether to round the result of a DateOffset addition down to the
205 previous midnight.
206 **kwds
207 Temporal parameter that add to or replace the offset value.
209 Parameters that **add** to the offset (like Timedelta):
211 - years
212 - months
213 - weeks
214 - days
215 - hours
216 - minutes
217 - seconds
218 - microseconds
219 - nanoseconds
221 Parameters that **replace** the offset value:
223 - year
224 - month
225 - day
226 - weekday
227 - hour
228 - minute
229 - second
230 - microsecond
231 - nanosecond.
233 See Also
234 --------
235 dateutil.relativedelta.relativedelta : The relativedelta type is designed
236 to be applied to an existing datetime an can replace specific components of
237 that datetime, or represents an interval of time.
239 Examples
240 --------
241 >>> from pandas.tseries.offsets import DateOffset
242 >>> ts = pd.Timestamp('2017-01-01 09:10:11')
243 >>> ts + DateOffset(months=3)
244 Timestamp('2017-04-01 09:10:11')
246 >>> ts = pd.Timestamp('2017-01-01 09:10:11')
247 >>> ts + DateOffset(months=2)
248 Timestamp('2017-03-01 09:10:11')
249 """
251 _params = cache_readonly(BaseOffset._params.fget)
252 _use_relativedelta = False
253 _adjust_dst = False
254 _attributes = frozenset(["n", "normalize"] + list(liboffsets.relativedelta_kwds))
255 _deprecations = frozenset(["isAnchored", "onOffset"])
257 # default for prior pickles
258 normalize = False
260 def __init__(self, n=1, normalize=False, **kwds):
261 BaseOffset.__init__(self, n, normalize)
263 off, use_rd = liboffsets._determine_offset(kwds)
264 object.__setattr__(self, "_offset", off)
265 object.__setattr__(self, "_use_relativedelta", use_rd)
266 for key in kwds:
267 val = kwds[key]
268 object.__setattr__(self, key, val)
270 @apply_wraps
271 def apply(self, other):
272 if self._use_relativedelta:
273 other = as_datetime(other)
275 if len(self.kwds) > 0:
276 tzinfo = getattr(other, "tzinfo", None)
277 if tzinfo is not None and self._use_relativedelta:
278 # perform calculation in UTC
279 other = other.replace(tzinfo=None)
281 if self.n > 0:
282 for i in range(self.n):
283 other = other + self._offset
284 else:
285 for i in range(-self.n):
286 other = other - self._offset
288 if tzinfo is not None and self._use_relativedelta:
289 # bring tz back from UTC calculation
290 other = conversion.localize_pydatetime(other, tzinfo)
292 return as_timestamp(other)
293 else:
294 return other + timedelta(self.n)
296 @apply_index_wraps
297 def apply_index(self, i):
298 """
299 Vectorized apply of DateOffset to DatetimeIndex,
300 raises NotImplentedError for offsets without a
301 vectorized implementation.
303 Parameters
304 ----------
305 i : DatetimeIndex
307 Returns
308 -------
309 y : DatetimeIndex
310 """
312 if type(self) is not DateOffset:
313 raise NotImplementedError(
314 f"DateOffset subclass {type(self).__name__} "
315 "does not have a vectorized implementation"
316 )
317 kwds = self.kwds
318 relativedelta_fast = {
319 "years",
320 "months",
321 "weeks",
322 "days",
323 "hours",
324 "minutes",
325 "seconds",
326 "microseconds",
327 }
328 # relativedelta/_offset path only valid for base DateOffset
329 if self._use_relativedelta and set(kwds).issubset(relativedelta_fast):
331 months = (kwds.get("years", 0) * 12 + kwds.get("months", 0)) * self.n
332 if months:
333 shifted = liboffsets.shift_months(i.asi8, months)
334 i = type(i)(shifted, dtype=i.dtype)
336 weeks = (kwds.get("weeks", 0)) * self.n
337 if weeks:
338 # integer addition on PeriodIndex is deprecated,
339 # so we directly use _time_shift instead
340 asper = i.to_period("W")
341 if not isinstance(asper._data, np.ndarray):
342 # unwrap PeriodIndex --> PeriodArray
343 asper = asper._data
344 shifted = asper._time_shift(weeks)
345 i = shifted.to_timestamp() + i.to_perioddelta("W")
347 timedelta_kwds = {
348 k: v
349 for k, v in kwds.items()
350 if k in ["days", "hours", "minutes", "seconds", "microseconds"]
351 }
352 if timedelta_kwds:
353 delta = Timedelta(**timedelta_kwds)
354 i = i + (self.n * delta)
355 return i
356 elif not self._use_relativedelta and hasattr(self, "_offset"):
357 # timedelta
358 return i + (self._offset * self.n)
359 else:
360 # relativedelta with other keywords
361 kwd = set(kwds) - relativedelta_fast
362 raise NotImplementedError(
363 "DateOffset with relativedelta "
364 f"keyword(s) {kwd} not able to be "
365 "applied vectorized"
366 )
368 def is_anchored(self):
369 # TODO: Does this make sense for the general case? It would help
370 # if there were a canonical docstring for what is_anchored means.
371 return self.n == 1
373 def onOffset(self, dt):
374 warnings.warn(
375 "onOffset is a deprecated, use is_on_offset instead",
376 FutureWarning,
377 stacklevel=2,
378 )
379 return self.is_on_offset(dt)
381 def isAnchored(self):
382 warnings.warn(
383 "isAnchored is a deprecated, use is_anchored instead",
384 FutureWarning,
385 stacklevel=2,
386 )
387 return self.is_anchored()
389 # TODO: Combine this with BusinessMixin version by defining a whitelisted
390 # set of attributes on each object rather than the existing behavior of
391 # iterating over internal ``__dict__``
392 def _repr_attrs(self):
393 exclude = {"n", "inc", "normalize"}
394 attrs = []
395 for attr in sorted(self.__dict__):
396 if attr.startswith("_") or attr == "kwds":
397 continue
398 elif attr not in exclude:
399 value = getattr(self, attr)
400 attrs.append(f"{attr}={value}")
402 out = ""
403 if attrs:
404 out += ": " + ", ".join(attrs)
405 return out
407 @property
408 def name(self):
409 return self.rule_code
411 def rollback(self, dt):
412 """
413 Roll provided date backward to next offset only if not on offset.
415 Returns
416 -------
417 TimeStamp
418 Rolled timestamp if not on offset, otherwise unchanged timestamp.
419 """
420 dt = as_timestamp(dt)
421 if not self.is_on_offset(dt):
422 dt = dt - type(self)(1, normalize=self.normalize, **self.kwds)
423 return dt
425 def rollforward(self, dt):
426 """
427 Roll provided date forward to next offset only if not on offset.
429 Returns
430 -------
431 TimeStamp
432 Rolled timestamp if not on offset, otherwise unchanged timestamp.
433 """
434 dt = as_timestamp(dt)
435 if not self.is_on_offset(dt):
436 dt = dt + type(self)(1, normalize=self.normalize, **self.kwds)
437 return dt
439 def is_on_offset(self, dt):
440 if self.normalize and not _is_normalized(dt):
441 return False
442 # XXX, see #1395
443 if type(self) == DateOffset or isinstance(self, Tick):
444 return True
446 # Default (slow) method for determining if some date is a member of the
447 # date range generated by this offset. Subclasses may have this
448 # re-implemented in a nicer way.
449 a = dt
450 b = (dt + self) - self
451 return a == b
453 # way to get around weirdness with rule_code
454 @property
455 def _prefix(self):
456 raise NotImplementedError("Prefix not defined")
458 @property
459 def rule_code(self):
460 return self._prefix
462 @cache_readonly
463 def freqstr(self):
464 try:
465 code = self.rule_code
466 except NotImplementedError:
467 return repr(self)
469 if self.n != 1:
470 fstr = f"{self.n}{code}"
471 else:
472 fstr = code
474 try:
475 if self._offset:
476 fstr += self._offset_str()
477 except AttributeError:
478 # TODO: standardize `_offset` vs `offset` naming convention
479 pass
481 return fstr
483 def _offset_str(self):
484 return ""
486 @property
487 def nanos(self):
488 raise ValueError(f"{self} is a non-fixed frequency")
491class SingleConstructorOffset(DateOffset):
492 @classmethod
493 def _from_name(cls, suffix=None):
494 # default _from_name calls cls with no args
495 if suffix:
496 raise ValueError(f"Bad freq suffix {suffix}")
497 return cls()
500class _CustomMixin:
501 """
502 Mixin for classes that define and validate calendar, holidays,
503 and weekdays attributes.
504 """
506 def __init__(self, weekmask, holidays, calendar):
507 calendar, holidays = _get_calendar(
508 weekmask=weekmask, holidays=holidays, calendar=calendar
509 )
510 # Custom offset instances are identified by the
511 # following two attributes. See DateOffset._params()
512 # holidays, weekmask
514 object.__setattr__(self, "weekmask", weekmask)
515 object.__setattr__(self, "holidays", holidays)
516 object.__setattr__(self, "calendar", calendar)
519class BusinessMixin:
520 """
521 Mixin to business types to provide related functions.
522 """
524 @property
525 def offset(self):
526 """
527 Alias for self._offset.
528 """
529 # Alias for backward compat
530 return self._offset
532 def _repr_attrs(self):
533 if self.offset:
534 attrs = [f"offset={repr(self.offset)}"]
535 else:
536 attrs = None
537 out = ""
538 if attrs:
539 out += ": " + ", ".join(attrs)
540 return out
543class BusinessDay(BusinessMixin, SingleConstructorOffset):
544 """
545 DateOffset subclass representing possibly n business days.
546 """
548 _prefix = "B"
549 _adjust_dst = True
550 _attributes = frozenset(["n", "normalize", "offset"])
552 def __init__(self, n=1, normalize=False, offset=timedelta(0)):
553 BaseOffset.__init__(self, n, normalize)
554 object.__setattr__(self, "_offset", offset)
556 def _offset_str(self):
557 def get_str(td):
558 off_str = ""
559 if td.days > 0:
560 off_str += str(td.days) + "D"
561 if td.seconds > 0:
562 s = td.seconds
563 hrs = int(s / 3600)
564 if hrs != 0:
565 off_str += str(hrs) + "H"
566 s -= hrs * 3600
567 mts = int(s / 60)
568 if mts != 0:
569 off_str += str(mts) + "Min"
570 s -= mts * 60
571 if s != 0:
572 off_str += str(s) + "s"
573 if td.microseconds > 0:
574 off_str += str(td.microseconds) + "us"
575 return off_str
577 if isinstance(self.offset, timedelta):
578 zero = timedelta(0, 0, 0)
579 if self.offset >= zero:
580 off_str = "+" + get_str(self.offset)
581 else:
582 off_str = "-" + get_str(-self.offset)
583 return off_str
584 else:
585 return "+" + repr(self.offset)
587 @apply_wraps
588 def apply(self, other):
589 if isinstance(other, datetime):
590 n = self.n
591 wday = other.weekday()
593 # avoid slowness below by operating on weeks first
594 weeks = n // 5
595 if n <= 0 and wday > 4:
596 # roll forward
597 n += 1
599 n -= 5 * weeks
601 # n is always >= 0 at this point
602 if n == 0 and wday > 4:
603 # roll back
604 days = 4 - wday
605 elif wday > 4:
606 # roll forward
607 days = (7 - wday) + (n - 1)
608 elif wday + n <= 4:
609 # shift by n days without leaving the current week
610 days = n
611 else:
612 # shift by n days plus 2 to get past the weekend
613 days = n + 2
615 result = other + timedelta(days=7 * weeks + days)
616 if self.offset:
617 result = result + self.offset
618 return result
620 elif isinstance(other, (timedelta, Tick)):
621 return BDay(self.n, offset=self.offset + other, normalize=self.normalize)
622 else:
623 raise ApplyTypeError(
624 "Only know how to combine business day with datetime or timedelta."
625 )
627 @apply_index_wraps
628 def apply_index(self, i):
629 time = i.to_perioddelta("D")
630 # to_period rolls forward to next BDay; track and
631 # reduce n where it does when rolling forward
632 asper = i.to_period("B")
633 if not isinstance(asper._data, np.ndarray):
634 # unwrap PeriodIndex --> PeriodArray
635 asper = asper._data
637 if self.n > 0:
638 shifted = (i.to_perioddelta("B") - time).asi8 != 0
640 # Integer-array addition is deprecated, so we use
641 # _time_shift directly
642 roll = np.where(shifted, self.n - 1, self.n)
643 shifted = asper._addsub_int_array(roll, operator.add)
644 else:
645 # Integer addition is deprecated, so we use _time_shift directly
646 roll = self.n
647 shifted = asper._time_shift(roll)
649 result = shifted.to_timestamp() + time
650 return result
652 def is_on_offset(self, dt):
653 if self.normalize and not _is_normalized(dt):
654 return False
655 return dt.weekday() < 5
658class BusinessHourMixin(BusinessMixin):
659 def __init__(self, start="09:00", end="17:00", offset=timedelta(0)):
660 # must be validated here to equality check
661 if not is_list_like(start):
662 start = [start]
663 if not len(start):
664 raise ValueError("Must include at least 1 start time")
666 if not is_list_like(end):
667 end = [end]
668 if not len(end):
669 raise ValueError("Must include at least 1 end time")
671 start = np.array([liboffsets._validate_business_time(x) for x in start])
672 end = np.array([liboffsets._validate_business_time(x) for x in end])
674 # Validation of input
675 if len(start) != len(end):
676 raise ValueError("number of starting time and ending time must be the same")
677 num_openings = len(start)
679 # sort starting and ending time by starting time
680 index = np.argsort(start)
682 # convert to tuple so that start and end are hashable
683 start = tuple(start[index])
684 end = tuple(end[index])
686 total_secs = 0
687 for i in range(num_openings):
688 total_secs += self._get_business_hours_by_sec(start[i], end[i])
689 total_secs += self._get_business_hours_by_sec(
690 end[i], start[(i + 1) % num_openings]
691 )
692 if total_secs != 24 * 60 * 60:
693 raise ValueError(
694 "invalid starting and ending time(s): "
695 "opening hours should not touch or overlap with "
696 "one another"
697 )
699 object.__setattr__(self, "start", start)
700 object.__setattr__(self, "end", end)
701 object.__setattr__(self, "_offset", offset)
703 @cache_readonly
704 def next_bday(self):
705 """
706 Used for moving to next business day.
707 """
708 if self.n >= 0:
709 nb_offset = 1
710 else:
711 nb_offset = -1
712 if self._prefix.startswith("C"):
713 # CustomBusinessHour
714 return CustomBusinessDay(
715 n=nb_offset,
716 weekmask=self.weekmask,
717 holidays=self.holidays,
718 calendar=self.calendar,
719 )
720 else:
721 return BusinessDay(n=nb_offset)
723 def _next_opening_time(self, other, sign=1):
724 """
725 If self.n and sign have the same sign, return the earliest opening time
726 later than or equal to current time.
727 Otherwise the latest opening time earlier than or equal to current
728 time.
730 Opening time always locates on BusinessDay.
731 However, closing time may not if business hour extends over midnight.
733 Parameters
734 ----------
735 other : datetime
736 Current time.
737 sign : int, default 1.
738 Either 1 or -1. Going forward in time if it has the same sign as
739 self.n. Going backward in time otherwise.
741 Returns
742 -------
743 result : datetime
744 Next opening time.
745 """
746 earliest_start = self.start[0]
747 latest_start = self.start[-1]
749 if not self.next_bday.is_on_offset(other):
750 # today is not business day
751 other = other + sign * self.next_bday
752 if self.n * sign >= 0:
753 hour, minute = earliest_start.hour, earliest_start.minute
754 else:
755 hour, minute = latest_start.hour, latest_start.minute
756 else:
757 if self.n * sign >= 0:
758 if latest_start < other.time():
759 # current time is after latest starting time in today
760 other = other + sign * self.next_bday
761 hour, minute = earliest_start.hour, earliest_start.minute
762 else:
763 # find earliest starting time no earlier than current time
764 for st in self.start:
765 if other.time() <= st:
766 hour, minute = st.hour, st.minute
767 break
768 else:
769 if other.time() < earliest_start:
770 # current time is before earliest starting time in today
771 other = other + sign * self.next_bday
772 hour, minute = latest_start.hour, latest_start.minute
773 else:
774 # find latest starting time no later than current time
775 for st in reversed(self.start):
776 if other.time() >= st:
777 hour, minute = st.hour, st.minute
778 break
780 return datetime(other.year, other.month, other.day, hour, minute)
782 def _prev_opening_time(self, other):
783 """
784 If n is positive, return the latest opening time earlier than or equal
785 to current time.
786 Otherwise the earliest opening time later than or equal to current
787 time.
789 Parameters
790 ----------
791 other : datetime
792 Current time.
794 Returns
795 -------
796 result : datetime
797 Previous opening time.
798 """
799 return self._next_opening_time(other, sign=-1)
801 def _get_business_hours_by_sec(self, start, end):
802 """
803 Return business hours in a day by seconds.
804 """
805 # create dummy datetime to calculate businesshours in a day
806 dtstart = datetime(2014, 4, 1, start.hour, start.minute)
807 day = 1 if start < end else 2
808 until = datetime(2014, 4, day, end.hour, end.minute)
809 return int((until - dtstart).total_seconds())
811 @apply_wraps
812 def rollback(self, dt):
813 """
814 Roll provided date backward to next offset only if not on offset.
815 """
816 if not self.is_on_offset(dt):
817 if self.n >= 0:
818 dt = self._prev_opening_time(dt)
819 else:
820 dt = self._next_opening_time(dt)
821 return self._get_closing_time(dt)
822 return dt
824 @apply_wraps
825 def rollforward(self, dt):
826 """
827 Roll provided date forward to next offset only if not on offset.
828 """
829 if not self.is_on_offset(dt):
830 if self.n >= 0:
831 return self._next_opening_time(dt)
832 else:
833 return self._prev_opening_time(dt)
834 return dt
836 def _get_closing_time(self, dt):
837 """
838 Get the closing time of a business hour interval by its opening time.
840 Parameters
841 ----------
842 dt : datetime
843 Opening time of a business hour interval.
845 Returns
846 -------
847 result : datetime
848 Corresponding closing time.
849 """
850 for i, st in enumerate(self.start):
851 if st.hour == dt.hour and st.minute == dt.minute:
852 return dt + timedelta(
853 seconds=self._get_business_hours_by_sec(st, self.end[i])
854 )
855 assert False
857 @apply_wraps
858 def apply(self, other):
859 if isinstance(other, datetime):
860 # used for detecting edge condition
861 nanosecond = getattr(other, "nanosecond", 0)
862 # reset timezone and nanosecond
863 # other may be a Timestamp, thus not use replace
864 other = datetime(
865 other.year,
866 other.month,
867 other.day,
868 other.hour,
869 other.minute,
870 other.second,
871 other.microsecond,
872 )
873 n = self.n
875 # adjust other to reduce number of cases to handle
876 if n >= 0:
877 if other.time() in self.end or not self._is_on_offset(other):
878 other = self._next_opening_time(other)
879 else:
880 if other.time() in self.start:
881 # adjustment to move to previous business day
882 other = other - timedelta(seconds=1)
883 if not self._is_on_offset(other):
884 other = self._next_opening_time(other)
885 other = self._get_closing_time(other)
887 # get total business hours by sec in one business day
888 businesshours = sum(
889 self._get_business_hours_by_sec(st, en)
890 for st, en in zip(self.start, self.end)
891 )
893 bd, r = divmod(abs(n * 60), businesshours // 60)
894 if n < 0:
895 bd, r = -bd, -r
897 # adjust by business days first
898 if bd != 0:
899 if isinstance(self, _CustomMixin): # GH 30593
900 skip_bd = CustomBusinessDay(
901 n=bd,
902 weekmask=self.weekmask,
903 holidays=self.holidays,
904 calendar=self.calendar,
905 )
906 else:
907 skip_bd = BusinessDay(n=bd)
908 # midnight business hour may not on BusinessDay
909 if not self.next_bday.is_on_offset(other):
910 prev_open = self._prev_opening_time(other)
911 remain = other - prev_open
912 other = prev_open + skip_bd + remain
913 else:
914 other = other + skip_bd
916 # remaining business hours to adjust
917 bhour_remain = timedelta(minutes=r)
919 if n >= 0:
920 while bhour_remain != timedelta(0):
921 # business hour left in this business time interval
922 bhour = (
923 self._get_closing_time(self._prev_opening_time(other)) - other
924 )
925 if bhour_remain < bhour:
926 # finish adjusting if possible
927 other += bhour_remain
928 bhour_remain = timedelta(0)
929 else:
930 # go to next business time interval
931 bhour_remain -= bhour
932 other = self._next_opening_time(other + bhour)
933 else:
934 while bhour_remain != timedelta(0):
935 # business hour left in this business time interval
936 bhour = self._next_opening_time(other) - other
937 if (
938 bhour_remain > bhour
939 or bhour_remain == bhour
940 and nanosecond != 0
941 ):
942 # finish adjusting if possible
943 other += bhour_remain
944 bhour_remain = timedelta(0)
945 else:
946 # go to next business time interval
947 bhour_remain -= bhour
948 other = self._get_closing_time(
949 self._next_opening_time(
950 other + bhour - timedelta(seconds=1)
951 )
952 )
954 return other
955 else:
956 raise ApplyTypeError("Only know how to combine business hour with datetime")
958 def is_on_offset(self, dt):
959 if self.normalize and not _is_normalized(dt):
960 return False
962 if dt.tzinfo is not None:
963 dt = datetime(
964 dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second, dt.microsecond
965 )
966 # Valid BH can be on the different BusinessDay during midnight
967 # Distinguish by the time spent from previous opening time
968 return self._is_on_offset(dt)
970 def _is_on_offset(self, dt):
971 """
972 Slight speedups using calculated values.
973 """
974 # if self.normalize and not _is_normalized(dt):
975 # return False
976 # Valid BH can be on the different BusinessDay during midnight
977 # Distinguish by the time spent from previous opening time
978 if self.n >= 0:
979 op = self._prev_opening_time(dt)
980 else:
981 op = self._next_opening_time(dt)
982 span = (dt - op).total_seconds()
983 businesshours = 0
984 for i, st in enumerate(self.start):
985 if op.hour == st.hour and op.minute == st.minute:
986 businesshours = self._get_business_hours_by_sec(st, self.end[i])
987 if span <= businesshours:
988 return True
989 else:
990 return False
992 def _repr_attrs(self):
993 out = super()._repr_attrs()
994 hours = ",".join(
995 f'{st.strftime("%H:%M")}-{en.strftime("%H:%M")}'
996 for st, en in zip(self.start, self.end)
997 )
998 attrs = [f"{self._prefix}={hours}"]
999 out += ": " + ", ".join(attrs)
1000 return out
1003class BusinessHour(BusinessHourMixin, SingleConstructorOffset):
1004 """
1005 DateOffset subclass representing possibly n business hours.
1006 """
1008 _prefix = "BH"
1009 _anchor = 0
1010 _attributes = frozenset(["n", "normalize", "start", "end", "offset"])
1012 def __init__(
1013 self, n=1, normalize=False, start="09:00", end="17:00", offset=timedelta(0)
1014 ):
1015 BaseOffset.__init__(self, n, normalize)
1016 super().__init__(start=start, end=end, offset=offset)
1019class CustomBusinessDay(_CustomMixin, BusinessDay):
1020 """
1021 DateOffset subclass representing possibly n custom business days,
1022 excluding holidays.
1024 Parameters
1025 ----------
1026 n : int, default 1
1027 normalize : bool, default False
1028 Normalize start/end dates to midnight before generating date range.
1029 weekmask : str, Default 'Mon Tue Wed Thu Fri'
1030 Weekmask of valid business days, passed to ``numpy.busdaycalendar``.
1031 holidays : list
1032 List/array of dates to exclude from the set of valid business days,
1033 passed to ``numpy.busdaycalendar``.
1034 calendar : pd.HolidayCalendar or np.busdaycalendar
1035 offset : timedelta, default timedelta(0)
1036 """
1038 _prefix = "C"
1039 _attributes = frozenset(
1040 ["n", "normalize", "weekmask", "holidays", "calendar", "offset"]
1041 )
1043 def __init__(
1044 self,
1045 n=1,
1046 normalize=False,
1047 weekmask="Mon Tue Wed Thu Fri",
1048 holidays=None,
1049 calendar=None,
1050 offset=timedelta(0),
1051 ):
1052 BaseOffset.__init__(self, n, normalize)
1053 object.__setattr__(self, "_offset", offset)
1055 _CustomMixin.__init__(self, weekmask, holidays, calendar)
1057 @apply_wraps
1058 def apply(self, other):
1059 if self.n <= 0:
1060 roll = "forward"
1061 else:
1062 roll = "backward"
1064 if isinstance(other, datetime):
1065 date_in = other
1066 np_dt = np.datetime64(date_in.date())
1068 np_incr_dt = np.busday_offset(
1069 np_dt, self.n, roll=roll, busdaycal=self.calendar
1070 )
1072 dt_date = np_incr_dt.astype(datetime)
1073 result = datetime.combine(dt_date, date_in.time())
1075 if self.offset:
1076 result = result + self.offset
1077 return result
1079 elif isinstance(other, (timedelta, Tick)):
1080 return BDay(self.n, offset=self.offset + other, normalize=self.normalize)
1081 else:
1082 raise ApplyTypeError(
1083 "Only know how to combine trading day with "
1084 "datetime, datetime64 or timedelta."
1085 )
1087 def apply_index(self, i):
1088 raise NotImplementedError
1090 def is_on_offset(self, dt):
1091 if self.normalize and not _is_normalized(dt):
1092 return False
1093 day64 = _to_dt64(dt, "datetime64[D]")
1094 return np.is_busday(day64, busdaycal=self.calendar)
1097class CustomBusinessHour(_CustomMixin, BusinessHourMixin, SingleConstructorOffset):
1098 """
1099 DateOffset subclass representing possibly n custom business days.
1100 """
1102 _prefix = "CBH"
1103 _anchor = 0
1104 _attributes = frozenset(
1105 ["n", "normalize", "weekmask", "holidays", "calendar", "start", "end", "offset"]
1106 )
1108 def __init__(
1109 self,
1110 n=1,
1111 normalize=False,
1112 weekmask="Mon Tue Wed Thu Fri",
1113 holidays=None,
1114 calendar=None,
1115 start="09:00",
1116 end="17:00",
1117 offset=timedelta(0),
1118 ):
1119 BaseOffset.__init__(self, n, normalize)
1120 object.__setattr__(self, "_offset", offset)
1122 _CustomMixin.__init__(self, weekmask, holidays, calendar)
1123 BusinessHourMixin.__init__(self, start=start, end=end, offset=offset)
1126# ---------------------------------------------------------------------
1127# Month-Based Offset Classes
1130class MonthOffset(SingleConstructorOffset):
1131 _adjust_dst = True
1132 _attributes = frozenset(["n", "normalize"])
1134 __init__ = BaseOffset.__init__
1136 @property
1137 def name(self):
1138 if self.is_anchored:
1139 return self.rule_code
1140 else:
1141 month = ccalendar.MONTH_ALIASES[self.n]
1142 return f"{self.code_rule}-{month}"
1144 def is_on_offset(self, dt):
1145 if self.normalize and not _is_normalized(dt):
1146 return False
1147 return dt.day == self._get_offset_day(dt)
1149 @apply_wraps
1150 def apply(self, other):
1151 compare_day = self._get_offset_day(other)
1152 n = liboffsets.roll_convention(other.day, self.n, compare_day)
1153 return shift_month(other, n, self._day_opt)
1155 @apply_index_wraps
1156 def apply_index(self, i):
1157 shifted = liboffsets.shift_months(i.asi8, self.n, self._day_opt)
1158 # TODO: going through __new__ raises on call to _validate_frequency;
1159 # are we passing incorrect freq?
1160 return type(i)._simple_new(shifted, freq=i.freq, dtype=i.dtype)
1163class MonthEnd(MonthOffset):
1164 """
1165 DateOffset of one month end.
1166 """
1168 _prefix = "M"
1169 _day_opt = "end"
1172class MonthBegin(MonthOffset):
1173 """
1174 DateOffset of one month at beginning.
1175 """
1177 _prefix = "MS"
1178 _day_opt = "start"
1181class BusinessMonthEnd(MonthOffset):
1182 """
1183 DateOffset increments between business EOM dates.
1184 """
1186 _prefix = "BM"
1187 _day_opt = "business_end"
1190class BusinessMonthBegin(MonthOffset):
1191 """
1192 DateOffset of one business month at beginning.
1193 """
1195 _prefix = "BMS"
1196 _day_opt = "business_start"
1199class _CustomBusinessMonth(_CustomMixin, BusinessMixin, MonthOffset):
1200 """
1201 DateOffset subclass representing custom business month(s).
1203 Increments between %(bound)s of month dates.
1205 Parameters
1206 ----------
1207 n : int, default 1
1208 The number of months represented.
1209 normalize : bool, default False
1210 Normalize start/end dates to midnight before generating date range.
1211 weekmask : str, Default 'Mon Tue Wed Thu Fri'
1212 Weekmask of valid business days, passed to ``numpy.busdaycalendar``.
1213 holidays : list
1214 List/array of dates to exclude from the set of valid business days,
1215 passed to ``numpy.busdaycalendar``.
1216 calendar : pd.HolidayCalendar or np.busdaycalendar
1217 Calendar to integrate.
1218 offset : timedelta, default timedelta(0)
1219 Time offset to apply.
1220 """
1222 _attributes = frozenset(
1223 ["n", "normalize", "weekmask", "holidays", "calendar", "offset"]
1224 )
1226 is_on_offset = DateOffset.is_on_offset # override MonthOffset method
1227 apply_index = DateOffset.apply_index # override MonthOffset method
1229 def __init__(
1230 self,
1231 n=1,
1232 normalize=False,
1233 weekmask="Mon Tue Wed Thu Fri",
1234 holidays=None,
1235 calendar=None,
1236 offset=timedelta(0),
1237 ):
1238 BaseOffset.__init__(self, n, normalize)
1239 object.__setattr__(self, "_offset", offset)
1241 _CustomMixin.__init__(self, weekmask, holidays, calendar)
1243 @cache_readonly
1244 def cbday_roll(self):
1245 """
1246 Define default roll function to be called in apply method.
1247 """
1248 cbday = CustomBusinessDay(n=self.n, normalize=False, **self.kwds)
1250 if self._prefix.endswith("S"):
1251 # MonthBegin
1252 roll_func = cbday.rollforward
1253 else:
1254 # MonthEnd
1255 roll_func = cbday.rollback
1256 return roll_func
1258 @cache_readonly
1259 def m_offset(self):
1260 if self._prefix.endswith("S"):
1261 # MonthBegin
1262 moff = MonthBegin(n=1, normalize=False)
1263 else:
1264 # MonthEnd
1265 moff = MonthEnd(n=1, normalize=False)
1266 return moff
1268 @cache_readonly
1269 def month_roll(self):
1270 """
1271 Define default roll function to be called in apply method.
1272 """
1273 if self._prefix.endswith("S"):
1274 # MonthBegin
1275 roll_func = self.m_offset.rollback
1276 else:
1277 # MonthEnd
1278 roll_func = self.m_offset.rollforward
1279 return roll_func
1281 @apply_wraps
1282 def apply(self, other):
1283 # First move to month offset
1284 cur_month_offset_date = self.month_roll(other)
1286 # Find this custom month offset
1287 compare_date = self.cbday_roll(cur_month_offset_date)
1288 n = liboffsets.roll_convention(other.day, self.n, compare_date.day)
1290 new = cur_month_offset_date + n * self.m_offset
1291 result = self.cbday_roll(new)
1292 return result
1295@Substitution(bound="end")
1296@Appender(_CustomBusinessMonth.__doc__)
1297class CustomBusinessMonthEnd(_CustomBusinessMonth):
1298 _prefix = "CBM"
1301@Substitution(bound="beginning")
1302@Appender(_CustomBusinessMonth.__doc__)
1303class CustomBusinessMonthBegin(_CustomBusinessMonth):
1304 _prefix = "CBMS"
1307# ---------------------------------------------------------------------
1308# Semi-Month Based Offset Classes
1311class SemiMonthOffset(DateOffset):
1312 _adjust_dst = True
1313 _default_day_of_month = 15
1314 _min_day_of_month = 2
1315 _attributes = frozenset(["n", "normalize", "day_of_month"])
1317 def __init__(self, n=1, normalize=False, day_of_month=None):
1318 BaseOffset.__init__(self, n, normalize)
1320 if day_of_month is None:
1321 object.__setattr__(self, "day_of_month", self._default_day_of_month)
1322 else:
1323 object.__setattr__(self, "day_of_month", int(day_of_month))
1324 if not self._min_day_of_month <= self.day_of_month <= 27:
1325 raise ValueError(
1326 "day_of_month must be "
1327 f"{self._min_day_of_month}<=day_of_month<=27, "
1328 f"got {self.day_of_month}"
1329 )
1331 @classmethod
1332 def _from_name(cls, suffix=None):
1333 return cls(day_of_month=suffix)
1335 @property
1336 def rule_code(self):
1337 suffix = f"-{self.day_of_month}"
1338 return self._prefix + suffix
1340 @apply_wraps
1341 def apply(self, other):
1342 # shift `other` to self.day_of_month, incrementing `n` if necessary
1343 n = liboffsets.roll_convention(other.day, self.n, self.day_of_month)
1345 days_in_month = ccalendar.get_days_in_month(other.year, other.month)
1347 # For SemiMonthBegin on other.day == 1 and
1348 # SemiMonthEnd on other.day == days_in_month,
1349 # shifting `other` to `self.day_of_month` _always_ requires
1350 # incrementing/decrementing `n`, regardless of whether it is
1351 # initially positive.
1352 if type(self) is SemiMonthBegin and (self.n <= 0 and other.day == 1):
1353 n -= 1
1354 elif type(self) is SemiMonthEnd and (self.n > 0 and other.day == days_in_month):
1355 n += 1
1357 return self._apply(n, other)
1359 def _apply(self, n, other):
1360 """
1361 Handle specific apply logic for child classes.
1362 """
1363 raise AbstractMethodError(self)
1365 @apply_index_wraps
1366 def apply_index(self, i):
1367 # determine how many days away from the 1st of the month we are
1368 dti = i
1369 days_from_start = i.to_perioddelta("M").asi8
1370 delta = Timedelta(days=self.day_of_month - 1).value
1372 # get boolean array for each element before the day_of_month
1373 before_day_of_month = days_from_start < delta
1375 # get boolean array for each element after the day_of_month
1376 after_day_of_month = days_from_start > delta
1378 # determine the correct n for each date in i
1379 roll = self._get_roll(i, before_day_of_month, after_day_of_month)
1381 # isolate the time since it will be striped away one the next line
1382 time = i.to_perioddelta("D")
1384 # apply the correct number of months
1386 # integer-array addition on PeriodIndex is deprecated,
1387 # so we use _addsub_int_array directly
1388 asper = i.to_period("M")
1389 if not isinstance(asper._data, np.ndarray):
1390 # unwrap PeriodIndex --> PeriodArray
1391 asper = asper._data
1393 shifted = asper._addsub_int_array(roll // 2, operator.add)
1394 i = type(dti)(shifted.to_timestamp())
1396 # apply the correct day
1397 i = self._apply_index_days(i, roll)
1399 return i + time
1401 def _get_roll(self, i, before_day_of_month, after_day_of_month):
1402 """
1403 Return an array with the correct n for each date in i.
1405 The roll array is based on the fact that i gets rolled back to
1406 the first day of the month.
1407 """
1408 raise AbstractMethodError(self)
1410 def _apply_index_days(self, i, roll):
1411 """
1412 Apply the correct day for each date in i.
1413 """
1414 raise AbstractMethodError(self)
1417class SemiMonthEnd(SemiMonthOffset):
1418 """
1419 Two DateOffset's per month repeating on the last
1420 day of the month and day_of_month.
1422 Parameters
1423 ----------
1424 n : int
1425 normalize : bool, default False
1426 day_of_month : int, {1, 3,...,27}, default 15
1427 """
1429 _prefix = "SM"
1430 _min_day_of_month = 1
1432 def is_on_offset(self, dt):
1433 if self.normalize and not _is_normalized(dt):
1434 return False
1435 days_in_month = ccalendar.get_days_in_month(dt.year, dt.month)
1436 return dt.day in (self.day_of_month, days_in_month)
1438 def _apply(self, n, other):
1439 months = n // 2
1440 day = 31 if n % 2 else self.day_of_month
1441 return shift_month(other, months, day)
1443 def _get_roll(self, i, before_day_of_month, after_day_of_month):
1444 n = self.n
1445 is_month_end = i.is_month_end
1446 if n > 0:
1447 roll_end = np.where(is_month_end, 1, 0)
1448 roll_before = np.where(before_day_of_month, n, n + 1)
1449 roll = roll_end + roll_before
1450 elif n == 0:
1451 roll_after = np.where(after_day_of_month, 2, 0)
1452 roll_before = np.where(~after_day_of_month, 1, 0)
1453 roll = roll_before + roll_after
1454 else:
1455 roll = np.where(after_day_of_month, n + 2, n + 1)
1456 return roll
1458 def _apply_index_days(self, i, roll):
1459 """
1460 Add days portion of offset to DatetimeIndex i.
1462 Parameters
1463 ----------
1464 i : DatetimeIndex
1465 roll : ndarray[int64_t]
1467 Returns
1468 -------
1469 result : DatetimeIndex
1470 """
1471 nanos = (roll % 2) * Timedelta(days=self.day_of_month).value
1472 i += nanos.astype("timedelta64[ns]")
1473 return i + Timedelta(days=-1)
1476class SemiMonthBegin(SemiMonthOffset):
1477 """
1478 Two DateOffset's per month repeating on the first
1479 day of the month and day_of_month.
1481 Parameters
1482 ----------
1483 n : int
1484 normalize : bool, default False
1485 day_of_month : int, {2, 3,...,27}, default 15
1486 """
1488 _prefix = "SMS"
1490 def is_on_offset(self, dt):
1491 if self.normalize and not _is_normalized(dt):
1492 return False
1493 return dt.day in (1, self.day_of_month)
1495 def _apply(self, n, other):
1496 months = n // 2 + n % 2
1497 day = 1 if n % 2 else self.day_of_month
1498 return shift_month(other, months, day)
1500 def _get_roll(self, i, before_day_of_month, after_day_of_month):
1501 n = self.n
1502 is_month_start = i.is_month_start
1503 if n > 0:
1504 roll = np.where(before_day_of_month, n, n + 1)
1505 elif n == 0:
1506 roll_start = np.where(is_month_start, 0, 1)
1507 roll_after = np.where(after_day_of_month, 1, 0)
1508 roll = roll_start + roll_after
1509 else:
1510 roll_after = np.where(after_day_of_month, n + 2, n + 1)
1511 roll_start = np.where(is_month_start, -1, 0)
1512 roll = roll_after + roll_start
1513 return roll
1515 def _apply_index_days(self, i, roll):
1516 """
1517 Add days portion of offset to DatetimeIndex i.
1519 Parameters
1520 ----------
1521 i : DatetimeIndex
1522 roll : ndarray[int64_t]
1524 Returns
1525 -------
1526 result : DatetimeIndex
1527 """
1528 nanos = (roll % 2) * Timedelta(days=self.day_of_month - 1).value
1529 return i + nanos.astype("timedelta64[ns]")
1532# ---------------------------------------------------------------------
1533# Week-Based Offset Classes
1536class Week(DateOffset):
1537 """
1538 Weekly offset.
1540 Parameters
1541 ----------
1542 weekday : int, default None
1543 Always generate specific day of week. 0 for Monday.
1544 """
1546 _adjust_dst = True
1547 _inc = timedelta(weeks=1)
1548 _prefix = "W"
1549 _attributes = frozenset(["n", "normalize", "weekday"])
1551 def __init__(self, n=1, normalize=False, weekday=None):
1552 BaseOffset.__init__(self, n, normalize)
1553 object.__setattr__(self, "weekday", weekday)
1555 if self.weekday is not None:
1556 if self.weekday < 0 or self.weekday > 6:
1557 raise ValueError(f"Day must be 0<=day<=6, got {self.weekday}")
1559 def is_anchored(self):
1560 return self.n == 1 and self.weekday is not None
1562 @apply_wraps
1563 def apply(self, other):
1564 if self.weekday is None:
1565 return other + self.n * self._inc
1567 if not isinstance(other, datetime):
1568 raise TypeError(
1569 f"Cannot add {type(other).__name__} to {type(self).__name__}"
1570 )
1572 k = self.n
1573 otherDay = other.weekday()
1574 if otherDay != self.weekday:
1575 other = other + timedelta((self.weekday - otherDay) % 7)
1576 if k > 0:
1577 k -= 1
1579 return other + timedelta(weeks=k)
1581 @apply_index_wraps
1582 def apply_index(self, i):
1583 if self.weekday is None:
1584 # integer addition on PeriodIndex is deprecated,
1585 # so we use _time_shift directly
1586 asper = i.to_period("W")
1587 if not isinstance(asper._data, np.ndarray):
1588 # unwrap PeriodIndex --> PeriodArray
1589 asper = asper._data
1591 shifted = asper._time_shift(self.n)
1592 return shifted.to_timestamp() + i.to_perioddelta("W")
1593 else:
1594 return self._end_apply_index(i)
1596 def _end_apply_index(self, dtindex):
1597 """
1598 Add self to the given DatetimeIndex, specialized for case where
1599 self.weekday is non-null.
1601 Parameters
1602 ----------
1603 dtindex : DatetimeIndex
1605 Returns
1606 -------
1607 result : DatetimeIndex
1608 """
1609 off = dtindex.to_perioddelta("D")
1611 base, mult = libfrequencies.get_freq_code(self.freqstr)
1612 base_period = dtindex.to_period(base)
1613 if not isinstance(base_period._data, np.ndarray):
1614 # unwrap PeriodIndex --> PeriodArray
1615 base_period = base_period._data
1617 if self.n > 0:
1618 # when adding, dates on end roll to next
1619 normed = dtindex - off + Timedelta(1, "D") - Timedelta(1, "ns")
1620 roll = np.where(
1621 base_period.to_timestamp(how="end") == normed, self.n, self.n - 1
1622 )
1623 # integer-array addition on PeriodIndex is deprecated,
1624 # so we use _addsub_int_array directly
1625 shifted = base_period._addsub_int_array(roll, operator.add)
1626 base = shifted.to_timestamp(how="end")
1627 else:
1628 # integer addition on PeriodIndex is deprecated,
1629 # so we use _time_shift directly
1630 roll = self.n
1631 base = base_period._time_shift(roll).to_timestamp(how="end")
1633 return base + off + Timedelta(1, "ns") - Timedelta(1, "D")
1635 def is_on_offset(self, dt):
1636 if self.normalize and not _is_normalized(dt):
1637 return False
1638 elif self.weekday is None:
1639 return True
1640 return dt.weekday() == self.weekday
1642 @property
1643 def rule_code(self):
1644 suffix = ""
1645 if self.weekday is not None:
1646 weekday = ccalendar.int_to_weekday[self.weekday]
1647 suffix = f"-{weekday}"
1648 return self._prefix + suffix
1650 @classmethod
1651 def _from_name(cls, suffix=None):
1652 if not suffix:
1653 weekday = None
1654 else:
1655 weekday = ccalendar.weekday_to_int[suffix]
1656 return cls(weekday=weekday)
1659class _WeekOfMonthMixin:
1660 """
1661 Mixin for methods common to WeekOfMonth and LastWeekOfMonth.
1662 """
1664 @apply_wraps
1665 def apply(self, other):
1666 compare_day = self._get_offset_day(other)
1668 months = self.n
1669 if months > 0 and compare_day > other.day:
1670 months -= 1
1671 elif months <= 0 and compare_day < other.day:
1672 months += 1
1674 shifted = shift_month(other, months, "start")
1675 to_day = self._get_offset_day(shifted)
1676 return liboffsets.shift_day(shifted, to_day - shifted.day)
1678 def is_on_offset(self, dt):
1679 if self.normalize and not _is_normalized(dt):
1680 return False
1681 return dt.day == self._get_offset_day(dt)
1684class WeekOfMonth(_WeekOfMonthMixin, DateOffset):
1685 """
1686 Describes monthly dates like "the Tuesday of the 2nd week of each month".
1688 Parameters
1689 ----------
1690 n : int
1691 week : int {0, 1, 2, 3, ...}, default 0
1692 A specific integer for the week of the month.
1693 e.g. 0 is 1st week of month, 1 is the 2nd week, etc.
1694 weekday : int {0, 1, ..., 6}, default 0
1695 A specific integer for the day of the week.
1697 - 0 is Monday
1698 - 1 is Tuesday
1699 - 2 is Wednesday
1700 - 3 is Thursday
1701 - 4 is Friday
1702 - 5 is Saturday
1703 - 6 is Sunday.
1704 """
1706 _prefix = "WOM"
1707 _adjust_dst = True
1708 _attributes = frozenset(["n", "normalize", "week", "weekday"])
1710 def __init__(self, n=1, normalize=False, week=0, weekday=0):
1711 BaseOffset.__init__(self, n, normalize)
1712 object.__setattr__(self, "weekday", weekday)
1713 object.__setattr__(self, "week", week)
1715 if self.weekday < 0 or self.weekday > 6:
1716 raise ValueError(f"Day must be 0<=day<=6, got {self.weekday}")
1717 if self.week < 0 or self.week > 3:
1718 raise ValueError(f"Week must be 0<=week<=3, got {self.week}")
1720 def _get_offset_day(self, other):
1721 """
1722 Find the day in the same month as other that has the same
1723 weekday as self.weekday and is the self.week'th such day in the month.
1725 Parameters
1726 ----------
1727 other : datetime
1729 Returns
1730 -------
1731 day : int
1732 """
1733 mstart = datetime(other.year, other.month, 1)
1734 wday = mstart.weekday()
1735 shift_days = (self.weekday - wday) % 7
1736 return 1 + shift_days + self.week * 7
1738 @property
1739 def rule_code(self):
1740 weekday = ccalendar.int_to_weekday.get(self.weekday, "")
1741 return f"{self._prefix}-{self.week + 1}{weekday}"
1743 @classmethod
1744 def _from_name(cls, suffix=None):
1745 if not suffix:
1746 raise ValueError(f"Prefix {repr(cls._prefix)} requires a suffix.")
1747 # TODO: handle n here...
1748 # only one digit weeks (1 --> week 0, 2 --> week 1, etc.)
1749 week = int(suffix[0]) - 1
1750 weekday = ccalendar.weekday_to_int[suffix[1:]]
1751 return cls(week=week, weekday=weekday)
1754class LastWeekOfMonth(_WeekOfMonthMixin, DateOffset):
1755 """
1756 Describes monthly dates in last week of month like "the last Tuesday of
1757 each month".
1759 Parameters
1760 ----------
1761 n : int, default 1
1762 weekday : int {0, 1, ..., 6}, default 0
1763 A specific integer for the day of the week.
1765 - 0 is Monday
1766 - 1 is Tuesday
1767 - 2 is Wednesday
1768 - 3 is Thursday
1769 - 4 is Friday
1770 - 5 is Saturday
1771 - 6 is Sunday.
1772 """
1774 _prefix = "LWOM"
1775 _adjust_dst = True
1776 _attributes = frozenset(["n", "normalize", "weekday"])
1778 def __init__(self, n=1, normalize=False, weekday=0):
1779 BaseOffset.__init__(self, n, normalize)
1780 object.__setattr__(self, "weekday", weekday)
1782 if self.n == 0:
1783 raise ValueError("N cannot be 0")
1785 if self.weekday < 0 or self.weekday > 6:
1786 raise ValueError(f"Day must be 0<=day<=6, got {self.weekday}")
1788 def _get_offset_day(self, other):
1789 """
1790 Find the day in the same month as other that has the same
1791 weekday as self.weekday and is the last such day in the month.
1793 Parameters
1794 ----------
1795 other: datetime
1797 Returns
1798 -------
1799 day: int
1800 """
1801 dim = ccalendar.get_days_in_month(other.year, other.month)
1802 mend = datetime(other.year, other.month, dim)
1803 wday = mend.weekday()
1804 shift_days = (wday - self.weekday) % 7
1805 return dim - shift_days
1807 @property
1808 def rule_code(self):
1809 weekday = ccalendar.int_to_weekday.get(self.weekday, "")
1810 return f"{self._prefix}-{weekday}"
1812 @classmethod
1813 def _from_name(cls, suffix=None):
1814 if not suffix:
1815 raise ValueError(f"Prefix {repr(cls._prefix)} requires a suffix.")
1816 # TODO: handle n here...
1817 weekday = ccalendar.weekday_to_int[suffix]
1818 return cls(weekday=weekday)
1821# ---------------------------------------------------------------------
1822# Quarter-Based Offset Classes
1825class QuarterOffset(DateOffset):
1826 """
1827 Quarter representation - doesn't call super.
1828 """
1830 _default_startingMonth: Optional[int] = None
1831 _from_name_startingMonth: Optional[int] = None
1832 _adjust_dst = True
1833 _attributes = frozenset(["n", "normalize", "startingMonth"])
1834 # TODO: Consider combining QuarterOffset and YearOffset __init__ at some
1835 # point. Also apply_index, is_on_offset, rule_code if
1836 # startingMonth vs month attr names are resolved
1838 def __init__(self, n=1, normalize=False, startingMonth=None):
1839 BaseOffset.__init__(self, n, normalize)
1841 if startingMonth is None:
1842 startingMonth = self._default_startingMonth
1843 object.__setattr__(self, "startingMonth", startingMonth)
1845 def is_anchored(self):
1846 return self.n == 1 and self.startingMonth is not None
1848 @classmethod
1849 def _from_name(cls, suffix=None):
1850 kwargs = {}
1851 if suffix:
1852 kwargs["startingMonth"] = ccalendar.MONTH_TO_CAL_NUM[suffix]
1853 else:
1854 if cls._from_name_startingMonth is not None:
1855 kwargs["startingMonth"] = cls._from_name_startingMonth
1856 return cls(**kwargs)
1858 @property
1859 def rule_code(self):
1860 month = ccalendar.MONTH_ALIASES[self.startingMonth]
1861 return f"{self._prefix}-{month}"
1863 @apply_wraps
1864 def apply(self, other):
1865 # months_since: find the calendar quarter containing other.month,
1866 # e.g. if other.month == 8, the calendar quarter is [Jul, Aug, Sep].
1867 # Then find the month in that quarter containing an is_on_offset date for
1868 # self. `months_since` is the number of months to shift other.month
1869 # to get to this on-offset month.
1870 months_since = other.month % 3 - self.startingMonth % 3
1871 qtrs = liboffsets.roll_qtrday(
1872 other, self.n, self.startingMonth, day_opt=self._day_opt, modby=3
1873 )
1874 months = qtrs * 3 - months_since
1875 return shift_month(other, months, self._day_opt)
1877 def is_on_offset(self, dt):
1878 if self.normalize and not _is_normalized(dt):
1879 return False
1880 mod_month = (dt.month - self.startingMonth) % 3
1881 return mod_month == 0 and dt.day == self._get_offset_day(dt)
1883 @apply_index_wraps
1884 def apply_index(self, dtindex):
1885 shifted = liboffsets.shift_quarters(
1886 dtindex.asi8, self.n, self.startingMonth, self._day_opt
1887 )
1888 # TODO: going through __new__ raises on call to _validate_frequency;
1889 # are we passing incorrect freq?
1890 return type(dtindex)._simple_new(
1891 shifted, freq=dtindex.freq, dtype=dtindex.dtype
1892 )
1895class BQuarterEnd(QuarterOffset):
1896 """
1897 DateOffset increments between business Quarter dates.
1899 startingMonth = 1 corresponds to dates like 1/31/2007, 4/30/2007, ...
1900 startingMonth = 2 corresponds to dates like 2/28/2007, 5/31/2007, ...
1901 startingMonth = 3 corresponds to dates like 3/30/2007, 6/29/2007, ...
1902 """
1904 _outputName = "BusinessQuarterEnd"
1905 _default_startingMonth = 3
1906 _from_name_startingMonth = 12
1907 _prefix = "BQ"
1908 _day_opt = "business_end"
1911# TODO: This is basically the same as BQuarterEnd
1912class BQuarterBegin(QuarterOffset):
1913 _outputName = "BusinessQuarterBegin"
1914 # I suspect this is wrong for *all* of them.
1915 _default_startingMonth = 3
1916 _from_name_startingMonth = 1
1917 _prefix = "BQS"
1918 _day_opt = "business_start"
1921class QuarterEnd(QuarterOffset):
1922 """
1923 DateOffset increments between business Quarter dates.
1925 startingMonth = 1 corresponds to dates like 1/31/2007, 4/30/2007, ...
1926 startingMonth = 2 corresponds to dates like 2/28/2007, 5/31/2007, ...
1927 startingMonth = 3 corresponds to dates like 3/31/2007, 6/30/2007, ...
1928 """
1930 _outputName = "QuarterEnd"
1931 _default_startingMonth = 3
1932 _prefix = "Q"
1933 _day_opt = "end"
1936class QuarterBegin(QuarterOffset):
1937 _outputName = "QuarterBegin"
1938 _default_startingMonth = 3
1939 _from_name_startingMonth = 1
1940 _prefix = "QS"
1941 _day_opt = "start"
1944# ---------------------------------------------------------------------
1945# Year-Based Offset Classes
1948class YearOffset(DateOffset):
1949 """
1950 DateOffset that just needs a month.
1951 """
1953 _adjust_dst = True
1954 _attributes = frozenset(["n", "normalize", "month"])
1956 def _get_offset_day(self, other):
1957 # override BaseOffset method to use self.month instead of other.month
1958 # TODO: there may be a more performant way to do this
1959 return liboffsets.get_day_of_month(
1960 other.replace(month=self.month), self._day_opt
1961 )
1963 @apply_wraps
1964 def apply(self, other):
1965 years = roll_yearday(other, self.n, self.month, self._day_opt)
1966 months = years * 12 + (self.month - other.month)
1967 return shift_month(other, months, self._day_opt)
1969 @apply_index_wraps
1970 def apply_index(self, dtindex):
1971 shifted = liboffsets.shift_quarters(
1972 dtindex.asi8, self.n, self.month, self._day_opt, modby=12
1973 )
1974 # TODO: going through __new__ raises on call to _validate_frequency;
1975 # are we passing incorrect freq?
1976 return type(dtindex)._simple_new(
1977 shifted, freq=dtindex.freq, dtype=dtindex.dtype
1978 )
1980 def is_on_offset(self, dt):
1981 if self.normalize and not _is_normalized(dt):
1982 return False
1983 return dt.month == self.month and dt.day == self._get_offset_day(dt)
1985 def __init__(self, n=1, normalize=False, month=None):
1986 BaseOffset.__init__(self, n, normalize)
1988 month = month if month is not None else self._default_month
1989 object.__setattr__(self, "month", month)
1991 if self.month < 1 or self.month > 12:
1992 raise ValueError("Month must go from 1 to 12")
1994 @classmethod
1995 def _from_name(cls, suffix=None):
1996 kwargs = {}
1997 if suffix:
1998 kwargs["month"] = ccalendar.MONTH_TO_CAL_NUM[suffix]
1999 return cls(**kwargs)
2001 @property
2002 def rule_code(self):
2003 month = ccalendar.MONTH_ALIASES[self.month]
2004 return f"{self._prefix}-{month}"
2007class BYearEnd(YearOffset):
2008 """
2009 DateOffset increments between business EOM dates.
2010 """
2012 _outputName = "BusinessYearEnd"
2013 _default_month = 12
2014 _prefix = "BA"
2015 _day_opt = "business_end"
2018class BYearBegin(YearOffset):
2019 """
2020 DateOffset increments between business year begin dates.
2021 """
2023 _outputName = "BusinessYearBegin"
2024 _default_month = 1
2025 _prefix = "BAS"
2026 _day_opt = "business_start"
2029class YearEnd(YearOffset):
2030 """
2031 DateOffset increments between calendar year ends.
2032 """
2034 _default_month = 12
2035 _prefix = "A"
2036 _day_opt = "end"
2039class YearBegin(YearOffset):
2040 """
2041 DateOffset increments between calendar year begin dates.
2042 """
2044 _default_month = 1
2045 _prefix = "AS"
2046 _day_opt = "start"
2049# ---------------------------------------------------------------------
2050# Special Offset Classes
2053class FY5253(DateOffset):
2054 """
2055 Describes 52-53 week fiscal year. This is also known as a 4-4-5 calendar.
2057 It is used by companies that desire that their
2058 fiscal year always end on the same day of the week.
2060 It is a method of managing accounting periods.
2061 It is a common calendar structure for some industries,
2062 such as retail, manufacturing and parking industry.
2064 For more information see:
2065 http://en.wikipedia.org/wiki/4-4-5_calendar
2067 The year may either:
2069 - end on the last X day of the Y month.
2070 - end on the last X day closest to the last day of the Y month.
2072 X is a specific day of the week.
2073 Y is a certain month of the year
2075 Parameters
2076 ----------
2077 n : int
2078 weekday : int {0, 1, ..., 6}, default 0
2079 A specific integer for the day of the week.
2081 - 0 is Monday
2082 - 1 is Tuesday
2083 - 2 is Wednesday
2084 - 3 is Thursday
2085 - 4 is Friday
2086 - 5 is Saturday
2087 - 6 is Sunday.
2089 startingMonth : int {1, 2, ... 12}, default 1
2090 The month in which the fiscal year ends.
2092 variation : str, default "nearest"
2093 Method of employing 4-4-5 calendar.
2095 There are two options:
2097 - "nearest" means year end is **weekday** closest to last day of month in year.
2098 - "last" means year end is final **weekday** of the final month in fiscal year.
2099 """
2101 _prefix = "RE"
2102 _adjust_dst = True
2103 _attributes = frozenset(["weekday", "startingMonth", "variation"])
2105 def __init__(
2106 self, n=1, normalize=False, weekday=0, startingMonth=1, variation="nearest"
2107 ):
2108 BaseOffset.__init__(self, n, normalize)
2109 object.__setattr__(self, "startingMonth", startingMonth)
2110 object.__setattr__(self, "weekday", weekday)
2112 object.__setattr__(self, "variation", variation)
2114 if self.n == 0:
2115 raise ValueError("N cannot be 0")
2117 if self.variation not in ["nearest", "last"]:
2118 raise ValueError(f"{self.variation} is not a valid variation")
2120 def is_anchored(self):
2121 return (
2122 self.n == 1 and self.startingMonth is not None and self.weekday is not None
2123 )
2125 def is_on_offset(self, dt):
2126 if self.normalize and not _is_normalized(dt):
2127 return False
2128 dt = datetime(dt.year, dt.month, dt.day)
2129 year_end = self.get_year_end(dt)
2131 if self.variation == "nearest":
2132 # We have to check the year end of "this" cal year AND the previous
2133 return year_end == dt or self.get_year_end(shift_month(dt, -1, None)) == dt
2134 else:
2135 return year_end == dt
2137 @apply_wraps
2138 def apply(self, other):
2139 norm = Timestamp(other).normalize()
2141 n = self.n
2142 prev_year = self.get_year_end(datetime(other.year - 1, self.startingMonth, 1))
2143 cur_year = self.get_year_end(datetime(other.year, self.startingMonth, 1))
2144 next_year = self.get_year_end(datetime(other.year + 1, self.startingMonth, 1))
2146 prev_year = conversion.localize_pydatetime(prev_year, other.tzinfo)
2147 cur_year = conversion.localize_pydatetime(cur_year, other.tzinfo)
2148 next_year = conversion.localize_pydatetime(next_year, other.tzinfo)
2150 # Note: next_year.year == other.year + 1, so we will always
2151 # have other < next_year
2152 if norm == prev_year:
2153 n -= 1
2154 elif norm == cur_year:
2155 pass
2156 elif n > 0:
2157 if norm < prev_year:
2158 n -= 2
2159 elif prev_year < norm < cur_year:
2160 n -= 1
2161 elif cur_year < norm < next_year:
2162 pass
2163 else:
2164 if cur_year < norm < next_year:
2165 n += 1
2166 elif prev_year < norm < cur_year:
2167 pass
2168 elif (
2169 norm.year == prev_year.year
2170 and norm < prev_year
2171 and prev_year - norm <= timedelta(6)
2172 ):
2173 # GH#14774, error when next_year.year == cur_year.year
2174 # e.g. prev_year == datetime(2004, 1, 3),
2175 # other == datetime(2004, 1, 1)
2176 n -= 1
2177 else:
2178 assert False
2180 shifted = datetime(other.year + n, self.startingMonth, 1)
2181 result = self.get_year_end(shifted)
2182 result = datetime(
2183 result.year,
2184 result.month,
2185 result.day,
2186 other.hour,
2187 other.minute,
2188 other.second,
2189 other.microsecond,
2190 )
2191 return result
2193 def get_year_end(self, dt):
2194 assert dt.tzinfo is None
2196 dim = ccalendar.get_days_in_month(dt.year, self.startingMonth)
2197 target_date = datetime(dt.year, self.startingMonth, dim)
2198 wkday_diff = self.weekday - target_date.weekday()
2199 if wkday_diff == 0:
2200 # year_end is the same for "last" and "nearest" cases
2201 return target_date
2203 if self.variation == "last":
2204 days_forward = (wkday_diff % 7) - 7
2206 # days_forward is always negative, so we always end up
2207 # in the same year as dt
2208 return target_date + timedelta(days=days_forward)
2209 else:
2210 # variation == "nearest":
2211 days_forward = wkday_diff % 7
2212 if days_forward <= 3:
2213 # The upcoming self.weekday is closer than the previous one
2214 return target_date + timedelta(days_forward)
2215 else:
2216 # The previous self.weekday is closer than the upcoming one
2217 return target_date + timedelta(days_forward - 7)
2219 @property
2220 def rule_code(self):
2221 prefix = self._prefix
2222 suffix = self.get_rule_code_suffix()
2223 return f"{prefix}-{suffix}"
2225 def _get_suffix_prefix(self):
2226 if self.variation == "nearest":
2227 return "N"
2228 else:
2229 return "L"
2231 def get_rule_code_suffix(self):
2232 prefix = self._get_suffix_prefix()
2233 month = ccalendar.MONTH_ALIASES[self.startingMonth]
2234 weekday = ccalendar.int_to_weekday[self.weekday]
2235 return f"{prefix}-{month}-{weekday}"
2237 @classmethod
2238 def _parse_suffix(cls, varion_code, startingMonth_code, weekday_code):
2239 if varion_code == "N":
2240 variation = "nearest"
2241 elif varion_code == "L":
2242 variation = "last"
2243 else:
2244 raise ValueError(f"Unable to parse varion_code: {varion_code}")
2246 startingMonth = ccalendar.MONTH_TO_CAL_NUM[startingMonth_code]
2247 weekday = ccalendar.weekday_to_int[weekday_code]
2249 return {
2250 "weekday": weekday,
2251 "startingMonth": startingMonth,
2252 "variation": variation,
2253 }
2255 @classmethod
2256 def _from_name(cls, *args):
2257 return cls(**cls._parse_suffix(*args))
2260class FY5253Quarter(DateOffset):
2261 """
2262 DateOffset increments between business quarter dates
2263 for 52-53 week fiscal year (also known as a 4-4-5 calendar).
2265 It is used by companies that desire that their
2266 fiscal year always end on the same day of the week.
2268 It is a method of managing accounting periods.
2269 It is a common calendar structure for some industries,
2270 such as retail, manufacturing and parking industry.
2272 For more information see:
2273 http://en.wikipedia.org/wiki/4-4-5_calendar
2275 The year may either:
2277 - end on the last X day of the Y month.
2278 - end on the last X day closest to the last day of the Y month.
2280 X is a specific day of the week.
2281 Y is a certain month of the year
2283 startingMonth = 1 corresponds to dates like 1/31/2007, 4/30/2007, ...
2284 startingMonth = 2 corresponds to dates like 2/28/2007, 5/31/2007, ...
2285 startingMonth = 3 corresponds to dates like 3/30/2007, 6/29/2007, ...
2287 Parameters
2288 ----------
2289 n : int
2290 weekday : int {0, 1, ..., 6}, default 0
2291 A specific integer for the day of the week.
2293 - 0 is Monday
2294 - 1 is Tuesday
2295 - 2 is Wednesday
2296 - 3 is Thursday
2297 - 4 is Friday
2298 - 5 is Saturday
2299 - 6 is Sunday.
2301 startingMonth : int {1, 2, ..., 12}, default 1
2302 The month in which fiscal years end.
2304 qtr_with_extra_week : int {1, 2, 3, 4}, default 1
2305 The quarter number that has the leap or 14 week when needed.
2307 variation : str, default "nearest"
2308 Method of employing 4-4-5 calendar.
2310 There are two options:
2312 - "nearest" means year end is **weekday** closest to last day of month in year.
2313 - "last" means year end is final **weekday** of the final month in fiscal year.
2314 """
2316 _prefix = "REQ"
2317 _adjust_dst = True
2318 _attributes = frozenset(
2319 ["weekday", "startingMonth", "qtr_with_extra_week", "variation"]
2320 )
2322 def __init__(
2323 self,
2324 n=1,
2325 normalize=False,
2326 weekday=0,
2327 startingMonth=1,
2328 qtr_with_extra_week=1,
2329 variation="nearest",
2330 ):
2331 BaseOffset.__init__(self, n, normalize)
2333 object.__setattr__(self, "startingMonth", startingMonth)
2334 object.__setattr__(self, "weekday", weekday)
2335 object.__setattr__(self, "qtr_with_extra_week", qtr_with_extra_week)
2336 object.__setattr__(self, "variation", variation)
2338 if self.n == 0:
2339 raise ValueError("N cannot be 0")
2341 @cache_readonly
2342 def _offset(self):
2343 return FY5253(
2344 startingMonth=self.startingMonth,
2345 weekday=self.weekday,
2346 variation=self.variation,
2347 )
2349 def is_anchored(self):
2350 return self.n == 1 and self._offset.is_anchored()
2352 def _rollback_to_year(self, other):
2353 """
2354 Roll `other` back to the most recent date that was on a fiscal year
2355 end.
2357 Return the date of that year-end, the number of full quarters
2358 elapsed between that year-end and other, and the remaining Timedelta
2359 since the most recent quarter-end.
2361 Parameters
2362 ----------
2363 other : datetime or Timestamp
2365 Returns
2366 -------
2367 tuple of
2368 prev_year_end : Timestamp giving most recent fiscal year end
2369 num_qtrs : int
2370 tdelta : Timedelta
2371 """
2372 num_qtrs = 0
2374 norm = Timestamp(other).tz_localize(None)
2375 start = self._offset.rollback(norm)
2376 # Note: start <= norm and self._offset.is_on_offset(start)
2378 if start < norm:
2379 # roll adjustment
2380 qtr_lens = self.get_weeks(norm)
2382 # check thet qtr_lens is consistent with self._offset addition
2383 end = liboffsets.shift_day(start, days=7 * sum(qtr_lens))
2384 assert self._offset.is_on_offset(end), (start, end, qtr_lens)
2386 tdelta = norm - start
2387 for qlen in qtr_lens:
2388 if qlen * 7 <= tdelta.days:
2389 num_qtrs += 1
2390 tdelta -= Timedelta(days=qlen * 7)
2391 else:
2392 break
2393 else:
2394 tdelta = Timedelta(0)
2396 # Note: we always have tdelta.value >= 0
2397 return start, num_qtrs, tdelta
2399 @apply_wraps
2400 def apply(self, other):
2401 # Note: self.n == 0 is not allowed.
2402 n = self.n
2404 prev_year_end, num_qtrs, tdelta = self._rollback_to_year(other)
2405 res = prev_year_end
2406 n += num_qtrs
2407 if self.n <= 0 and tdelta.value > 0:
2408 n += 1
2410 # Possible speedup by handling years first.
2411 years = n // 4
2412 if years:
2413 res += self._offset * years
2414 n -= years * 4
2416 # Add an extra day to make *sure* we are getting the quarter lengths
2417 # for the upcoming year, not the previous year
2418 qtr_lens = self.get_weeks(res + Timedelta(days=1))
2420 # Note: we always have 0 <= n < 4
2421 weeks = sum(qtr_lens[:n])
2422 if weeks:
2423 res = liboffsets.shift_day(res, days=weeks * 7)
2425 return res
2427 def get_weeks(self, dt):
2428 ret = [13] * 4
2430 year_has_extra_week = self.year_has_extra_week(dt)
2432 if year_has_extra_week:
2433 ret[self.qtr_with_extra_week - 1] = 14
2435 return ret
2437 def year_has_extra_week(self, dt):
2438 # Avoid round-down errors --> normalize to get
2439 # e.g. '370D' instead of '360D23H'
2440 norm = Timestamp(dt).normalize().tz_localize(None)
2442 next_year_end = self._offset.rollforward(norm)
2443 prev_year_end = norm - self._offset
2444 weeks_in_year = (next_year_end - prev_year_end).days / 7
2445 assert weeks_in_year in [52, 53], weeks_in_year
2446 return weeks_in_year == 53
2448 def is_on_offset(self, dt):
2449 if self.normalize and not _is_normalized(dt):
2450 return False
2451 if self._offset.is_on_offset(dt):
2452 return True
2454 next_year_end = dt - self._offset
2456 qtr_lens = self.get_weeks(dt)
2458 current = next_year_end
2459 for qtr_len in qtr_lens:
2460 current = liboffsets.shift_day(current, days=qtr_len * 7)
2461 if dt == current:
2462 return True
2463 return False
2465 @property
2466 def rule_code(self):
2467 suffix = self._offset.get_rule_code_suffix()
2468 qtr = self.qtr_with_extra_week
2469 return f"{self._prefix}-{suffix}-{qtr}"
2471 @classmethod
2472 def _from_name(cls, *args):
2473 return cls(
2474 **dict(FY5253._parse_suffix(*args[:-1]), qtr_with_extra_week=int(args[-1]))
2475 )
2478class Easter(DateOffset):
2479 """
2480 DateOffset for the Easter holiday using logic defined in dateutil.
2482 Right now uses the revised method which is valid in years 1583-4099.
2483 """
2485 _adjust_dst = True
2486 _attributes = frozenset(["n", "normalize"])
2488 __init__ = BaseOffset.__init__
2490 @apply_wraps
2491 def apply(self, other):
2492 current_easter = easter(other.year)
2493 current_easter = datetime(
2494 current_easter.year, current_easter.month, current_easter.day
2495 )
2496 current_easter = conversion.localize_pydatetime(current_easter, other.tzinfo)
2498 n = self.n
2499 if n >= 0 and other < current_easter:
2500 n -= 1
2501 elif n < 0 and other > current_easter:
2502 n += 1
2503 # TODO: Why does this handle the 0 case the opposite of others?
2505 # NOTE: easter returns a datetime.date so we have to convert to type of
2506 # other
2507 new = easter(other.year + n)
2508 new = datetime(
2509 new.year,
2510 new.month,
2511 new.day,
2512 other.hour,
2513 other.minute,
2514 other.second,
2515 other.microsecond,
2516 )
2517 return new
2519 def is_on_offset(self, dt):
2520 if self.normalize and not _is_normalized(dt):
2521 return False
2522 return date(dt.year, dt.month, dt.day) == easter(dt.year)
2525# ---------------------------------------------------------------------
2526# Ticks
2529def _tick_comp(op):
2530 assert op not in [operator.eq, operator.ne]
2532 def f(self, other):
2533 try:
2534 return op(self.delta, other.delta)
2535 except AttributeError:
2536 # comparing with a non-Tick object
2537 raise TypeError(
2538 f"Invalid comparison between {type(self).__name__} "
2539 f"and {type(other).__name__}"
2540 )
2542 f.__name__ = f"__{op.__name__}__"
2543 return f
2546class Tick(liboffsets._Tick, SingleConstructorOffset):
2547 _inc = Timedelta(microseconds=1000)
2548 _prefix = "undefined"
2549 _attributes = frozenset(["n", "normalize"])
2551 def __init__(self, n=1, normalize=False):
2552 BaseOffset.__init__(self, n, normalize)
2553 if normalize:
2554 raise ValueError(
2555 "Tick offset with `normalize=True` are not allowed."
2556 ) # GH#21427
2558 __gt__ = _tick_comp(operator.gt)
2559 __ge__ = _tick_comp(operator.ge)
2560 __lt__ = _tick_comp(operator.lt)
2561 __le__ = _tick_comp(operator.le)
2563 def __add__(self, other):
2564 if isinstance(other, Tick):
2565 if type(self) == type(other):
2566 return type(self)(self.n + other.n)
2567 else:
2568 return _delta_to_tick(self.delta + other.delta)
2569 elif isinstance(other, Period):
2570 return other + self
2571 try:
2572 return self.apply(other)
2573 except ApplyTypeError:
2574 return NotImplemented
2575 except OverflowError:
2576 raise OverflowError(
2577 f"the add operation between {self} and {other} will overflow"
2578 )
2580 def __eq__(self, other: Any) -> bool:
2581 if isinstance(other, str):
2582 from pandas.tseries.frequencies import to_offset
2584 try:
2585 # GH#23524 if to_offset fails, we are dealing with an
2586 # incomparable type so == is False and != is True
2587 other = to_offset(other)
2588 except ValueError:
2589 # e.g. "infer"
2590 return False
2592 if isinstance(other, Tick):
2593 return self.delta == other.delta
2594 else:
2595 return False
2597 # This is identical to DateOffset.__hash__, but has to be redefined here
2598 # for Python 3, because we've redefined __eq__.
2599 def __hash__(self):
2600 return hash(self._params)
2602 def __ne__(self, other):
2603 if isinstance(other, str):
2604 from pandas.tseries.frequencies import to_offset
2606 try:
2607 # GH#23524 if to_offset fails, we are dealing with an
2608 # incomparable type so == is False and != is True
2609 other = to_offset(other)
2610 except ValueError:
2611 # e.g. "infer"
2612 return True
2614 if isinstance(other, Tick):
2615 return self.delta != other.delta
2616 else:
2617 return True
2619 @property
2620 def delta(self):
2621 return self.n * self._inc
2623 @property
2624 def nanos(self):
2625 return delta_to_nanoseconds(self.delta)
2627 # TODO: Should Tick have its own apply_index?
2628 def apply(self, other):
2629 # Timestamp can handle tz and nano sec, thus no need to use apply_wraps
2630 if isinstance(other, Timestamp):
2632 # GH 15126
2633 # in order to avoid a recursive
2634 # call of __add__ and __radd__ if there is
2635 # an exception, when we call using the + operator,
2636 # we directly call the known method
2637 result = other.__add__(self)
2638 if result is NotImplemented:
2639 raise OverflowError
2640 return result
2641 elif isinstance(other, (datetime, np.datetime64, date)):
2642 return as_timestamp(other) + self
2644 if isinstance(other, timedelta):
2645 return other + self.delta
2646 elif isinstance(other, type(self)):
2647 return type(self)(self.n + other.n)
2649 raise ApplyTypeError(f"Unhandled type: {type(other).__name__}")
2651 def is_anchored(self):
2652 return False
2655def _delta_to_tick(delta):
2656 if delta.microseconds == 0 and getattr(delta, "nanoseconds", 0) == 0:
2657 # nanoseconds only for pd.Timedelta
2658 if delta.seconds == 0:
2659 return Day(delta.days)
2660 else:
2661 seconds = delta.days * 86400 + delta.seconds
2662 if seconds % 3600 == 0:
2663 return Hour(seconds / 3600)
2664 elif seconds % 60 == 0:
2665 return Minute(seconds / 60)
2666 else:
2667 return Second(seconds)
2668 else:
2669 nanos = delta_to_nanoseconds(delta)
2670 if nanos % 1000000 == 0:
2671 return Milli(nanos // 1000000)
2672 elif nanos % 1000 == 0:
2673 return Micro(nanos // 1000)
2674 else: # pragma: no cover
2675 return Nano(nanos)
2678class Day(Tick):
2679 _inc = Timedelta(days=1)
2680 _prefix = "D"
2683class Hour(Tick):
2684 _inc = Timedelta(hours=1)
2685 _prefix = "H"
2688class Minute(Tick):
2689 _inc = Timedelta(minutes=1)
2690 _prefix = "T"
2693class Second(Tick):
2694 _inc = Timedelta(seconds=1)
2695 _prefix = "S"
2698class Milli(Tick):
2699 _inc = Timedelta(milliseconds=1)
2700 _prefix = "L"
2703class Micro(Tick):
2704 _inc = Timedelta(microseconds=1)
2705 _prefix = "U"
2708class Nano(Tick):
2709 _inc = Timedelta(nanoseconds=1)
2710 _prefix = "N"
2713BDay = BusinessDay
2714BMonthEnd = BusinessMonthEnd
2715BMonthBegin = BusinessMonthBegin
2716CBMonthEnd = CustomBusinessMonthEnd
2717CBMonthBegin = CustomBusinessMonthBegin
2718CDay = CustomBusinessDay
2720# ---------------------------------------------------------------------
2723def generate_range(start=None, end=None, periods=None, offset=BDay()):
2724 """
2725 Generates a sequence of dates corresponding to the specified time
2726 offset. Similar to dateutil.rrule except uses pandas DateOffset
2727 objects to represent time increments.
2729 Parameters
2730 ----------
2731 start : datetime, (default None)
2732 end : datetime, (default None)
2733 periods : int, (default None)
2734 offset : DateOffset, (default BDay())
2736 Notes
2737 -----
2738 * This method is faster for generating weekdays than dateutil.rrule
2739 * At least two of (start, end, periods) must be specified.
2740 * If both start and end are specified, the returned dates will
2741 satisfy start <= date <= end.
2743 Returns
2744 -------
2745 dates : generator object
2746 """
2747 from pandas.tseries.frequencies import to_offset
2749 offset = to_offset(offset)
2751 start = Timestamp(start)
2752 start = start if start is not NaT else None
2753 end = Timestamp(end)
2754 end = end if end is not NaT else None
2756 if start and not offset.is_on_offset(start):
2757 start = offset.rollforward(start)
2759 elif end and not offset.is_on_offset(end):
2760 end = offset.rollback(end)
2762 if periods is None and end < start and offset.n >= 0:
2763 end = None
2764 periods = 0
2766 if end is None:
2767 end = start + (periods - 1) * offset
2769 if start is None:
2770 start = end - (periods - 1) * offset
2772 cur = start
2773 if offset.n >= 0:
2774 while cur <= end:
2775 yield cur
2777 if cur == end:
2778 # GH#24252 avoid overflows by not performing the addition
2779 # in offset.apply unless we have to
2780 break
2782 # faster than cur + offset
2783 next_date = offset.apply(cur)
2784 if next_date <= cur:
2785 raise ValueError(f"Offset {offset} did not increment date")
2786 cur = next_date
2787 else:
2788 while cur >= end:
2789 yield cur
2791 if cur == end:
2792 # GH#24252 avoid overflows by not performing the addition
2793 # in offset.apply unless we have to
2794 break
2796 # faster than cur + offset
2797 next_date = offset.apply(cur)
2798 if next_date >= cur:
2799 raise ValueError(f"Offset {offset} did not decrement date")
2800 cur = next_date
2803prefix_mapping = {
2804 offset._prefix: offset
2805 for offset in [
2806 YearBegin, # 'AS'
2807 YearEnd, # 'A'
2808 BYearBegin, # 'BAS'
2809 BYearEnd, # 'BA'
2810 BusinessDay, # 'B'
2811 BusinessMonthBegin, # 'BMS'
2812 BusinessMonthEnd, # 'BM'
2813 BQuarterEnd, # 'BQ'
2814 BQuarterBegin, # 'BQS'
2815 BusinessHour, # 'BH'
2816 CustomBusinessDay, # 'C'
2817 CustomBusinessMonthEnd, # 'CBM'
2818 CustomBusinessMonthBegin, # 'CBMS'
2819 CustomBusinessHour, # 'CBH'
2820 MonthEnd, # 'M'
2821 MonthBegin, # 'MS'
2822 Nano, # 'N'
2823 SemiMonthEnd, # 'SM'
2824 SemiMonthBegin, # 'SMS'
2825 Week, # 'W'
2826 Second, # 'S'
2827 Minute, # 'T'
2828 Micro, # 'U'
2829 QuarterEnd, # 'Q'
2830 QuarterBegin, # 'QS'
2831 Milli, # 'L'
2832 Hour, # 'H'
2833 Day, # 'D'
2834 WeekOfMonth, # 'WOM'
2835 FY5253,
2836 FY5253Quarter,
2837 ]
2838}