opendate 0.1.12__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,35 +39,20 @@ __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
 
48
50
  def Timezone(name:str = 'US/Eastern') -> _zoneinfo.ZoneInfo:
49
- """Simple wrapper around Pendulum `Timezone`
50
-
51
- Ex: sanity check US/Eastern == America/New_York
52
-
53
- >>> winter1 = DateTime(2000, 1, 1, 12, tzinfo=Timezone('US/Eastern'))
54
- >>> winter2 = DateTime(2000, 1, 1, 12, tzinfo=Timezone('America/New_York'))
55
-
56
- >>> summer1 = DateTime(2000, 7, 1, 12, tzinfo=Timezone('US/Eastern'))
57
- >>> summer2 = DateTime(2000, 7, 1, 12, tzinfo=Timezone('America/New_York'))
58
-
59
- >>> winter = [winter1, winter2,
60
- ... winter1.astimezone(Timezone('America/New_York')),
61
- ... winter2.astimezone(Timezone('US/Eastern')),
62
- ... ]
63
- >>> assert all(x==winter[0] for x in winter)
64
-
65
- >>> summer = [summer1, summer2,
66
- ... summer1.astimezone(Timezone('America/New_York')),
67
- ... summer2.astimezone(Timezone('US/Eastern')),
68
- ... ]
69
- >>> assert all(x==summer[0] for x in summer)
51
+ """Create a timezone object with the specified name.
70
52
 
53
+ Simple wrapper around Pendulum's Timezone function that ensures
54
+ consistent timezone handling across the library. Note that 'US/Eastern'
55
+ is equivalent to 'America/New_York' for all dates.
71
56
  """
72
57
  return _pendulum.tz.Timezone(name)
73
58
 
@@ -123,8 +108,8 @@ DATEMATCH = re.compile(r'^(?P<d>N|T|Y|P|M)(?P<n>[-+]?\d+)?(?P<b>b?)?$')
123
108
  # return entity
124
109
 
125
110
 
126
- def isdateish(x):
127
- 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)
128
113
 
129
114
 
130
115
  def parse_arg(typ, arg):
@@ -162,6 +147,9 @@ def expect(func, typ: type[_datetime.date], exclkw: bool = False) -> Callable:
162
147
  continue
163
148
  if typ == _datetime.date:
164
149
  kwargs[k] = Date.instance(v)
150
+ continue
151
+ if typ == _datetime.time:
152
+ kwargs[k] = Time.instance(v)
165
153
  return func(*args, **kwargs)
166
154
  return wrapper
167
155
 
@@ -172,8 +160,17 @@ expect_time = partial(expect, typ=_datetime.time)
172
160
 
173
161
 
174
162
  def type_class(typ, obj):
163
+ if isinstance(typ, str):
164
+ if typ == 'Date':
165
+ return Date
166
+ if typ == 'DateTime':
167
+ return DateTime
168
+ if typ == 'Interval':
169
+ return Interval
175
170
  if typ:
176
171
  return typ
172
+ if obj.__class__ in {_pendulum.Interval, Interval}:
173
+ return Interval
177
174
  if obj.__class__ in {_datetime.datetime, _pendulum.DateTime, DateTime}:
178
175
  return DateTime
179
176
  if obj.__class__ in {_datetime.date, _pendulum.Date, Date}:
@@ -207,7 +204,21 @@ def store_both(func=None, *, typ=None):
207
204
  return wrapper
208
205
 
209
206
 
210
- 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:
211
222
  """Return datetime as UTC.
212
223
  """
213
224
  @wraps(func)
@@ -221,7 +232,7 @@ def prefer_utc_timezone(func, force:bool = False):
221
232
  return wrapper
222
233
 
223
234
 
224
- def prefer_native_timezone(func, force:bool = False):
235
+ def prefer_native_timezone(func, force:bool = False) -> Callable:
225
236
  """Return datetime as native.
226
237
  """
227
238
  @wraps(func)
@@ -240,7 +251,15 @@ expect_utc_timezone = partial(prefer_utc_timezone, force=True)
240
251
 
241
252
 
242
253
  class Entity(ABC):
243
- """ABC for named entity types"""
254
+ """Abstract base class for calendar entities with business day definitions.
255
+
256
+ This class defines the interface for calendar entities that provide
257
+ business day information, such as market open/close times and holidays.
258
+ Not available in pendulum.
259
+
260
+ Concrete implementations (like NYSE) provide specific calendar rules
261
+ for different business contexts.
262
+ """
244
263
 
245
264
  tz = UTC
246
265
 
@@ -261,7 +280,15 @@ class Entity(ABC):
261
280
 
262
281
 
263
282
  class NYSE(Entity):
264
- """New York Stock Exchange"""
283
+ """New York Stock Exchange calendar entity.
284
+
285
+ Provides business day definitions, market hours, and holidays
286
+ according to the NYSE trading calendar. Uses pandas_market_calendars
287
+ for the underlying implementation.
288
+
289
+ This entity is used as the default for business day calculations
290
+ throughout the library.
291
+ """
265
292
 
266
293
  BEGDATE = _datetime.date(1900, 1, 1)
267
294
  ENDDATE = _datetime.date(2200, 1, 1)
@@ -289,31 +316,74 @@ class NYSE(Entity):
289
316
  def business_holidays(begdate=BEGDATE, enddate=ENDDATE) -> set:
290
317
  return {Date.instance(d.date())
291
318
  for d in map(pd.to_datetime, NYSE.calendar.holidays().holidays)
292
- if begdate <= d <= enddate}
319
+ if begdate <= d.date() <= enddate}
293
320
 
294
321
 
295
322
  class DateBusinessMixin:
323
+ """Mixin class providing business day functionality.
324
+
325
+ This mixin adds business day awareness to Date and DateTime classes,
326
+ allowing date operations to account for weekends and holidays according
327
+ to a specified calendar entity.
328
+
329
+ Features not available in pendulum:
330
+ - Business day mode toggle
331
+ - Entity-specific calendar rules
332
+ - Business-aware date arithmetic
333
+ """
296
334
 
297
335
  _entity: type[NYSE] = NYSE
298
336
  _business: bool = False
299
337
 
300
338
  def business(self) -> Self:
339
+ """Switch to business day mode for date calculations.
340
+
341
+ In business day mode, date arithmetic only counts business days
342
+ as defined by the associated entity (default NYSE).
343
+
344
+ Returns
345
+ Self instance for method chaining
346
+ """
301
347
  self._business = True
302
348
  return self
303
349
 
304
350
  @property
305
351
  def b(self) -> Self:
352
+ """Shorthand property for business() method.
353
+
354
+ Returns
355
+ Self instance for method chaining
356
+ """
306
357
  return self.business()
307
358
 
308
359
  def entity(self, entity: type[NYSE] = NYSE) -> Self:
360
+ """Set the calendar entity for business day calculations.
361
+
362
+ Parameters
363
+ entity: Calendar entity class (defaults to NYSE)
364
+
365
+ Returns
366
+ Self instance for method chaining
367
+ """
309
368
  self._entity = entity
310
369
  return self
311
370
 
312
371
  @store_entity
313
372
  def add(self, years: int = 0, months: int = 0, weeks: int = 0, days: int = 0, **kwargs) -> Self:
314
- """Add wrapper
315
- If not business use Pendulum
316
- If business assume only days (for now) and use local logic
373
+ """Add time periods to the current date or datetime.
374
+
375
+ Extends pendulum's add method with business day awareness. When in business mode,
376
+ only counts business days for the 'days' parameter.
377
+
378
+ Parameters
379
+ years: Number of years to add
380
+ months: Number of months to add
381
+ weeks: Number of weeks to add
382
+ days: Number of days to add (business days if in business mode)
383
+ **kwargs: Additional time units to add
384
+
385
+ Returns
386
+ New instance with added time
317
387
  """
318
388
  _business = self._business
319
389
  self._business = False
@@ -418,63 +488,21 @@ class DateBusinessMixin:
418
488
 
419
489
  @expect_date
420
490
  def business_open(self) -> bool:
421
- """Business open
422
-
423
- >>> thedate = Date(2021, 4, 19) # Monday
424
- >>> thedate.business_open()
425
- True
426
- >>> thedate = Date(2021, 4, 17) # Saturday
427
- >>> thedate.business_open()
428
- False
429
- >>> thedate = Date(2021, 1, 18) # MLK Day
430
- >>> thedate.business_open()
431
- False
491
+ """Check if the date is a business day (market is open).
432
492
  """
433
493
  return self.is_business_day()
434
494
 
435
495
  @expect_date
436
496
  def is_business_day(self) -> bool:
437
- """Is business date.
438
-
439
- >>> thedate = Date(2021, 4, 19) # Monday
440
- >>> thedate.is_business_day()
441
- True
442
- >>> thedate = Date(2021, 4, 17) # Saturday
443
- >>> thedate.is_business_day()
444
- False
445
- >>> thedate = Date(2021, 1, 18) # MLK Day
446
- >>> thedate.is_business_day()
447
- False
448
- >>> thedate = Date(2021, 11, 25) # Thanksgiving
449
- >>> thedate.is_business_day()
450
- False
451
- >>> thedate = Date(2021, 11, 26) # Day after ^
452
- >>> thedate.is_business_day()
453
- True
497
+ """Check if the date is a business day according to the entity calendar.
454
498
  """
455
499
  return self in self._entity.business_days()
456
500
 
457
501
  @expect_date
458
502
  def business_hours(self) -> 'tuple[DateTime, DateTime]':
459
- """Business hours
460
-
461
- Returns (None, None) if not a business day
503
+ """Get market open and close times for this date.
462
504
 
463
- >>> thedate = Date(2023, 1, 5)
464
- >>> thedate.business_hours()
465
- (... 9, 30, ... 16, 0, ...)
466
-
467
- >>> thedate = Date(2023, 7, 3)
468
- >>> thedate.business_hours()
469
- (... 9, 30, ... 13, 0, ...)
470
-
471
- >>> thedate = Date(2023, 11, 24)
472
- >>> thedate.business_hours()
473
- (... 9, 30, ... 13, 0, ...)
474
-
475
- >>> thedate = Date(2024, 5, 27) # memorial day
476
- >>> thedate.business_hours()
477
- (None, None)
505
+ Returns (None, None) if not a business day.
478
506
  """
479
507
  return self._entity.business_hours(self, self)\
480
508
  .get(self, (None, None))
@@ -521,35 +549,27 @@ class DateBusinessMixin:
521
549
 
522
550
 
523
551
  class DateExtrasMixin:
524
- """Legacy support functionality well outside the
525
- scope of Pendulum. Ideally these should be removed.
552
+ """Extended date functionality not provided by Pendulum.
526
553
 
527
- See how pendulum does end_of and next_ with getattr
554
+ .. note::
555
+ This mixin exists primarily for legacy backward compatibility.
556
+ New code should prefer using built-in methods where possible.
528
557
 
529
- Create a nearest [start_of, end_of] [week, day, month, quarter, year]
558
+ This mixin provides additional date utilities primarily focused on:
559
+ - Financial date calculations (nearest month start/end)
560
+ - Weekday-oriented date navigation
561
+ - Relative date lookups
530
562
 
531
- combo that accounts for whatever prefix and unit is passed in
563
+ These methods extend OpenDate functionality with features commonly
564
+ needed in financial applications and reporting scenarios.
532
565
  """
533
566
 
534
- def nearest_start_of_month(self):
535
- """Get `nearest` start of month
536
-
537
- 1/1/2015 -> Thursday (New Year's Day)
538
- 2/1/2015 -> Sunday
539
-
540
- >>> from date import Date
541
- >>> Date(2015, 1, 1).nearest_start_of_month()
542
- Date(2015, 1, 1)
543
- >>> Date(2015, 1, 15).nearest_start_of_month()
544
- Date(2015, 1, 1)
545
- >>> Date(2015, 1, 15).b.nearest_start_of_month()
546
- Date(2015, 1, 2)
547
- >>> Date(2015, 1, 16).nearest_start_of_month()
548
- Date(2015, 2, 1)
549
- >>> Date(2015, 1, 31).nearest_start_of_month()
550
- Date(2015, 2, 1)
551
- >>> Date(2015, 1, 31).b.nearest_start_of_month()
552
- 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.
553
573
  """
554
574
  _business = self._business
555
575
  self._business = False
@@ -563,25 +583,12 @@ class DateExtrasMixin:
563
583
  return d.business().add(days=1)
564
584
  return d
565
585
 
566
- def nearest_end_of_month(self):
567
- """Get `nearest` end of month
568
-
569
- 12/31/2014 -> Wednesday
570
- 1/31/2015 -> Saturday
571
-
572
- >>> from date import Date
573
- >>> Date(2015, 1, 1).nearest_end_of_month()
574
- Date(2014, 12, 31)
575
- >>> Date(2015, 1, 15).nearest_end_of_month()
576
- Date(2014, 12, 31)
577
- >>> Date(2015, 1, 15).b.nearest_end_of_month()
578
- Date(2014, 12, 31)
579
- >>> Date(2015, 1, 16).nearest_end_of_month()
580
- Date(2015, 1, 31)
581
- >>> Date(2015, 1, 31).nearest_end_of_month()
582
- Date(2015, 1, 31)
583
- >>> Date(2015, 1, 31).b.nearest_end_of_month()
584
- 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.
585
592
  """
586
593
  _business = self._business
587
594
  self._business = False
@@ -595,77 +602,135 @@ class DateExtrasMixin:
595
602
  return d.business().subtract(days=1)
596
603
  return d
597
604
 
598
- def next_relative_date_of_week_by_day(self, day='MO'):
599
- """Get next relative day of week by relativedelta code
600
-
601
- >>> from date import Date
602
- >>> Date(2020, 5, 18).next_relative_date_of_week_by_day('SU')
603
- Date(2020, 5, 24)
604
- >>> Date(2020, 5, 24).next_relative_date_of_week_by_day('SU')
605
- 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).
606
607
  """
607
608
  if self.weekday() == WEEKDAY_SHORTNAME.get(day):
608
609
  return self
609
610
  return self.next(WEEKDAY_SHORTNAME.get(day))
610
611
 
611
- def weekday_or_previous_friday(self):
612
- """Return the date if it is a weekday, else previous Friday
613
-
614
- >>> from date import Date
615
- >>> Date(2019, 10, 6).weekday_or_previous_friday() # Sunday
616
- Date(2019, 10, 4)
617
- >>> Date(2019, 10, 5).weekday_or_previous_friday() # Saturday
618
- Date(2019, 10, 4)
619
- >>> Date(2019, 10, 4).weekday_or_previous_friday() # Friday
620
- Date(2019, 10, 4)
621
- >>> Date(2019, 10, 3).weekday_or_previous_friday() # Thursday
622
- 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.
623
614
  """
624
- dnum = self.weekday()
625
- if dnum in {WeekDay.SATURDAY, WeekDay.SUNDAY}:
626
- return self.subtract(days=dnum - 4)
615
+ if self.weekday() in {WeekDay.SATURDAY, WeekDay.SUNDAY}:
616
+ return self.previous(WeekDay.FRIDAY)
627
617
  return self
628
618
 
629
- """
630
- create a simple nth weekday function that accounts for
631
- [1,2,3,4] and weekday as options
632
- or weekday, [1,2,3,4]
619
+ @classmethod
620
+ def third_wednesday(cls, year, month) -> Self:
621
+ """Calculate the date of the third Wednesday in a given month/year.
633
622
 
634
- """
623
+ .. deprecated::
624
+ Use Date(year, month, 1).nth_of('month', 3, WeekDay.WEDNESDAY) instead.
635
625
 
636
- @classmethod
637
- def third_wednesday(cls, year, month):
638
- """Third Wednesday date of a given month/year
639
-
640
- >>> from date import Date
641
- >>> Date.third_wednesday(2022, 6)
642
- Date(2022, 6, 15)
643
- >>> Date.third_wednesday(2023, 3)
644
- Date(2023, 3, 15)
645
- >>> Date.third_wednesday(2022, 12)
646
- Date(2022, 12, 21)
647
- >>> Date.third_wednesday(2023, 6)
648
- Date(2023, 6, 21)
626
+ Parameters
627
+ year: The year to use
628
+ month: The month to use (1-12)
629
+
630
+ Returns
631
+ A Date object representing the third Wednesday of the specified month
649
632
  """
650
- third = cls(year, month, 15) # lowest 3rd day
651
- w = third.weekday()
652
- if w != WeekDay.WEDNESDAY:
653
- third = third.replace(day=(15 + (WeekDay.WEDNESDAY - w) % 7))
654
- return third
633
+ return cls(year, month, 1).nth_of('month', 3, WeekDay.WEDNESDAY)
655
634
 
656
635
 
657
636
  class Date(DateExtrasMixin, DateBusinessMixin, _pendulum.Date):
658
- """Inherits and wraps pendulum.Date
637
+ """Date class extending pendulum.Date with business day and additional functionality.
638
+
639
+ This class inherits all pendulum.Date functionality while adding:
640
+ - Business day calculations with NYSE calendar integration
641
+ - Additional date navigation methods
642
+ - Enhanced parsing capabilities
643
+ - Custom financial date utilities
644
+
645
+ Unlike pendulum.Date, methods that create new instances return Date objects
646
+ that preserve business status and entity association when chained.
659
647
  """
660
648
 
661
649
  def to_string(self, fmt: str) -> str:
662
- """Format cleaner https://stackoverflow.com/a/2073189.
650
+ """Format date to string, handling platform-specific format codes.
663
651
 
664
- >>> Date(2022, 1, 5).to_string('%-m/%-d/%Y')
665
- '1/5/2022'
652
+ Automatically converts '%-' format codes to '%#' on Windows.
666
653
  """
667
654
  return self.strftime(fmt.replace('%-', '%#') if os.name == 'nt' else fmt)
668
655
 
656
+ @store_entity(typ='Date')
657
+ def replace(self, *args, **kwargs):
658
+ """Replace method that preserves entity and business status.
659
+ """
660
+ return _pendulum.Date.replace(self, *args, **kwargs)
661
+
662
+ @store_entity(typ='Date')
663
+ def closest(self, *args, **kwargs):
664
+ """Closest method that preserves entity and business status.
665
+ """
666
+ return _pendulum.Date.closest(self, *args, **kwargs)
667
+
668
+ @store_entity(typ='Date')
669
+ def farthest(self, *args, **kwargs):
670
+ """Farthest method that preserves entity and business status.
671
+ """
672
+ return _pendulum.Date.farthest(self, *args, **kwargs)
673
+
674
+ @store_entity(typ='Date')
675
+ def average(self, dt=None):
676
+ """Modify the current instance to the average
677
+ of a given instance (default now) and the current instance.
678
+
679
+ Parameters
680
+ dt: The date to average with (defaults to today)
681
+
682
+ Returns
683
+ A new Date object representing the average date
684
+ """
685
+ return _pendulum.Date.average(self, dt)
686
+
687
+ @classmethod
688
+ def fromordinal(cls, *args, **kwargs) -> Self:
689
+ """Create a Date from an ordinal.
690
+
691
+ Parameters
692
+ n: The ordinal value
693
+
694
+ Returns
695
+ Date instance
696
+ """
697
+ result = _pendulum.Date.fromordinal(*args, **kwargs)
698
+ return cls.instance(result)
699
+
700
+ @classmethod
701
+ def fromtimestamp(cls, timestamp, tz=None) -> Self:
702
+ """Create a Date from a timestamp.
703
+
704
+ Parameters
705
+ timestamp: Unix timestamp
706
+ tz: Optional timezone (defaults to UTC)
707
+
708
+ Returns
709
+ Date instance
710
+ """
711
+ # Ensure timezone is always applied to get consistent results
712
+ tz = tz or UTC
713
+ dt = _datetime.datetime.fromtimestamp(timestamp, tz=tz)
714
+ return cls(dt.year, dt.month, dt.day)
715
+
716
+ @store_entity(typ='Date')
717
+ def nth_of(self, unit: str, nth: int, day_of_week: WeekDay) -> Self:
718
+ """Returns a new instance set to the given occurrence
719
+ of a given day of the week in the current unit.
720
+
721
+ Parameters
722
+ unit: The unit to use ("month", "quarter", or "year")
723
+ nth: The position of the day in the unit (1 to 5)
724
+ day_of_week: The day of the week (pendulum.MONDAY to pendulum.SUNDAY)
725
+
726
+ Returns
727
+ A new Date object for the nth occurrence
728
+
729
+ Raises
730
+ ValueError: If the occurrence can't be found
731
+ """
732
+ return _pendulum.Date.nth_of(self, unit, nth, day_of_week)
733
+
669
734
  @classmethod
670
735
  def parse(
671
736
  cls,
@@ -676,84 +741,47 @@ class Date(DateExtrasMixin, DateBusinessMixin, _pendulum.Date):
676
741
  ) -> Self | None:
677
742
  """Convert a string to a date handling many different formats.
678
743
 
679
- creating a new Date object
680
- >>> Date.parse('2022/1/1')
681
- Date(2022, 1, 1)
682
-
683
- previous business day accessed with 'P'
684
- >>> Date.parse('P')==Date.today().b.subtract(days=1)
685
- True
686
- >>> Date.parse('T-3b')==Date.today().b.subtract(days=3)
687
- True
688
- >>> Date.parse('T-3b')==Date.today().b.add(days=-3)
689
- True
690
- >>> Date.parse('T+3b')==Date.today().b.subtract(days=-3)
691
- True
692
- >>> Date.parse('T+3b')==Date.today().b.add(days=3)
693
- True
694
- >>> Date.parse('M')==Date.today().start_of('month').subtract(days=1)
695
- True
696
-
697
- m[/-]d[/-]yyyy 6-23-2006
698
- >>> Date.parse('6-23-2006')
699
- Date(2006, 6, 23)
700
-
701
- m[/-]d[/-]yy 6/23/06
702
- >>> Date.parse('6/23/06')
703
- Date(2006, 6, 23)
704
-
705
- m[/-]d 6/23
706
- >>> Date.parse('6/23') == Date(Date.today().year, 6, 23)
707
- True
708
-
709
- yyyy-mm-dd 2006-6-23
710
- >>> Date.parse('2006-6-23')
711
- Date(2006, 6, 23)
712
-
713
- yyyymmdd 20060623
714
- >>> Date.parse('20060623')
715
- Date(2006, 6, 23)
716
-
717
- dd-mon-yyyy 23-JUN-2006
718
- >>> Date.parse('23-JUN-2006')
719
- Date(2006, 6, 23)
720
-
721
- mon-dd-yyyy JUN-23-2006
722
- >>> Date.parse('20 Jan 2009')
723
- Date(2009, 1, 20)
724
-
725
- month dd, yyyy June 23, 2006
726
- >>> Date.parse('June 23, 2006')
727
- Date(2006, 6, 23)
728
-
729
- dd-mon-yy
730
- >>> Date.parse('23-May-12')
731
- Date(2012, 5, 23)
732
-
733
- ddmonyyyy
734
- >>> Date.parse('23May2012')
735
- Date(2012, 5, 23)
736
-
737
- >>> Date.parse('Oct. 24, 2007', fmt='%b. %d, %Y')
738
- Date(2007, 10, 24)
739
-
740
- >>> Date.parse('Yesterday') == DateTime.now().subtract(days=1).date()
741
- True
742
- >>> Date.parse('TODAY') == Date.today()
743
- True
744
- >>> Date.parse('Jan. 13, 2014')
745
- Date(2014, 1, 13)
746
-
747
- >>> Date.parse('March') == Date(Date.today().year, 3, Date.today().day)
748
- True
749
-
750
- only raise error when we explicitly say so
751
- >>> Date.parse('bad date') is None
752
- True
753
- >>> Date.parse('bad date', raise_err=True)
754
- Traceback (most recent call last):
755
- ...
756
- 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)
757
785
  """
758
786
 
759
787
  def date_for_symbol(s):
@@ -795,7 +823,7 @@ class Date(DateExtrasMixin, DateBusinessMixin, _pendulum.Date):
795
823
  return
796
824
 
797
825
  with contextlib.suppress(ValueError):
798
- if float(s) and not len(s) == 8: # 20000101
826
+ if float(s) and len(s) != 8: # 20000101
799
827
  if raise_err:
800
828
  raise ValueError('Invalid date: %s', s)
801
829
  return
@@ -872,20 +900,17 @@ class Date(DateExtrasMixin, DateBusinessMixin, _pendulum.Date):
872
900
  | None,
873
901
  raise_err: bool = False,
874
902
  ) -> Self | None:
875
- """From datetime.date like object
876
-
877
- >>> Date.instance(_datetime.date(2022, 1, 1))
878
- Date(2022, 1, 1)
879
- >>> Date.instance(Date(2022, 1, 1))
880
- Date(2022, 1, 1)
881
- >>> Date.instance(_pendulum.Date(2022, 1, 1))
882
- Date(2022, 1, 1)
883
- >>> Date.instance(Date(2022, 1, 1))
884
- Date(2022, 1, 1)
885
- >>> Date.instance(np.datetime64('2000-01', 'D'))
886
- Date(2000, 1, 1)
887
- >>> 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.
888
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
889
914
  """
890
915
  if pd.isna(obj):
891
916
  if raise_err:
@@ -901,39 +926,21 @@ class Date(DateExtrasMixin, DateBusinessMixin, _pendulum.Date):
901
926
  return cls(obj.year, obj.month, obj.day)
902
927
 
903
928
  @classmethod
904
- def today(cls):
929
+ def today(cls) -> Self:
905
930
  d = _datetime.datetime.now(LCL)
906
931
  return cls(d.year, d.month, d.day)
907
932
 
908
- def isoweek(self):
909
- """Week number 1-52 following ISO week-numbering
910
-
911
- Standard weeks
912
- >>> Date(2023, 1, 2).isoweek()
913
- 1
914
- >>> Date(2023, 4, 27).isoweek()
915
- 17
916
- >>> Date(2023, 12, 31).isoweek()
917
- 52
918
-
919
- Belongs to week of previous year
920
- >>> Date(2023, 1, 1).isoweek()
921
- 52
933
+ def isoweek(self) -> int | None:
934
+ """Get ISO week number (1-52/53) following ISO week-numbering standard.
922
935
  """
923
936
  with contextlib.suppress(Exception):
924
937
  return self.isocalendar()[1]
925
938
 
926
939
  def lookback(self, unit='last') -> Self:
927
- """Date back based on lookback string, ie last, week, month.
928
-
929
- >>> Date(2018, 12, 7).b.lookback('last')
930
- Date(2018, 12, 6)
931
- >>> Date(2018, 12, 7).b.lookback('day')
932
- Date(2018, 12, 6)
933
- >>> Date(2018, 12, 7).b.lookback('week')
934
- Date(2018, 11, 30)
935
- >>> Date(2018, 12, 7).b.lookback('month')
936
- 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.
937
944
  """
938
945
  def _lookback(years=0, months=0, weeks=0, days=0):
939
946
  _business = self._business
@@ -955,42 +962,60 @@ class Date(DateExtrasMixin, DateBusinessMixin, _pendulum.Date):
955
962
 
956
963
 
957
964
  class Time(_pendulum.Time):
965
+ """Time class extending pendulum.Time with additional functionality.
966
+
967
+ This class inherits all pendulum.Time functionality while adding:
968
+ - Enhanced parsing for various time formats
969
+ - Default UTC timezone when created
970
+ - Simple timezone conversion utilities
971
+
972
+ Unlike pendulum.Time, this class has more lenient parsing capabilities
973
+ and different timezone defaults.
974
+ """
958
975
 
959
976
  @classmethod
960
977
  @prefer_utc_timezone
961
978
  def parse(cls, s: str | None, fmt: str | None = None, raise_err: bool = False) -> Self | None:
962
- """Convert a string to a time handling many formats::
963
-
964
- handle many time formats:
965
- hh[:.]mm
966
- hh[:.]mm am/pm
967
- hh[:.]mm[:.]ss
968
- hh[:.]mm[:.]ss[.,]uuu am/pm
969
- hhmmss[.,]uuu
970
- hhmmss[.,]uuu am/pm
971
-
972
- >>> Time.parse('9:30')
973
- Time(9, 30, 0, tzinfo=Timezone('UTC'))
974
- >>> Time.parse('9:30:15')
975
- Time(9, 30, 15, tzinfo=Timezone('UTC'))
976
- >>> Time.parse('9:30:15.751')
977
- Time(9, 30, 15, 751000, tzinfo=Timezone('UTC'))
978
- >>> Time.parse('9:30 AM')
979
- Time(9, 30, 0, tzinfo=Timezone('UTC'))
980
- >>> Time.parse('9:30 pm')
981
- Time(21, 30, 0, tzinfo=Timezone('UTC'))
982
- >>> Time.parse('9:30:15.751 PM')
983
- Time(21, 30, 15, 751000, tzinfo=Timezone('UTC'))
984
- >>> Time.parse('0930') # Date treats this as a date, careful!!
985
- Time(9, 30, 0, tzinfo=Timezone('UTC'))
986
- >>> Time.parse('093015')
987
- Time(9, 30, 15, tzinfo=Timezone('UTC'))
988
- >>> Time.parse('093015,751')
989
- Time(9, 30, 15, 751000, tzinfo=Timezone('UTC'))
990
- >>> Time.parse('0930 pm')
991
- Time(21, 30, 0, tzinfo=Timezone('UTC'))
992
- >>> Time.parse('093015,751 PM')
993
- 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)
994
1019
  """
995
1020
 
996
1021
  def seconds(m):
@@ -1060,18 +1085,9 @@ class Time(_pendulum.Time):
1060
1085
  tz: str | _zoneinfo.ZoneInfo | _datetime.tzinfo | None = None,
1061
1086
  raise_err: bool = False,
1062
1087
  ) -> Self | None:
1063
- """From datetime-like object
1064
-
1065
- >>> Time.instance(_datetime.time(12, 30, 1))
1066
- Time(12, 30, 1, tzinfo=Timezone('UTC'))
1067
- >>> Time.instance(_pendulum.Time(12, 30, 1))
1068
- Time(12, 30, 1, tzinfo=Timezone('UTC'))
1069
- >>> Time.instance(None)
1070
-
1071
- like Pendulum, do not add timzone if no timezone and Time object
1072
- >>> Time.instance(Time(12, 30, 1))
1073
- Time(12, 30, 1)
1088
+ """Create Time instance from time-like object.
1074
1089
 
1090
+ Adds UTC timezone by default unless obj is already a Time instance.
1075
1091
  """
1076
1092
  if pd.isna(obj):
1077
1093
  if raise_err:
@@ -1085,15 +1101,8 @@ class Time(_pendulum.Time):
1085
1101
 
1086
1102
  return cls(obj.hour, obj.minute, obj.second, obj.microsecond, tzinfo=tz)
1087
1103
 
1088
- def in_timezone(self, tz: str | _zoneinfo.ZoneInfo | _datetime.tzinfo):
1089
- """Convert timezone
1090
-
1091
- >>> Time(12, 0).in_timezone(Timezone('America/Sao_Paulo'))
1092
- Time(9, 0, 0, tzinfo=Timezone('America/Sao_Paulo'))
1093
-
1094
- >>> Time(12, 0, tzinfo=Timezone('Europe/Moscow')).in_timezone(Timezone('America/Sao_Paulo'))
1095
- Time(6, 0, 0, tzinfo=Timezone('America/Sao_Paulo'))
1096
-
1104
+ def in_timezone(self, tz: str | _zoneinfo.ZoneInfo | _datetime.tzinfo) -> Self:
1105
+ """Convert time to a different timezone.
1097
1106
  """
1098
1107
  _dt = DateTime.combine(Date.today(), self, tzinfo=self.tzinfo or UTC)
1099
1108
  return _dt.in_timezone(tz).time()
@@ -1102,14 +1111,114 @@ class Time(_pendulum.Time):
1102
1111
 
1103
1112
 
1104
1113
  class DateTime(DateBusinessMixin, _pendulum.DateTime):
1105
- """Inherits and wraps pendulum.DateTime
1114
+ """DateTime class extending pendulum.DateTime with business day and additional functionality.
1115
+
1116
+ This class inherits all pendulum.DateTime functionality while adding:
1117
+ - Business day calculations with NYSE calendar integration
1118
+ - Enhanced timezone handling
1119
+ - Extended parsing capabilities
1120
+ - Custom utility methods for financial applications
1121
+
1122
+ Unlike pendulum.DateTime:
1123
+ - today() returns start of day rather than current time
1124
+ - Methods preserve business status and entity when chaining
1125
+ - Has timezone handling helpers not present in pendulum
1106
1126
  """
1107
1127
 
1108
- def epoch(self):
1128
+ def epoch(self) -> float:
1109
1129
  """Translate a datetime object into unix seconds since epoch
1110
1130
  """
1111
1131
  return self.timestamp()
1112
1132
 
1133
+ @store_entity(typ='DateTime')
1134
+ def astimezone(self, *args, **kwargs):
1135
+ """Convert to a timezone-aware datetime in a different timezone.
1136
+ """
1137
+ return _pendulum.DateTime.astimezone(self, *args, **kwargs)
1138
+
1139
+ @store_entity(typ='DateTime')
1140
+ def in_timezone(self, *args, **kwargs):
1141
+ """Convert to a timezone-aware datetime in a different timezone.
1142
+ """
1143
+ return _pendulum.DateTime.in_timezone(self, *args, **kwargs)
1144
+
1145
+ @store_entity(typ='DateTime')
1146
+ def in_tz(self, *args, **kwargs):
1147
+ """Convert to a timezone-aware datetime in a different timezone.
1148
+ """
1149
+ return _pendulum.DateTime.in_tz(self, *args, **kwargs)
1150
+
1151
+ @store_entity(typ='DateTime')
1152
+ def replace(self, *args, **kwargs):
1153
+ """Replace method that preserves entity and business status.
1154
+ """
1155
+ return _pendulum.DateTime.replace(self, *args, **kwargs)
1156
+
1157
+ @classmethod
1158
+ def fromordinal(cls, *args, **kwargs) -> Self:
1159
+ """Create a DateTime from an ordinal.
1160
+
1161
+ Parameters
1162
+ n: The ordinal value
1163
+
1164
+ Returns
1165
+ DateTime instance
1166
+ """
1167
+ result = _pendulum.DateTime.fromordinal(*args, **kwargs)
1168
+ return cls.instance(result)
1169
+
1170
+ @classmethod
1171
+ def fromtimestamp(cls, timestamp, tz=None) -> Self:
1172
+ """Create a DateTime from a timestamp.
1173
+
1174
+ Parameters
1175
+ timestamp: Unix timestamp
1176
+ tz: Optional timezone
1177
+
1178
+ Returns
1179
+ DateTime instance
1180
+ """
1181
+ tz = tz or UTC
1182
+ result = _pendulum.DateTime.fromtimestamp(timestamp, tz)
1183
+ return cls.instance(result)
1184
+
1185
+ @classmethod
1186
+ def strptime(cls, time_str, fmt) -> Self:
1187
+ """Parse a string into a DateTime according to a format.
1188
+
1189
+ Parameters
1190
+ time_str: String to parse
1191
+ fmt: Format string
1192
+
1193
+ Returns
1194
+ DateTime instance
1195
+ """
1196
+ result = _pendulum.DateTime.strptime(time_str, fmt)
1197
+ return cls.instance(result)
1198
+
1199
+ @classmethod
1200
+ def utcfromtimestamp(cls, timestamp) -> Self:
1201
+ """Create a UTC DateTime from a timestamp.
1202
+
1203
+ Parameters
1204
+ timestamp: Unix timestamp
1205
+
1206
+ Returns
1207
+ DateTime instance
1208
+ """
1209
+ result = _pendulum.DateTime.utcfromtimestamp(timestamp)
1210
+ return cls.instance(result)
1211
+
1212
+ @classmethod
1213
+ def utcnow(cls) -> Self:
1214
+ """Create a DateTime representing current UTC time.
1215
+
1216
+ Returns
1217
+ DateTime instance
1218
+ """
1219
+ result = _pendulum.DateTime.utcnow()
1220
+ return cls.instance(result)
1221
+
1113
1222
  @classmethod
1114
1223
  def now(cls, tz: str | _zoneinfo.ZoneInfo | _datetime.tzinfo | None = None) -> Self:
1115
1224
  """Get a DateTime instance for the current date and time.
@@ -1126,12 +1235,21 @@ class DateTime(DateBusinessMixin, _pendulum.DateTime):
1126
1235
  d.microsecond, tzinfo=d.tzinfo, fold=d.fold)
1127
1236
 
1128
1237
  @classmethod
1129
- def today(cls, tz: str | _zoneinfo.ZoneInfo | None = None):
1130
- """Unlike Pendulum, returns DateTime object at start of day
1238
+ def today(cls, tz: str | _zoneinfo.ZoneInfo | None = None) -> Self:
1239
+ """Create a DateTime object representing today at the start of day.
1240
+
1241
+ Unlike pendulum.today() which returns current time, this method
1242
+ returns a DateTime object at 00:00:00 of the current day.
1243
+
1244
+ Parameters
1245
+ tz: Optional timezone (defaults to local timezone)
1246
+
1247
+ Returns
1248
+ DateTime instance representing start of current day
1131
1249
  """
1132
1250
  return DateTime.now(tz).start_of('day')
1133
1251
 
1134
- def date(self):
1252
+ def date(self) -> Date:
1135
1253
  return Date(self.year, self.month, self.day)
1136
1254
 
1137
1255
  @classmethod
@@ -1146,25 +1264,13 @@ class DateTime(DateBusinessMixin, _pendulum.DateTime):
1146
1264
  _tzinfo = tzinfo or time.tzinfo
1147
1265
  return DateTime.instance(_datetime.datetime.combine(date, time, tzinfo=_tzinfo))
1148
1266
 
1149
- def rfc3339(self):
1150
- """
1151
- >>> DateTime.parse('Fri, 31 Oct 2014 10:55:00')
1152
- DateTime(2014, 10, 31, 10, 55, 0, tzinfo=Timezone('UTC'))
1153
- >>> DateTime.parse('Fri, 31 Oct 2014 10:55:00').rfc3339()
1154
- '2014-10-31T10:55:00+00:00'
1267
+ def rfc3339(self) -> str:
1268
+ """Return RFC 3339 formatted string (same as isoformat()).
1155
1269
  """
1156
1270
  return self.isoformat()
1157
1271
 
1158
- def time(self):
1159
- """Extract time from self (preserve timezone)
1160
-
1161
- >>> d = DateTime(2022, 1, 1, 12, 30, 15, tzinfo=EST)
1162
- >>> d.time()
1163
- Time(12, 30, 15, tzinfo=Timezone('US/Eastern'))
1164
-
1165
- >>> d = DateTime(2022, 1, 1, 12, 30, 15, tzinfo=UTC)
1166
- >>> d.time()
1167
- Time(12, 30, 15, tzinfo=Timezone('UTC'))
1272
+ def time(self) -> Time:
1273
+ """Extract time component from datetime (preserving timezone).
1168
1274
  """
1169
1275
  return Time.instance(self)
1170
1276
 
@@ -1174,38 +1280,46 @@ class DateTime(DateBusinessMixin, _pendulum.DateTime):
1174
1280
  entity: Entity = NYSE,
1175
1281
  raise_err: bool = False
1176
1282
  ) -> Self | None:
1177
- """Thin layer on Date parser and our custom `Date.parse``
1178
-
1179
- >>> DateTime.parse('2022/1/1')
1180
- DateTime(2022, 1, 1, 0, 0, 0, tzinfo=Timezone('...'))
1181
-
1182
- Assume UTC, convert to EST
1183
- >>> this_est1 = DateTime.parse('Fri, 31 Oct 2014 18:55:00').in_timezone(EST)
1184
- >>> this_est1
1185
- DateTime(2014, 10, 31, 14, 55, 0, tzinfo=Timezone('US/Eastern'))
1186
-
1187
- This is actually 18:55 UTC with -4 hours applied = EST
1188
- >>> this_est2 = DateTime.parse('Fri, 31 Oct 2014 14:55:00 -0400')
1189
- >>> this_est2
1190
- DateTime(2014, 10, 31, 14, 55, 0, tzinfo=...)
1191
-
1192
- UTC time technically equals GMT
1193
- >>> this_utc = DateTime.parse('Fri, 31 Oct 2014 18:55:00 GMT')
1194
- >>> this_utc
1195
- DateTime(2014, 10, 31, 18, 55, 0, tzinfo=tzutc())
1196
-
1197
- We can freely compare time zones
1198
- >>> this_est1==this_est2==this_utc
1199
- True
1200
-
1201
- Format tests
1202
- >>> DateTime.parse(1707856982).replace(tzinfo=UTC).epoch()
1203
- 1707856982.0
1204
- >>> DateTime.parse('Jan 29 2010')
1205
- DateTime(2010, 1, 29, 0, 0, 0, tzinfo=Timezone('UTC'))
1206
- >>> _ = DateTime.parse('Sep 27 17:11')
1207
- >>> _.month, _.day, _.hour, _.minute
1208
- (9, 27, 17, 11)
1283
+ """Convert a string or timestamp to a DateTime with extended format support.
1284
+
1285
+ Unlike pendulum's parse, this method supports:
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)
1289
+ - Multiple date-time formats beyond ISO 8601
1290
+ - Combined date and time strings with various separators
1291
+
1292
+ Parameters
1293
+ s: String or timestamp to parse
1294
+ entity: Calendar entity for business day calculations (default NYSE)
1295
+ raise_err: If True, raises ValueError on parse failure instead of returning None
1296
+
1297
+ Returns
1298
+ DateTime instance or None if parsing fails and raise_err is False
1299
+
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)
1304
+
1305
+ ISO 8601 format:
1306
+ DateTime.parse('2020-01-15T14:30:00') → DateTime(2020, 1, 15, 14, 30, 0)
1307
+
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)
1311
+
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)
1315
+
1316
+ Time only (uses today's date):
1317
+ DateTime.parse('14:30:00') → DateTime(today's year, month, day, 14, 30, 0, tzinfo=LCL)
1318
+
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
1209
1323
  """
1210
1324
  if not s:
1211
1325
  if raise_err:
@@ -1216,6 +1330,8 @@ class DateTime(DateBusinessMixin, _pendulum.DateTime):
1216
1330
  raise TypeError(f'Invalid type for datetime parse: {s.__class__}')
1217
1331
 
1218
1332
  if isinstance(s, int | float):
1333
+ if len(str(int(s))) == 13:
1334
+ s /= 1000 # Convert from milliseconds to seconds
1219
1335
  iso = _datetime.datetime.fromtimestamp(s).isoformat()
1220
1336
  return cls.parse(iso).replace(tzinfo=LCL)
1221
1337
 
@@ -1254,41 +1370,23 @@ class DateTime(DateBusinessMixin, _pendulum.DateTime):
1254
1370
  tz: str | _zoneinfo.ZoneInfo | _datetime.tzinfo | None = None,
1255
1371
  raise_err: bool = False,
1256
1372
  ) -> Self | None:
1257
- """From datetime-like object
1258
-
1259
- >>> DateTime.instance(_datetime.date(2022, 1, 1))
1260
- DateTime(2022, 1, 1, 0, 0, 0, tzinfo=Timezone('...'))
1261
- >>> DateTime.instance(Date(2022, 1, 1))
1262
- DateTime(2022, 1, 1, 0, 0, 0, tzinfo=Timezone('...'))
1263
- >>> DateTime.instance(_datetime.datetime(2022, 1, 1, 0, 0, 0))
1264
- DateTime(2022, 1, 1, 0, 0, 0, tzinfo=Timezone('...'))
1265
- >>> DateTime.instance(_pendulum.DateTime(2022, 1, 1, 0, 0, 0))
1266
- DateTime(2022, 1, 1, 0, 0, 0, tzinfo=Timezone('...'))
1267
- >>> DateTime.instance(None)
1268
-
1269
- like Pendulum, do not add timzone if no timezone and DateTime object
1270
- >>> DateTime.instance(DateTime(2022, 1, 1, 0, 0, 0))
1271
- DateTime(2022, 1, 1, 0, 0, 0)
1272
- >>> DateTime.instance(DateTime(2000, 1, 1))
1273
- DateTime(2000, 1, 1, 0, 0, 0)
1274
-
1275
- no tz -> UTC
1276
- >>> DateTime.instance(Time(4, 4, 21))
1277
- DateTime(..., 4, 4, 21, tzinfo=Timezone('UTC'))
1278
-
1279
- tzinfo on time -> time tzinfo (precedence)
1280
- >>> DateTime.instance(Time(4, 4, 21, tzinfo=UTC))
1281
- DateTime(..., 4, 4, 21, tzinfo=Timezone('UTC'))
1282
- >>> DateTime.instance(Time(4, 4, 21, tzinfo=LCL))
1283
- DateTime(..., 4, 4, 21, tzinfo=Timezone('...'))
1284
-
1285
- >>> DateTime.instance(np.datetime64('2000-01', 'D'))
1286
- DateTime(2000, 1, 1, 0, 0, 0, tzinfo=Timezone('UTC'))
1287
-
1288
- Convert date to datetime (will use native time zone)
1289
- >>> DateTime.instance(_datetime.date(2000, 1, 1))
1290
- DateTime(2000, 1, 1, 0, 0, 0, tzinfo=Timezone('...'))
1373
+ """Create a DateTime instance from various datetime-like objects.
1374
+
1375
+ Provides unified interface for converting different date/time types
1376
+ including pandas and numpy datetime objects into DateTime instances.
1291
1377
 
1378
+ Unlike pendulum, this method:
1379
+ - Handles pandas Timestamp and numpy datetime64 objects
1380
+ - Adds timezone (UTC by default) when none is specified
1381
+ - Has special handling for Time objects (combines with current date)
1382
+
1383
+ Parameters
1384
+ obj: Date, datetime, time, or compatible object to convert
1385
+ tz: Optional timezone to apply (if None, uses obj's timezone or UTC)
1386
+ raise_err: If True, raises ValueError for None/NA values instead of returning None
1387
+
1388
+ Returns
1389
+ DateTime instance or None if obj is None/NA and raise_err is False
1292
1390
  """
1293
1391
  if pd.isna(obj):
1294
1392
  if raise_err:
@@ -1321,25 +1419,91 @@ class DateTime(DateBusinessMixin, _pendulum.DateTime):
1321
1419
  obj.second, obj.microsecond, tzinfo=tz)
1322
1420
 
1323
1421
 
1324
- class IntervalError(AttributeError):
1325
- pass
1422
+ class Interval(_pendulum.Interval):
1423
+ """Interval class extending pendulum.Interval with business day awareness.
1326
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.
1327
1428
 
1328
- 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
+ """
1329
1435
 
1330
1436
  _business: bool = False
1331
1437
  _entity: type[NYSE] = NYSE
1332
1438
 
1333
- def __init__(self, begdate: str | Date | None = None, enddate: str | Date | None = None):
1334
- self.begdate = Date.parse(begdate) if isinstance(begdate, str) else Date.instance(begdate)
1335
- 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
+ }
1336
1502
 
1337
1503
  def business(self) -> Self:
1338
1504
  self._business = True
1339
- if self.begdate:
1340
- self.begdate.business()
1341
- if self.enddate:
1342
- self.enddate.business()
1505
+ self._start.business()
1506
+ self._end.business()
1343
1507
  return self
1344
1508
 
1345
1509
  @property
@@ -1348,249 +1512,120 @@ class Interval:
1348
1512
 
1349
1513
  def entity(self, e: type[NYSE] = NYSE) -> Self:
1350
1514
  self._entity = e
1351
- if self.begdate:
1352
- self.enddate._entity = e
1353
- if self.enddate:
1354
- self.enddate._entity = e
1515
+ if self._start:
1516
+ self._end._entity = e
1517
+ if self._end:
1518
+ self._end._entity = e
1355
1519
  return self
1356
1520
 
1357
- def range(self, window=0) -> tuple[_datetime.date, _datetime.date]:
1358
- """Set date ranges based on begdate, enddate and window.
1359
-
1360
- The combinations are as follows:
1361
-
1362
- beg end num action
1363
- --- --- --- ---------------------
1364
- - - - Error, underspecified
1365
- set set set Error, overspecified
1366
- set set -
1367
- set - - end=max date
1368
- - set - beg=min date
1369
- - - set end=max date, beg=end - num
1370
- set - set end=beg + num
1371
- - set set beg=end - num
1372
-
1373
- Basic/legacy cases
1374
- >>> Interval(Date(2014, 4, 3), None).b.range(3)
1375
- (Date(2014, 4, 3), Date(2014, 4, 8))
1376
- >>> Interval(None, Date(2014, 7, 27)).range(20)
1377
- (Date(2014, 7, 7), Date(2014, 7, 27))
1378
- >>> Interval(None, Date(2014, 7, 27)).b.range(20)
1379
- (Date(2014, 6, 27), Date(2014, 7, 27))
1380
-
1381
- Do not modify dates if both are provided
1382
- >>> Interval(Date(2024, 7, 25), Date(2024, 7, 25)).b.range(None)
1383
- (Date(2024, 7, 25), Date(2024, 7, 25))
1384
- >>> Interval(Date(2024, 7, 27), Date(2024, 7, 27)).b.range(None)
1385
- (Date(2024, 7, 27), Date(2024, 7, 27))
1386
-
1387
- Edge cases (7/27/24 is weekend)
1388
- >>> Interval(Date(2024, 7, 27), None).b.range(0)
1389
- (Date(2024, 7, 27), Date(2024, 7, 27))
1390
- >>> Interval(None, Date(2024, 7, 27)).b.range(0)
1391
- (Date(2024, 7, 27), Date(2024, 7, 27))
1392
- >>> Interval(Date(2024, 7, 27), None).b.range(1)
1393
- (Date(2024, 7, 27), Date(2024, 7, 29))
1394
- >>> Interval(None, Date(2024, 7, 27)).b.range(1)
1395
- (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.
1396
1523
  """
1397
- begdate, enddate = self.begdate, self.enddate
1524
+ self._business = False
1525
+ for thedate in self.range('days'):
1526
+ yield thedate.is_business_day()
1398
1527
 
1399
- 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.
1400
1531
 
1401
- if begdate and enddate and window:
1402
- raise IntervalError('Window requested and begdate and enddate provided')
1403
- if not begdate and not enddate and not window:
1404
- raise IntervalError('Missing begdate, enddate, and window')
1405
- if not begdate and not enddate and window:
1406
- 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)
1407
1535
 
1408
- if begdate and enddate:
1409
- pass # do nothing if both provided
1410
- elif (not begdate and not enddate) or enddate:
1411
- begdate = enddate.subtract(days=window) if window else enddate
1412
- else:
1413
- 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
1414
1540
 
1415
- enddate._business = False
1416
- 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
1417
1545
 
1418
- 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)
1419
1562
 
1420
- def is_business_day_series(self) -> list[bool]:
1421
- """Is business date range.
1563
+ return _range_generator()
1422
1564
 
1423
- >>> list(Interval(Date(2018, 11, 19), Date(2018, 11, 25)).is_business_day_series())
1424
- [True, True, True, False, True, False, False]
1425
- >>> list(Interval(Date(2021, 11, 22),Date(2021, 11, 28)).is_business_day_series())
1426
- [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).
1427
1569
  """
1428
- for thedate in self.series():
1429
- yield thedate.is_business_day()
1570
+ return self._sign * len(tuple(self.range('days'))) - self._sign
1430
1571
 
1431
- def series(self, window=0):
1432
- """Get a series of datetime.date objects.
1433
-
1434
- give the function since and until wherever possible (more explicit)
1435
- else pass in a window to back out since or until
1436
- - Window gives window=N additional days. So `until`-`window`=1
1437
- defaults to include ALL days (not just business days)
1438
-
1439
- >>> next(Interval(Date(2014,7,16), Date(2014,7,16)).series())
1440
- Date(2014, 7, 16)
1441
- >>> next(Interval(Date(2014,7,12), Date(2014,7,16)).series())
1442
- Date(2014, 7, 12)
1443
- >>> len(list(Interval(Date(2014,7,12), Date(2014,7,16)).series()))
1444
- 5
1445
- >>> len(list(Interval(Date(2014,7,12), None).series(window=4)))
1446
- 5
1447
- >>> len(list(Interval(Date(2014,7,16)).series(window=4)))
1448
- 5
1449
-
1450
- Weekend and a holiday
1451
- >>> len(list(Interval(Date(2014,7,3), Date(2014,7,5)).b.series()))
1452
- 1
1453
- >>> len(list(Interval(Date(2014,7,17), Date(2014,7,16)).series()))
1454
- Traceback (most recent call last):
1455
- ...
1456
- AssertionError: Begdate must be earlier or equal to Enddate
1457
-
1458
- since != business day and want business days
1459
- 1/[3,10]/2015 is a Saturday, 1/7/2015 is a Wednesday
1460
- >>> len(list(Interval(Date(2015,1,3), Date(2015,1,7)).b.series()))
1461
- 3
1462
- >>> len(list(Interval(Date(2015,1,3), None).b.series(window=3)))
1463
- 3
1464
- >>> len(list(Interval(Date(2015,1,3), Date(2015,1,10)).b.series()))
1465
- 5
1466
- >>> len(list(Interval(Date(2015,1,3), None).b.series(window=5)))
1467
- 5
1468
- """
1469
- window = abs(int(window))
1470
- since, until = self.begdate, self.enddate
1471
- _business = self._business
1472
- assert until or since, 'Since or until is required'
1473
- if not since and until:
1474
- since = (until.business() if _business else
1475
- until).subtract(days=window)
1476
- elif since and not until:
1477
- until = (since.business() if _business else
1478
- since).add(days=window)
1479
- assert since <= until, 'Since date must be earlier or equal to Until date'
1480
- thedate = since
1481
- while thedate <= until:
1482
- if _business:
1483
- if thedate.is_business_day():
1484
- yield thedate
1485
- else:
1486
- yield thedate
1487
- thedate = thedate.add(days=1)
1488
-
1489
- def start_of_series(self, unit='month') -> list[Date]:
1490
- """Return a series between and inclusive of begdate and enddate.
1491
-
1492
- >>> Interval(Date(2018, 1, 5), Date(2018, 4, 5)).start_of_series('month')
1493
- [Date(2018, 1, 1), Date(2018, 2, 1), Date(2018, 3, 1), Date(2018, 4, 1)]
1494
- >>> Interval(Date(2018, 4, 30), Date(2018, 7, 30)).start_of_series('month')
1495
- [Date(2018, 4, 1), Date(2018, 5, 1), Date(2018, 6, 1), Date(2018, 7, 1)]
1496
- >>> Interval(Date(2018, 1, 5), Date(2018, 4, 5)).start_of_series('week')
1497
- [Date(2018, 1, 1), Date(2018, 1, 8), ..., Date(2018, 4, 2)]
1498
- """
1499
- begdate = self.begdate.start_of(unit)
1500
- enddate = self.enddate.start_of(unit)
1501
- interval = _pendulum.interval(begdate, enddate)
1502
- return [Date.instance(d).start_of(unit) for d in interval.range(f'{unit}s')]
1503
-
1504
- def end_of_series(self, unit='month') -> list[Date]:
1505
- """Return a series between and inclusive of begdate and enddate.
1506
-
1507
- >>> Interval(Date(2018, 1, 5), Date(2018, 4, 5)).end_of_series('month')
1508
- [Date(2018, 1, 31), Date(2018, 2, 28), Date(2018, 3, 31), Date(2018, 4, 30)]
1509
- >>> Interval(Date(2018, 4, 30), Date(2018, 7, 30)).end_of_series('month')
1510
- [Date(2018, 4, 30), Date(2018, 5, 31), Date(2018, 6, 30), Date(2018, 7, 31)]
1511
- >>> Interval(Date(2018, 1, 5), Date(2018, 4, 5)).end_of_series('week')
1512
- [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.
1513
1578
  """
1514
- begdate = self.begdate.end_of(unit)
1515
- enddate = self.enddate.end_of(unit)
1516
- interval = _pendulum.interval(begdate, enddate)
1517
- 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
1518
1592
 
1519
- def days(self) -> int:
1520
- """Return days between (begdate, enddate] or negative (enddate, begdate].
1521
-
1522
- >>> Interval(Date(2018, 9, 6), Date(2018, 9, 10)).days()
1523
- 4
1524
- >>> Interval(Date(2018, 9, 10), Date(2018, 9, 6)).days()
1525
- -4
1526
- >>> Interval(Date(2018, 9, 6), Date(2018, 9, 10)).b.days()
1527
- 2
1528
- >>> Interval(Date(2018, 9, 10), Date(2018, 9, 6)).b.days()
1529
- -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.
1530
1600
  """
1531
- assert self.begdate
1532
- assert self.enddate
1533
- if self.begdate == self.enddate:
1534
- return 0
1535
- if not self._business:
1536
- return (self.enddate - self.begdate).days
1537
- if self.begdate < self.enddate:
1538
- return len(list(self.series())) - 1
1539
- _reverse = Interval(self.enddate, self.begdate)
1540
- _reverse._entity = self._entity
1541
- _reverse._business = self._business
1542
- return -len(list(_reverse.series())) + 1
1543
-
1544
- def quarters(self):
1545
- """Return the number of quarters between two dates
1546
- TODO: good enough implementation; refine rules to be heuristically precise
1547
-
1548
- >>> round(Interval(Date(2020, 1, 1), Date(2020, 2, 16)).quarters(), 2)
1549
- 0.5
1550
- >>> round(Interval(Date(2020, 1, 1), Date(2020, 4, 1)).quarters(), 2)
1551
- 1.0
1552
- >>> round(Interval(Date(2020, 1, 1), Date(2020, 7, 1)).quarters(), 2)
1553
- 1.99
1554
- >>> round(Interval(Date(2020, 1, 1), Date(2020, 8, 1)).quarters(), 2)
1555
- 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).
1556
1606
  """
1557
- return 4 * self.days() / 365.0
1558
-
1559
- def years(self, basis: int = 0):
1560
- """Years with Fractions (matches Excel YEARFRAC)
1561
-
1562
- Adapted from https://web.archive.org/web/20200915094905/https://dwheeler.com/yearfrac/calc_yearfrac.py
1563
-
1564
- Basis:
1565
- 0 = US (NASD) 30/360
1566
- 1 = Actual/actual
1567
- 2 = Actual/360
1568
- 3 = Actual/365
1569
- 4 = European 30/360
1570
-
1571
- >>> begdate = Date(1978, 2, 28)
1572
- >>> enddate = Date(2020, 5, 17)
1573
-
1574
- Tested Against Excel
1575
- >>> "{:.4f}".format(Interval(begdate, enddate).years(0))
1576
- '42.2139'
1577
- >>> '{:.4f}'.format(Interval(begdate, enddate).years(1))
1578
- '42.2142'
1579
- >>> '{:.4f}'.format(Interval(begdate, enddate).years(2))
1580
- '42.8306'
1581
- >>> '{:.4f}'.format(Interval(begdate, enddate).years(3))
1582
- '42.2438'
1583
- >>> '{:.4f}'.format(Interval(begdate, enddate).years(4))
1584
- '42.2194'
1585
- >>> '{:.4f}'.format(Interval(enddate, begdate).years(4))
1586
- '-42.2194'
1587
-
1588
- Excel has a known leap year bug when year == 1900 (=YEARFRAC("1900-1-1", "1900-12-1", 1) -> 0.9178)
1589
- The bug originated from Lotus 1-2-3, and was purposely implemented in Excel for the purpose of backward compatibility.
1590
- >>> begdate = Date(1900, 1, 1)
1591
- >>> enddate = Date(1900, 12, 1)
1592
- >>> '{:.4f}'.format(Interval(begdate, enddate).years(4))
1593
- '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).
1594
1629
  """
1595
1630
 
1596
1631
  def average_year_length(date1, date2):
@@ -1675,32 +1710,84 @@ class Interval:
1675
1710
  (date1day + date1month * 30 + date1year * 360)
1676
1711
  return daydiff360 / 360
1677
1712
 
1678
- begdate, enddate = self.begdate, self.enddate
1679
- if enddate is None:
1680
- return
1681
-
1682
- sign = 1
1683
- if begdate > enddate:
1684
- begdate, enddate = enddate, begdate
1685
- sign = -1
1686
- if begdate == enddate:
1713
+ if self._start == self._end:
1687
1714
  return 0.0
1688
-
1689
1715
  if basis == 0:
1690
- return basis0(begdate, enddate) * sign
1716
+ return basis0(self._start, self._end) * self._sign
1691
1717
  if basis == 1:
1692
- return basis1(begdate, enddate) * sign
1718
+ return basis1(self._start, self._end) * self._sign
1693
1719
  if basis == 2:
1694
- return basis2(begdate, enddate) * sign
1720
+ return basis2(self._start, self._end) * self._sign
1695
1721
  if basis == 3:
1696
- return basis3(begdate, enddate) * sign
1722
+ return basis3(self._start, self._end) * self._sign
1697
1723
  if basis == 4:
1698
- return basis4(begdate, enddate) * sign
1724
+ return basis4(self._start, self._end) * self._sign
1699
1725
 
1700
1726
  raise ValueError('Basis range [0, 4]. Unknown basis {basis}.')
1701
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
1756
+
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
1702
1766
 
1703
- def create_ics(begdate, enddate, summary, location):
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:
1704
1791
  """Create a simple .ics file per RFC 5545 guidelines."""
1705
1792
 
1706
1793
  return f"""BEGIN:VCALENDAR
@@ -1714,35 +1801,3 @@ LOCATION:{location}
1714
1801
  END:VEVENT
1715
1802
  END:VCALENDAR
1716
1803
  """
1717
-
1718
-
1719
- # apply any missing Date functions
1720
- for func in (
1721
- 'average',
1722
- 'closest',
1723
- 'farthest',
1724
- 'fromordinal',
1725
- 'fromtimestamp',
1726
- 'nth_of',
1727
- 'replace',
1728
- ):
1729
- setattr(Date, func, store_entity(getattr(_pendulum.Date, func), typ=Date))
1730
-
1731
- # apply any missing DateTime functions
1732
- for func in (
1733
- 'astimezone',
1734
- 'date',
1735
- 'fromordinal',
1736
- 'fromtimestamp',
1737
- 'in_timezone',
1738
- 'in_tz',
1739
- 'replace',
1740
- 'strptime',
1741
- 'utcfromtimestamp',
1742
- 'utcnow',
1743
- ):
1744
- setattr(DateTime, func, store_entity(getattr(_pendulum.DateTime, func), typ=DateTime))
1745
-
1746
-
1747
- if __name__ == '__main__':
1748
- __import__('doctest').testmod(optionflags=4 | 8 | 32)