opendate 0.1.34__cp39-cp39-win_amd64.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.
opendate/interval.py ADDED
@@ -0,0 +1,408 @@
1
+ from __future__ import annotations
2
+
3
+ import calendar
4
+ import operator
5
+ import sys
6
+ from collections.abc import Iterator
7
+ from typing import TYPE_CHECKING
8
+
9
+ import pendulum as _pendulum
10
+
11
+ import opendate as _date
12
+ from opendate.decorators import expect_date_or_datetime
13
+ from opendate.decorators import normalize_date_datetime_pairs, reset_business
14
+
15
+ if sys.version_info >= (3, 11):
16
+ from typing import Self
17
+ else:
18
+ from typing_extensions import Self
19
+
20
+ if TYPE_CHECKING:
21
+ from opendate.calendars import Calendar
22
+ from opendate.date_ import Date
23
+ from opendate.datetime_ import DateTime
24
+
25
+
26
+ class Interval(_pendulum.Interval):
27
+ """Interval class extending pendulum.Interval with business day awareness.
28
+
29
+ This class represents the difference between two dates or datetimes with
30
+ additional support for business day calculations, calendar awareness, and
31
+ financial period calculations.
32
+
33
+ Unlike pendulum.Interval:
34
+ - Has business day mode that only counts business days
35
+ - Preserves calendar association (e.g., NYSE, LSE)
36
+ - Additional financial methods like yearfrac()
37
+ - Support for range operations that respect business days
38
+ """
39
+
40
+ _business: bool = False
41
+ _calendar: Calendar | None = None
42
+
43
+ @expect_date_or_datetime
44
+ @normalize_date_datetime_pairs
45
+ def __new__(cls, begdate: Date | DateTime, enddate: Date | DateTime) -> Self:
46
+ assert begdate and enddate, 'Interval dates cannot be None'
47
+ instance = super().__new__(cls, begdate, enddate, False)
48
+ return instance
49
+
50
+ @expect_date_or_datetime
51
+ @normalize_date_datetime_pairs
52
+ def __init__(self, begdate: Date | DateTime, enddate: Date | DateTime) -> None:
53
+ super().__init__(begdate, enddate, False)
54
+ self._direction = 1 if begdate <= enddate else -1
55
+ if begdate <= enddate:
56
+ self._start = begdate
57
+ self._end = enddate
58
+ else:
59
+ self._start = enddate
60
+ self._end = begdate
61
+
62
+ @staticmethod
63
+ def _get_quarter_start(date: Date | DateTime) -> Date | DateTime:
64
+ """Get the start date of the quarter containing the given date.
65
+ """
66
+ quarter_month = ((date.month - 1) // 3) * 3 + 1
67
+ return date.replace(month=quarter_month, day=1)
68
+
69
+ @staticmethod
70
+ def _get_quarter_end(date: Date | DateTime) -> Date | DateTime:
71
+ """Get the end date of the quarter containing the given date.
72
+ """
73
+ quarter_month = ((date.month - 1) // 3) * 3 + 3
74
+ return date.replace(month=quarter_month).end_of('month')
75
+
76
+ def _get_unit_handlers(self, unit: str) -> dict:
77
+ """Get handlers for the specified time unit.
78
+
79
+ Returns a dict with:
80
+ get_start: Function to get start of period containing date
81
+ get_end: Function to get end of period containing date
82
+ advance: Function to advance to next period start
83
+ """
84
+ if unit == 'quarter':
85
+ return {
86
+ 'get_start': self._get_quarter_start,
87
+ 'get_end': self._get_quarter_end,
88
+ 'advance': lambda date: self._get_quarter_start(date.add(months=3)),
89
+ }
90
+
91
+ if unit == 'decade':
92
+ return {
93
+ 'get_start': lambda date: date.start_of('decade'),
94
+ 'get_end': lambda date: date.end_of('decade'),
95
+ 'advance': lambda date: date.add(years=10).start_of('decade'),
96
+ }
97
+
98
+ if unit == 'century':
99
+ return {
100
+ 'get_start': lambda date: date.start_of('century'),
101
+ 'get_end': lambda date: date.end_of('century'),
102
+ 'advance': lambda date: date.add(years=100).start_of('century'),
103
+ }
104
+
105
+ return {
106
+ 'get_start': lambda date: date.start_of(unit),
107
+ 'get_end': lambda date: date.end_of(unit),
108
+ 'advance': lambda date: date.add(**{f'{unit}s': 1}).start_of(unit),
109
+ }
110
+
111
+ def business(self) -> Self:
112
+ self._business = True
113
+ self._start.business()
114
+ self._end.business()
115
+ return self
116
+
117
+ @property
118
+ def b(self) -> Self:
119
+ return self.business()
120
+
121
+ def calendar(self, cal: str | Calendar | None = None) -> Self:
122
+ """Set the calendar for business day calculations.
123
+
124
+ Parameters
125
+ cal: Calendar name (str), Calendar instance, or None for default
126
+ """
127
+ from opendate.calendars import get_calendar, get_default_calendar
128
+
129
+ if cal is None:
130
+ cal = get_default_calendar()
131
+ if isinstance(cal, str):
132
+ cal = get_calendar(cal)
133
+ self._calendar = cal
134
+ if self._start:
135
+ self._start._calendar = cal
136
+ if self._end:
137
+ self._end._calendar = cal
138
+ return self
139
+
140
+ def is_business_day_range(self) -> Iterator[bool]:
141
+ """Generate boolean values indicating whether each day in the range is a business day.
142
+ """
143
+ self._business = False
144
+ for thedate in self.range('days'):
145
+ yield thedate.is_business_day()
146
+
147
+ @reset_business
148
+ def range(self, unit: str = 'days', amount: int = 1) -> Iterator[DateTime | Date]:
149
+ """Generate dates/datetimes over the interval.
150
+
151
+ Parameters
152
+ unit: Time unit ('days', 'weeks', 'months', 'years')
153
+ amount: Step size (e.g., every N units)
154
+
155
+ In business mode (for 'days' only), skips non-business days.
156
+ """
157
+ _business = self._business
158
+ parent_range = _pendulum.Interval.range
159
+
160
+ def _range_generator():
161
+ if unit != 'days':
162
+ yield from (type(d).instance(d) for d in parent_range(self, unit, amount))
163
+ return
164
+
165
+ if self._direction == 1:
166
+ op = operator.le
167
+ this = self._start
168
+ thru = self._end
169
+ else:
170
+ op = operator.ge
171
+ this = self._end
172
+ thru = self._start
173
+
174
+ while op(this, thru):
175
+ if _business:
176
+ if this.is_business_day():
177
+ yield this
178
+ else:
179
+ yield this
180
+ this = this.add(days=self._direction * amount)
181
+
182
+ return _range_generator()
183
+
184
+ @property
185
+ @reset_business
186
+ def days(self) -> int:
187
+ """Get number of days in the interval (respects business mode and sign).
188
+ """
189
+ if not self._business:
190
+ return self._direction * (self._end - self._start).days
191
+ return self._direction * len(tuple(self.range('days'))) - self._direction
192
+
193
+ @property
194
+ def months(self) -> float:
195
+ """Get number of months in the interval including fractional parts.
196
+
197
+ Overrides pendulum's months property to return a float instead of an integer.
198
+ Calculates fractional months based on actual day counts within partial months.
199
+ """
200
+ year_diff = self._end.year - self._start.year
201
+ month_diff = self._end.month - self._start.month
202
+ total_months = year_diff * 12 + month_diff
203
+
204
+ if self._end.day >= self._start.day:
205
+ day_diff = self._end.day - self._start.day
206
+ days_in_month = calendar.monthrange(self._start.year, self._start.month)[1]
207
+ fraction = day_diff / days_in_month
208
+ else:
209
+ total_months -= 1
210
+ days_in_start_month = calendar.monthrange(self._start.year, self._start.month)[1]
211
+ day_diff = (days_in_start_month - self._start.day) + self._end.day
212
+ fraction = day_diff / days_in_start_month
213
+
214
+ return self._direction * (total_months + fraction)
215
+
216
+ @property
217
+ def quarters(self) -> float:
218
+ """Get approximate number of quarters in the interval.
219
+
220
+ Note: This is an approximation using day count / 365 * 4.
221
+ """
222
+ return self._direction * 4 * self.days / 365.0
223
+
224
+ @property
225
+ def years(self) -> int:
226
+ """Get number of complete years in the interval (always floors).
227
+ """
228
+ year_diff = self._end.year - self._start.year
229
+ if self._end.month < self._start.month or \
230
+ (self._end.month == self._start.month and self._end.day < self._start.day):
231
+ year_diff -= 1
232
+ return self._direction * year_diff
233
+
234
+ def yearfrac(self, basis: int = 0) -> float:
235
+ """Calculate the fraction of years between two dates (Excel-compatible).
236
+
237
+ This method provides precise calculation using various day count conventions
238
+ used in finance. Results are tested against Excel for compatibility.
239
+
240
+ Parameters
241
+ basis: Day count convention to use:
242
+ 0 = US (NASD) 30/360 (default)
243
+ 1 = Actual/actual
244
+ 2 = Actual/360
245
+ 3 = Actual/365
246
+ 4 = European 30/360
247
+
248
+ Note: Excel has a known leap year bug for year 1900 which is intentionally
249
+ replicated for compatibility (1900 is treated as a leap year even though it wasn't).
250
+ """
251
+
252
+ def average_year_length(date1, date2):
253
+ """Algorithm for average year length"""
254
+ days = (_date.Date(date2.year + 1, 1, 1) - _date.Date(date1.year, 1, 1)).days
255
+ years = (date2.year - date1.year) + 1
256
+ return days / years
257
+
258
+ def feb29_between(date1, date2):
259
+ """Requires date2.year = (date1.year + 1) or date2.year = date1.year.
260
+
261
+ Returns True if "Feb 29" is between the two dates (date1 may be Feb29).
262
+ Two possibilities: date1.year is a leap year, and date1 <= Feb 29 y1,
263
+ or date2.year is a leap year, and date2 > Feb 29 y2.
264
+ """
265
+ mar1_date1_year = _date.Date(date1.year, 3, 1)
266
+ if calendar.isleap(date1.year) and (date1 < mar1_date1_year) and (date2 >= mar1_date1_year):
267
+ return True
268
+ mar1_date2_year = _date.Date(date2.year, 3, 1)
269
+ return bool(calendar.isleap(date2.year) and date2 >= mar1_date2_year and date1 < mar1_date2_year)
270
+
271
+ def appears_lte_one_year(date1, date2):
272
+ """Returns True if date1 and date2 "appear" to be 1 year or less apart.
273
+
274
+ This compares the values of year, month, and day directly to each other.
275
+ Requires date1 <= date2; returns boolean. Used by basis 1.
276
+ """
277
+ if date1.year == date2.year:
278
+ return True
279
+ return bool(date1.year + 1 == date2.year and (date1.month > date2.month or date1.month == date2.month and date1.day >= date2.day))
280
+
281
+ def basis0(date1, date2):
282
+ # change day-of-month for purposes of calculation.
283
+ date1day, date1month, date1year = date1.day, date1.month, date1.year
284
+ date2day, date2month, date2year = date2.day, date2.month, date2.year
285
+ if date1day == 31 and date2day == 31:
286
+ date1day = 30
287
+ date2day = 30
288
+ elif date1day == 31:
289
+ date1day = 30
290
+ elif date1day == 30 and date2day == 31:
291
+ date2day = 30
292
+ # Note: If date2day==31, it STAYS 31 if date1day < 30.
293
+ # Special fixes for February:
294
+ elif date1month == 2 and date2month == 2 and date1 == date1.end_of('month') \
295
+ and date2 == date2.end_of('month'):
296
+ date1day = 30 # Set the day values to be equal
297
+ date2day = 30
298
+ elif date1month == 2 and date1 == date1.end_of('month'):
299
+ date1day = 30 # "Illegal" Feb 30 date.
300
+ daydiff360 = (date2day + date2month * 30 + date2year * 360) \
301
+ - (date1day + date1month * 30 + date1year * 360)
302
+ return daydiff360 / 360
303
+
304
+ def basis1(date1, date2):
305
+ if appears_lte_one_year(date1, date2):
306
+ if date1.year == date2.year and calendar.isleap(date1.year):
307
+ year_length = 366.0
308
+ elif feb29_between(date1, date2) or (date2.month == 2 and date2.day == 29):
309
+ year_length = 366.0
310
+ else:
311
+ year_length = 365.0
312
+ return (date2 - date1).days / year_length
313
+ return (date2 - date1).days / average_year_length(date1, date2)
314
+
315
+ def basis2(date1, date2):
316
+ return (date2 - date1).days / 360.0
317
+
318
+ def basis3(date1, date2):
319
+ return (date2 - date1).days / 365.0
320
+
321
+ def basis4(date1, date2):
322
+ # change day-of-month for purposes of calculation.
323
+ date1day, date1month, date1year = date1.day, date1.month, date1.year
324
+ date2day, date2month, date2year = date2.day, date2.month, date2.year
325
+ if date1day == 31:
326
+ date1day = 30
327
+ if date2day == 31:
328
+ date2day = 30
329
+ # Remarkably, do NOT change Feb. 28 or 29 at ALL.
330
+ daydiff360 = (date2day + date2month * 30 + date2year * 360) - \
331
+ (date1day + date1month * 30 + date1year * 360)
332
+ return daydiff360 / 360
333
+
334
+ if self._start == self._end:
335
+ return 0.0
336
+ if basis == 0:
337
+ return basis0(self._start, self._end) * self._direction
338
+ if basis == 1:
339
+ return basis1(self._start, self._end) * self._direction
340
+ if basis == 2:
341
+ return basis2(self._start, self._end) * self._direction
342
+ if basis == 3:
343
+ return basis3(self._start, self._end) * self._direction
344
+ if basis == 4:
345
+ return basis4(self._start, self._end) * self._direction
346
+
347
+ raise ValueError(f'Basis range [0, 4]. Unknown basis {basis}.')
348
+
349
+ @reset_business
350
+ def start_of(self, unit: str = 'month') -> list[Date | DateTime]:
351
+ """Return the start of each unit within the interval.
352
+
353
+ Parameters
354
+ unit: Time unit ('month', 'week', 'year', 'quarter')
355
+
356
+ Returns
357
+ List of Date or DateTime objects representing start of each unit
358
+
359
+ In business mode, each start date is adjusted to the next business day
360
+ if it falls on a non-business day.
361
+ """
362
+ handlers = self._get_unit_handlers(unit)
363
+ result = []
364
+
365
+ current = handlers['get_start'](self._start)
366
+
367
+ if self._business:
368
+ current._calendar = self._calendar
369
+
370
+ while current <= self._end:
371
+ if self._business:
372
+ current = current._business_or_next()
373
+ result.append(current)
374
+ current = handlers['advance'](current)
375
+
376
+ return result
377
+
378
+ @reset_business
379
+ def end_of(self, unit: str = 'month') -> list[Date | DateTime]:
380
+ """Return the end of each unit within the interval.
381
+
382
+ Parameters
383
+ unit: Time unit ('month', 'week', 'year', 'quarter')
384
+
385
+ Returns
386
+ List of Date or DateTime objects representing end of each unit
387
+
388
+ In business mode, each end date is adjusted to the previous business day
389
+ if it falls on a non-business day.
390
+ """
391
+ handlers = self._get_unit_handlers(unit)
392
+ result = []
393
+
394
+ current = handlers['get_start'](self._start)
395
+
396
+ if self._business:
397
+ current._calendar = self._calendar
398
+
399
+ while current <= self._end:
400
+ end_date = handlers['get_end'](current)
401
+
402
+ if self._business:
403
+ end_date = end_date._business_or_previous()
404
+ result.append(end_date)
405
+
406
+ current = handlers['advance'](current)
407
+
408
+ return result
opendate/metaclass.py ADDED
@@ -0,0 +1,123 @@
1
+ """Metaclass for automatic context preservation on pendulum methods.
2
+
3
+ This module provides a metaclass that automatically wraps pendulum methods
4
+ to preserve the _calendar attribute when they return new Date/DateTime objects.
5
+ This ensures users don't accidentally lose business context when calling
6
+ pendulum methods that aren't explicitly overridden.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from functools import wraps
11
+ from typing import TYPE_CHECKING
12
+
13
+ import pendulum as _pendulum
14
+
15
+ if TYPE_CHECKING:
16
+ from opendate.calendars import Calendar
17
+
18
+ DATE_METHODS_RETURNING_DATE = {
19
+ 'add',
20
+ 'subtract',
21
+ 'replace',
22
+ 'set',
23
+ 'average',
24
+ 'closest',
25
+ 'farthest',
26
+ 'end_of',
27
+ 'start_of',
28
+ 'first_of',
29
+ 'last_of',
30
+ 'next',
31
+ 'previous',
32
+ 'nth_of',
33
+ }
34
+
35
+ DATETIME_METHODS_RETURNING_DATETIME = DATE_METHODS_RETURNING_DATE | {
36
+ 'at',
37
+ 'on',
38
+ 'naive',
39
+ 'astimezone',
40
+ 'in_timezone',
41
+ 'in_tz',
42
+ }
43
+
44
+
45
+ def _make_context_preserver(original_method, target_cls):
46
+ """Create a wrapper that preserves _calendar context.
47
+
48
+ Parameters
49
+ original_method: The original pendulum method
50
+ target_cls: The target class (Date or DateTime) for instance creation
51
+ """
52
+ @wraps(original_method)
53
+ def wrapper(self, *args, **kwargs):
54
+ _calendar: Calendar | None = getattr(self, '_calendar', None)
55
+ result = original_method(self, *args, **kwargs)
56
+
57
+ if isinstance(result, (_pendulum.Date, _pendulum.DateTime)):
58
+ if not isinstance(result, target_cls):
59
+ result = target_cls.instance(result)
60
+ if hasattr(result, '_calendar'):
61
+ result._calendar = _calendar
62
+ return result
63
+ return wrapper
64
+
65
+
66
+ class DateContextMeta(type):
67
+ """Metaclass that auto-wraps pendulum methods to preserve context.
68
+
69
+ When a class is created with this metaclass, it automatically wraps
70
+ specified pendulum methods to preserve the _calendar attribute.
71
+
72
+ Usage:
73
+ class Date(
74
+ DateBusinessMixin,
75
+ _pendulum.Date,
76
+ metaclass=DateContextMeta,
77
+ methods_to_wrap=DATE_METHODS_RETURNING_DATE
78
+ ):
79
+ pass
80
+
81
+ The metaclass will NOT wrap methods that are already defined in the
82
+ class namespace - explicit overrides (like those in DateBusinessMixin)
83
+ take precedence.
84
+ """
85
+
86
+ def __new__(mcs, name, bases, namespace, methods_to_wrap=None, **kwargs):
87
+ cls = super().__new__(mcs, name, bases, namespace, **kwargs)
88
+
89
+ if methods_to_wrap:
90
+ # Identify which bases are pendulum classes (we want to wrap their methods)
91
+ pendulum_bases = tuple(
92
+ base for base in bases
93
+ if issubclass(base, (_pendulum.Date, _pendulum.DateTime))
94
+ )
95
+
96
+ for method_name in methods_to_wrap:
97
+ # Skip if method is already explicitly defined in namespace
98
+ if method_name in namespace:
99
+ continue
100
+
101
+ # Check if any NON-pendulum base class has this method defined
102
+ # (i.e., our mixins like DateBusinessMixin have explicit overrides)
103
+ explicitly_defined = False
104
+ for base in bases:
105
+ if base in pendulum_bases:
106
+ continue # Skip pendulum classes
107
+ if method_name in base.__dict__:
108
+ explicitly_defined = True
109
+ break
110
+
111
+ if explicitly_defined:
112
+ continue
113
+
114
+ # Find the method in pendulum base classes and wrap it
115
+ for base in pendulum_bases:
116
+ if hasattr(base, method_name):
117
+ original = getattr(base, method_name)
118
+ if callable(original):
119
+ wrapped = _make_context_preserver(original, cls)
120
+ setattr(cls, method_name, wrapped)
121
+ break
122
+
123
+ return cls
@@ -0,0 +1,4 @@
1
+ from opendate.mixins.business import DateBusinessMixin
2
+ from opendate.mixins.extras_ import DateExtrasMixin
3
+
4
+ __all__ = ['DateBusinessMixin', 'DateExtrasMixin']