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.
@@ -0,0 +1,295 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from typing import TYPE_CHECKING
5
+
6
+ from opendate.calendars import get_calendar, get_default_calendar
7
+ from opendate.constants import MAX_YEAR, MIN_YEAR, WeekDay
8
+ from opendate.decorators import expect_date, store_calendar
9
+
10
+ if sys.version_info >= (3, 11):
11
+ from typing import Self
12
+ else:
13
+ from typing_extensions import Self
14
+
15
+ if TYPE_CHECKING:
16
+ from opendate.calendars import Calendar
17
+ from opendate.datetime_ import DateTime
18
+
19
+
20
+ class DateBusinessMixin:
21
+ """Mixin class providing business day functionality.
22
+
23
+ This mixin adds business day awareness to Date and DateTime classes,
24
+ allowing date operations to account for weekends and holidays according
25
+ to a specified calendar.
26
+
27
+ Features not available in pendulum:
28
+ - Business day mode toggle
29
+ - Calendar-specific rules (exchanges, custom)
30
+ - Business-aware date arithmetic
31
+ """
32
+
33
+ _calendar: Calendar | None = None
34
+ _business: bool = False
35
+
36
+ def business(self) -> Self:
37
+ """Switch to business day mode for date calculations.
38
+
39
+ In business day mode, date arithmetic only counts business days
40
+ as defined by the associated calendar (default NYSE).
41
+
42
+ Returns
43
+ Self instance for method chaining
44
+ """
45
+ self._business = True
46
+ return self
47
+
48
+ @property
49
+ def b(self) -> Self:
50
+ """Shorthand property for business() method.
51
+
52
+ Returns
53
+ Self instance for method chaining
54
+ """
55
+ return self.business()
56
+
57
+ def calendar(self, cal: str | Calendar | None = None) -> Self:
58
+ """Set the calendar for business day calculations.
59
+
60
+ Parameters
61
+ cal: Calendar name (str), Calendar instance, or None for default
62
+
63
+ Returns
64
+ Self instance for method chaining
65
+
66
+ Examples
67
+ d.calendar('NYSE').b.add(days=1)
68
+ d.calendar('LSE').b.subtract(days=5)
69
+ d.calendar(my_custom_calendar).is_business_day()
70
+ """
71
+ if cal is None:
72
+ cal = get_default_calendar()
73
+ if isinstance(cal, str):
74
+ self._calendar = get_calendar(cal)
75
+ else:
76
+ self._calendar = cal
77
+ return self
78
+
79
+ @property
80
+ def _active_calendar(self) -> Calendar:
81
+ """Get the active calendar (uses module default if not set).
82
+ """
83
+ if self._calendar is None:
84
+ return get_calendar(get_default_calendar())
85
+ return self._calendar
86
+
87
+ def _is_out_of_range(self) -> bool:
88
+ """Check if date is outside valid calendar range (1900-2100)."""
89
+ return self.year < MIN_YEAR or self.year > MAX_YEAR
90
+
91
+ @store_calendar
92
+ def add(self, years: int = 0, months: int = 0, weeks: int = 0, days: int = 0, **kwargs) -> Self:
93
+ """Add time periods to the current date or datetime.
94
+
95
+ Extends pendulum's add method with business day awareness. When in business mode,
96
+ only counts business days for the 'days' parameter.
97
+
98
+ Parameters
99
+ years: Number of years to add
100
+ months: Number of months to add
101
+ weeks: Number of weeks to add
102
+ days: Number of days to add (business days if in business mode)
103
+ **kwargs: Additional time units to add
104
+
105
+ Returns
106
+ New instance with added time
107
+ """
108
+ _business = self._business
109
+ self._business = False
110
+ if _business:
111
+ if days == 0:
112
+ return self._business_or_next()
113
+ if days < 0:
114
+ return self.business().subtract(days=abs(days))
115
+ if self._is_out_of_range() and self.year > MAX_YEAR:
116
+ return self
117
+ target = self._business_or_next() if self._is_out_of_range() else self
118
+ result = target._add_business_days(days)
119
+ return result if result is not None else self
120
+ return super().add(years, months, weeks, days, **kwargs)
121
+
122
+ @store_calendar
123
+ def subtract(self, years: int = 0, months: int = 0, weeks: int = 0, days: int = 0, **kwargs) -> Self:
124
+ """Subtract wrapper
125
+ If not business use Pendulum
126
+ If business assume only days (for now) and use local logic
127
+ """
128
+ _business = self._business
129
+ self._business = False
130
+ if _business:
131
+ if days == 0:
132
+ return self._business_or_previous()
133
+ if days < 0:
134
+ return self.business().add(days=abs(days))
135
+ target = self._business_or_previous() if self._is_out_of_range() else self
136
+ result = target._add_business_days(-days)
137
+ return result if result is not None else self
138
+ kwargs = {k: -1*v for k, v in kwargs.items()}
139
+ return super().add(-years, -months, -weeks, -days, **kwargs)
140
+
141
+ @store_calendar
142
+ def first_of(self, unit: str, day_of_week: WeekDay | None = None) -> Self:
143
+ """Returns an instance set to the first occurrence
144
+ of a given day of the week in the current unit.
145
+ """
146
+ _business = self._business
147
+ self._business = False
148
+ self = super().first_of(unit, day_of_week)
149
+ if _business:
150
+ self = self._business_or_next()
151
+ return self
152
+
153
+ @store_calendar
154
+ def last_of(self, unit: str, day_of_week: WeekDay | None = None) -> Self:
155
+ """Returns an instance set to the last occurrence
156
+ of a given day of the week in the current unit.
157
+ """
158
+ _business = self._business
159
+ self._business = False
160
+ self = super().last_of(unit, day_of_week)
161
+ if _business:
162
+ self = self._business_or_previous()
163
+ return self
164
+
165
+ @store_calendar
166
+ def start_of(self, unit: str) -> Self:
167
+ """Returns a copy of the instance with the time reset
168
+ """
169
+ _business = self._business
170
+ self._business = False
171
+ self = super().start_of(unit)
172
+ if _business:
173
+ self = self._business_or_next()
174
+ return self
175
+
176
+ @store_calendar
177
+ def end_of(self, unit: str) -> Self:
178
+ """Returns a copy of the instance with the time reset
179
+ """
180
+ _business = self._business
181
+ self._business = False
182
+ self = super().end_of(unit)
183
+ if _business:
184
+ self = self._business_or_previous()
185
+ return self
186
+
187
+ @store_calendar
188
+ def previous(self, day_of_week: WeekDay | None = None) -> Self:
189
+ """Modify to the previous occurrence of a given day of the week.
190
+
191
+ In business mode, snaps BACKWARD to maintain 'previous' semantics.
192
+ """
193
+ _business = self._business
194
+ self._business = False
195
+ self = super().previous(day_of_week)
196
+ if _business:
197
+ self = self._business_or_previous()
198
+ return self
199
+
200
+ @store_calendar
201
+ def next(self, day_of_week: WeekDay | None = None) -> Self:
202
+ """Modify to the next occurrence of a given day of the week.
203
+
204
+ In business mode, snaps FORWARD to maintain 'next' semantics.
205
+ """
206
+ _business = self._business
207
+ self._business = False
208
+ self = super().next(day_of_week)
209
+ if _business:
210
+ self = self._business_or_next()
211
+ return self
212
+
213
+ @expect_date
214
+ def is_business_day(self) -> bool:
215
+ """Check if the date is a business day according to the calendar.
216
+
217
+ Returns False for dates outside valid calendar range (1900-2100).
218
+ """
219
+ if self._is_out_of_range():
220
+ return False
221
+ cal = self._active_calendar._get_calendar(self)
222
+ if cal is None:
223
+ return False
224
+ return cal.is_business_day(self.toordinal())
225
+
226
+ # Alias for backwards compatibility
227
+ business_open = is_business_day
228
+
229
+ @expect_date
230
+ def business_hours(self) -> tuple[DateTime, DateTime]:
231
+ """Get market open and close times for this date.
232
+
233
+ Returns (None, None) if not a business day.
234
+ """
235
+ return self._active_calendar.business_hours(self, self)\
236
+ .get(self, (None, None))
237
+
238
+ def _add_business_days(self, days: int) -> Self | None:
239
+ """Add business days using Rust calendar.
240
+
241
+ Returns self unchanged for dates outside valid range (1900-2100).
242
+ """
243
+ if self._is_out_of_range():
244
+ return self
245
+ cal = self._active_calendar._get_calendar(self)
246
+ if cal is None:
247
+ return None
248
+ start_ord = self.toordinal()
249
+ forward = days > 0
250
+ offset = 1 if forward else -1
251
+ first_bd = (cal.next_business_day if forward else cal.prev_business_day)(start_ord + offset)
252
+ if first_bd is None:
253
+ return None
254
+ result_ord = cal.add_business_days(first_bd, (abs(days) - 1) * offset)
255
+ if result_ord is None:
256
+ return None
257
+ return super().add(days=result_ord - start_ord)
258
+
259
+ @store_calendar
260
+ def _snap_to_business_day(self, forward: bool = True) -> Self:
261
+ """Snap to nearest business day if not already on one.
262
+
263
+ For dates outside valid range (1900-2100):
264
+ - If forward=False and year > 2100: snap to last business day of 2100
265
+ - If forward=True and year < 1900: snap to first business day of 1900
266
+ - Otherwise return self unchanged
267
+ """
268
+ self._business = False
269
+ if self._is_out_of_range():
270
+ from opendate.date_ import Date
271
+ if self.year > MAX_YEAR and not forward:
272
+ boundary = Date(MAX_YEAR, 12, 31)
273
+ boundary._calendar = self._calendar
274
+ return boundary._snap_to_business_day(forward=False)
275
+ elif self.year < MIN_YEAR and forward:
276
+ boundary = Date(MIN_YEAR, 1, 1)
277
+ boundary._calendar = self._calendar
278
+ return boundary._snap_to_business_day(forward=True)
279
+ return self
280
+ cal = self._active_calendar._get_calendar(self)
281
+ if cal is None:
282
+ return self
283
+ if self.is_business_day():
284
+ return self
285
+ ordinal = self.toordinal()
286
+ target = cal.next_business_day(ordinal) if forward else cal.prev_business_day(ordinal)
287
+ if target is None:
288
+ return self
289
+ return super().add(days=target - ordinal)
290
+
291
+ def _business_or_next(self) -> Self:
292
+ return self._snap_to_business_day(forward=True)
293
+
294
+ def _business_or_previous(self) -> Self:
295
+ return self._snap_to_business_day(forward=False)
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from typing import TYPE_CHECKING
5
+
6
+ from opendate.constants import WEEKDAY_SHORTNAME, WeekDay
7
+ from opendate.decorators import store_calendar
8
+
9
+ if sys.version_info >= (3, 11):
10
+ from typing import Self
11
+ else:
12
+ from typing_extensions import Self
13
+
14
+ if TYPE_CHECKING:
15
+ pass
16
+
17
+
18
+ class DateExtrasMixin:
19
+ """Extended date functionality not provided by Pendulum.
20
+
21
+ .. note::
22
+ This mixin exists primarily for legacy backward compatibility.
23
+ New code should prefer using built-in methods where possible.
24
+
25
+ This mixin provides additional date utilities primarily focused on:
26
+ - Financial date calculations (nearest month start/end)
27
+ - Weekday-oriented date navigation
28
+ - Relative date lookups
29
+
30
+ These methods extend OpenDate functionality with features commonly
31
+ needed in financial applications and reporting scenarios.
32
+ """
33
+
34
+ @store_calendar
35
+ def nearest_start_of_month(self) -> Self:
36
+ """Get the nearest start of month.
37
+
38
+ If day <= 15, returns start of current month.
39
+ If day > 15, returns start of next month.
40
+ In business mode, snaps to next business day if needed.
41
+ """
42
+ _business = self._business
43
+ self._business = False
44
+ if self.day > 15:
45
+ d = self.end_of('month').add(days=1) # First of next month
46
+ else:
47
+ d = self.start_of('month') # First of current month
48
+ if _business:
49
+ d = d._business_or_next()
50
+ return d
51
+
52
+ @store_calendar
53
+ def nearest_end_of_month(self) -> Self:
54
+ """Get the nearest end of month.
55
+
56
+ If day <= 15, returns end of previous month.
57
+ If day > 15, returns end of current month.
58
+ In business mode, snaps to previous business day if needed.
59
+ """
60
+ _business = self._business
61
+ self._business = False
62
+ if self.day <= 15:
63
+ d = self.start_of('month').subtract(days=1) # End of previous month
64
+ else:
65
+ d = self.end_of('month') # End of current month
66
+ if _business:
67
+ d = d._business_or_previous()
68
+ return d
69
+
70
+ def next_relative_date_of_week_by_day(self, day='MO') -> Self:
71
+ """Get next occurrence of the specified weekday (or current date if already that day).
72
+ """
73
+ if self.weekday() == WEEKDAY_SHORTNAME.get(day):
74
+ return self
75
+ return self.next(WEEKDAY_SHORTNAME.get(day))
76
+
77
+ def weekday_or_previous_friday(self) -> Self:
78
+ """Return the date if it is a weekday, otherwise return the previous Friday.
79
+ """
80
+ if self.weekday() in {WeekDay.SATURDAY, WeekDay.SUNDAY}:
81
+ return self.previous(WeekDay.FRIDAY)
82
+ return self
83
+
84
+ @classmethod
85
+ def third_wednesday(cls, year, month) -> Self:
86
+ """Calculate the date of the third Wednesday in a given month/year.
87
+
88
+ .. deprecated::
89
+ Use Date(year, month, 1).nth_of('month', 3, WeekDay.WEDNESDAY) instead.
90
+
91
+ Parameters
92
+ year: The year to use
93
+ month: The month to use (1-12)
94
+
95
+ Returns
96
+ A Date object representing the third Wednesday of the specified month
97
+ """
98
+ return cls(year, month, 1).nth_of('month', 3, WeekDay.WEDNESDAY)
opendate/time_.py ADDED
@@ -0,0 +1,139 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime as _datetime
4
+ import sys
5
+ import time
6
+ import zoneinfo as _zoneinfo
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+ import pendulum as _pendulum
11
+
12
+ from opendate.constants import UTC
13
+ from opendate.decorators import prefer_utc_timezone
14
+ from opendate.helpers import _rust_parse_time
15
+
16
+ if sys.version_info >= (3, 11):
17
+ from typing import Self
18
+ else:
19
+ from typing_extensions import Self
20
+
21
+
22
+ class Time(_pendulum.Time):
23
+ """Time class extending pendulum.Time with additional functionality.
24
+
25
+ This class inherits all pendulum.Time functionality while adding:
26
+ - Enhanced parsing for various time formats
27
+ - Default UTC timezone when created
28
+ - Simple timezone conversion utilities
29
+
30
+ Unlike pendulum.Time, this class has more lenient parsing capabilities
31
+ and different timezone defaults.
32
+ """
33
+
34
+ @classmethod
35
+ @prefer_utc_timezone
36
+ def parse(cls, s: str | None, fmt: str | None = None, raise_err: bool = False) -> Self | None:
37
+ """Parse time string in various formats.
38
+
39
+ Supported formats:
40
+ - hh:mm or hh.mm
41
+ - hh:mm:ss or hh.mm.ss
42
+ - hh:mm:ss.microseconds
43
+ - Any of above with AM/PM
44
+ - Compact: hhmmss or hhmmss.microseconds
45
+
46
+ Returns Time with UTC timezone by default.
47
+
48
+ Parameters
49
+ s: String to parse or None
50
+ fmt: Optional strftime format string for custom parsing
51
+ raise_err: If True, raises ValueError on parse failure instead of returning None
52
+
53
+ Returns
54
+ Time instance with UTC timezone or None if parsing fails and raise_err is False
55
+
56
+ Examples
57
+ Basic time formats:
58
+ Time.parse('14:30') → Time(14, 30, 0, 0, tzinfo=UTC)
59
+ Time.parse('14.30') → Time(14, 30, 0, 0, tzinfo=UTC)
60
+ Time.parse('14:30:45') → Time(14, 30, 45, 0, tzinfo=UTC)
61
+
62
+ With microseconds:
63
+ Time.parse('14:30:45.123456') → Time(14, 30, 45, 123456000, tzinfo=UTC)
64
+ Time.parse('14:30:45,500000') → Time(14, 30, 45, 500000000, tzinfo=UTC)
65
+
66
+ AM/PM formats:
67
+ Time.parse('2:30 PM') → Time(14, 30, 0, 0, tzinfo=UTC)
68
+ Time.parse('11:30 AM') → Time(11, 30, 0, 0, tzinfo=UTC)
69
+ Time.parse('12:30 PM') → Time(12, 30, 0, 0, tzinfo=UTC)
70
+
71
+ Compact formats:
72
+ Time.parse('143045') → Time(14, 30, 45, 0, tzinfo=UTC)
73
+ Time.parse('1430') → Time(14, 30, 0, 0, tzinfo=UTC)
74
+
75
+ Custom format:
76
+ Time.parse('14-30-45', fmt='%H-%M-%S') → Time(14, 30, 45, 0, tzinfo=UTC)
77
+ """
78
+ if not s:
79
+ if raise_err:
80
+ raise ValueError('Empty value')
81
+ return
82
+
83
+ if not isinstance(s, str):
84
+ raise TypeError(f'Invalid type for time parse: {s.__class__}')
85
+
86
+ if fmt:
87
+ try:
88
+ return cls(*time.strptime(s, fmt)[3:6])
89
+ except (ValueError, TypeError):
90
+ if raise_err:
91
+ raise ValueError(f'Unable to parse {s} using fmt {fmt}')
92
+ return
93
+
94
+ result = _rust_parse_time(s)
95
+ if result is not None:
96
+ hour, minute, second, microsecond = result
97
+ return cls(hour, minute, second, microsecond)
98
+
99
+ if raise_err:
100
+ raise ValueError('Failed to parse time: %s', s)
101
+
102
+ @classmethod
103
+ def instance(
104
+ cls,
105
+ obj: _datetime.time
106
+ | _datetime.datetime
107
+ | pd.Timestamp
108
+ | np.datetime64
109
+ | Self
110
+ | None,
111
+ tz: str | _zoneinfo.ZoneInfo | _datetime.tzinfo | None = None,
112
+ raise_err: bool = False,
113
+ ) -> Self | None:
114
+ """Create Time instance from time-like object.
115
+
116
+ Adds UTC timezone by default unless obj is already a Time instance.
117
+ """
118
+ if pd.isna(obj):
119
+ if raise_err:
120
+ raise ValueError('Empty value')
121
+ return
122
+
123
+ if type(obj) is cls and not tz:
124
+ return obj
125
+
126
+ tz = tz or obj.tzinfo or UTC
127
+
128
+ return cls(obj.hour, obj.minute, obj.second, obj.microsecond, tzinfo=tz)
129
+
130
+ def in_timezone(self, tz: str | _zoneinfo.ZoneInfo | _datetime.tzinfo) -> Self:
131
+ """Convert time to a different timezone.
132
+ """
133
+ from opendate.date_ import Date
134
+ from opendate.datetime_ import DateTime
135
+
136
+ _dt = DateTime.combine(Date.today(), self, tzinfo=self.tzinfo or UTC)
137
+ return _dt.in_timezone(tz).time()
138
+
139
+ in_tz = in_timezone