opendate 0.1.13__py3-none-any.whl → 0.1.19__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,11 @@ __all__ = [
39
39
  'prefer_utc_timezone',
40
40
  'expect_date',
41
41
  'expect_datetime',
42
+ 'expect_time',
42
43
  'Entity',
43
- 'NYSE'
44
+ 'NYSE',
44
45
  'WEEKDAY_SHORTNAME',
46
+ 'WeekDay',
45
47
  ]
46
48
 
47
49
 
@@ -49,27 +51,8 @@ def Timezone(name:str = 'US/Eastern') -> _zoneinfo.ZoneInfo:
49
51
  """Create a timezone object with the specified name.
50
52
 
51
53
  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
54
+ consistent timezone handling across the library. Note that 'US/Eastern'
55
+ is equivalent to 'America/New_York' for all dates.
73
56
  """
74
57
  return _pendulum.tz.Timezone(name)
75
58
 
@@ -125,8 +108,8 @@ DATEMATCH = re.compile(r'^(?P<d>N|T|Y|P|M)(?P<n>[-+]?\d+)?(?P<b>b?)?$')
125
108
  # return entity
126
109
 
127
110
 
128
- def isdateish(x):
129
- return isinstance(x, _datetime.date | _datetime.datetime | pd.Timestamp | np.datetime64)
111
+ def isdateish(x) -> bool:
112
+ return isinstance(x, _datetime.date | _datetime.datetime | _datetime.time | pd.Timestamp | np.datetime64)
130
113
 
131
114
 
132
115
  def parse_arg(typ, arg):
@@ -164,6 +147,9 @@ def expect(func, typ: type[_datetime.date], exclkw: bool = False) -> Callable:
164
147
  continue
165
148
  if typ == _datetime.date:
166
149
  kwargs[k] = Date.instance(v)
150
+ continue
151
+ if typ == _datetime.time:
152
+ kwargs[k] = Time.instance(v)
167
153
  return func(*args, **kwargs)
168
154
  return wrapper
169
155
 
@@ -218,7 +204,21 @@ def store_both(func=None, *, typ=None):
218
204
  return wrapper
219
205
 
220
206
 
221
- def prefer_utc_timezone(func, force:bool = False):
207
+ def reset_business(func):
208
+ """Decorator to reset business mode after function execution.
209
+ """
210
+ @wraps(func)
211
+ def wrapper(self, *args, **kwargs):
212
+ try:
213
+ return func(self, *args, **kwargs)
214
+ finally:
215
+ self._business = False
216
+ self._start._business = False
217
+ self._end._business = False
218
+ return wrapper
219
+
220
+
221
+ def prefer_utc_timezone(func, force:bool = False) -> Callable:
222
222
  """Return datetime as UTC.
223
223
  """
224
224
  @wraps(func)
@@ -232,7 +232,7 @@ def prefer_utc_timezone(func, force:bool = False):
232
232
  return wrapper
233
233
 
234
234
 
235
- def prefer_native_timezone(func, force:bool = False):
235
+ def prefer_native_timezone(func, force:bool = False) -> Callable:
236
236
  """Return datetime as native.
237
237
  """
238
238
  @wraps(func)
@@ -316,7 +316,7 @@ class NYSE(Entity):
316
316
  def business_holidays(begdate=BEGDATE, enddate=ENDDATE) -> set:
317
317
  return {Date.instance(d.date())
318
318
  for d in map(pd.to_datetime, NYSE.calendar.holidays().holidays)
319
- if begdate <= d <= enddate}
319
+ if begdate <= d.date() <= enddate}
320
320
 
321
321
 
322
322
  class DateBusinessMixin:
@@ -488,63 +488,21 @@ class DateBusinessMixin:
488
488
 
489
489
  @expect_date
490
490
  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
491
+ """Check if the date is a business day (market is open).
502
492
  """
503
493
  return self.is_business_day()
504
494
 
505
495
  @expect_date
506
496
  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
497
+ """Check if the date is a business day according to the entity calendar.
524
498
  """
525
499
  return self in self._entity.business_days()
526
500
 
527
501
  @expect_date
528
502
  def business_hours(self) -> 'tuple[DateTime, DateTime]':
529
- """Business hours
530
-
531
- Returns (None, None) if not a business day
532
-
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, ...)
503
+ """Get market open and close times for this date.
544
504
 
545
- >>> thedate = Date(2024, 5, 27) # memorial day
546
- >>> thedate.business_hours()
547
- (None, None)
505
+ Returns (None, None) if not a business day.
548
506
  """
549
507
  return self._entity.business_hours(self, self)\
550
508
  .get(self, (None, None))
@@ -593,34 +551,25 @@ class DateBusinessMixin:
593
551
  class DateExtrasMixin:
594
552
  """Extended date functionality not provided by Pendulum.
595
553
 
554
+ .. note::
555
+ This mixin exists primarily for legacy backward compatibility.
556
+ New code should prefer using built-in methods where possible.
557
+
596
558
  This mixin provides additional date utilities primarily focused on:
597
559
  - Financial date calculations (nearest month start/end)
598
560
  - Weekday-oriented date navigation
599
561
  - Relative date lookups
600
562
 
601
- These methods extend Pendulum's functionality with features commonly
563
+ These methods extend OpenDate functionality with features commonly
602
564
  needed in financial applications and reporting scenarios.
603
565
  """
604
566
 
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)
567
+ def nearest_start_of_month(self) -> Self:
568
+ """Get the nearest start of month.
569
+
570
+ If day <= 15, returns start of current month.
571
+ If day > 15, returns start of next month.
572
+ In business mode, adjusts to next business day if needed.
624
573
  """
625
574
  _business = self._business
626
575
  self._business = False
@@ -634,25 +583,12 @@ class DateExtrasMixin:
634
583
  return d.business().add(days=1)
635
584
  return d
636
585
 
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)
586
+ def nearest_end_of_month(self) -> Self:
587
+ """Get the nearest end of month.
588
+
589
+ If day <= 15, returns end of previous month.
590
+ If day > 15, returns end of current month.
591
+ In business mode, adjusts to previous business day if needed.
656
592
  """
657
593
  _business = self._business
658
594
  self._business = False
@@ -666,48 +602,27 @@ class DateExtrasMixin:
666
602
  return d.business().subtract(days=1)
667
603
  return d
668
604
 
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)
605
+ def next_relative_date_of_week_by_day(self, day='MO') -> Self:
606
+ """Get next occurrence of the specified weekday (or current date if already that day).
677
607
  """
678
608
  if self.weekday() == WEEKDAY_SHORTNAME.get(day):
679
609
  return self
680
610
  return self.next(WEEKDAY_SHORTNAME.get(day))
681
611
 
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)
612
+ def weekday_or_previous_friday(self) -> Self:
613
+ """Return the date if it is a weekday, otherwise return the previous Friday.
694
614
  """
695
- dnum = self.weekday()
696
- if dnum in {WeekDay.SATURDAY, WeekDay.SUNDAY}:
697
- return self.subtract(days=dnum - 4)
615
+ if self.weekday() in {WeekDay.SATURDAY, WeekDay.SUNDAY}:
616
+ return self.previous(WeekDay.FRIDAY)
698
617
  return self
699
618
 
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
619
  @classmethod
708
- def third_wednesday(cls, year, month):
620
+ def third_wednesday(cls, year, month) -> Self:
709
621
  """Calculate the date of the third Wednesday in a given month/year.
710
622
 
623
+ .. deprecated::
624
+ Use Date(year, month, 1).nth_of('month', 3, WeekDay.WEDNESDAY) instead.
625
+
711
626
  Parameters
712
627
  year: The year to use
713
628
  month: The month to use (1-12)
@@ -715,11 +630,7 @@ class DateExtrasMixin:
715
630
  Returns
716
631
  A Date object representing the third Wednesday of the specified month
717
632
  """
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
633
+ return cls(year, month, 1).nth_of('month', 3, WeekDay.WEDNESDAY)
723
634
 
724
635
 
725
636
  class Date(DateExtrasMixin, DateBusinessMixin, _pendulum.Date):
@@ -736,10 +647,9 @@ class Date(DateExtrasMixin, DateBusinessMixin, _pendulum.Date):
736
647
  """
737
648
 
738
649
  def to_string(self, fmt: str) -> str:
739
- """Format cleaner https://stackoverflow.com/a/2073189.
650
+ """Format date to string, handling platform-specific format codes.
740
651
 
741
- >>> Date(2022, 1, 5).to_string('%-m/%-d/%Y')
742
- '1/5/2022'
652
+ Automatically converts '%-' format codes to '%#' on Windows.
743
653
  """
744
654
  return self.strftime(fmt.replace('%-', '%#') if os.name == 'nt' else fmt)
745
655
 
@@ -772,10 +682,10 @@ class Date(DateExtrasMixin, DateBusinessMixin, _pendulum.Date):
772
682
  Returns
773
683
  A new Date object representing the average date
774
684
  """
775
- return _pendulum.Date.average(self)
685
+ return _pendulum.Date.average(self, dt)
776
686
 
777
687
  @classmethod
778
- def fromordinal(cls, *args, **kwargs):
688
+ def fromordinal(cls, *args, **kwargs) -> Self:
779
689
  """Create a Date from an ordinal.
780
690
 
781
691
  Parameters
@@ -788,7 +698,7 @@ class Date(DateExtrasMixin, DateBusinessMixin, _pendulum.Date):
788
698
  return cls.instance(result)
789
699
 
790
700
  @classmethod
791
- def fromtimestamp(cls, timestamp, tz=None):
701
+ def fromtimestamp(cls, timestamp, tz=None) -> Self:
792
702
  """Create a Date from a timestamp.
793
703
 
794
704
  Parameters
@@ -831,84 +741,47 @@ class Date(DateExtrasMixin, DateBusinessMixin, _pendulum.Date):
831
741
  ) -> Self | None:
832
742
  """Convert a string to a date handling many different formats.
833
743
 
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
744
+ Supports various date formats including:
745
+ - Standard formats: YYYY-MM-DD, MM/DD/YYYY, MM/DD/YY, YYYYMMDD
746
+ - Named months: DD-MON-YYYY, MON-DD-YYYY, Month DD, YYYY
747
+ - Special codes: T (today), Y (yesterday), P (previous business day)
748
+ - Business day offsets: T-3b, P+2b (add/subtract business days)
749
+ - Custom format strings via fmt parameter
750
+
751
+ Parameters
752
+ s: String to parse or None
753
+ fmt: Optional strftime format string for custom parsing
754
+ entity: Calendar entity for business day calculations (default NYSE)
755
+ raise_err: If True, raises ValueError on parse failure instead of returning None
756
+
757
+ Returns
758
+ Date instance or None if parsing fails and raise_err is False
759
+
760
+ Examples
761
+ Standard numeric formats:
762
+ Date.parse('2020-01-15') → Date(2020, 1, 15)
763
+ Date.parse('01/15/2020') → Date(2020, 1, 15)
764
+ Date.parse('01/15/20') → Date(2020, 1, 15)
765
+ Date.parse('20200115') → Date(2020, 1, 15)
766
+
767
+ Named month formats:
768
+ Date.parse('15-Jan-2020') → Date(2020, 1, 15)
769
+ Date.parse('Jan 15, 2020') → Date(2020, 1, 15)
770
+ Date.parse('15JAN2020') → Date(2020, 1, 15)
771
+
772
+ Special codes:
773
+ Date.parse('T') → today's date
774
+ Date.parse('Y') → yesterday's date
775
+ Date.parse('P') → previous business day
776
+ Date.parse('M') last day of previous month
777
+
778
+ Business day offsets:
779
+ Date.parse('T-3b') → 3 business days ago
780
+ Date.parse('P+2b') 2 business days after previous business day
781
+ Date.parse('T+5') → 5 calendar days from today
782
+
783
+ Custom format:
784
+ Date.parse('15-Jan-2020', fmt='%d-%b-%Y') → Date(2020, 1, 15)
912
785
  """
913
786
 
914
787
  def date_for_symbol(s):
@@ -1027,20 +900,17 @@ class Date(DateExtrasMixin, DateBusinessMixin, _pendulum.Date):
1027
900
  | None,
1028
901
  raise_err: bool = False,
1029
902
  ) -> 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)
903
+ """Create a Date instance from various date-like objects.
904
+
905
+ Converts datetime.date, datetime.datetime, pandas Timestamp,
906
+ numpy datetime64, and other date-like objects to Date instances.
1043
907
 
908
+ Parameters
909
+ obj: Date-like object to convert
910
+ raise_err: If True, raises ValueError for None/NA values instead of returning None
911
+
912
+ Returns
913
+ Date instance or None if obj is None/NA and raise_err is False
1044
914
  """
1045
915
  if pd.isna(obj):
1046
916
  if raise_err:
@@ -1056,39 +926,21 @@ class Date(DateExtrasMixin, DateBusinessMixin, _pendulum.Date):
1056
926
  return cls(obj.year, obj.month, obj.day)
1057
927
 
1058
928
  @classmethod
1059
- def today(cls):
929
+ def today(cls) -> Self:
1060
930
  d = _datetime.datetime.now(LCL)
1061
931
  return cls(d.year, d.month, d.day)
1062
932
 
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
933
+ def isoweek(self) -> int | None:
934
+ """Get ISO week number (1-52/53) following ISO week-numbering standard.
1077
935
  """
1078
936
  with contextlib.suppress(Exception):
1079
937
  return self.isocalendar()[1]
1080
938
 
1081
939
  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)
940
+ """Get date in the past based on lookback unit.
941
+
942
+ Supported units: 'last'/'day' (1 day), 'week', 'month', 'quarter', 'year'.
943
+ Respects business day mode if enabled.
1092
944
  """
1093
945
  def _lookback(years=0, months=0, weeks=0, days=0):
1094
946
  _business = self._business
@@ -1124,38 +976,46 @@ class Time(_pendulum.Time):
1124
976
  @classmethod
1125
977
  @prefer_utc_timezone
1126
978
  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'))
979
+ """Parse time string in various formats.
980
+
981
+ Supported formats:
982
+ - hh:mm or hh.mm
983
+ - hh:mm:ss or hh.mm.ss
984
+ - hh:mm:ss.microseconds
985
+ - Any of above with AM/PM
986
+ - Compact: hhmmss or hhmmss.microseconds
987
+
988
+ Returns Time with UTC timezone by default.
989
+
990
+ Parameters
991
+ s: String to parse or None
992
+ fmt: Optional strftime format string for custom parsing
993
+ raise_err: If True, raises ValueError on parse failure instead of returning None
994
+
995
+ Returns
996
+ Time instance with UTC timezone or None if parsing fails and raise_err is False
997
+
998
+ Examples
999
+ Basic time formats:
1000
+ Time.parse('14:30') → Time(14, 30, 0, 0, tzinfo=UTC)
1001
+ Time.parse('14.30') Time(14, 30, 0, 0, tzinfo=UTC)
1002
+ Time.parse('14:30:45') → Time(14, 30, 45, 0, tzinfo=UTC)
1003
+
1004
+ With microseconds:
1005
+ Time.parse('14:30:45.123456') → Time(14, 30, 45, 123456000, tzinfo=UTC)
1006
+ Time.parse('14:30:45,500000') → Time(14, 30, 45, 500000000, tzinfo=UTC)
1007
+
1008
+ AM/PM formats:
1009
+ Time.parse('2:30 PM') → Time(14, 30, 0, 0, tzinfo=UTC)
1010
+ Time.parse('11:30 AM') → Time(11, 30, 0, 0, tzinfo=UTC)
1011
+ Time.parse('12:30 PM') → Time(12, 30, 0, 0, tzinfo=UTC)
1012
+
1013
+ Compact formats:
1014
+ Time.parse('143045') → Time(14, 30, 45, 0, tzinfo=UTC)
1015
+ Time.parse('1430') → Time(14, 30, 0, 0, tzinfo=UTC)
1016
+
1017
+ Custom format:
1018
+ Time.parse('14-30-45', fmt='%H-%M-%S') → Time(14, 30, 45, 0, tzinfo=UTC)
1159
1019
  """
1160
1020
 
1161
1021
  def seconds(m):
@@ -1225,18 +1085,9 @@ class Time(_pendulum.Time):
1225
1085
  tz: str | _zoneinfo.ZoneInfo | _datetime.tzinfo | None = None,
1226
1086
  raise_err: bool = False,
1227
1087
  ) -> 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)
1088
+ """Create Time instance from time-like object.
1239
1089
 
1090
+ Adds UTC timezone by default unless obj is already a Time instance.
1240
1091
  """
1241
1092
  if pd.isna(obj):
1242
1093
  if raise_err:
@@ -1250,15 +1101,8 @@ class Time(_pendulum.Time):
1250
1101
 
1251
1102
  return cls(obj.hour, obj.minute, obj.second, obj.microsecond, tzinfo=tz)
1252
1103
 
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
-
1104
+ def in_timezone(self, tz: str | _zoneinfo.ZoneInfo | _datetime.tzinfo) -> Self:
1105
+ """Convert time to a different timezone.
1262
1106
  """
1263
1107
  _dt = DateTime.combine(Date.today(), self, tzinfo=self.tzinfo or UTC)
1264
1108
  return _dt.in_timezone(tz).time()
@@ -1281,7 +1125,7 @@ class DateTime(DateBusinessMixin, _pendulum.DateTime):
1281
1125
  - Has timezone handling helpers not present in pendulum
1282
1126
  """
1283
1127
 
1284
- def epoch(self):
1128
+ def epoch(self) -> float:
1285
1129
  """Translate a datetime object into unix seconds since epoch
1286
1130
  """
1287
1131
  return self.timestamp()
@@ -1311,7 +1155,7 @@ class DateTime(DateBusinessMixin, _pendulum.DateTime):
1311
1155
  return _pendulum.DateTime.replace(self, *args, **kwargs)
1312
1156
 
1313
1157
  @classmethod
1314
- def fromordinal(cls, *args, **kwargs):
1158
+ def fromordinal(cls, *args, **kwargs) -> Self:
1315
1159
  """Create a DateTime from an ordinal.
1316
1160
 
1317
1161
  Parameters
@@ -1324,7 +1168,7 @@ class DateTime(DateBusinessMixin, _pendulum.DateTime):
1324
1168
  return cls.instance(result)
1325
1169
 
1326
1170
  @classmethod
1327
- def fromtimestamp(cls, timestamp, tz=None):
1171
+ def fromtimestamp(cls, timestamp, tz=None) -> Self:
1328
1172
  """Create a DateTime from a timestamp.
1329
1173
 
1330
1174
  Parameters
@@ -1339,7 +1183,7 @@ class DateTime(DateBusinessMixin, _pendulum.DateTime):
1339
1183
  return cls.instance(result)
1340
1184
 
1341
1185
  @classmethod
1342
- def strptime(cls, time_str, fmt):
1186
+ def strptime(cls, time_str, fmt) -> Self:
1343
1187
  """Parse a string into a DateTime according to a format.
1344
1188
 
1345
1189
  Parameters
@@ -1353,7 +1197,7 @@ class DateTime(DateBusinessMixin, _pendulum.DateTime):
1353
1197
  return cls.instance(result)
1354
1198
 
1355
1199
  @classmethod
1356
- def utcfromtimestamp(cls, timestamp):
1200
+ def utcfromtimestamp(cls, timestamp) -> Self:
1357
1201
  """Create a UTC DateTime from a timestamp.
1358
1202
 
1359
1203
  Parameters
@@ -1366,7 +1210,7 @@ class DateTime(DateBusinessMixin, _pendulum.DateTime):
1366
1210
  return cls.instance(result)
1367
1211
 
1368
1212
  @classmethod
1369
- def utcnow(cls):
1213
+ def utcnow(cls) -> Self:
1370
1214
  """Create a DateTime representing current UTC time.
1371
1215
 
1372
1216
  Returns
@@ -1391,7 +1235,7 @@ class DateTime(DateBusinessMixin, _pendulum.DateTime):
1391
1235
  d.microsecond, tzinfo=d.tzinfo, fold=d.fold)
1392
1236
 
1393
1237
  @classmethod
1394
- def today(cls, tz: str | _zoneinfo.ZoneInfo | None = None):
1238
+ def today(cls, tz: str | _zoneinfo.ZoneInfo | None = None) -> Self:
1395
1239
  """Create a DateTime object representing today at the start of day.
1396
1240
 
1397
1241
  Unlike pendulum.today() which returns current time, this method
@@ -1405,7 +1249,7 @@ class DateTime(DateBusinessMixin, _pendulum.DateTime):
1405
1249
  """
1406
1250
  return DateTime.now(tz).start_of('day')
1407
1251
 
1408
- def date(self):
1252
+ def date(self) -> Date:
1409
1253
  return Date(self.year, self.month, self.day)
1410
1254
 
1411
1255
  @classmethod
@@ -1420,25 +1264,13 @@ class DateTime(DateBusinessMixin, _pendulum.DateTime):
1420
1264
  _tzinfo = tzinfo or time.tzinfo
1421
1265
  return DateTime.instance(_datetime.datetime.combine(date, time, tzinfo=_tzinfo))
1422
1266
 
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'
1267
+ def rfc3339(self) -> str:
1268
+ """Return RFC 3339 formatted string (same as isoformat()).
1429
1269
  """
1430
1270
  return self.isoformat()
1431
1271
 
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'))
1272
+ def time(self) -> Time:
1273
+ """Extract time component from datetime (preserving timezone).
1442
1274
  """
1443
1275
  return Time.instance(self)
1444
1276
 
@@ -1451,41 +1283,43 @@ class DateTime(DateBusinessMixin, _pendulum.DateTime):
1451
1283
  """Convert a string or timestamp to a DateTime with extended format support.
1452
1284
 
1453
1285
  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)
1286
+ - Unix timestamps (int/float, handles milliseconds automatically)
1287
+ - Special codes: T (today), Y (yesterday), P (previous business day)
1288
+ - Business day offsets: T-3b, P+2b (add/subtract business days)
1457
1289
  - Multiple date-time formats beyond ISO 8601
1290
+ - Combined date and time strings with various separators
1458
1291
 
1459
1292
  Parameters
1460
1293
  s: String or timestamp to parse
1461
- entity: Calendar entity for business day calculations
1462
- raise_err: Whether to raise error on parse failure
1294
+ entity: Calendar entity for business day calculations (default NYSE)
1295
+ raise_err: If True, raises ValueError on parse failure instead of returning None
1463
1296
 
1464
1297
  Returns
1465
1298
  DateTime instance or None if parsing fails and raise_err is False
1466
1299
 
1467
1300
  Examples
1301
+ Unix timestamps:
1302
+ DateTime.parse(1609459200) → DateTime(2021, 1, 1, 0, 0, 0, tzinfo=LCL)
1303
+ DateTime.parse(1609459200000) → DateTime(2021, 1, 1, 0, 0, 0, tzinfo=LCL)
1468
1304
 
1469
- Basic formats:
1470
- >>> DateTime.parse('2022/1/1')
1471
- DateTime(2022, 1, 1, 0, 0, 0, tzinfo=Timezone('...'))
1305
+ ISO 8601 format:
1306
+ DateTime.parse('2020-01-15T14:30:00') → DateTime(2020, 1, 15, 14, 30, 0)
1472
1307
 
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'))
1308
+ Date and time separated:
1309
+ DateTime.parse('2020-01-15 14:30:00') → DateTime(2020, 1, 15, 14, 30, 0, tzinfo=LCL)
1310
+ DateTime.parse('01/15/2020:14:30:00') → DateTime(2020, 1, 15, 14, 30, 0, tzinfo=LCL)
1477
1311
 
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=...)
1312
+ Date only (time defaults to 00:00:00):
1313
+ DateTime.parse('2020-01-15') → DateTime(2020, 1, 15, 0, 0, 0)
1314
+ DateTime.parse('01/15/2020') → DateTime(2020, 1, 15, 0, 0, 0)
1481
1315
 
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())
1316
+ Time only (uses today's date):
1317
+ DateTime.parse('14:30:00') → DateTime(today's year, month, day, 14, 30, 0, tzinfo=LCL)
1485
1318
 
1486
- Timestamp parsing:
1487
- >>> DateTime.parse(1707856982).replace(tzinfo=UTC).epoch()
1488
- 1707856982.0
1319
+ Special codes:
1320
+ DateTime.parse('T') → today at 00:00:00
1321
+ DateTime.parse('Y') → yesterday at 00:00:00
1322
+ DateTime.parse('P') → previous business day at 00:00:00
1489
1323
  """
1490
1324
  if not s:
1491
1325
  if raise_err:
@@ -1538,44 +1372,21 @@ class DateTime(DateBusinessMixin, _pendulum.DateTime):
1538
1372
  ) -> Self | None:
1539
1373
  """Create a DateTime instance from various datetime-like objects.
1540
1374
 
1541
- This method provides a unified interface for converting different
1542
- date/time types including pandas and numpy datetime objects into
1543
- DateTime instances.
1375
+ Provides unified interface for converting different date/time types
1376
+ including pandas and numpy datetime objects into DateTime instances.
1544
1377
 
1545
1378
  Unlike pendulum, this method:
1546
1379
  - Handles pandas Timestamp and numpy datetime64 objects
1547
1380
  - Adds timezone (UTC by default) when none is specified
1548
- - Has special handling for time objects
1381
+ - Has special handling for Time objects (combines with current date)
1549
1382
 
1550
1383
  Parameters
1551
1384
  obj: Date, datetime, time, or compatible object to convert
1552
1385
  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
1386
+ raise_err: If True, raises ValueError for None/NA values instead of returning None
1554
1387
 
1555
1388
  Returns
1556
1389
  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
1390
  """
1580
1391
  if pd.isna(obj):
1581
1392
  if raise_err:
@@ -1608,25 +1419,91 @@ class DateTime(DateBusinessMixin, _pendulum.DateTime):
1608
1419
  obj.second, obj.microsecond, tzinfo=tz)
1609
1420
 
1610
1421
 
1611
- class IntervalError(AttributeError):
1612
- pass
1422
+ class Interval(_pendulum.Interval):
1423
+ """Interval class extending pendulum.Interval with business day awareness.
1613
1424
 
1425
+ This class represents the difference between two dates or datetimes with
1426
+ additional support for business day calculations, entity awareness, and
1427
+ financial period calculations.
1614
1428
 
1615
- class Interval:
1429
+ Unlike pendulum.Interval:
1430
+ - Has business day mode that only counts business days
1431
+ - Preserves entity association (e.g., NYSE)
1432
+ - Additional financial methods like yearfrac()
1433
+ - Support for range operations that respect business days
1434
+ """
1616
1435
 
1617
1436
  _business: bool = False
1618
1437
  _entity: type[NYSE] = NYSE
1619
1438
 
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)
1439
+ def __new__(cls, begdate: Date | DateTime, enddate: Date | DateTime) -> Self:
1440
+ assert begdate and enddate, 'Interval dates cannot be None'
1441
+ instance = super().__new__(cls, begdate, enddate, False)
1442
+ return instance
1443
+
1444
+ def __init__(self, begdate: Date | DateTime, enddate: Date | DateTime) -> None:
1445
+ super().__init__(begdate, enddate, False)
1446
+ self._sign = 1 if begdate <= enddate else -1
1447
+ if begdate <= enddate:
1448
+ self._start = begdate
1449
+ self._end = enddate
1450
+ else:
1451
+ self._start = enddate
1452
+ self._end = begdate
1453
+
1454
+ @staticmethod
1455
+ def _get_quarter_start(date: Date | DateTime) -> Date | DateTime:
1456
+ """Get the start date of the quarter containing the given date.
1457
+ """
1458
+ quarter_month = ((date.month - 1) // 3) * 3 + 1
1459
+ return date.replace(month=quarter_month, day=1)
1460
+
1461
+ @staticmethod
1462
+ def _get_quarter_end(date: Date | DateTime) -> Date | DateTime:
1463
+ """Get the end date of the quarter containing the given date.
1464
+ """
1465
+ quarter_month = ((date.month - 1) // 3) * 3 + 3
1466
+ return date.replace(month=quarter_month).end_of('month')
1467
+
1468
+ def _get_unit_handlers(self, unit: str) -> dict:
1469
+ """Get handlers for the specified time unit.
1470
+
1471
+ Returns a dict with:
1472
+ get_start: Function to get start of period containing date
1473
+ get_end: Function to get end of period containing date
1474
+ advance: Function to advance to next period start
1475
+ """
1476
+ if unit == 'quarter':
1477
+ return {
1478
+ 'get_start': self._get_quarter_start,
1479
+ 'get_end': self._get_quarter_end,
1480
+ 'advance': lambda date: self._get_quarter_start(date.add(months=3)),
1481
+ }
1482
+
1483
+ if unit == 'decade':
1484
+ return {
1485
+ 'get_start': lambda date: date.start_of('decade'),
1486
+ 'get_end': lambda date: date.end_of('decade'),
1487
+ 'advance': lambda date: date.add(years=10).start_of('decade'),
1488
+ }
1489
+
1490
+ if unit == 'century':
1491
+ return {
1492
+ 'get_start': lambda date: date.start_of('century'),
1493
+ 'get_end': lambda date: date.end_of('century'),
1494
+ 'advance': lambda date: date.add(years=100).start_of('century'),
1495
+ }
1496
+
1497
+ return {
1498
+ 'get_start': lambda date: date.start_of(unit),
1499
+ 'get_end': lambda date: date.end_of(unit),
1500
+ 'advance': lambda date: date.add(**{f'{unit}s': 1}).start_of(unit),
1501
+ }
1623
1502
 
1624
1503
  def business(self) -> Self:
1625
1504
  self._business = True
1626
- if self.begdate:
1627
- self.begdate.business()
1628
- if self.enddate:
1629
- self.enddate.business()
1505
+ self._start.business()
1506
+ self._end.business()
1630
1507
  return self
1631
1508
 
1632
1509
  @property
@@ -1635,249 +1512,120 @@ class Interval:
1635
1512
 
1636
1513
  def entity(self, e: type[NYSE] = NYSE) -> Self:
1637
1514
  self._entity = e
1638
- if self.begdate:
1639
- self.enddate._entity = e
1640
- if self.enddate:
1641
- self.enddate._entity = e
1515
+ if self._start:
1516
+ self._end._entity = e
1517
+ if self._end:
1518
+ self._end._entity = e
1642
1519
  return self
1643
1520
 
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))
1521
+ def is_business_day_range(self) -> list[bool]:
1522
+ """Generate boolean values indicating whether each day in the range is a business day.
1683
1523
  """
1684
- begdate, enddate = self.begdate, self.enddate
1524
+ self._business = False
1525
+ for thedate in self.range('days'):
1526
+ yield thedate.is_business_day()
1685
1527
 
1686
- window = abs(int(window or 0))
1528
+ @reset_business
1529
+ def range(self, unit: str = 'days', amount: int = 1) -> Iterator[DateTime | Date]:
1530
+ """Generate dates/datetimes over the interval.
1687
1531
 
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')
1532
+ Parameters
1533
+ unit: Time unit ('days', 'weeks', 'months', 'years')
1534
+ amount: Step size (e.g., every N units)
1694
1535
 
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
1536
+ In business mode (for 'days' only), skips non-business days.
1537
+ """
1538
+ _business = self._business
1539
+ parent_range = _pendulum.Interval.range
1701
1540
 
1702
- enddate._business = False
1703
- begdate._business = False
1541
+ def _range_generator():
1542
+ if unit != 'days':
1543
+ yield from (type(d).instance(d) for d in parent_range(self, unit, amount))
1544
+ return
1704
1545
 
1705
- return begdate, enddate
1546
+ if self._sign == 1:
1547
+ op = operator.le
1548
+ this = self._start
1549
+ thru = self._end
1550
+ else:
1551
+ op = operator.ge
1552
+ this = self._end
1553
+ thru = self._start
1554
+
1555
+ while op(this, thru):
1556
+ if _business:
1557
+ if this.is_business_day():
1558
+ yield this
1559
+ else:
1560
+ yield this
1561
+ this = this.add(days=self._sign * amount)
1706
1562
 
1707
- def is_business_day_series(self) -> list[bool]:
1708
- """Is business date range.
1563
+ return _range_generator()
1709
1564
 
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]
1565
+ @property
1566
+ @reset_business
1567
+ def days(self) -> int:
1568
+ """Get number of days in the interval (respects business mode and sign).
1714
1569
  """
1715
- for thedate in self.series():
1716
- yield thedate.is_business_day()
1570
+ return self._sign * len(tuple(self.range('days'))) - self._sign
1717
1571
 
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)]
1572
+ @property
1573
+ def months(self) -> float:
1574
+ """Get number of months in the interval including fractional parts.
1575
+
1576
+ Overrides pendulum's months property to return a float instead of an integer.
1577
+ Calculates fractional months based on actual day counts within partial months.
1800
1578
  """
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')]
1579
+ year_diff = self._end.year - self._start.year
1580
+ month_diff = self._end.month - self._start.month
1581
+ total_months = year_diff * 12 + month_diff
1582
+
1583
+ if self._end.day >= self._start.day:
1584
+ day_diff = self._end.day - self._start.day
1585
+ days_in_month = calendar.monthrange(self._start.year, self._start.month)[1]
1586
+ fraction = day_diff / days_in_month
1587
+ else:
1588
+ total_months -= 1
1589
+ days_in_start_month = calendar.monthrange(self._start.year, self._start.month)[1]
1590
+ day_diff = (days_in_start_month - self._start.day) + self._end.day
1591
+ fraction = day_diff / days_in_start_month
1805
1592
 
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
1593
+ return self._sign * (total_months + fraction)
1594
+
1595
+ @property
1596
+ def quarters(self) -> float:
1597
+ """Get approximate number of quarters in the interval.
1598
+
1599
+ Note: This is an approximation using day count / 365 * 4.
1817
1600
  """
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
1601
+ return self._sign * 4 * self.days / 365.0
1602
+
1603
+ @property
1604
+ def years(self) -> int:
1605
+ """Get number of complete years in the interval (always floors).
1843
1606
  """
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'
1607
+ year_diff = self._end.year - self._start.year
1608
+ if self._end.month < self._start.month or \
1609
+ (self._end.month == self._start.month and self._end.day < self._start.day):
1610
+ year_diff -= 1
1611
+ return self._sign * year_diff
1612
+
1613
+ def yearfrac(self, basis: int = 0) -> float:
1614
+ """Calculate the fraction of years between two dates (Excel-compatible).
1615
+
1616
+ This method provides precise calculation using various day count conventions
1617
+ used in finance. Results are tested against Excel for compatibility.
1618
+
1619
+ Parameters
1620
+ basis: Day count convention to use:
1621
+ 0 = US (NASD) 30/360 (default)
1622
+ 1 = Actual/actual
1623
+ 2 = Actual/360
1624
+ 3 = Actual/365
1625
+ 4 = European 30/360
1626
+
1627
+ Note: Excel has a known leap year bug for year 1900 which is intentionally
1628
+ replicated for compatibility (1900 is treated as a leap year even though it wasn't).
1881
1629
  """
1882
1630
 
1883
1631
  def average_year_length(date1, date2):
@@ -1962,32 +1710,84 @@ class Interval:
1962
1710
  (date1day + date1month * 30 + date1year * 360)
1963
1711
  return daydiff360 / 360
1964
1712
 
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:
1713
+ if self._start == self._end:
1974
1714
  return 0.0
1975
-
1976
1715
  if basis == 0:
1977
- return basis0(begdate, enddate) * sign
1716
+ return basis0(self._start, self._end) * self._sign
1978
1717
  if basis == 1:
1979
- return basis1(begdate, enddate) * sign
1718
+ return basis1(self._start, self._end) * self._sign
1980
1719
  if basis == 2:
1981
- return basis2(begdate, enddate) * sign
1720
+ return basis2(self._start, self._end) * self._sign
1982
1721
  if basis == 3:
1983
- return basis3(begdate, enddate) * sign
1722
+ return basis3(self._start, self._end) * self._sign
1984
1723
  if basis == 4:
1985
- return basis4(begdate, enddate) * sign
1724
+ return basis4(self._start, self._end) * self._sign
1986
1725
 
1987
1726
  raise ValueError('Basis range [0, 4]. Unknown basis {basis}.')
1988
1727
 
1728
+ @reset_business
1729
+ def start_of(self, unit: str = 'month') -> list[Date | DateTime]:
1730
+ """Return the start of each unit within the interval.
1731
+
1732
+ Parameters
1733
+ unit: Time unit ('month', 'week', 'year', 'quarter')
1734
+
1735
+ Returns
1736
+ List of Date or DateTime objects representing start of each unit
1737
+
1738
+ In business mode, each start date is adjusted to the next business day
1739
+ if it falls on a non-business day.
1740
+ """
1741
+ handlers = self._get_unit_handlers(unit)
1742
+ result = []
1743
+
1744
+ current = handlers['get_start'](self._start)
1745
+
1746
+ if self._business:
1747
+ current._entity = self._entity
1748
+
1749
+ while current <= self._end:
1750
+ if self._business:
1751
+ current = current._business_or_next()
1752
+ result.append(current)
1753
+ current = handlers['advance'](current)
1754
+
1755
+ return result
1989
1756
 
1990
- def create_ics(begdate, enddate, summary, location):
1757
+ @reset_business
1758
+ def end_of(self, unit: str = 'month') -> list[Date | DateTime]:
1759
+ """Return the end of each unit within the interval.
1760
+
1761
+ Parameters
1762
+ unit: Time unit ('month', 'week', 'year', 'quarter')
1763
+
1764
+ Returns
1765
+ List of Date or DateTime objects representing end of each unit
1766
+
1767
+ In business mode, each end date is adjusted to the previous business day
1768
+ if it falls on a non-business day.
1769
+ """
1770
+ handlers = self._get_unit_handlers(unit)
1771
+ result = []
1772
+
1773
+ current = handlers['get_start'](self._start)
1774
+
1775
+ if self._business:
1776
+ current._entity = self._entity
1777
+
1778
+ while current <= self._end:
1779
+ end_date = handlers['get_end'](current)
1780
+
1781
+ if self._business:
1782
+ end_date = end_date._business_or_previous()
1783
+ result.append(end_date)
1784
+
1785
+ current = handlers['advance'](current)
1786
+
1787
+ return result
1788
+
1789
+
1790
+ def create_ics(begdate, enddate, summary, location) -> str:
1991
1791
  """Create a simple .ics file per RFC 5545 guidelines."""
1992
1792
 
1993
1793
  return f"""BEGIN:VCALENDAR
@@ -2001,7 +1801,3 @@ LOCATION:{location}
2001
1801
  END:VEVENT
2002
1802
  END:VCALENDAR
2003
1803
  """
2004
-
2005
-
2006
- if __name__ == '__main__':
2007
- __import__('doctest').testmod(optionflags=4 | 8 | 32)