opendate 0.1.13__py3-none-any.whl → 0.1.20__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of opendate might be problematic. Click here for more details.

date/date.py CHANGED
@@ -2,13 +2,14 @@ import calendar
2
2
  import contextlib
3
3
  import datetime as _datetime
4
4
  import logging
5
+ import operator
5
6
  import os
6
7
  import re
7
8
  import time
8
9
  import warnings
9
10
  import zoneinfo as _zoneinfo
10
11
  from abc import ABC, abstractmethod
11
- from collections.abc import Callable, Sequence
12
+ from collections.abc import Callable, Iterator, Sequence
12
13
  from functools import lru_cache, partial, wraps
13
14
  from typing import Self
14
15
 
@@ -26,7 +27,6 @@ __all__ = [
26
27
  'Date',
27
28
  'DateTime',
28
29
  'Interval',
29
- 'IntervalError',
30
30
  'Time',
31
31
  'Timezone',
32
32
  'EST',
@@ -39,9 +39,12 @@ __all__ = [
39
39
  'prefer_utc_timezone',
40
40
  'expect_date',
41
41
  'expect_datetime',
42
+ 'expect_time',
43
+ 'expect_date_or_datetime',
42
44
  'Entity',
43
- 'NYSE'
45
+ 'NYSE',
44
46
  'WEEKDAY_SHORTNAME',
47
+ 'WeekDay',
45
48
  ]
46
49
 
47
50
 
@@ -49,27 +52,8 @@ def Timezone(name:str = 'US/Eastern') -> _zoneinfo.ZoneInfo:
49
52
  """Create a timezone object with the specified name.
50
53
 
51
54
  Simple wrapper around Pendulum's Timezone function that ensures
52
- consistent timezone handling across the library.
53
-
54
- Parameters
55
- name: Timezone name (e.g., 'US/Eastern', 'UTC')
56
-
57
- Returns
58
- A timezone object for the specified timezone
59
-
60
- Examples
61
-
62
- US/Eastern is equivalent to America/New_York:
63
- >>> winter1 = DateTime(2000, 1, 1, 12, tzinfo=Timezone('US/Eastern'))
64
- >>> winter2 = DateTime(2000, 1, 1, 12, tzinfo=Timezone('America/New_York'))
65
- >>> winter1 == winter2
66
- True
67
-
68
- This works in both summer and winter:
69
- >>> summer1 = DateTime(2000, 7, 1, 12, tzinfo=Timezone('US/Eastern'))
70
- >>> summer2 = DateTime(2000, 7, 1, 12, tzinfo=Timezone('America/New_York'))
71
- >>> summer1 == summer2
72
- True
55
+ consistent timezone handling across the library. Note that 'US/Eastern'
56
+ is equivalent to 'America/New_York' for all dates.
73
57
  """
74
58
  return _pendulum.tz.Timezone(name)
75
59
 
@@ -125,22 +109,39 @@ DATEMATCH = re.compile(r'^(?P<d>N|T|Y|P|M)(?P<n>[-+]?\d+)?(?P<b>b?)?$')
125
109
  # return entity
126
110
 
127
111
 
128
- def isdateish(x):
129
- return isinstance(x, _datetime.date | _datetime.datetime | pd.Timestamp | np.datetime64)
112
+ def isdateish(x) -> bool:
113
+ return isinstance(x, _datetime.date | _datetime.datetime | _datetime.time | pd.Timestamp | np.datetime64)
130
114
 
131
115
 
132
116
  def parse_arg(typ, arg):
133
- if isdateish(arg):
134
- if typ == _datetime.datetime:
117
+ """Parse argument to specified type or 'smart' to preserve Date/DateTime.
118
+ """
119
+ if not isdateish(arg):
120
+ return arg
121
+
122
+ if typ == 'smart':
123
+ if isinstance(arg, Date | DateTime):
124
+ return arg
125
+ if isinstance(arg, _datetime.datetime | pd.Timestamp | np.datetime64):
135
126
  return DateTime.instance(arg)
136
- if typ == _datetime.date:
127
+ if isinstance(arg, _datetime.date):
137
128
  return Date.instance(arg)
138
- if typ == _datetime.time:
129
+ if isinstance(arg, _datetime.time):
139
130
  return Time.instance(arg)
131
+ return arg
132
+
133
+ if typ == _datetime.datetime:
134
+ return DateTime.instance(arg)
135
+ if typ == _datetime.date:
136
+ return Date.instance(arg)
137
+ if typ == _datetime.time:
138
+ return Time.instance(arg)
140
139
  return arg
141
140
 
142
141
 
143
142
  def parse_args(typ, *args):
143
+ """Parse args to specified type or 'smart' mode.
144
+ """
144
145
  this = []
145
146
  for a in args:
146
147
  if isinstance(a, Sequence) and not isinstance(a, str):
@@ -150,27 +151,31 @@ def parse_args(typ, *args):
150
151
  return this
151
152
 
152
153
 
153
- def expect(func, typ: type[_datetime.date], exclkw: bool = False) -> Callable:
154
- """Decorator to force input type of date/datetime inputs
154
+ def expect(func=None, *, typ: type[_datetime.date] | str = None, exclkw: bool = False) -> Callable:
155
+ """Decorator to force input type of date/datetime inputs.
156
+
157
+ typ can be _datetime.date, _datetime.datetime, _datetime.time, or 'smart'
155
158
  """
156
- @wraps(func)
157
- def wrapper(*args, **kwargs):
158
- args = parse_args(typ, *args)
159
- if not exclkw:
160
- for k, v in kwargs.items():
161
- if isdateish(v):
162
- if typ == _datetime.datetime:
163
- kwargs[k] = DateTime.instance(v)
164
- continue
165
- if typ == _datetime.date:
166
- kwargs[k] = Date.instance(v)
167
- return func(*args, **kwargs)
168
- return wrapper
159
+ def decorator(func):
160
+ @wraps(func)
161
+ def wrapper(*args, **kwargs):
162
+ args = parse_args(typ, *args)
163
+ if not exclkw:
164
+ for k, v in kwargs.items():
165
+ if isdateish(v):
166
+ kwargs[k] = parse_arg(typ, v)
167
+ return func(*args, **kwargs)
168
+ return wrapper
169
+
170
+ if func is None:
171
+ return decorator
172
+ return decorator(func)
169
173
 
170
174
 
171
175
  expect_date = partial(expect, typ=_datetime.date)
172
176
  expect_datetime = partial(expect, typ=_datetime.datetime)
173
177
  expect_time = partial(expect, typ=_datetime.time)
178
+ expect_date_or_datetime = partial(expect, typ='smart')
174
179
 
175
180
 
176
181
  def type_class(typ, obj):
@@ -218,7 +223,49 @@ def store_both(func=None, *, typ=None):
218
223
  return wrapper
219
224
 
220
225
 
221
- def prefer_utc_timezone(func, force:bool = False):
226
+ def reset_business(func):
227
+ """Decorator to reset business mode after function execution.
228
+ """
229
+ @wraps(func)
230
+ def wrapper(self, *args, **kwargs):
231
+ try:
232
+ return func(self, *args, **kwargs)
233
+ finally:
234
+ self._business = False
235
+ self._start._business = False
236
+ self._end._business = False
237
+ return wrapper
238
+
239
+
240
+ def normalize_date_datetime_pairs(func):
241
+ """Decorator to normalize mixed Date/DateTime pairs to DateTime.
242
+ """
243
+ @wraps(func)
244
+ def wrapper(*args, **kwargs):
245
+ if len(args) >= 3:
246
+ cls_or_self, begdate, enddate = args[0], args[1], args[2]
247
+ rest_args = args[3:]
248
+
249
+ tz = UTC
250
+ if isinstance(begdate, DateTime) and begdate.tzinfo:
251
+ tz = begdate.tzinfo
252
+ elif isinstance(enddate, DateTime) and enddate.tzinfo:
253
+ tz = enddate.tzinfo
254
+
255
+ if isinstance(begdate, Date) and not isinstance(begdate, DateTime):
256
+ if isinstance(enddate, DateTime):
257
+ begdate = DateTime(begdate.year, begdate.month, begdate.day, tzinfo=tz)
258
+ elif isinstance(enddate, Date) and not isinstance(enddate, DateTime):
259
+ if isinstance(begdate, DateTime):
260
+ enddate = DateTime(enddate.year, enddate.month, enddate.day, tzinfo=tz)
261
+
262
+ args = (cls_or_self, begdate, enddate) + rest_args
263
+
264
+ return func(*args, **kwargs)
265
+ return wrapper
266
+
267
+
268
+ def prefer_utc_timezone(func, force:bool = False) -> Callable:
222
269
  """Return datetime as UTC.
223
270
  """
224
271
  @wraps(func)
@@ -232,7 +279,7 @@ def prefer_utc_timezone(func, force:bool = False):
232
279
  return wrapper
233
280
 
234
281
 
235
- def prefer_native_timezone(func, force:bool = False):
282
+ def prefer_native_timezone(func, force:bool = False) -> Callable:
236
283
  """Return datetime as native.
237
284
  """
238
285
  @wraps(func)
@@ -316,7 +363,7 @@ class NYSE(Entity):
316
363
  def business_holidays(begdate=BEGDATE, enddate=ENDDATE) -> set:
317
364
  return {Date.instance(d.date())
318
365
  for d in map(pd.to_datetime, NYSE.calendar.holidays().holidays)
319
- if begdate <= d <= enddate}
366
+ if begdate <= d.date() <= enddate}
320
367
 
321
368
 
322
369
  class DateBusinessMixin:
@@ -488,63 +535,21 @@ class DateBusinessMixin:
488
535
 
489
536
  @expect_date
490
537
  def business_open(self) -> bool:
491
- """Business open
492
-
493
- >>> thedate = Date(2021, 4, 19) # Monday
494
- >>> thedate.business_open()
495
- True
496
- >>> thedate = Date(2021, 4, 17) # Saturday
497
- >>> thedate.business_open()
498
- False
499
- >>> thedate = Date(2021, 1, 18) # MLK Day
500
- >>> thedate.business_open()
501
- False
538
+ """Check if the date is a business day (market is open).
502
539
  """
503
540
  return self.is_business_day()
504
541
 
505
542
  @expect_date
506
543
  def is_business_day(self) -> bool:
507
- """Is business date.
508
-
509
- >>> thedate = Date(2021, 4, 19) # Monday
510
- >>> thedate.is_business_day()
511
- True
512
- >>> thedate = Date(2021, 4, 17) # Saturday
513
- >>> thedate.is_business_day()
514
- False
515
- >>> thedate = Date(2021, 1, 18) # MLK Day
516
- >>> thedate.is_business_day()
517
- False
518
- >>> thedate = Date(2021, 11, 25) # Thanksgiving
519
- >>> thedate.is_business_day()
520
- False
521
- >>> thedate = Date(2021, 11, 26) # Day after ^
522
- >>> thedate.is_business_day()
523
- True
544
+ """Check if the date is a business day according to the entity calendar.
524
545
  """
525
546
  return self in self._entity.business_days()
526
547
 
527
548
  @expect_date
528
549
  def business_hours(self) -> 'tuple[DateTime, DateTime]':
529
- """Business hours
530
-
531
- Returns (None, None) if not a business day
550
+ """Get market open and close times for this date.
532
551
 
533
- >>> thedate = Date(2023, 1, 5)
534
- >>> thedate.business_hours()
535
- (... 9, 30, ... 16, 0, ...)
536
-
537
- >>> thedate = Date(2023, 7, 3)
538
- >>> thedate.business_hours()
539
- (... 9, 30, ... 13, 0, ...)
540
-
541
- >>> thedate = Date(2023, 11, 24)
542
- >>> thedate.business_hours()
543
- (... 9, 30, ... 13, 0, ...)
544
-
545
- >>> thedate = Date(2024, 5, 27) # memorial day
546
- >>> thedate.business_hours()
547
- (None, None)
552
+ Returns (None, None) if not a business day.
548
553
  """
549
554
  return self._entity.business_hours(self, self)\
550
555
  .get(self, (None, None))
@@ -593,34 +598,25 @@ class DateBusinessMixin:
593
598
  class DateExtrasMixin:
594
599
  """Extended date functionality not provided by Pendulum.
595
600
 
601
+ .. note::
602
+ This mixin exists primarily for legacy backward compatibility.
603
+ New code should prefer using built-in methods where possible.
604
+
596
605
  This mixin provides additional date utilities primarily focused on:
597
606
  - Financial date calculations (nearest month start/end)
598
607
  - Weekday-oriented date navigation
599
608
  - Relative date lookups
600
609
 
601
- These methods extend Pendulum's functionality with features commonly
610
+ These methods extend OpenDate functionality with features commonly
602
611
  needed in financial applications and reporting scenarios.
603
612
  """
604
613
 
605
- def nearest_start_of_month(self):
606
- """Get `nearest` start of month
607
-
608
- 1/1/2015 -> Thursday (New Year's Day)
609
- 2/1/2015 -> Sunday
610
-
611
- >>> from date import Date
612
- >>> Date(2015, 1, 1).nearest_start_of_month()
613
- Date(2015, 1, 1)
614
- >>> Date(2015, 1, 15).nearest_start_of_month()
615
- Date(2015, 1, 1)
616
- >>> Date(2015, 1, 15).b.nearest_start_of_month()
617
- Date(2015, 1, 2)
618
- >>> Date(2015, 1, 16).nearest_start_of_month()
619
- Date(2015, 2, 1)
620
- >>> Date(2015, 1, 31).nearest_start_of_month()
621
- Date(2015, 2, 1)
622
- >>> Date(2015, 1, 31).b.nearest_start_of_month()
623
- Date(2015, 2, 2)
614
+ def nearest_start_of_month(self) -> Self:
615
+ """Get the nearest start of month.
616
+
617
+ If day <= 15, returns start of current month.
618
+ If day > 15, returns start of next month.
619
+ In business mode, adjusts to next business day if needed.
624
620
  """
625
621
  _business = self._business
626
622
  self._business = False
@@ -634,25 +630,12 @@ class DateExtrasMixin:
634
630
  return d.business().add(days=1)
635
631
  return d
636
632
 
637
- def nearest_end_of_month(self):
638
- """Get `nearest` end of month
639
-
640
- 12/31/2014 -> Wednesday
641
- 1/31/2015 -> Saturday
642
-
643
- >>> from date import Date
644
- >>> Date(2015, 1, 1).nearest_end_of_month()
645
- Date(2014, 12, 31)
646
- >>> Date(2015, 1, 15).nearest_end_of_month()
647
- Date(2014, 12, 31)
648
- >>> Date(2015, 1, 15).b.nearest_end_of_month()
649
- Date(2014, 12, 31)
650
- >>> Date(2015, 1, 16).nearest_end_of_month()
651
- Date(2015, 1, 31)
652
- >>> Date(2015, 1, 31).nearest_end_of_month()
653
- Date(2015, 1, 31)
654
- >>> Date(2015, 1, 31).b.nearest_end_of_month()
655
- Date(2015, 1, 30)
633
+ def nearest_end_of_month(self) -> Self:
634
+ """Get the nearest end of month.
635
+
636
+ If day <= 15, returns end of previous month.
637
+ If day > 15, returns end of current month.
638
+ In business mode, adjusts to previous business day if needed.
656
639
  """
657
640
  _business = self._business
658
641
  self._business = False
@@ -666,48 +649,27 @@ class DateExtrasMixin:
666
649
  return d.business().subtract(days=1)
667
650
  return d
668
651
 
669
- def next_relative_date_of_week_by_day(self, day='MO'):
670
- """Get next relative day of week by relativedelta code
671
-
672
- >>> from date import Date
673
- >>> Date(2020, 5, 18).next_relative_date_of_week_by_day('SU')
674
- Date(2020, 5, 24)
675
- >>> Date(2020, 5, 24).next_relative_date_of_week_by_day('SU')
676
- Date(2020, 5, 24)
652
+ def next_relative_date_of_week_by_day(self, day='MO') -> Self:
653
+ """Get next occurrence of the specified weekday (or current date if already that day).
677
654
  """
678
655
  if self.weekday() == WEEKDAY_SHORTNAME.get(day):
679
656
  return self
680
657
  return self.next(WEEKDAY_SHORTNAME.get(day))
681
658
 
682
- def weekday_or_previous_friday(self):
683
- """Return the date if it is a weekday, else previous Friday
684
-
685
- >>> from date import Date
686
- >>> Date(2019, 10, 6).weekday_or_previous_friday() # Sunday
687
- Date(2019, 10, 4)
688
- >>> Date(2019, 10, 5).weekday_or_previous_friday() # Saturday
689
- Date(2019, 10, 4)
690
- >>> Date(2019, 10, 4).weekday_or_previous_friday() # Friday
691
- Date(2019, 10, 4)
692
- >>> Date(2019, 10, 3).weekday_or_previous_friday() # Thursday
693
- Date(2019, 10, 3)
659
+ def weekday_or_previous_friday(self) -> Self:
660
+ """Return the date if it is a weekday, otherwise return the previous Friday.
694
661
  """
695
- dnum = self.weekday()
696
- if dnum in {WeekDay.SATURDAY, WeekDay.SUNDAY}:
697
- return self.subtract(days=dnum - 4)
662
+ if self.weekday() in {WeekDay.SATURDAY, WeekDay.SUNDAY}:
663
+ return self.previous(WeekDay.FRIDAY)
698
664
  return self
699
665
 
700
- """
701
- create a simple nth weekday function that accounts for
702
- [1,2,3,4] and weekday as options
703
- or weekday, [1,2,3,4]
704
-
705
- """
706
-
707
666
  @classmethod
708
- def third_wednesday(cls, year, month):
667
+ def third_wednesday(cls, year, month) -> Self:
709
668
  """Calculate the date of the third Wednesday in a given month/year.
710
669
 
670
+ .. deprecated::
671
+ Use Date(year, month, 1).nth_of('month', 3, WeekDay.WEDNESDAY) instead.
672
+
711
673
  Parameters
712
674
  year: The year to use
713
675
  month: The month to use (1-12)
@@ -715,11 +677,7 @@ class DateExtrasMixin:
715
677
  Returns
716
678
  A Date object representing the third Wednesday of the specified month
717
679
  """
718
- third = cls(year, month, 15) # lowest 3rd day
719
- w = third.weekday()
720
- if w != WeekDay.WEDNESDAY:
721
- third = third.replace(day=(15 + (WeekDay.WEDNESDAY - w) % 7))
722
- return third
680
+ return cls(year, month, 1).nth_of('month', 3, WeekDay.WEDNESDAY)
723
681
 
724
682
 
725
683
  class Date(DateExtrasMixin, DateBusinessMixin, _pendulum.Date):
@@ -736,10 +694,9 @@ class Date(DateExtrasMixin, DateBusinessMixin, _pendulum.Date):
736
694
  """
737
695
 
738
696
  def to_string(self, fmt: str) -> str:
739
- """Format cleaner https://stackoverflow.com/a/2073189.
697
+ """Format date to string, handling platform-specific format codes.
740
698
 
741
- >>> Date(2022, 1, 5).to_string('%-m/%-d/%Y')
742
- '1/5/2022'
699
+ Automatically converts '%-' format codes to '%#' on Windows.
743
700
  """
744
701
  return self.strftime(fmt.replace('%-', '%#') if os.name == 'nt' else fmt)
745
702
 
@@ -772,10 +729,10 @@ class Date(DateExtrasMixin, DateBusinessMixin, _pendulum.Date):
772
729
  Returns
773
730
  A new Date object representing the average date
774
731
  """
775
- return _pendulum.Date.average(self)
732
+ return _pendulum.Date.average(self, dt)
776
733
 
777
734
  @classmethod
778
- def fromordinal(cls, *args, **kwargs):
735
+ def fromordinal(cls, *args, **kwargs) -> Self:
779
736
  """Create a Date from an ordinal.
780
737
 
781
738
  Parameters
@@ -788,7 +745,7 @@ class Date(DateExtrasMixin, DateBusinessMixin, _pendulum.Date):
788
745
  return cls.instance(result)
789
746
 
790
747
  @classmethod
791
- def fromtimestamp(cls, timestamp, tz=None):
748
+ def fromtimestamp(cls, timestamp, tz=None) -> Self:
792
749
  """Create a Date from a timestamp.
793
750
 
794
751
  Parameters
@@ -831,84 +788,47 @@ class Date(DateExtrasMixin, DateBusinessMixin, _pendulum.Date):
831
788
  ) -> Self | None:
832
789
  """Convert a string to a date handling many different formats.
833
790
 
834
- creating a new Date object
835
- >>> Date.parse('2022/1/1')
836
- Date(2022, 1, 1)
837
-
838
- previous business day accessed with 'P'
839
- >>> Date.parse('P')==Date.today().b.subtract(days=1)
840
- True
841
- >>> Date.parse('T-3b')==Date.today().b.subtract(days=3)
842
- True
843
- >>> Date.parse('T-3b')==Date.today().b.add(days=-3)
844
- True
845
- >>> Date.parse('T+3b')==Date.today().b.subtract(days=-3)
846
- True
847
- >>> Date.parse('T+3b')==Date.today().b.add(days=3)
848
- True
849
- >>> Date.parse('M')==Date.today().start_of('month').subtract(days=1)
850
- True
851
-
852
- m[/-]d[/-]yyyy 6-23-2006
853
- >>> Date.parse('6-23-2006')
854
- Date(2006, 6, 23)
855
-
856
- m[/-]d[/-]yy 6/23/06
857
- >>> Date.parse('6/23/06')
858
- Date(2006, 6, 23)
859
-
860
- m[/-]d 6/23
861
- >>> Date.parse('6/23') == Date(Date.today().year, 6, 23)
862
- True
863
-
864
- yyyy-mm-dd 2006-6-23
865
- >>> Date.parse('2006-6-23')
866
- Date(2006, 6, 23)
867
-
868
- yyyymmdd 20060623
869
- >>> Date.parse('20060623')
870
- Date(2006, 6, 23)
871
-
872
- dd-mon-yyyy 23-JUN-2006
873
- >>> Date.parse('23-JUN-2006')
874
- Date(2006, 6, 23)
875
-
876
- mon-dd-yyyy JUN-23-2006
877
- >>> Date.parse('20 Jan 2009')
878
- Date(2009, 1, 20)
879
-
880
- month dd, yyyy June 23, 2006
881
- >>> Date.parse('June 23, 2006')
882
- Date(2006, 6, 23)
883
-
884
- dd-mon-yy
885
- >>> Date.parse('23-May-12')
886
- Date(2012, 5, 23)
887
-
888
- ddmonyyyy
889
- >>> Date.parse('23May2012')
890
- Date(2012, 5, 23)
891
-
892
- >>> Date.parse('Oct. 24, 2007', fmt='%b. %d, %Y')
893
- Date(2007, 10, 24)
894
-
895
- >>> Date.parse('Yesterday') == DateTime.now().subtract(days=1).date()
896
- True
897
- >>> Date.parse('TODAY') == Date.today()
898
- True
899
- >>> Date.parse('Jan. 13, 2014')
900
- Date(2014, 1, 13)
901
-
902
- >>> Date.parse('March') == Date(Date.today().year, 3, Date.today().day)
903
- True
904
-
905
- only raise error when we explicitly say so
906
- >>> Date.parse('bad date') is None
907
- True
908
- >>> Date.parse('bad date', raise_err=True)
909
- Traceback (most recent call last):
910
- ...
911
- ValueError: Failed to parse date: bad date
791
+ Supports various date formats including:
792
+ - Standard formats: YYYY-MM-DD, MM/DD/YYYY, MM/DD/YY, YYYYMMDD
793
+ - Named months: DD-MON-YYYY, MON-DD-YYYY, Month DD, YYYY
794
+ - Special codes: T (today), Y (yesterday), P (previous business day)
795
+ - Business day offsets: T-3b, P+2b (add/subtract business days)
796
+ - Custom format strings via fmt parameter
797
+
798
+ Parameters
799
+ s: String to parse or None
800
+ fmt: Optional strftime format string for custom parsing
801
+ entity: Calendar entity for business day calculations (default NYSE)
802
+ raise_err: If True, raises ValueError on parse failure instead of returning None
803
+
804
+ Returns
805
+ Date instance or None if parsing fails and raise_err is False
806
+
807
+ Examples
808
+ Standard numeric formats:
809
+ Date.parse('2020-01-15') → Date(2020, 1, 15)
810
+ Date.parse('01/15/2020') → Date(2020, 1, 15)
811
+ Date.parse('01/15/20') → Date(2020, 1, 15)
812
+ Date.parse('20200115') → Date(2020, 1, 15)
813
+
814
+ Named month formats:
815
+ Date.parse('15-Jan-2020') → Date(2020, 1, 15)
816
+ Date.parse('Jan 15, 2020') → Date(2020, 1, 15)
817
+ Date.parse('15JAN2020') → Date(2020, 1, 15)
818
+
819
+ Special codes:
820
+ Date.parse('T') → today's date
821
+ Date.parse('Y') → yesterday's date
822
+ Date.parse('P') → previous business day
823
+ Date.parse('M') last day of previous month
824
+
825
+ Business day offsets:
826
+ Date.parse('T-3b') → 3 business days ago
827
+ Date.parse('P+2b') 2 business days after previous business day
828
+ Date.parse('T+5') → 5 calendar days from today
829
+
830
+ Custom format:
831
+ Date.parse('15-Jan-2020', fmt='%d-%b-%Y') → Date(2020, 1, 15)
912
832
  """
913
833
 
914
834
  def date_for_symbol(s):
@@ -1027,20 +947,17 @@ class Date(DateExtrasMixin, DateBusinessMixin, _pendulum.Date):
1027
947
  | None,
1028
948
  raise_err: bool = False,
1029
949
  ) -> Self | None:
1030
- """From datetime.date like object
1031
-
1032
- >>> Date.instance(_datetime.date(2022, 1, 1))
1033
- Date(2022, 1, 1)
1034
- >>> Date.instance(Date(2022, 1, 1))
1035
- Date(2022, 1, 1)
1036
- >>> Date.instance(_pendulum.Date(2022, 1, 1))
1037
- Date(2022, 1, 1)
1038
- >>> Date.instance(Date(2022, 1, 1))
1039
- Date(2022, 1, 1)
1040
- >>> Date.instance(np.datetime64('2000-01', 'D'))
1041
- Date(2000, 1, 1)
1042
- >>> Date.instance(None)
950
+ """Create a Date instance from various date-like objects.
951
+
952
+ Converts datetime.date, datetime.datetime, pandas Timestamp,
953
+ numpy datetime64, and other date-like objects to Date instances.
1043
954
 
955
+ Parameters
956
+ obj: Date-like object to convert
957
+ raise_err: If True, raises ValueError for None/NA values instead of returning None
958
+
959
+ Returns
960
+ Date instance or None if obj is None/NA and raise_err is False
1044
961
  """
1045
962
  if pd.isna(obj):
1046
963
  if raise_err:
@@ -1056,39 +973,21 @@ class Date(DateExtrasMixin, DateBusinessMixin, _pendulum.Date):
1056
973
  return cls(obj.year, obj.month, obj.day)
1057
974
 
1058
975
  @classmethod
1059
- def today(cls):
976
+ def today(cls) -> Self:
1060
977
  d = _datetime.datetime.now(LCL)
1061
978
  return cls(d.year, d.month, d.day)
1062
979
 
1063
- def isoweek(self):
1064
- """Week number 1-52 following ISO week-numbering
1065
-
1066
- Standard weeks
1067
- >>> Date(2023, 1, 2).isoweek()
1068
- 1
1069
- >>> Date(2023, 4, 27).isoweek()
1070
- 17
1071
- >>> Date(2023, 12, 31).isoweek()
1072
- 52
1073
-
1074
- Belongs to week of previous year
1075
- >>> Date(2023, 1, 1).isoweek()
1076
- 52
980
+ def isoweek(self) -> int | None:
981
+ """Get ISO week number (1-52/53) following ISO week-numbering standard.
1077
982
  """
1078
983
  with contextlib.suppress(Exception):
1079
984
  return self.isocalendar()[1]
1080
985
 
1081
986
  def lookback(self, unit='last') -> Self:
1082
- """Date back based on lookback string, ie last, week, month.
1083
-
1084
- >>> Date(2018, 12, 7).b.lookback('last')
1085
- Date(2018, 12, 6)
1086
- >>> Date(2018, 12, 7).b.lookback('day')
1087
- Date(2018, 12, 6)
1088
- >>> Date(2018, 12, 7).b.lookback('week')
1089
- Date(2018, 11, 30)
1090
- >>> Date(2018, 12, 7).b.lookback('month')
1091
- Date(2018, 11, 7)
987
+ """Get date in the past based on lookback unit.
988
+
989
+ Supported units: 'last'/'day' (1 day), 'week', 'month', 'quarter', 'year'.
990
+ Respects business day mode if enabled.
1092
991
  """
1093
992
  def _lookback(years=0, months=0, weeks=0, days=0):
1094
993
  _business = self._business
@@ -1124,38 +1023,46 @@ class Time(_pendulum.Time):
1124
1023
  @classmethod
1125
1024
  @prefer_utc_timezone
1126
1025
  def parse(cls, s: str | None, fmt: str | None = None, raise_err: bool = False) -> Self | None:
1127
- """Convert a string to a time handling many formats::
1128
-
1129
- handle many time formats:
1130
- hh[:.]mm
1131
- hh[:.]mm am/pm
1132
- hh[:.]mm[:.]ss
1133
- hh[:.]mm[:.]ss[.,]uuu am/pm
1134
- hhmmss[.,]uuu
1135
- hhmmss[.,]uuu am/pm
1136
-
1137
- >>> Time.parse('9:30')
1138
- Time(9, 30, 0, tzinfo=Timezone('UTC'))
1139
- >>> Time.parse('9:30:15')
1140
- Time(9, 30, 15, tzinfo=Timezone('UTC'))
1141
- >>> Time.parse('9:30:15.751')
1142
- Time(9, 30, 15, 751000, tzinfo=Timezone('UTC'))
1143
- >>> Time.parse('9:30 AM')
1144
- Time(9, 30, 0, tzinfo=Timezone('UTC'))
1145
- >>> Time.parse('9:30 pm')
1146
- Time(21, 30, 0, tzinfo=Timezone('UTC'))
1147
- >>> Time.parse('9:30:15.751 PM')
1148
- Time(21, 30, 15, 751000, tzinfo=Timezone('UTC'))
1149
- >>> Time.parse('0930') # Date treats this as a date, careful!!
1150
- Time(9, 30, 0, tzinfo=Timezone('UTC'))
1151
- >>> Time.parse('093015')
1152
- Time(9, 30, 15, tzinfo=Timezone('UTC'))
1153
- >>> Time.parse('093015,751')
1154
- Time(9, 30, 15, 751000, tzinfo=Timezone('UTC'))
1155
- >>> Time.parse('0930 pm')
1156
- Time(21, 30, 0, tzinfo=Timezone('UTC'))
1157
- >>> Time.parse('093015,751 PM')
1158
- Time(21, 30, 15, 751000, tzinfo=Timezone('UTC'))
1026
+ """Parse time string in various formats.
1027
+
1028
+ Supported formats:
1029
+ - hh:mm or hh.mm
1030
+ - hh:mm:ss or hh.mm.ss
1031
+ - hh:mm:ss.microseconds
1032
+ - Any of above with AM/PM
1033
+ - Compact: hhmmss or hhmmss.microseconds
1034
+
1035
+ Returns Time with UTC timezone by default.
1036
+
1037
+ Parameters
1038
+ s: String to parse or None
1039
+ fmt: Optional strftime format string for custom parsing
1040
+ raise_err: If True, raises ValueError on parse failure instead of returning None
1041
+
1042
+ Returns
1043
+ Time instance with UTC timezone or None if parsing fails and raise_err is False
1044
+
1045
+ Examples
1046
+ Basic time formats:
1047
+ Time.parse('14:30') → Time(14, 30, 0, 0, tzinfo=UTC)
1048
+ Time.parse('14.30') Time(14, 30, 0, 0, tzinfo=UTC)
1049
+ Time.parse('14:30:45') → Time(14, 30, 45, 0, tzinfo=UTC)
1050
+
1051
+ With microseconds:
1052
+ Time.parse('14:30:45.123456') → Time(14, 30, 45, 123456000, tzinfo=UTC)
1053
+ Time.parse('14:30:45,500000') → Time(14, 30, 45, 500000000, tzinfo=UTC)
1054
+
1055
+ AM/PM formats:
1056
+ Time.parse('2:30 PM') → Time(14, 30, 0, 0, tzinfo=UTC)
1057
+ Time.parse('11:30 AM') → Time(11, 30, 0, 0, tzinfo=UTC)
1058
+ Time.parse('12:30 PM') → Time(12, 30, 0, 0, tzinfo=UTC)
1059
+
1060
+ Compact formats:
1061
+ Time.parse('143045') → Time(14, 30, 45, 0, tzinfo=UTC)
1062
+ Time.parse('1430') → Time(14, 30, 0, 0, tzinfo=UTC)
1063
+
1064
+ Custom format:
1065
+ Time.parse('14-30-45', fmt='%H-%M-%S') → Time(14, 30, 45, 0, tzinfo=UTC)
1159
1066
  """
1160
1067
 
1161
1068
  def seconds(m):
@@ -1225,18 +1132,9 @@ class Time(_pendulum.Time):
1225
1132
  tz: str | _zoneinfo.ZoneInfo | _datetime.tzinfo | None = None,
1226
1133
  raise_err: bool = False,
1227
1134
  ) -> Self | None:
1228
- """From datetime-like object
1229
-
1230
- >>> Time.instance(_datetime.time(12, 30, 1))
1231
- Time(12, 30, 1, tzinfo=Timezone('UTC'))
1232
- >>> Time.instance(_pendulum.Time(12, 30, 1))
1233
- Time(12, 30, 1, tzinfo=Timezone('UTC'))
1234
- >>> Time.instance(None)
1235
-
1236
- like Pendulum, do not add timzone if no timezone and Time object
1237
- >>> Time.instance(Time(12, 30, 1))
1238
- Time(12, 30, 1)
1135
+ """Create Time instance from time-like object.
1239
1136
 
1137
+ Adds UTC timezone by default unless obj is already a Time instance.
1240
1138
  """
1241
1139
  if pd.isna(obj):
1242
1140
  if raise_err:
@@ -1250,15 +1148,8 @@ class Time(_pendulum.Time):
1250
1148
 
1251
1149
  return cls(obj.hour, obj.minute, obj.second, obj.microsecond, tzinfo=tz)
1252
1150
 
1253
- def in_timezone(self, tz: str | _zoneinfo.ZoneInfo | _datetime.tzinfo):
1254
- """Convert timezone
1255
-
1256
- >>> Time(12, 0).in_timezone(Timezone('America/Sao_Paulo'))
1257
- Time(9, 0, 0, tzinfo=Timezone('America/Sao_Paulo'))
1258
-
1259
- >>> Time(12, 0, tzinfo=Timezone('Europe/Moscow')).in_timezone(Timezone('America/Sao_Paulo'))
1260
- Time(6, 0, 0, tzinfo=Timezone('America/Sao_Paulo'))
1261
-
1151
+ def in_timezone(self, tz: str | _zoneinfo.ZoneInfo | _datetime.tzinfo) -> Self:
1152
+ """Convert time to a different timezone.
1262
1153
  """
1263
1154
  _dt = DateTime.combine(Date.today(), self, tzinfo=self.tzinfo or UTC)
1264
1155
  return _dt.in_timezone(tz).time()
@@ -1281,7 +1172,7 @@ class DateTime(DateBusinessMixin, _pendulum.DateTime):
1281
1172
  - Has timezone handling helpers not present in pendulum
1282
1173
  """
1283
1174
 
1284
- def epoch(self):
1175
+ def epoch(self) -> float:
1285
1176
  """Translate a datetime object into unix seconds since epoch
1286
1177
  """
1287
1178
  return self.timestamp()
@@ -1311,7 +1202,7 @@ class DateTime(DateBusinessMixin, _pendulum.DateTime):
1311
1202
  return _pendulum.DateTime.replace(self, *args, **kwargs)
1312
1203
 
1313
1204
  @classmethod
1314
- def fromordinal(cls, *args, **kwargs):
1205
+ def fromordinal(cls, *args, **kwargs) -> Self:
1315
1206
  """Create a DateTime from an ordinal.
1316
1207
 
1317
1208
  Parameters
@@ -1324,7 +1215,7 @@ class DateTime(DateBusinessMixin, _pendulum.DateTime):
1324
1215
  return cls.instance(result)
1325
1216
 
1326
1217
  @classmethod
1327
- def fromtimestamp(cls, timestamp, tz=None):
1218
+ def fromtimestamp(cls, timestamp, tz=None) -> Self:
1328
1219
  """Create a DateTime from a timestamp.
1329
1220
 
1330
1221
  Parameters
@@ -1339,7 +1230,7 @@ class DateTime(DateBusinessMixin, _pendulum.DateTime):
1339
1230
  return cls.instance(result)
1340
1231
 
1341
1232
  @classmethod
1342
- def strptime(cls, time_str, fmt):
1233
+ def strptime(cls, time_str, fmt) -> Self:
1343
1234
  """Parse a string into a DateTime according to a format.
1344
1235
 
1345
1236
  Parameters
@@ -1353,7 +1244,7 @@ class DateTime(DateBusinessMixin, _pendulum.DateTime):
1353
1244
  return cls.instance(result)
1354
1245
 
1355
1246
  @classmethod
1356
- def utcfromtimestamp(cls, timestamp):
1247
+ def utcfromtimestamp(cls, timestamp) -> Self:
1357
1248
  """Create a UTC DateTime from a timestamp.
1358
1249
 
1359
1250
  Parameters
@@ -1366,7 +1257,7 @@ class DateTime(DateBusinessMixin, _pendulum.DateTime):
1366
1257
  return cls.instance(result)
1367
1258
 
1368
1259
  @classmethod
1369
- def utcnow(cls):
1260
+ def utcnow(cls) -> Self:
1370
1261
  """Create a DateTime representing current UTC time.
1371
1262
 
1372
1263
  Returns
@@ -1391,7 +1282,7 @@ class DateTime(DateBusinessMixin, _pendulum.DateTime):
1391
1282
  d.microsecond, tzinfo=d.tzinfo, fold=d.fold)
1392
1283
 
1393
1284
  @classmethod
1394
- def today(cls, tz: str | _zoneinfo.ZoneInfo | None = None):
1285
+ def today(cls, tz: str | _zoneinfo.ZoneInfo | None = None) -> Self:
1395
1286
  """Create a DateTime object representing today at the start of day.
1396
1287
 
1397
1288
  Unlike pendulum.today() which returns current time, this method
@@ -1405,7 +1296,7 @@ class DateTime(DateBusinessMixin, _pendulum.DateTime):
1405
1296
  """
1406
1297
  return DateTime.now(tz).start_of('day')
1407
1298
 
1408
- def date(self):
1299
+ def date(self) -> Date:
1409
1300
  return Date(self.year, self.month, self.day)
1410
1301
 
1411
1302
  @classmethod
@@ -1420,25 +1311,13 @@ class DateTime(DateBusinessMixin, _pendulum.DateTime):
1420
1311
  _tzinfo = tzinfo or time.tzinfo
1421
1312
  return DateTime.instance(_datetime.datetime.combine(date, time, tzinfo=_tzinfo))
1422
1313
 
1423
- def rfc3339(self):
1424
- """
1425
- >>> DateTime.parse('Fri, 31 Oct 2014 10:55:00')
1426
- DateTime(2014, 10, 31, 10, 55, 0, tzinfo=Timezone('UTC'))
1427
- >>> DateTime.parse('Fri, 31 Oct 2014 10:55:00').rfc3339()
1428
- '2014-10-31T10:55:00+00:00'
1314
+ def rfc3339(self) -> str:
1315
+ """Return RFC 3339 formatted string (same as isoformat()).
1429
1316
  """
1430
1317
  return self.isoformat()
1431
1318
 
1432
- def time(self):
1433
- """Extract time from self (preserve timezone)
1434
-
1435
- >>> d = DateTime(2022, 1, 1, 12, 30, 15, tzinfo=EST)
1436
- >>> d.time()
1437
- Time(12, 30, 15, tzinfo=Timezone('US/Eastern'))
1438
-
1439
- >>> d = DateTime(2022, 1, 1, 12, 30, 15, tzinfo=UTC)
1440
- >>> d.time()
1441
- Time(12, 30, 15, tzinfo=Timezone('UTC'))
1319
+ def time(self) -> Time:
1320
+ """Extract time component from datetime (preserving timezone).
1442
1321
  """
1443
1322
  return Time.instance(self)
1444
1323
 
@@ -1451,41 +1330,43 @@ class DateTime(DateBusinessMixin, _pendulum.DateTime):
1451
1330
  """Convert a string or timestamp to a DateTime with extended format support.
1452
1331
 
1453
1332
  Unlike pendulum's parse, this method supports:
1454
- - Unix timestamps (int/float)
1455
- - Special codes (T=today, Y=yesterday, P=previous business day)
1456
- - Business day offsets (e.g., 'T-3b' for 3 business days before today)
1333
+ - Unix timestamps (int/float, handles milliseconds automatically)
1334
+ - Special codes: T (today), Y (yesterday), P (previous business day)
1335
+ - Business day offsets: T-3b, P+2b (add/subtract business days)
1457
1336
  - Multiple date-time formats beyond ISO 8601
1337
+ - Combined date and time strings with various separators
1458
1338
 
1459
1339
  Parameters
1460
1340
  s: String or timestamp to parse
1461
- entity: Calendar entity for business day calculations
1462
- raise_err: Whether to raise error on parse failure
1341
+ entity: Calendar entity for business day calculations (default NYSE)
1342
+ raise_err: If True, raises ValueError on parse failure instead of returning None
1463
1343
 
1464
1344
  Returns
1465
1345
  DateTime instance or None if parsing fails and raise_err is False
1466
1346
 
1467
1347
  Examples
1348
+ Unix timestamps:
1349
+ DateTime.parse(1609459200) → DateTime(2021, 1, 1, 0, 0, 0, tzinfo=LCL)
1350
+ DateTime.parse(1609459200000) → DateTime(2021, 1, 1, 0, 0, 0, tzinfo=LCL)
1468
1351
 
1469
- Basic formats:
1470
- >>> DateTime.parse('2022/1/1')
1471
- DateTime(2022, 1, 1, 0, 0, 0, tzinfo=Timezone('...'))
1352
+ ISO 8601 format:
1353
+ DateTime.parse('2020-01-15T14:30:00') → DateTime(2020, 1, 15, 14, 30, 0)
1472
1354
 
1473
- Timezone handling:
1474
- >>> this_est1 = DateTime.parse('Fri, 31 Oct 2014 18:55:00').in_timezone(EST)
1475
- >>> this_est1
1476
- DateTime(2014, 10, 31, 14, 55, 0, tzinfo=Timezone('US/Eastern'))
1355
+ Date and time separated:
1356
+ DateTime.parse('2020-01-15 14:30:00') → DateTime(2020, 1, 15, 14, 30, 0, tzinfo=LCL)
1357
+ DateTime.parse('01/15/2020:14:30:00') → DateTime(2020, 1, 15, 14, 30, 0, tzinfo=LCL)
1477
1358
 
1478
- >>> this_est2 = DateTime.parse('Fri, 31 Oct 2014 14:55:00 -0400')
1479
- >>> this_est2
1480
- DateTime(2014, 10, 31, 14, 55, 0, tzinfo=...)
1359
+ Date only (time defaults to 00:00:00):
1360
+ DateTime.parse('2020-01-15') → DateTime(2020, 1, 15, 0, 0, 0)
1361
+ DateTime.parse('01/15/2020') → DateTime(2020, 1, 15, 0, 0, 0)
1481
1362
 
1482
- >>> this_utc = DateTime.parse('Fri, 31 Oct 2014 18:55:00 GMT')
1483
- >>> this_utc
1484
- DateTime(2014, 10, 31, 18, 55, 0, tzinfo=tzutc())
1363
+ Time only (uses today's date):
1364
+ DateTime.parse('14:30:00') → DateTime(today's year, month, day, 14, 30, 0, tzinfo=LCL)
1485
1365
 
1486
- Timestamp parsing:
1487
- >>> DateTime.parse(1707856982).replace(tzinfo=UTC).epoch()
1488
- 1707856982.0
1366
+ Special codes:
1367
+ DateTime.parse('T') → today at 00:00:00
1368
+ DateTime.parse('Y') → yesterday at 00:00:00
1369
+ DateTime.parse('P') → previous business day at 00:00:00
1489
1370
  """
1490
1371
  if not s:
1491
1372
  if raise_err:
@@ -1538,44 +1419,21 @@ class DateTime(DateBusinessMixin, _pendulum.DateTime):
1538
1419
  ) -> Self | None:
1539
1420
  """Create a DateTime instance from various datetime-like objects.
1540
1421
 
1541
- This method provides a unified interface for converting different
1542
- date/time types including pandas and numpy datetime objects into
1543
- DateTime instances.
1422
+ Provides unified interface for converting different date/time types
1423
+ including pandas and numpy datetime objects into DateTime instances.
1544
1424
 
1545
1425
  Unlike pendulum, this method:
1546
1426
  - Handles pandas Timestamp and numpy datetime64 objects
1547
1427
  - Adds timezone (UTC by default) when none is specified
1548
- - Has special handling for time objects
1428
+ - Has special handling for Time objects (combines with current date)
1549
1429
 
1550
1430
  Parameters
1551
1431
  obj: Date, datetime, time, or compatible object to convert
1552
1432
  tz: Optional timezone to apply (if None, uses obj's timezone or UTC)
1553
- raise_err: Whether to raise error if obj is None/NA
1433
+ raise_err: If True, raises ValueError for None/NA values instead of returning None
1554
1434
 
1555
1435
  Returns
1556
1436
  DateTime instance or None if obj is None/NA and raise_err is False
1557
-
1558
- Examples
1559
-
1560
- From Python datetime types:
1561
- >>> DateTime.instance(_datetime.date(2022, 1, 1))
1562
- DateTime(2022, 1, 1, 0, 0, 0, tzinfo=Timezone('...'))
1563
- >>> DateTime.instance(_datetime.datetime(2022, 1, 1, 0, 0, 0))
1564
- DateTime(2022, 1, 1, 0, 0, 0, tzinfo=Timezone('...'))
1565
-
1566
- Preserves timezone behavior:
1567
- >>> DateTime.instance(DateTime(2022, 1, 1, 0, 0, 0))
1568
- DateTime(2022, 1, 1, 0, 0, 0)
1569
-
1570
- From Time objects:
1571
- >>> DateTime.instance(Time(4, 4, 21))
1572
- DateTime(..., 4, 4, 21, tzinfo=Timezone('UTC'))
1573
- >>> DateTime.instance(Time(4, 4, 21, tzinfo=UTC))
1574
- DateTime(..., 4, 4, 21, tzinfo=Timezone('UTC'))
1575
-
1576
- From numpy/pandas datetime:
1577
- >>> DateTime.instance(np.datetime64('2000-01', 'D'))
1578
- DateTime(2000, 1, 1, 0, 0, 0, tzinfo=Timezone('UTC'))
1579
1437
  """
1580
1438
  if pd.isna(obj):
1581
1439
  if raise_err:
@@ -1608,25 +1466,95 @@ class DateTime(DateBusinessMixin, _pendulum.DateTime):
1608
1466
  obj.second, obj.microsecond, tzinfo=tz)
1609
1467
 
1610
1468
 
1611
- class IntervalError(AttributeError):
1612
- pass
1469
+ class Interval(_pendulum.Interval):
1470
+ """Interval class extending pendulum.Interval with business day awareness.
1613
1471
 
1472
+ This class represents the difference between two dates or datetimes with
1473
+ additional support for business day calculations, entity awareness, and
1474
+ financial period calculations.
1614
1475
 
1615
- class Interval:
1476
+ Unlike pendulum.Interval:
1477
+ - Has business day mode that only counts business days
1478
+ - Preserves entity association (e.g., NYSE)
1479
+ - Additional financial methods like yearfrac()
1480
+ - Support for range operations that respect business days
1481
+ """
1616
1482
 
1617
1483
  _business: bool = False
1618
1484
  _entity: type[NYSE] = NYSE
1619
1485
 
1620
- def __init__(self, begdate: str | Date | None = None, enddate: str | Date | None = None):
1621
- self.begdate = Date.parse(begdate) if isinstance(begdate, str) else Date.instance(begdate)
1622
- self.enddate = Date.parse(enddate) if isinstance(enddate, str) else Date.instance(enddate)
1486
+ @expect_date_or_datetime
1487
+ @normalize_date_datetime_pairs
1488
+ def __new__(cls, begdate: Date | DateTime, enddate: Date | DateTime) -> Self:
1489
+ assert begdate and enddate, 'Interval dates cannot be None'
1490
+ instance = super().__new__(cls, begdate, enddate, False)
1491
+ return instance
1492
+
1493
+ @expect_date_or_datetime
1494
+ @normalize_date_datetime_pairs
1495
+ def __init__(self, begdate: Date | DateTime, enddate: Date | DateTime) -> None:
1496
+ super().__init__(begdate, enddate, False)
1497
+ self._sign = 1 if begdate <= enddate else -1
1498
+ if begdate <= enddate:
1499
+ self._start = begdate
1500
+ self._end = enddate
1501
+ else:
1502
+ self._start = enddate
1503
+ self._end = begdate
1504
+
1505
+ @staticmethod
1506
+ def _get_quarter_start(date: Date | DateTime) -> Date | DateTime:
1507
+ """Get the start date of the quarter containing the given date.
1508
+ """
1509
+ quarter_month = ((date.month - 1) // 3) * 3 + 1
1510
+ return date.replace(month=quarter_month, day=1)
1511
+
1512
+ @staticmethod
1513
+ def _get_quarter_end(date: Date | DateTime) -> Date | DateTime:
1514
+ """Get the end date of the quarter containing the given date.
1515
+ """
1516
+ quarter_month = ((date.month - 1) // 3) * 3 + 3
1517
+ return date.replace(month=quarter_month).end_of('month')
1518
+
1519
+ def _get_unit_handlers(self, unit: str) -> dict:
1520
+ """Get handlers for the specified time unit.
1521
+
1522
+ Returns a dict with:
1523
+ get_start: Function to get start of period containing date
1524
+ get_end: Function to get end of period containing date
1525
+ advance: Function to advance to next period start
1526
+ """
1527
+ if unit == 'quarter':
1528
+ return {
1529
+ 'get_start': self._get_quarter_start,
1530
+ 'get_end': self._get_quarter_end,
1531
+ 'advance': lambda date: self._get_quarter_start(date.add(months=3)),
1532
+ }
1533
+
1534
+ if unit == 'decade':
1535
+ return {
1536
+ 'get_start': lambda date: date.start_of('decade'),
1537
+ 'get_end': lambda date: date.end_of('decade'),
1538
+ 'advance': lambda date: date.add(years=10).start_of('decade'),
1539
+ }
1540
+
1541
+ if unit == 'century':
1542
+ return {
1543
+ 'get_start': lambda date: date.start_of('century'),
1544
+ 'get_end': lambda date: date.end_of('century'),
1545
+ 'advance': lambda date: date.add(years=100).start_of('century'),
1546
+ }
1547
+
1548
+ return {
1549
+ 'get_start': lambda date: date.start_of(unit),
1550
+ 'get_end': lambda date: date.end_of(unit),
1551
+ 'advance': lambda date: date.add(**{f'{unit}s': 1}).start_of(unit),
1552
+ }
1623
1553
 
1624
1554
  def business(self) -> Self:
1625
1555
  self._business = True
1626
- if self.begdate:
1627
- self.begdate.business()
1628
- if self.enddate:
1629
- self.enddate.business()
1556
+ self._start.business()
1557
+ self._end.business()
1630
1558
  return self
1631
1559
 
1632
1560
  @property
@@ -1635,249 +1563,120 @@ class Interval:
1635
1563
 
1636
1564
  def entity(self, e: type[NYSE] = NYSE) -> Self:
1637
1565
  self._entity = e
1638
- if self.begdate:
1639
- self.enddate._entity = e
1640
- if self.enddate:
1641
- self.enddate._entity = e
1566
+ if self._start:
1567
+ self._end._entity = e
1568
+ if self._end:
1569
+ self._end._entity = e
1642
1570
  return self
1643
1571
 
1644
- def range(self, window=0) -> tuple[_datetime.date, _datetime.date]:
1645
- """Set date ranges based on begdate, enddate and window.
1646
-
1647
- The combinations are as follows:
1648
-
1649
- beg end num action
1650
- --- --- --- ---------------------
1651
- - - - Error, underspecified
1652
- set set set Error, overspecified
1653
- set set -
1654
- set - - end=max date
1655
- - set - beg=min date
1656
- - - set end=max date, beg=end - num
1657
- set - set end=beg + num
1658
- - set set beg=end - num
1659
-
1660
- Basic/legacy cases
1661
- >>> Interval(Date(2014, 4, 3), None).b.range(3)
1662
- (Date(2014, 4, 3), Date(2014, 4, 8))
1663
- >>> Interval(None, Date(2014, 7, 27)).range(20)
1664
- (Date(2014, 7, 7), Date(2014, 7, 27))
1665
- >>> Interval(None, Date(2014, 7, 27)).b.range(20)
1666
- (Date(2014, 6, 27), Date(2014, 7, 27))
1667
-
1668
- Do not modify dates if both are provided
1669
- >>> Interval(Date(2024, 7, 25), Date(2024, 7, 25)).b.range(None)
1670
- (Date(2024, 7, 25), Date(2024, 7, 25))
1671
- >>> Interval(Date(2024, 7, 27), Date(2024, 7, 27)).b.range(None)
1672
- (Date(2024, 7, 27), Date(2024, 7, 27))
1673
-
1674
- Edge cases (7/27/24 is weekend)
1675
- >>> Interval(Date(2024, 7, 27), None).b.range(0)
1676
- (Date(2024, 7, 27), Date(2024, 7, 27))
1677
- >>> Interval(None, Date(2024, 7, 27)).b.range(0)
1678
- (Date(2024, 7, 27), Date(2024, 7, 27))
1679
- >>> Interval(Date(2024, 7, 27), None).b.range(1)
1680
- (Date(2024, 7, 27), Date(2024, 7, 29))
1681
- >>> Interval(None, Date(2024, 7, 27)).b.range(1)
1682
- (Date(2024, 7, 26), Date(2024, 7, 27))
1572
+ def is_business_day_range(self) -> list[bool]:
1573
+ """Generate boolean values indicating whether each day in the range is a business day.
1683
1574
  """
1684
- begdate, enddate = self.begdate, self.enddate
1575
+ self._business = False
1576
+ for thedate in self.range('days'):
1577
+ yield thedate.is_business_day()
1685
1578
 
1686
- window = abs(int(window or 0))
1579
+ @reset_business
1580
+ def range(self, unit: str = 'days', amount: int = 1) -> Iterator[DateTime | Date]:
1581
+ """Generate dates/datetimes over the interval.
1687
1582
 
1688
- if begdate and enddate and window:
1689
- raise IntervalError('Window requested and begdate and enddate provided')
1690
- if not begdate and not enddate and not window:
1691
- raise IntervalError('Missing begdate, enddate, and window')
1692
- if not begdate and not enddate and window:
1693
- raise IntervalError('Missing begdate and enddate, window specified')
1583
+ Parameters
1584
+ unit: Time unit ('days', 'weeks', 'months', 'years')
1585
+ amount: Step size (e.g., every N units)
1694
1586
 
1695
- if begdate and enddate:
1696
- pass # do nothing if both provided
1697
- elif (not begdate and not enddate) or enddate:
1698
- begdate = enddate.subtract(days=window) if window else enddate
1699
- else:
1700
- enddate = begdate.add(days=window) if window else begdate
1587
+ In business mode (for 'days' only), skips non-business days.
1588
+ """
1589
+ _business = self._business
1590
+ parent_range = _pendulum.Interval.range
1701
1591
 
1702
- enddate._business = False
1703
- begdate._business = False
1592
+ def _range_generator():
1593
+ if unit != 'days':
1594
+ yield from (type(d).instance(d) for d in parent_range(self, unit, amount))
1595
+ return
1704
1596
 
1705
- return begdate, enddate
1597
+ if self._sign == 1:
1598
+ op = operator.le
1599
+ this = self._start
1600
+ thru = self._end
1601
+ else:
1602
+ op = operator.ge
1603
+ this = self._end
1604
+ thru = self._start
1605
+
1606
+ while op(this, thru):
1607
+ if _business:
1608
+ if this.is_business_day():
1609
+ yield this
1610
+ else:
1611
+ yield this
1612
+ this = this.add(days=self._sign * amount)
1706
1613
 
1707
- def is_business_day_series(self) -> list[bool]:
1708
- """Is business date range.
1614
+ return _range_generator()
1709
1615
 
1710
- >>> list(Interval(Date(2018, 11, 19), Date(2018, 11, 25)).is_business_day_series())
1711
- [True, True, True, False, True, False, False]
1712
- >>> list(Interval(Date(2021, 11, 22),Date(2021, 11, 28)).is_business_day_series())
1713
- [True, True, True, False, True, False, False]
1616
+ @property
1617
+ @reset_business
1618
+ def days(self) -> int:
1619
+ """Get number of days in the interval (respects business mode and sign).
1714
1620
  """
1715
- for thedate in self.series():
1716
- yield thedate.is_business_day()
1621
+ return self._sign * len(tuple(self.range('days'))) - self._sign
1717
1622
 
1718
- def series(self, window=0):
1719
- """Get a series of datetime.date objects.
1720
-
1721
- give the function since and until wherever possible (more explicit)
1722
- else pass in a window to back out since or until
1723
- - Window gives window=N additional days. So `until`-`window`=1
1724
- defaults to include ALL days (not just business days)
1725
-
1726
- >>> next(Interval(Date(2014,7,16), Date(2014,7,16)).series())
1727
- Date(2014, 7, 16)
1728
- >>> next(Interval(Date(2014,7,12), Date(2014,7,16)).series())
1729
- Date(2014, 7, 12)
1730
- >>> len(list(Interval(Date(2014,7,12), Date(2014,7,16)).series()))
1731
- 5
1732
- >>> len(list(Interval(Date(2014,7,12), None).series(window=4)))
1733
- 5
1734
- >>> len(list(Interval(Date(2014,7,16)).series(window=4)))
1735
- 5
1736
-
1737
- Weekend and a holiday
1738
- >>> len(list(Interval(Date(2014,7,3), Date(2014,7,5)).b.series()))
1739
- 1
1740
- >>> len(list(Interval(Date(2014,7,17), Date(2014,7,16)).series()))
1741
- Traceback (most recent call last):
1742
- ...
1743
- AssertionError: Begdate must be earlier or equal to Enddate
1744
-
1745
- since != business day and want business days
1746
- 1/[3,10]/2015 is a Saturday, 1/7/2015 is a Wednesday
1747
- >>> len(list(Interval(Date(2015,1,3), Date(2015,1,7)).b.series()))
1748
- 3
1749
- >>> len(list(Interval(Date(2015,1,3), None).b.series(window=3)))
1750
- 3
1751
- >>> len(list(Interval(Date(2015,1,3), Date(2015,1,10)).b.series()))
1752
- 5
1753
- >>> len(list(Interval(Date(2015,1,3), None).b.series(window=5)))
1754
- 5
1755
- """
1756
- window = abs(int(window))
1757
- since, until = self.begdate, self.enddate
1758
- _business = self._business
1759
- assert until or since, 'Since or until is required'
1760
- if not since and until:
1761
- since = (until.business() if _business else
1762
- until).subtract(days=window)
1763
- elif since and not until:
1764
- until = (since.business() if _business else
1765
- since).add(days=window)
1766
- assert since <= until, 'Since date must be earlier or equal to Until date'
1767
- thedate = since
1768
- while thedate <= until:
1769
- if _business:
1770
- if thedate.is_business_day():
1771
- yield thedate
1772
- else:
1773
- yield thedate
1774
- thedate = thedate.add(days=1)
1775
-
1776
- def start_of_series(self, unit='month') -> list[Date]:
1777
- """Return a series between and inclusive of begdate and enddate.
1778
-
1779
- >>> Interval(Date(2018, 1, 5), Date(2018, 4, 5)).start_of_series('month')
1780
- [Date(2018, 1, 1), Date(2018, 2, 1), Date(2018, 3, 1), Date(2018, 4, 1)]
1781
- >>> Interval(Date(2018, 4, 30), Date(2018, 7, 30)).start_of_series('month')
1782
- [Date(2018, 4, 1), Date(2018, 5, 1), Date(2018, 6, 1), Date(2018, 7, 1)]
1783
- >>> Interval(Date(2018, 1, 5), Date(2018, 4, 5)).start_of_series('week')
1784
- [Date(2018, 1, 1), Date(2018, 1, 8), ..., Date(2018, 4, 2)]
1785
- """
1786
- begdate = self.begdate.start_of(unit)
1787
- enddate = self.enddate.start_of(unit)
1788
- interval = _pendulum.interval(begdate, enddate)
1789
- return [Date.instance(d).start_of(unit) for d in interval.range(f'{unit}s')]
1790
-
1791
- def end_of_series(self, unit='month') -> list[Date]:
1792
- """Return a series between and inclusive of begdate and enddate.
1793
-
1794
- >>> Interval(Date(2018, 1, 5), Date(2018, 4, 5)).end_of_series('month')
1795
- [Date(2018, 1, 31), Date(2018, 2, 28), Date(2018, 3, 31), Date(2018, 4, 30)]
1796
- >>> Interval(Date(2018, 4, 30), Date(2018, 7, 30)).end_of_series('month')
1797
- [Date(2018, 4, 30), Date(2018, 5, 31), Date(2018, 6, 30), Date(2018, 7, 31)]
1798
- >>> Interval(Date(2018, 1, 5), Date(2018, 4, 5)).end_of_series('week')
1799
- [Date(2018, 1, 7), Date(2018, 1, 14), ..., Date(2018, 4, 8)]
1623
+ @property
1624
+ def months(self) -> float:
1625
+ """Get number of months in the interval including fractional parts.
1626
+
1627
+ Overrides pendulum's months property to return a float instead of an integer.
1628
+ Calculates fractional months based on actual day counts within partial months.
1800
1629
  """
1801
- begdate = self.begdate.end_of(unit)
1802
- enddate = self.enddate.end_of(unit)
1803
- interval = _pendulum.interval(begdate, enddate)
1804
- return [Date.instance(d).end_of(unit) for d in interval.range(f'{unit}s')]
1630
+ year_diff = self._end.year - self._start.year
1631
+ month_diff = self._end.month - self._start.month
1632
+ total_months = year_diff * 12 + month_diff
1633
+
1634
+ if self._end.day >= self._start.day:
1635
+ day_diff = self._end.day - self._start.day
1636
+ days_in_month = calendar.monthrange(self._start.year, self._start.month)[1]
1637
+ fraction = day_diff / days_in_month
1638
+ else:
1639
+ total_months -= 1
1640
+ days_in_start_month = calendar.monthrange(self._start.year, self._start.month)[1]
1641
+ day_diff = (days_in_start_month - self._start.day) + self._end.day
1642
+ fraction = day_diff / days_in_start_month
1805
1643
 
1806
- def days(self) -> int:
1807
- """Return days between (begdate, enddate] or negative (enddate, begdate].
1808
-
1809
- >>> Interval(Date(2018, 9, 6), Date(2018, 9, 10)).days()
1810
- 4
1811
- >>> Interval(Date(2018, 9, 10), Date(2018, 9, 6)).days()
1812
- -4
1813
- >>> Interval(Date(2018, 9, 6), Date(2018, 9, 10)).b.days()
1814
- 2
1815
- >>> Interval(Date(2018, 9, 10), Date(2018, 9, 6)).b.days()
1816
- -2
1644
+ return self._sign * (total_months + fraction)
1645
+
1646
+ @property
1647
+ def quarters(self) -> float:
1648
+ """Get approximate number of quarters in the interval.
1649
+
1650
+ Note: This is an approximation using day count / 365 * 4.
1817
1651
  """
1818
- assert self.begdate
1819
- assert self.enddate
1820
- if self.begdate == self.enddate:
1821
- return 0
1822
- if not self._business:
1823
- return (self.enddate - self.begdate).days
1824
- if self.begdate < self.enddate:
1825
- return len(list(self.series())) - 1
1826
- _reverse = Interval(self.enddate, self.begdate)
1827
- _reverse._entity = self._entity
1828
- _reverse._business = self._business
1829
- return -len(list(_reverse.series())) + 1
1830
-
1831
- def quarters(self):
1832
- """Return the number of quarters between two dates
1833
- TODO: good enough implementation; refine rules to be heuristically precise
1834
-
1835
- >>> round(Interval(Date(2020, 1, 1), Date(2020, 2, 16)).quarters(), 2)
1836
- 0.5
1837
- >>> round(Interval(Date(2020, 1, 1), Date(2020, 4, 1)).quarters(), 2)
1838
- 1.0
1839
- >>> round(Interval(Date(2020, 1, 1), Date(2020, 7, 1)).quarters(), 2)
1840
- 1.99
1841
- >>> round(Interval(Date(2020, 1, 1), Date(2020, 8, 1)).quarters(), 2)
1842
- 2.33
1652
+ return self._sign * 4 * self.days / 365.0
1653
+
1654
+ @property
1655
+ def years(self) -> int:
1656
+ """Get number of complete years in the interval (always floors).
1843
1657
  """
1844
- return 4 * self.days() / 365.0
1845
-
1846
- def years(self, basis: int = 0):
1847
- """Years with Fractions (matches Excel YEARFRAC)
1848
-
1849
- Adapted from https://web.archive.org/web/20200915094905/https://dwheeler.com/yearfrac/calc_yearfrac.py
1850
-
1851
- Basis:
1852
- 0 = US (NASD) 30/360
1853
- 1 = Actual/actual
1854
- 2 = Actual/360
1855
- 3 = Actual/365
1856
- 4 = European 30/360
1857
-
1858
- >>> begdate = Date(1978, 2, 28)
1859
- >>> enddate = Date(2020, 5, 17)
1860
-
1861
- Tested Against Excel
1862
- >>> "{:.4f}".format(Interval(begdate, enddate).years(0))
1863
- '42.2139'
1864
- >>> '{:.4f}'.format(Interval(begdate, enddate).years(1))
1865
- '42.2142'
1866
- >>> '{:.4f}'.format(Interval(begdate, enddate).years(2))
1867
- '42.8306'
1868
- >>> '{:.4f}'.format(Interval(begdate, enddate).years(3))
1869
- '42.2438'
1870
- >>> '{:.4f}'.format(Interval(begdate, enddate).years(4))
1871
- '42.2194'
1872
- >>> '{:.4f}'.format(Interval(enddate, begdate).years(4))
1873
- '-42.2194'
1874
-
1875
- Excel has a known leap year bug when year == 1900 (=YEARFRAC("1900-1-1", "1900-12-1", 1) -> 0.9178)
1876
- The bug originated from Lotus 1-2-3, and was purposely implemented in Excel for the purpose of backward compatibility.
1877
- >>> begdate = Date(1900, 1, 1)
1878
- >>> enddate = Date(1900, 12, 1)
1879
- >>> '{:.4f}'.format(Interval(begdate, enddate).years(4))
1880
- '0.9167'
1658
+ year_diff = self._end.year - self._start.year
1659
+ if self._end.month < self._start.month or \
1660
+ (self._end.month == self._start.month and self._end.day < self._start.day):
1661
+ year_diff -= 1
1662
+ return self._sign * year_diff
1663
+
1664
+ def yearfrac(self, basis: int = 0) -> float:
1665
+ """Calculate the fraction of years between two dates (Excel-compatible).
1666
+
1667
+ This method provides precise calculation using various day count conventions
1668
+ used in finance. Results are tested against Excel for compatibility.
1669
+
1670
+ Parameters
1671
+ basis: Day count convention to use:
1672
+ 0 = US (NASD) 30/360 (default)
1673
+ 1 = Actual/actual
1674
+ 2 = Actual/360
1675
+ 3 = Actual/365
1676
+ 4 = European 30/360
1677
+
1678
+ Note: Excel has a known leap year bug for year 1900 which is intentionally
1679
+ replicated for compatibility (1900 is treated as a leap year even though it wasn't).
1881
1680
  """
1882
1681
 
1883
1682
  def average_year_length(date1, date2):
@@ -1962,32 +1761,84 @@ class Interval:
1962
1761
  (date1day + date1month * 30 + date1year * 360)
1963
1762
  return daydiff360 / 360
1964
1763
 
1965
- begdate, enddate = self.begdate, self.enddate
1966
- if enddate is None:
1967
- return
1968
-
1969
- sign = 1
1970
- if begdate > enddate:
1971
- begdate, enddate = enddate, begdate
1972
- sign = -1
1973
- if begdate == enddate:
1764
+ if self._start == self._end:
1974
1765
  return 0.0
1975
-
1976
1766
  if basis == 0:
1977
- return basis0(begdate, enddate) * sign
1767
+ return basis0(self._start, self._end) * self._sign
1978
1768
  if basis == 1:
1979
- return basis1(begdate, enddate) * sign
1769
+ return basis1(self._start, self._end) * self._sign
1980
1770
  if basis == 2:
1981
- return basis2(begdate, enddate) * sign
1771
+ return basis2(self._start, self._end) * self._sign
1982
1772
  if basis == 3:
1983
- return basis3(begdate, enddate) * sign
1773
+ return basis3(self._start, self._end) * self._sign
1984
1774
  if basis == 4:
1985
- return basis4(begdate, enddate) * sign
1775
+ return basis4(self._start, self._end) * self._sign
1986
1776
 
1987
1777
  raise ValueError('Basis range [0, 4]. Unknown basis {basis}.')
1988
1778
 
1779
+ @reset_business
1780
+ def start_of(self, unit: str = 'month') -> list[Date | DateTime]:
1781
+ """Return the start of each unit within the interval.
1782
+
1783
+ Parameters
1784
+ unit: Time unit ('month', 'week', 'year', 'quarter')
1785
+
1786
+ Returns
1787
+ List of Date or DateTime objects representing start of each unit
1788
+
1789
+ In business mode, each start date is adjusted to the next business day
1790
+ if it falls on a non-business day.
1791
+ """
1792
+ handlers = self._get_unit_handlers(unit)
1793
+ result = []
1794
+
1795
+ current = handlers['get_start'](self._start)
1796
+
1797
+ if self._business:
1798
+ current._entity = self._entity
1989
1799
 
1990
- def create_ics(begdate, enddate, summary, location):
1800
+ while current <= self._end:
1801
+ if self._business:
1802
+ current = current._business_or_next()
1803
+ result.append(current)
1804
+ current = handlers['advance'](current)
1805
+
1806
+ return result
1807
+
1808
+ @reset_business
1809
+ def end_of(self, unit: str = 'month') -> list[Date | DateTime]:
1810
+ """Return the end of each unit within the interval.
1811
+
1812
+ Parameters
1813
+ unit: Time unit ('month', 'week', 'year', 'quarter')
1814
+
1815
+ Returns
1816
+ List of Date or DateTime objects representing end of each unit
1817
+
1818
+ In business mode, each end date is adjusted to the previous business day
1819
+ if it falls on a non-business day.
1820
+ """
1821
+ handlers = self._get_unit_handlers(unit)
1822
+ result = []
1823
+
1824
+ current = handlers['get_start'](self._start)
1825
+
1826
+ if self._business:
1827
+ current._entity = self._entity
1828
+
1829
+ while current <= self._end:
1830
+ end_date = handlers['get_end'](current)
1831
+
1832
+ if self._business:
1833
+ end_date = end_date._business_or_previous()
1834
+ result.append(end_date)
1835
+
1836
+ current = handlers['advance'](current)
1837
+
1838
+ return result
1839
+
1840
+
1841
+ def create_ics(begdate, enddate, summary, location) -> str:
1991
1842
  """Create a simple .ics file per RFC 5545 guidelines."""
1992
1843
 
1993
1844
  return f"""BEGIN:VCALENDAR
@@ -2001,7 +1852,3 @@ LOCATION:{location}
2001
1852
  END:VEVENT
2002
1853
  END:VCALENDAR
2003
1854
  """
2004
-
2005
-
2006
- if __name__ == '__main__':
2007
- __import__('doctest').testmod(optionflags=4 | 8 | 32)