opendate 0.1.1__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/__init__.py +109 -0
- date/business.py +42 -0
- date/date.py +1702 -0
- opendate-0.1.1.dist-info/LICENSE +23 -0
- opendate-0.1.1.dist-info/METADATA +61 -0
- opendate-0.1.1.dist-info/RECORD +7 -0
- opendate-0.1.1.dist-info/WHEEL +4 -0
date/date.py
ADDED
|
@@ -0,0 +1,1702 @@
|
|
|
1
|
+
import calendar
|
|
2
|
+
import contextlib
|
|
3
|
+
import datetime as _datetime
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
import warnings
|
|
9
|
+
import zoneinfo as _zoneinfo
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from collections import namedtuple
|
|
12
|
+
from collections.abc import Callable, Sequence
|
|
13
|
+
from enum import IntEnum
|
|
14
|
+
from functools import lru_cache, partial, wraps
|
|
15
|
+
from typing import Self
|
|
16
|
+
|
|
17
|
+
import dateutil as _dateutil
|
|
18
|
+
import numpy as np
|
|
19
|
+
import pandas as pd
|
|
20
|
+
import pandas_market_calendars as mcal
|
|
21
|
+
import pendulum as _pendulum
|
|
22
|
+
|
|
23
|
+
warnings.simplefilter(action='ignore', category=DeprecationWarning)
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
'Date',
|
|
29
|
+
'DateTime',
|
|
30
|
+
'Interval',
|
|
31
|
+
'IntervalError',
|
|
32
|
+
'Time',
|
|
33
|
+
'Timezone',
|
|
34
|
+
'WeekDay',
|
|
35
|
+
'WEEKDAY_SHORTNAME',
|
|
36
|
+
'EST',
|
|
37
|
+
'UTC',
|
|
38
|
+
'GMT',
|
|
39
|
+
'LCL',
|
|
40
|
+
'expect_native_timezone',
|
|
41
|
+
'expect_utc_timezone',
|
|
42
|
+
'prefer_native_timezone',
|
|
43
|
+
'prefer_utc_timezone',
|
|
44
|
+
'expect_date',
|
|
45
|
+
'expect_datetime',
|
|
46
|
+
'Entity',
|
|
47
|
+
'NYSE'
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def Timezone(name:str = 'US/Eastern') -> _zoneinfo.ZoneInfo:
|
|
52
|
+
"""Simple wrapper around Pendulum `Timezone`
|
|
53
|
+
"""
|
|
54
|
+
return _pendulum.tz.Timezone(name)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
UTC = Timezone('UTC')
|
|
58
|
+
GMT = Timezone('GMT')
|
|
59
|
+
EST = Timezone('US/Eastern')
|
|
60
|
+
LCL = _pendulum.tz.Timezone(_pendulum.tz.get_local_timezone().name)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class WeekDay(IntEnum):
|
|
64
|
+
MONDAY = 0
|
|
65
|
+
TUESDAY = 1
|
|
66
|
+
WEDNESDAY = 2
|
|
67
|
+
THURSDAY = 3
|
|
68
|
+
FRIDAY = 4
|
|
69
|
+
SATURDAY = 5
|
|
70
|
+
SUNDAY = 6
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
WEEKDAY_SHORTNAME = {
|
|
74
|
+
'MO': WeekDay.MONDAY,
|
|
75
|
+
'TU': WeekDay.TUESDAY,
|
|
76
|
+
'WE': WeekDay.WEDNESDAY,
|
|
77
|
+
'TH': WeekDay.THURSDAY,
|
|
78
|
+
'FR': WeekDay.FRIDAY,
|
|
79
|
+
'SA': WeekDay.SATURDAY,
|
|
80
|
+
'SU': WeekDay.SUNDAY
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
MONTH_SHORTNAME = {
|
|
85
|
+
'jan': 1,
|
|
86
|
+
'feb': 2,
|
|
87
|
+
'mar': 3,
|
|
88
|
+
'apr': 4,
|
|
89
|
+
'may': 5,
|
|
90
|
+
'jun': 6,
|
|
91
|
+
'jul': 7,
|
|
92
|
+
'aug': 8,
|
|
93
|
+
'sep': 9,
|
|
94
|
+
'oct': 10,
|
|
95
|
+
'nov': 11,
|
|
96
|
+
'dec': 12,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
DATEMATCH = re.compile(r'^(?P<d>N|T|Y|P|M)(?P<n>[-+]?\d+)?(?P<b>b?)?$')
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# def caller_entity(func):
|
|
103
|
+
# """Helper to get current entity from function"""
|
|
104
|
+
# # general frame args inspect
|
|
105
|
+
# import inspect
|
|
106
|
+
# frame = inspect.currentframe()
|
|
107
|
+
# outer_frames = inspect.getouterframes(frame)
|
|
108
|
+
# caller_frame = outer_frames[1][0]
|
|
109
|
+
# args = inspect.getargvalues(caller_frame)
|
|
110
|
+
# # find our entity
|
|
111
|
+
# param = inspect.signature(func).parameters.get('entity')
|
|
112
|
+
# default = param.default if param else NYSE
|
|
113
|
+
# entity = args.locals['kwargs'].get('entity', default)
|
|
114
|
+
# return entity
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def isdateish(x):
|
|
118
|
+
return isinstance(x, _datetime.date | _datetime.datetime | pd.Timestamp | np.datetime64)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def parse_arg(typ, arg):
|
|
122
|
+
if isdateish(arg):
|
|
123
|
+
if typ == _datetime.datetime:
|
|
124
|
+
return DateTime.instance(arg)
|
|
125
|
+
if typ == _datetime.date:
|
|
126
|
+
return Date.instance(arg)
|
|
127
|
+
if typ == _datetime.time:
|
|
128
|
+
return Time.instance(arg)
|
|
129
|
+
return arg
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def parse_args(typ, *args):
|
|
133
|
+
this = []
|
|
134
|
+
for a in args:
|
|
135
|
+
if isinstance(a, Sequence) and not isinstance(a, str):
|
|
136
|
+
this.append(parse_args(typ, *a))
|
|
137
|
+
else:
|
|
138
|
+
this.append(parse_arg(typ, a))
|
|
139
|
+
return this
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def expect(func, typ: type[_datetime.date], exclkw: bool = False) -> Callable:
|
|
143
|
+
"""Decorator to force input type of date/datetime inputs
|
|
144
|
+
"""
|
|
145
|
+
@wraps(func)
|
|
146
|
+
def wrapper(*args, **kwargs):
|
|
147
|
+
args = parse_args(typ, *args)
|
|
148
|
+
if not exclkw:
|
|
149
|
+
for k, v in kwargs.items():
|
|
150
|
+
if isdateish(v):
|
|
151
|
+
if typ == _datetime.datetime:
|
|
152
|
+
kwargs[k] = DateTime.instance(v)
|
|
153
|
+
continue
|
|
154
|
+
if typ == _datetime.date:
|
|
155
|
+
kwargs[k] = Date.instance(v)
|
|
156
|
+
return func(*args, **kwargs)
|
|
157
|
+
return wrapper
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
expect_date = partial(expect, typ=_datetime.date)
|
|
161
|
+
expect_datetime = partial(expect, typ=_datetime.datetime)
|
|
162
|
+
expect_time = partial(expect, typ=_datetime.time)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def type_class(typ, obj):
|
|
166
|
+
if typ:
|
|
167
|
+
return typ
|
|
168
|
+
if obj.__class__ in {_datetime.datetime, _pendulum.DateTime, DateTime}:
|
|
169
|
+
return DateTime
|
|
170
|
+
if obj.__class__ in {_datetime.date, _pendulum.Date, Date}:
|
|
171
|
+
return Date
|
|
172
|
+
raise ValueError(f'Unknown type {typ}')
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def store_entity(func=None, *, typ=None):
|
|
176
|
+
@wraps(func)
|
|
177
|
+
def wrapper(self, *args, **kwargs):
|
|
178
|
+
_entity = self._entity
|
|
179
|
+
d = type_class(typ, self).instance(func(self, *args, **kwargs))
|
|
180
|
+
d._entity = _entity
|
|
181
|
+
return d
|
|
182
|
+
if func is None:
|
|
183
|
+
return partial(store_entity, typ=typ)
|
|
184
|
+
return wrapper
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def store_both(func=None, *, typ=None):
|
|
188
|
+
@wraps(func)
|
|
189
|
+
def wrapper(self, *args, **kwargs):
|
|
190
|
+
_entity = self._entity
|
|
191
|
+
_business = self._business
|
|
192
|
+
d = type_class(typ, self).instance(func(self, *args, **kwargs))
|
|
193
|
+
d._entity = _entity
|
|
194
|
+
d._business = _business
|
|
195
|
+
return d
|
|
196
|
+
if func is None:
|
|
197
|
+
return partial(store_both, typ=typ)
|
|
198
|
+
return wrapper
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def prefer_utc_timezone(func, force:bool = False):
|
|
202
|
+
"""Return datetime as UTC.
|
|
203
|
+
"""
|
|
204
|
+
@wraps(func)
|
|
205
|
+
def wrapper(*args, **kwargs):
|
|
206
|
+
d = func(*args, **kwargs)
|
|
207
|
+
if not d:
|
|
208
|
+
return
|
|
209
|
+
if not force and d.tzinfo:
|
|
210
|
+
return d
|
|
211
|
+
return d.replace(tzinfo=UTC)
|
|
212
|
+
return wrapper
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def prefer_native_timezone(func, force:bool = False):
|
|
216
|
+
"""Return datetime as native.
|
|
217
|
+
"""
|
|
218
|
+
@wraps(func)
|
|
219
|
+
def wrapper(*args, **kwargs):
|
|
220
|
+
d = func(*args, **kwargs)
|
|
221
|
+
if not d:
|
|
222
|
+
return
|
|
223
|
+
if not force and d.tzinfo:
|
|
224
|
+
return d
|
|
225
|
+
return d.replace(tzinfo=LCL)
|
|
226
|
+
return wrapper
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
expect_native_timezone = partial(prefer_native_timezone, force=True)
|
|
230
|
+
expect_utc_timezone = partial(prefer_utc_timezone, force=True)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class Entity(ABC):
|
|
234
|
+
"""ABC for named entity types"""
|
|
235
|
+
|
|
236
|
+
tz = UTC
|
|
237
|
+
|
|
238
|
+
@staticmethod
|
|
239
|
+
@abstractmethod
|
|
240
|
+
def business_days(begdate: _datetime.date, enddate: _datetime.date):
|
|
241
|
+
"""Returns all business days over a range"""
|
|
242
|
+
|
|
243
|
+
@staticmethod
|
|
244
|
+
@abstractmethod
|
|
245
|
+
def business_hours(begdate: _datetime.date, enddate: _datetime.date):
|
|
246
|
+
"""Returns all business open and close times over a range"""
|
|
247
|
+
|
|
248
|
+
@staticmethod
|
|
249
|
+
@abstractmethod
|
|
250
|
+
def business_holidays(begdate: _datetime.date, enddate: _datetime.date):
|
|
251
|
+
"""Returns only holidays over a range"""
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class NYSE(Entity):
|
|
255
|
+
"""New York Stock Exchange"""
|
|
256
|
+
|
|
257
|
+
BEGDATE = _datetime.date(1900, 1, 1)
|
|
258
|
+
ENDDATE = _datetime.date(2200, 1, 1)
|
|
259
|
+
calendar = mcal.get_calendar('NYSE')
|
|
260
|
+
|
|
261
|
+
tz = EST
|
|
262
|
+
|
|
263
|
+
@staticmethod
|
|
264
|
+
@lru_cache
|
|
265
|
+
def business_days(begdate=BEGDATE, enddate=ENDDATE) -> set:
|
|
266
|
+
return {d.date() for d in NYSE.calendar.valid_days(begdate, enddate)}
|
|
267
|
+
|
|
268
|
+
@staticmethod
|
|
269
|
+
@lru_cache
|
|
270
|
+
def business_hours(begdate=BEGDATE, enddate=ENDDATE) -> dict:
|
|
271
|
+
df = NYSE.calendar.schedule(begdate, enddate, tz=EST)
|
|
272
|
+
open_close = [(o.to_pydatetime(), c.to_pydatetime())
|
|
273
|
+
for o, c in zip(df.market_open, df.market_close)]
|
|
274
|
+
return dict(zip(df.index.date, open_close))
|
|
275
|
+
|
|
276
|
+
@staticmethod
|
|
277
|
+
@lru_cache
|
|
278
|
+
def business_holidays(begdate=BEGDATE, enddate=ENDDATE) -> set:
|
|
279
|
+
return {d.date()
|
|
280
|
+
for d in map(pd.to_datetime, NYSE.calendar.holidays().holidays)
|
|
281
|
+
if begdate <= d <= enddate}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class PendulumBusinessDateMixin:
|
|
285
|
+
|
|
286
|
+
_entity: type[NYSE] = NYSE
|
|
287
|
+
_business: bool = False
|
|
288
|
+
|
|
289
|
+
def business(self) -> Self:
|
|
290
|
+
self._business = True
|
|
291
|
+
return self
|
|
292
|
+
|
|
293
|
+
@property
|
|
294
|
+
def b(self) -> Self:
|
|
295
|
+
return self.business()
|
|
296
|
+
|
|
297
|
+
def entity(self, entity: type[NYSE] = NYSE) -> Self:
|
|
298
|
+
self._entity = entity
|
|
299
|
+
return self
|
|
300
|
+
|
|
301
|
+
@store_entity
|
|
302
|
+
def add(self, years: int = 0, months: int = 0, weeks: int = 0, days: int = 0, **kwargs) -> Self:
|
|
303
|
+
"""Add wrapper
|
|
304
|
+
If not business use Pendulum
|
|
305
|
+
If business assume only days (for now) and use local logic
|
|
306
|
+
"""
|
|
307
|
+
_business = self._business
|
|
308
|
+
self._business = False
|
|
309
|
+
if _business:
|
|
310
|
+
if days == 0:
|
|
311
|
+
return self._business_or_next()
|
|
312
|
+
if days < 0:
|
|
313
|
+
return self.business().subtract(days=abs(days))
|
|
314
|
+
while days > 0:
|
|
315
|
+
self = self._business_next(days=1)
|
|
316
|
+
days -= 1
|
|
317
|
+
return self
|
|
318
|
+
return super().add(years, months, weeks, days, **kwargs)
|
|
319
|
+
|
|
320
|
+
@store_entity
|
|
321
|
+
def subtract(self, years: int = 0, months: int = 0, weeks: int = 0, days: int = 0, **kwargs) -> Self:
|
|
322
|
+
"""Subtract wrapper
|
|
323
|
+
If not business use Pendulum
|
|
324
|
+
If business assume only days (for now) and use local logic
|
|
325
|
+
"""
|
|
326
|
+
_business = self._business
|
|
327
|
+
self._business = False
|
|
328
|
+
if _business:
|
|
329
|
+
if days == 0:
|
|
330
|
+
return self._business_or_previous()
|
|
331
|
+
if days < 0:
|
|
332
|
+
return self.business().add(days=abs(days))
|
|
333
|
+
while days > 0:
|
|
334
|
+
self = self._business_previous(days=1)
|
|
335
|
+
days -= 1
|
|
336
|
+
return self
|
|
337
|
+
kwargs = {k: -1*v for k,v in kwargs.items()}
|
|
338
|
+
return super().add(-years, -months, -weeks, -days, **kwargs)
|
|
339
|
+
|
|
340
|
+
@store_entity
|
|
341
|
+
def first_of(self, unit: str, day_of_week: WeekDay | None = None) -> Self:
|
|
342
|
+
"""Returns an instance set to the first occurrence
|
|
343
|
+
of a given day of the week in the current unit.
|
|
344
|
+
"""
|
|
345
|
+
_business = self._business
|
|
346
|
+
self._business = False
|
|
347
|
+
self = super().first_of(unit, day_of_week)
|
|
348
|
+
if _business:
|
|
349
|
+
self = self._business_or_next()
|
|
350
|
+
return self
|
|
351
|
+
|
|
352
|
+
@store_entity
|
|
353
|
+
def last_of(self, unit: str, day_of_week: WeekDay | None = None) -> Self:
|
|
354
|
+
"""Returns an instance set to the last occurrence
|
|
355
|
+
of a given day of the week in the current unit.
|
|
356
|
+
"""
|
|
357
|
+
_business = self._business
|
|
358
|
+
self._business = False
|
|
359
|
+
self = super().last_of(unit, day_of_week)
|
|
360
|
+
if _business:
|
|
361
|
+
self = self._business_or_previous()
|
|
362
|
+
return self
|
|
363
|
+
|
|
364
|
+
@store_entity
|
|
365
|
+
def start_of(self, unit: str) -> Self:
|
|
366
|
+
"""Returns a copy of the instance with the time reset
|
|
367
|
+
"""
|
|
368
|
+
_business = self._business
|
|
369
|
+
self._business = False
|
|
370
|
+
self = super().start_of(unit)
|
|
371
|
+
if _business:
|
|
372
|
+
self = self._business_or_next()
|
|
373
|
+
return self
|
|
374
|
+
|
|
375
|
+
@store_entity
|
|
376
|
+
def end_of(self, unit: str) -> Self:
|
|
377
|
+
"""Returns a copy of the instance with the time reset
|
|
378
|
+
"""
|
|
379
|
+
_business = self._business
|
|
380
|
+
self._business = False
|
|
381
|
+
self = super().end_of(unit)
|
|
382
|
+
if _business:
|
|
383
|
+
self = self._business_or_previous()
|
|
384
|
+
return self
|
|
385
|
+
|
|
386
|
+
@store_entity
|
|
387
|
+
def previous(self, day_of_week: WeekDay | None = None) -> Self:
|
|
388
|
+
"""Modify to the previous occurrence of a given day of the week.
|
|
389
|
+
"""
|
|
390
|
+
_business = self._business
|
|
391
|
+
self._business = False
|
|
392
|
+
self = super().previous(day_of_week)
|
|
393
|
+
if _business:
|
|
394
|
+
self = self._business_or_next()
|
|
395
|
+
return self
|
|
396
|
+
|
|
397
|
+
@store_entity
|
|
398
|
+
def next(self, day_of_week: WeekDay | None = None) -> Self:
|
|
399
|
+
"""Modify to the next occurrence of a given day of the week.
|
|
400
|
+
"""
|
|
401
|
+
_business = self._business
|
|
402
|
+
self._business = False
|
|
403
|
+
self = super().next(day_of_week)
|
|
404
|
+
if _business:
|
|
405
|
+
self = self._business_or_previous()
|
|
406
|
+
return self
|
|
407
|
+
|
|
408
|
+
@expect_date
|
|
409
|
+
def business_open(self) -> bool:
|
|
410
|
+
"""Business open
|
|
411
|
+
|
|
412
|
+
>>> thedate = Date(2021, 4, 19) # Monday
|
|
413
|
+
>>> thedate.business_open()
|
|
414
|
+
True
|
|
415
|
+
>>> thedate = Date(2021, 4, 17) # Saturday
|
|
416
|
+
>>> thedate.business_open()
|
|
417
|
+
False
|
|
418
|
+
>>> thedate = Date(2021, 1, 18) # MLK Day
|
|
419
|
+
>>> thedate.business_open()
|
|
420
|
+
False
|
|
421
|
+
"""
|
|
422
|
+
return self.is_business_day()
|
|
423
|
+
|
|
424
|
+
@expect_date
|
|
425
|
+
def is_business_day(self) -> bool:
|
|
426
|
+
"""Is business date.
|
|
427
|
+
|
|
428
|
+
>>> thedate = Date(2021, 4, 19) # Monday
|
|
429
|
+
>>> thedate.is_business_day()
|
|
430
|
+
True
|
|
431
|
+
>>> thedate = Date(2021, 4, 17) # Saturday
|
|
432
|
+
>>> thedate.is_business_day()
|
|
433
|
+
False
|
|
434
|
+
>>> thedate = Date(2021, 1, 18) # MLK Day
|
|
435
|
+
>>> thedate.is_business_day()
|
|
436
|
+
False
|
|
437
|
+
>>> thedate = Date(2021, 11, 25) # Thanksgiving
|
|
438
|
+
>>> thedate.is_business_day()
|
|
439
|
+
False
|
|
440
|
+
>>> thedate = Date(2021, 11, 26) # Day after ^
|
|
441
|
+
>>> thedate.is_business_day()
|
|
442
|
+
True
|
|
443
|
+
"""
|
|
444
|
+
return self in self._entity.business_days()
|
|
445
|
+
|
|
446
|
+
@expect_date
|
|
447
|
+
def business_hours(self) -> 'tuple[DateTime, DateTime]':
|
|
448
|
+
"""Business hours
|
|
449
|
+
|
|
450
|
+
Returns (None, None) if not a business day
|
|
451
|
+
|
|
452
|
+
>>> thedate = Date(2023, 1, 5)
|
|
453
|
+
>>> thedate.business_hours()
|
|
454
|
+
(... 9, 30, ... 16, 0, ...)
|
|
455
|
+
|
|
456
|
+
>>> thedate = Date(2023, 7, 3)
|
|
457
|
+
>>> thedate.business_hours()
|
|
458
|
+
(... 9, 30, ... 13, 0, ...)
|
|
459
|
+
|
|
460
|
+
>>> thedate = Date(2023, 11, 24)
|
|
461
|
+
>>> thedate.business_hours()
|
|
462
|
+
(... 9, 30, ... 13, 0, ...)
|
|
463
|
+
|
|
464
|
+
>>> thedate = Date(2024, 5, 27) # memorial day
|
|
465
|
+
>>> thedate.business_hours()
|
|
466
|
+
(None, None)
|
|
467
|
+
"""
|
|
468
|
+
return self._entity.business_hours(self, self)\
|
|
469
|
+
.get(self, (None, None))
|
|
470
|
+
|
|
471
|
+
@store_both
|
|
472
|
+
def _business_next(self, days=0):
|
|
473
|
+
"""Helper for cycling through N business day"""
|
|
474
|
+
days = abs(days)
|
|
475
|
+
while days > 0:
|
|
476
|
+
try:
|
|
477
|
+
self = super().add(days=1)
|
|
478
|
+
except OverflowError:
|
|
479
|
+
break
|
|
480
|
+
if self.is_business_day():
|
|
481
|
+
days -= 1
|
|
482
|
+
return self
|
|
483
|
+
|
|
484
|
+
@store_both
|
|
485
|
+
def _business_previous(self, days=0):
|
|
486
|
+
"""Helper for cycling through N business day"""
|
|
487
|
+
days = abs(days)
|
|
488
|
+
while days > 0:
|
|
489
|
+
try:
|
|
490
|
+
self = super().add(days=-1)
|
|
491
|
+
except OverflowError:
|
|
492
|
+
break
|
|
493
|
+
if self.is_business_day():
|
|
494
|
+
days -= 1
|
|
495
|
+
return self
|
|
496
|
+
|
|
497
|
+
@store_entity
|
|
498
|
+
def _business_or_next(self):
|
|
499
|
+
self._business = False
|
|
500
|
+
self = super().subtract(days=1)
|
|
501
|
+
self = self._business_next(days=1)
|
|
502
|
+
return self
|
|
503
|
+
|
|
504
|
+
@store_entity
|
|
505
|
+
def _business_or_previous(self):
|
|
506
|
+
self._business = False
|
|
507
|
+
self = super().add(days=1)
|
|
508
|
+
self = self._business_previous(days=1)
|
|
509
|
+
return self
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
class Date(PendulumBusinessDateMixin, _pendulum.Date):
|
|
513
|
+
"""Inherits and wraps pendulum.Date
|
|
514
|
+
"""
|
|
515
|
+
|
|
516
|
+
def to_string(self, fmt: str) -> str:
|
|
517
|
+
"""Format cleaner https://stackoverflow.com/a/2073189.
|
|
518
|
+
|
|
519
|
+
>>> Date(2022, 1, 5).to_string('%-m/%-d/%Y')
|
|
520
|
+
'1/5/2022'
|
|
521
|
+
"""
|
|
522
|
+
return self.strftime(fmt.replace('%-', '%#') if os.name == 'nt' else fmt)
|
|
523
|
+
|
|
524
|
+
@classmethod
|
|
525
|
+
def parse(
|
|
526
|
+
cls,
|
|
527
|
+
s: str | None,
|
|
528
|
+
fmt: str = None,
|
|
529
|
+
entity: Entity = NYSE,
|
|
530
|
+
raise_err: bool = False,
|
|
531
|
+
) -> Self | None:
|
|
532
|
+
"""Convert a string to a date handling many different formats.
|
|
533
|
+
|
|
534
|
+
creating a new Date object
|
|
535
|
+
>>> Date.parse('2022/1/1')
|
|
536
|
+
Date(2022, 1, 1)
|
|
537
|
+
|
|
538
|
+
previous business day accessed with 'P'
|
|
539
|
+
>>> Date.parse('P')==Date.today().b.subtract(days=1)
|
|
540
|
+
True
|
|
541
|
+
>>> Date.parse('T-3b')==Date.today().b.subtract(days=3)
|
|
542
|
+
True
|
|
543
|
+
>>> Date.parse('T-3b')==Date.today().b.add(days=-3)
|
|
544
|
+
True
|
|
545
|
+
>>> Date.parse('T+3b')==Date.today().b.subtract(days=-3)
|
|
546
|
+
True
|
|
547
|
+
>>> Date.parse('T+3b')==Date.today().b.add(days=3)
|
|
548
|
+
True
|
|
549
|
+
>>> Date.parse('M')==Date.today().start_of('month').subtract(days=1)
|
|
550
|
+
True
|
|
551
|
+
|
|
552
|
+
m[/-]d[/-]yyyy 6-23-2006
|
|
553
|
+
>>> Date.parse('6-23-2006')
|
|
554
|
+
Date(2006, 6, 23)
|
|
555
|
+
|
|
556
|
+
m[/-]d[/-]yy 6/23/06
|
|
557
|
+
>>> Date.parse('6/23/06')
|
|
558
|
+
Date(2006, 6, 23)
|
|
559
|
+
|
|
560
|
+
m[/-]d 6/23
|
|
561
|
+
>>> Date.parse('6/23') == Date(Date.today().year, 6, 23)
|
|
562
|
+
True
|
|
563
|
+
|
|
564
|
+
yyyy-mm-dd 2006-6-23
|
|
565
|
+
>>> Date.parse('2006-6-23')
|
|
566
|
+
Date(2006, 6, 23)
|
|
567
|
+
|
|
568
|
+
yyyymmdd 20060623
|
|
569
|
+
>>> Date.parse('20060623')
|
|
570
|
+
Date(2006, 6, 23)
|
|
571
|
+
|
|
572
|
+
dd-mon-yyyy 23-JUN-2006
|
|
573
|
+
>>> Date.parse('23-JUN-2006')
|
|
574
|
+
Date(2006, 6, 23)
|
|
575
|
+
|
|
576
|
+
mon-dd-yyyy JUN-23-2006
|
|
577
|
+
>>> Date.parse('20 Jan 2009')
|
|
578
|
+
Date(2009, 1, 20)
|
|
579
|
+
|
|
580
|
+
month dd, yyyy June 23, 2006
|
|
581
|
+
>>> Date.parse('June 23, 2006')
|
|
582
|
+
Date(2006, 6, 23)
|
|
583
|
+
|
|
584
|
+
dd-mon-yy
|
|
585
|
+
>>> Date.parse('23-May-12')
|
|
586
|
+
Date(2012, 5, 23)
|
|
587
|
+
|
|
588
|
+
ddmonyyyy
|
|
589
|
+
>>> Date.parse('23May2012')
|
|
590
|
+
Date(2012, 5, 23)
|
|
591
|
+
|
|
592
|
+
>>> Date.parse('Oct. 24, 2007', fmt='%b. %d, %Y')
|
|
593
|
+
Date(2007, 10, 24)
|
|
594
|
+
|
|
595
|
+
>>> Date.parse('Yesterday') == DateTime.now().subtract(days=1).date()
|
|
596
|
+
True
|
|
597
|
+
>>> Date.parse('TODAY') == Date.today()
|
|
598
|
+
True
|
|
599
|
+
>>> Date.parse('Jan. 13, 2014')
|
|
600
|
+
Date(2014, 1, 13)
|
|
601
|
+
|
|
602
|
+
>>> Date.parse('March') == Date(Date.today().year, 3, Date.today().day)
|
|
603
|
+
True
|
|
604
|
+
|
|
605
|
+
only raise error when we explicitly say so
|
|
606
|
+
>>> Date.parse('bad date') is None
|
|
607
|
+
True
|
|
608
|
+
>>> Date.parse('bad date', raise_err=True)
|
|
609
|
+
Traceback (most recent call last):
|
|
610
|
+
...
|
|
611
|
+
ValueError: Failed to parse date: bad date
|
|
612
|
+
"""
|
|
613
|
+
|
|
614
|
+
def date_for_symbol(s):
|
|
615
|
+
if s == 'N':
|
|
616
|
+
return cls.today()
|
|
617
|
+
if s == 'T':
|
|
618
|
+
return cls.today()
|
|
619
|
+
if s == 'Y':
|
|
620
|
+
return cls.today().subtract(days=1)
|
|
621
|
+
if s == 'P':
|
|
622
|
+
return cls.today().entity(entity).business().subtract(days=1)
|
|
623
|
+
if s == 'M':
|
|
624
|
+
return cls.today().start_of('month').subtract(days=1)
|
|
625
|
+
|
|
626
|
+
def year(m):
|
|
627
|
+
try:
|
|
628
|
+
yy = int(m.group('y'))
|
|
629
|
+
if yy < 100:
|
|
630
|
+
yy += 2000
|
|
631
|
+
except IndexError:
|
|
632
|
+
logger.debug('Using default this year')
|
|
633
|
+
yy = cls.today().year
|
|
634
|
+
return yy
|
|
635
|
+
|
|
636
|
+
if not s:
|
|
637
|
+
if raise_err:
|
|
638
|
+
raise ValueError('Empty value')
|
|
639
|
+
return
|
|
640
|
+
|
|
641
|
+
if not isinstance(s, str):
|
|
642
|
+
raise TypeError(f'Invalid type for date parse: {s.__class__}')
|
|
643
|
+
|
|
644
|
+
if fmt:
|
|
645
|
+
with contextlib.suppress(ValueError):
|
|
646
|
+
return cls(*time.strptime(s, fmt)[:3])
|
|
647
|
+
|
|
648
|
+
# special shortcode symbolic values: T, Y-2, P-1b
|
|
649
|
+
if m := DATEMATCH.match(s):
|
|
650
|
+
d = date_for_symbol(m.groupdict().get('d'))
|
|
651
|
+
n = m.groupdict().get('n')
|
|
652
|
+
if not n:
|
|
653
|
+
return d
|
|
654
|
+
n = int(n)
|
|
655
|
+
b = m.groupdict().get('b')
|
|
656
|
+
if b:
|
|
657
|
+
assert b == 'b'
|
|
658
|
+
d = d.entity(entity).business().add(days=n)
|
|
659
|
+
else:
|
|
660
|
+
d = d.add(days=n)
|
|
661
|
+
return d
|
|
662
|
+
if 'today' in s.lower():
|
|
663
|
+
return cls.today()
|
|
664
|
+
if 'yester' in s.lower():
|
|
665
|
+
return cls.today().subtract(days=1)
|
|
666
|
+
|
|
667
|
+
with contextlib.suppress(TypeError, ValueError):
|
|
668
|
+
return cls.instance(_dateutil.parser.parse(s).date())
|
|
669
|
+
|
|
670
|
+
# Regex with Month Numbers
|
|
671
|
+
exps = (
|
|
672
|
+
r'^(?P<m>\d{1,2})[/-](?P<d>\d{1,2})[/-](?P<y>\d{4})$',
|
|
673
|
+
r'^(?P<m>\d{1,2})[/-](?P<d>\d{1,2})[/-](?P<y>\d{1,2})$',
|
|
674
|
+
r'^(?P<m>\d{1,2})[/-](?P<d>\d{1,2})$',
|
|
675
|
+
r'^(?P<y>\d{4})-(?P<m>\d{1,2})-(?P<d>\d{1,2})$',
|
|
676
|
+
r'^(?P<y>\d{4})(?P<m>\d{2})(?P<d>\d{2})$',
|
|
677
|
+
)
|
|
678
|
+
for exp in exps:
|
|
679
|
+
if m := re.match(exp, s):
|
|
680
|
+
mm = int(m.group('m'))
|
|
681
|
+
dd = int(m.group('d'))
|
|
682
|
+
yy = year(m)
|
|
683
|
+
return cls(yy, mm, dd)
|
|
684
|
+
|
|
685
|
+
# Regex with Month Name
|
|
686
|
+
exps = (
|
|
687
|
+
r'^(?P<d>\d{1,2})[- ](?P<m>[A-Za-z]{3,})[- ](?P<y>\d{4})$',
|
|
688
|
+
r'^(?P<m>[A-Za-z]{3,})[- ](?P<d>\d{1,2})[- ](?P<y>\d{4})$',
|
|
689
|
+
r'^(?P<m>[A-Za-z]{3,}) (?P<d>\d{1,2}), (?P<y>\d{4})$',
|
|
690
|
+
r'^(?P<d>\d{2})(?P<m>[A-Z][a-z]{2})(?P<y>\d{4})$',
|
|
691
|
+
r'^(?P<d>\d{1,2})-(?P<m>[A-Z][a-z][a-z])-(?P<y>\d{2})$',
|
|
692
|
+
r'^(?P<d>\d{1,2})-(?P<m>[A-Z]{3})-(?P<y>\d{2})$',
|
|
693
|
+
)
|
|
694
|
+
for exp in exps:
|
|
695
|
+
if m := re.match(exp, s):
|
|
696
|
+
try:
|
|
697
|
+
mm = MONTH_SHORTNAME[m.group('m').lower()[:3]]
|
|
698
|
+
except KeyError:
|
|
699
|
+
logger.debug('Month name did not match MONTH_SHORTNAME')
|
|
700
|
+
continue
|
|
701
|
+
dd = int(m.group('d'))
|
|
702
|
+
yy = year(m)
|
|
703
|
+
return cls(yy, mm, dd)
|
|
704
|
+
|
|
705
|
+
if raise_err:
|
|
706
|
+
raise ValueError('Failed to parse date: %s', s)
|
|
707
|
+
|
|
708
|
+
@classmethod
|
|
709
|
+
def instance(
|
|
710
|
+
cls,
|
|
711
|
+
obj: _datetime.date
|
|
712
|
+
| _datetime.datetime
|
|
713
|
+
| _datetime.time
|
|
714
|
+
| pd.Timestamp
|
|
715
|
+
| np.datetime64
|
|
716
|
+
| Self
|
|
717
|
+
| None,
|
|
718
|
+
raise_err: bool = False,
|
|
719
|
+
) -> Self | None:
|
|
720
|
+
"""From datetime.date like object
|
|
721
|
+
|
|
722
|
+
>>> Date.instance(_datetime.date(2022, 1, 1))
|
|
723
|
+
Date(2022, 1, 1)
|
|
724
|
+
>>> Date.instance(Date(2022, 1, 1))
|
|
725
|
+
Date(2022, 1, 1)
|
|
726
|
+
>>> Date.instance(_pendulum.Date(2022, 1, 1))
|
|
727
|
+
Date(2022, 1, 1)
|
|
728
|
+
>>> Date.instance(Date(2022, 1, 1))
|
|
729
|
+
Date(2022, 1, 1)
|
|
730
|
+
>>> Date.instance(np.datetime64('2000-01', 'D'))
|
|
731
|
+
Date(2000, 1, 1)
|
|
732
|
+
>>> Date.instance(None)
|
|
733
|
+
|
|
734
|
+
"""
|
|
735
|
+
if pd.isna(obj):
|
|
736
|
+
if raise_err:
|
|
737
|
+
raise ValueError('Empty value')
|
|
738
|
+
return
|
|
739
|
+
|
|
740
|
+
if obj.__class__ == cls:
|
|
741
|
+
return obj
|
|
742
|
+
|
|
743
|
+
if isinstance(obj, np.datetime64 | pd.Timestamp):
|
|
744
|
+
obj = DateTime.instance(obj)
|
|
745
|
+
|
|
746
|
+
return cls(obj.year, obj.month, obj.day)
|
|
747
|
+
|
|
748
|
+
@classmethod
|
|
749
|
+
def today(cls):
|
|
750
|
+
d = _datetime.date.today()
|
|
751
|
+
return cls(d.year, d.month, d.day)
|
|
752
|
+
|
|
753
|
+
def isoweek(self):
|
|
754
|
+
"""Week number 1-52 following ISO week-numbering
|
|
755
|
+
|
|
756
|
+
Standard weeks
|
|
757
|
+
>>> Date(2023, 1, 2).isoweek()
|
|
758
|
+
1
|
|
759
|
+
>>> Date(2023, 4, 27).isoweek()
|
|
760
|
+
17
|
|
761
|
+
>>> Date(2023, 12, 31).isoweek()
|
|
762
|
+
52
|
|
763
|
+
|
|
764
|
+
Belongs to week of previous year
|
|
765
|
+
>>> Date(2023, 1, 1).isoweek()
|
|
766
|
+
52
|
|
767
|
+
"""
|
|
768
|
+
with contextlib.suppress(Exception):
|
|
769
|
+
return self.isocalendar()[1]
|
|
770
|
+
|
|
771
|
+
"""
|
|
772
|
+
See how pendulum does end_of and next_ with getattr
|
|
773
|
+
|
|
774
|
+
Create a nearest [start_of, end_of] [week, day, month, quarter, year]
|
|
775
|
+
|
|
776
|
+
combo that accounts for whatever prefix and unit is passed in
|
|
777
|
+
"""
|
|
778
|
+
|
|
779
|
+
def nearest_start_of_month(self):
|
|
780
|
+
"""Get `nearest` start of month
|
|
781
|
+
|
|
782
|
+
1/1/2015 -> Thursday (New Year's Day)
|
|
783
|
+
2/1/2015 -> Sunday
|
|
784
|
+
|
|
785
|
+
>>> Date(2015, 1, 1).nearest_start_of_month()
|
|
786
|
+
Date(2015, 1, 1)
|
|
787
|
+
>>> Date(2015, 1, 15).nearest_start_of_month()
|
|
788
|
+
Date(2015, 1, 1)
|
|
789
|
+
>>> Date(2015, 1, 15).b.nearest_start_of_month()
|
|
790
|
+
Date(2015, 1, 2)
|
|
791
|
+
>>> Date(2015, 1, 16).nearest_start_of_month()
|
|
792
|
+
Date(2015, 2, 1)
|
|
793
|
+
>>> Date(2015, 1, 31).nearest_start_of_month()
|
|
794
|
+
Date(2015, 2, 1)
|
|
795
|
+
>>> Date(2015, 1, 31).b.nearest_start_of_month()
|
|
796
|
+
Date(2015, 2, 2)
|
|
797
|
+
"""
|
|
798
|
+
_business = self._business
|
|
799
|
+
self._business = False
|
|
800
|
+
if self.day > 15:
|
|
801
|
+
d = self.end_of('month')
|
|
802
|
+
if _business:
|
|
803
|
+
return d.business().add(days=1)
|
|
804
|
+
return d.add(days=1)
|
|
805
|
+
d = self.start_of('month')
|
|
806
|
+
if _business:
|
|
807
|
+
return d.business().add(days=1)
|
|
808
|
+
return d
|
|
809
|
+
|
|
810
|
+
def nearest_end_of_month(self):
|
|
811
|
+
"""Get `nearest` end of month
|
|
812
|
+
|
|
813
|
+
12/31/2014 -> Wednesday
|
|
814
|
+
1/31/2015 -> Saturday
|
|
815
|
+
|
|
816
|
+
>>> Date(2015, 1, 1).nearest_end_of_month()
|
|
817
|
+
Date(2014, 12, 31)
|
|
818
|
+
>>> Date(2015, 1, 15).nearest_end_of_month()
|
|
819
|
+
Date(2014, 12, 31)
|
|
820
|
+
>>> Date(2015, 1, 15).b.nearest_end_of_month()
|
|
821
|
+
Date(2014, 12, 31)
|
|
822
|
+
>>> Date(2015, 1, 16).nearest_end_of_month()
|
|
823
|
+
Date(2015, 1, 31)
|
|
824
|
+
>>> Date(2015, 1, 31).nearest_end_of_month()
|
|
825
|
+
Date(2015, 1, 31)
|
|
826
|
+
>>> Date(2015, 1, 31).b.nearest_end_of_month()
|
|
827
|
+
Date(2015, 1, 30)
|
|
828
|
+
"""
|
|
829
|
+
_business = self._business
|
|
830
|
+
self._business = False
|
|
831
|
+
if self.day <= 15:
|
|
832
|
+
d = self.start_of('month')
|
|
833
|
+
if _business:
|
|
834
|
+
return d.business().subtract(days=1)
|
|
835
|
+
return d.subtract(days=1)
|
|
836
|
+
d = self.end_of('month')
|
|
837
|
+
if _business:
|
|
838
|
+
return d.business().subtract(days=1)
|
|
839
|
+
return d
|
|
840
|
+
|
|
841
|
+
def next_relative_date_of_week_by_day(self, day='MO'):
|
|
842
|
+
"""Get next relative day of week by relativedelta code
|
|
843
|
+
|
|
844
|
+
>>> Date(2020, 5, 18).next_relative_date_of_week_by_day('SU')
|
|
845
|
+
Date(2020, 5, 24)
|
|
846
|
+
>>> Date(2020, 5, 24).next_relative_date_of_week_by_day('SU')
|
|
847
|
+
Date(2020, 5, 24)
|
|
848
|
+
"""
|
|
849
|
+
if self.weekday() == WEEKDAY_SHORTNAME.get(day):
|
|
850
|
+
return self
|
|
851
|
+
return self.next(WEEKDAY_SHORTNAME.get(day))
|
|
852
|
+
|
|
853
|
+
def weekday_or_previous_friday(self):
|
|
854
|
+
"""Return the date if it is a weekday, else previous Friday
|
|
855
|
+
|
|
856
|
+
>>> Date(2019, 10, 6).weekday_or_previous_friday() # Sunday
|
|
857
|
+
Date(2019, 10, 4)
|
|
858
|
+
>>> Date(2019, 10, 5).weekday_or_previous_friday() # Saturday
|
|
859
|
+
Date(2019, 10, 4)
|
|
860
|
+
>>> Date(2019, 10, 4).weekday_or_previous_friday() # Friday
|
|
861
|
+
Date(2019, 10, 4)
|
|
862
|
+
>>> Date(2019, 10, 3).weekday_or_previous_friday() # Thursday
|
|
863
|
+
Date(2019, 10, 3)
|
|
864
|
+
"""
|
|
865
|
+
dnum = self.weekday()
|
|
866
|
+
if dnum in {WeekDay.SATURDAY, WeekDay.SUNDAY}:
|
|
867
|
+
return self.subtract(days=dnum - 4)
|
|
868
|
+
return self
|
|
869
|
+
|
|
870
|
+
def lookback(self, unit='last') -> Self:
|
|
871
|
+
"""Date back based on lookback string, ie last, week, month.
|
|
872
|
+
|
|
873
|
+
>>> Date(2018, 12, 7).b.lookback('last')
|
|
874
|
+
Date(2018, 12, 6)
|
|
875
|
+
>>> Date(2018, 12, 7).b.lookback('day')
|
|
876
|
+
Date(2018, 12, 6)
|
|
877
|
+
>>> Date(2018, 12, 7).b.lookback('week')
|
|
878
|
+
Date(2018, 11, 30)
|
|
879
|
+
>>> Date(2018, 12, 7).b.lookback('month')
|
|
880
|
+
Date(2018, 11, 7)
|
|
881
|
+
"""
|
|
882
|
+
def _lookback(years=0, months=0, weeks=0, days=0):
|
|
883
|
+
_business = self._business
|
|
884
|
+
self._business = False
|
|
885
|
+
d = self\
|
|
886
|
+
.subtract(years=years, months=months, weeks=weeks, days=days)
|
|
887
|
+
if _business:
|
|
888
|
+
return d._business_or_previous()
|
|
889
|
+
return d
|
|
890
|
+
|
|
891
|
+
return {
|
|
892
|
+
'day': _lookback(days=1),
|
|
893
|
+
'last': _lookback(days=1),
|
|
894
|
+
'week': _lookback(weeks=1),
|
|
895
|
+
'month': _lookback(months=1),
|
|
896
|
+
'quarter': _lookback(months=3),
|
|
897
|
+
'year': _lookback(years=1),
|
|
898
|
+
}.get(unit)
|
|
899
|
+
|
|
900
|
+
"""
|
|
901
|
+
create a simple nth weekday function that accounts for
|
|
902
|
+
[1,2,3,4] and weekday as options
|
|
903
|
+
or weekday, [1,2,3,4]
|
|
904
|
+
|
|
905
|
+
"""
|
|
906
|
+
|
|
907
|
+
@staticmethod
|
|
908
|
+
def third_wednesday(year, month):
|
|
909
|
+
"""Third Wednesday date of a given month/year
|
|
910
|
+
|
|
911
|
+
>>> Date.third_wednesday(2022, 6)
|
|
912
|
+
Date(2022, 6, 15)
|
|
913
|
+
>>> Date.third_wednesday(2023, 3)
|
|
914
|
+
Date(2023, 3, 15)
|
|
915
|
+
>>> Date.third_wednesday(2022, 12)
|
|
916
|
+
Date(2022, 12, 21)
|
|
917
|
+
>>> Date.third_wednesday(2023, 6)
|
|
918
|
+
Date(2023, 6, 21)
|
|
919
|
+
"""
|
|
920
|
+
third = Date(year, month, 15) # lowest 3rd day
|
|
921
|
+
w = third.weekday()
|
|
922
|
+
if w != WeekDay.WEDNESDAY:
|
|
923
|
+
third = third.replace(day=(15 + (WeekDay.WEDNESDAY - w) % 7))
|
|
924
|
+
return third
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
class Time(_pendulum.Time):
|
|
928
|
+
|
|
929
|
+
@classmethod
|
|
930
|
+
@prefer_utc_timezone
|
|
931
|
+
def parse(cls, s: str | None, fmt: str | None = None, raise_err: bool = False) -> Self | None:
|
|
932
|
+
"""Convert a string to a time handling many formats::
|
|
933
|
+
|
|
934
|
+
handle many time formats:
|
|
935
|
+
hh[:.]mm
|
|
936
|
+
hh[:.]mm am/pm
|
|
937
|
+
hh[:.]mm[:.]ss
|
|
938
|
+
hh[:.]mm[:.]ss[.,]uuu am/pm
|
|
939
|
+
hhmmss[.,]uuu
|
|
940
|
+
hhmmss[.,]uuu am/pm
|
|
941
|
+
|
|
942
|
+
>>> Time.parse('9:30')
|
|
943
|
+
Time(9, 30, 0, tzinfo=Timezone('UTC'))
|
|
944
|
+
>>> Time.parse('9:30:15')
|
|
945
|
+
Time(9, 30, 15, tzinfo=Timezone('UTC'))
|
|
946
|
+
>>> Time.parse('9:30:15.751')
|
|
947
|
+
Time(9, 30, 15, 751000, tzinfo=Timezone('UTC'))
|
|
948
|
+
>>> Time.parse('9:30 AM')
|
|
949
|
+
Time(9, 30, 0, tzinfo=Timezone('UTC'))
|
|
950
|
+
>>> Time.parse('9:30 pm')
|
|
951
|
+
Time(21, 30, 0, tzinfo=Timezone('UTC'))
|
|
952
|
+
>>> Time.parse('9:30:15.751 PM')
|
|
953
|
+
Time(21, 30, 15, 751000, tzinfo=Timezone('UTC'))
|
|
954
|
+
>>> Time.parse('0930') # Date treats this as a date, careful!!
|
|
955
|
+
Time(9, 30, 0, tzinfo=Timezone('UTC'))
|
|
956
|
+
>>> Time.parse('093015')
|
|
957
|
+
Time(9, 30, 15, tzinfo=Timezone('UTC'))
|
|
958
|
+
>>> Time.parse('093015,751')
|
|
959
|
+
Time(9, 30, 15, 751000, tzinfo=Timezone('UTC'))
|
|
960
|
+
>>> Time.parse('0930 pm')
|
|
961
|
+
Time(21, 30, 0, tzinfo=Timezone('UTC'))
|
|
962
|
+
>>> Time.parse('093015,751 PM')
|
|
963
|
+
Time(21, 30, 15, 751000, tzinfo=Timezone('UTC'))
|
|
964
|
+
"""
|
|
965
|
+
|
|
966
|
+
def seconds(m):
|
|
967
|
+
try:
|
|
968
|
+
return int(m.group('s'))
|
|
969
|
+
except Exception:
|
|
970
|
+
return 0
|
|
971
|
+
|
|
972
|
+
def micros(m):
|
|
973
|
+
try:
|
|
974
|
+
return int(m.group('u'))
|
|
975
|
+
except Exception:
|
|
976
|
+
return 0
|
|
977
|
+
|
|
978
|
+
def is_pm(m):
|
|
979
|
+
try:
|
|
980
|
+
return m.group('ap').lower() == 'pm'
|
|
981
|
+
except Exception:
|
|
982
|
+
return False
|
|
983
|
+
|
|
984
|
+
if not s:
|
|
985
|
+
if raise_err:
|
|
986
|
+
raise ValueError('Empty value')
|
|
987
|
+
return
|
|
988
|
+
|
|
989
|
+
if not isinstance(s, str):
|
|
990
|
+
raise TypeError(f'Invalid type for time parse: {s.__class__}')
|
|
991
|
+
|
|
992
|
+
if fmt:
|
|
993
|
+
return cls(*time.strptime(s, fmt)[3:6])
|
|
994
|
+
|
|
995
|
+
exps = (
|
|
996
|
+
r'^(?P<h>\d{1,2})[:.](?P<m>\d{2})([:.](?P<s>\d{2})([.,](?P<u>\d+))?)?( +(?P<ap>[aApP][mM]))?$',
|
|
997
|
+
r'^(?P<h>\d{2})(?P<m>\d{2})((?P<s>\d{2})([.,](?P<u>\d+))?)?( +(?P<ap>[aApP][mM]))?$',
|
|
998
|
+
)
|
|
999
|
+
|
|
1000
|
+
for exp in exps:
|
|
1001
|
+
if m := re.match(exp, s):
|
|
1002
|
+
hh = int(m.group('h'))
|
|
1003
|
+
mm = int(m.group('m'))
|
|
1004
|
+
ss = seconds(m)
|
|
1005
|
+
uu = micros(m)
|
|
1006
|
+
if is_pm(m) and hh < 12:
|
|
1007
|
+
hh += 12
|
|
1008
|
+
return cls(hh, mm, ss, uu * 1000)
|
|
1009
|
+
|
|
1010
|
+
with contextlib.suppress(TypeError, ValueError):
|
|
1011
|
+
return cls.instance(_dateutil.parser.parse(s).time())
|
|
1012
|
+
|
|
1013
|
+
if raise_err:
|
|
1014
|
+
raise ValueError('Failed to parse time: %s', s)
|
|
1015
|
+
|
|
1016
|
+
@classmethod
|
|
1017
|
+
def instance(
|
|
1018
|
+
cls,
|
|
1019
|
+
obj: _datetime.time
|
|
1020
|
+
| _datetime.datetime
|
|
1021
|
+
| pd.Timestamp
|
|
1022
|
+
| np.datetime64
|
|
1023
|
+
| Self
|
|
1024
|
+
| None,
|
|
1025
|
+
tz: str | _zoneinfo.ZoneInfo | _datetime.tzinfo | None = UTC,
|
|
1026
|
+
raise_err: bool = False,
|
|
1027
|
+
) -> Self | None:
|
|
1028
|
+
"""From datetime-like object
|
|
1029
|
+
|
|
1030
|
+
>>> Time.instance(_datetime.time(12, 30, 1))
|
|
1031
|
+
Time(12, 30, 1, tzinfo=Timezone('UTC'))
|
|
1032
|
+
>>> Time.instance(_pendulum.Time(12, 30, 1))
|
|
1033
|
+
Time(12, 30, 1, tzinfo=Timezone('UTC'))
|
|
1034
|
+
>>> Time.instance(None)
|
|
1035
|
+
|
|
1036
|
+
like Pendulum, do not add timzone if no timezone and Time object
|
|
1037
|
+
>>> Time.instance(Time(12, 30, 1))
|
|
1038
|
+
Time(12, 30, 1)
|
|
1039
|
+
|
|
1040
|
+
"""
|
|
1041
|
+
if pd.isna(obj):
|
|
1042
|
+
if raise_err:
|
|
1043
|
+
raise ValueError('Empty value')
|
|
1044
|
+
return
|
|
1045
|
+
|
|
1046
|
+
if obj.__class__ == cls:
|
|
1047
|
+
return obj
|
|
1048
|
+
|
|
1049
|
+
return cls(obj.hour, obj.minute, obj.second, obj.microsecond,
|
|
1050
|
+
tzinfo=obj.tzinfo or tz)
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
class DateTime(PendulumBusinessDateMixin, _pendulum.DateTime):
|
|
1054
|
+
"""Inherits and wraps pendulum.DateTime
|
|
1055
|
+
"""
|
|
1056
|
+
|
|
1057
|
+
def epoch(self):
|
|
1058
|
+
"""Translate a datetime object into unix seconds since epoch
|
|
1059
|
+
"""
|
|
1060
|
+
return self.timestamp()
|
|
1061
|
+
|
|
1062
|
+
@classmethod
|
|
1063
|
+
def now(cls, tz: str | _zoneinfo.ZoneInfo | _datetime.tzinfo | None = None) -> Self:
|
|
1064
|
+
"""Get a DateTime instance for the current date and time.
|
|
1065
|
+
"""
|
|
1066
|
+
if tz is None or tz == 'local':
|
|
1067
|
+
d = _datetime.datetime.now(LCL)
|
|
1068
|
+
elif tz is UTC or tz == 'UTC':
|
|
1069
|
+
d = _datetime.datetime.now(UTC)
|
|
1070
|
+
else:
|
|
1071
|
+
d = _datetime.datetime.now(UTC)
|
|
1072
|
+
tz = _pendulum._safe_timezone(tz)
|
|
1073
|
+
d = d.astimezone(tz)
|
|
1074
|
+
return cls(d.year, d.month, d.day, d.hour, d.minute, d.second,
|
|
1075
|
+
d.microsecond, tzinfo=d.tzinfo, fold=d.fold)
|
|
1076
|
+
|
|
1077
|
+
@classmethod
|
|
1078
|
+
def today(cls, tz: str | _zoneinfo.ZoneInfo | None = None):
|
|
1079
|
+
"""Unlike Pendulum, returns DateTime object at start of day
|
|
1080
|
+
"""
|
|
1081
|
+
return DateTime.now(tz).start_of('day')
|
|
1082
|
+
|
|
1083
|
+
def date(self):
|
|
1084
|
+
return Date(self.year, self.month, self.day)
|
|
1085
|
+
|
|
1086
|
+
@classmethod
|
|
1087
|
+
def combine(
|
|
1088
|
+
cls,
|
|
1089
|
+
date: _datetime.date,
|
|
1090
|
+
time: _datetime.time,
|
|
1091
|
+
tzinfo: _zoneinfo.ZoneInfo | None = None,
|
|
1092
|
+
) -> Self:
|
|
1093
|
+
"""Combine date and time (*behaves differently from Pendulum `combine`*).
|
|
1094
|
+
"""
|
|
1095
|
+
_tzinfo = tzinfo or time.tzinfo
|
|
1096
|
+
return DateTime.instance(_datetime.datetime.combine(date, time, tzinfo=_tzinfo))
|
|
1097
|
+
|
|
1098
|
+
def rfc3339(self):
|
|
1099
|
+
"""
|
|
1100
|
+
>>> DateTime.parse('Fri, 31 Oct 2014 10:55:00')
|
|
1101
|
+
DateTime(2014, 10, 31, 10, 55, 0, tzinfo=Timezone('UTC'))
|
|
1102
|
+
>>> DateTime.parse('Fri, 31 Oct 2014 10:55:00').rfc3339()
|
|
1103
|
+
'2014-10-31T10:55:00+00:00'
|
|
1104
|
+
"""
|
|
1105
|
+
return self.isoformat()
|
|
1106
|
+
|
|
1107
|
+
def time(self):
|
|
1108
|
+
"""Extract time from self (preserve timezone)
|
|
1109
|
+
|
|
1110
|
+
>>> d = DateTime(2022, 1, 1, 12, 30, 15, tzinfo=EST)
|
|
1111
|
+
>>> d.time()
|
|
1112
|
+
Time(12, 30, 15, tzinfo=Timezone('US/Eastern'))
|
|
1113
|
+
|
|
1114
|
+
>>> d = DateTime(2022, 1, 1, 12, 30, 15, tzinfo=UTC)
|
|
1115
|
+
>>> d.time()
|
|
1116
|
+
Time(12, 30, 15, tzinfo=Timezone('UTC'))
|
|
1117
|
+
"""
|
|
1118
|
+
return Time.instance(self)
|
|
1119
|
+
|
|
1120
|
+
@classmethod
|
|
1121
|
+
def parse(cls, s: str | int | None, raise_err: bool = False) -> Self | None:
|
|
1122
|
+
"""Thin layer on Date parser and our custom `Date.parse``
|
|
1123
|
+
|
|
1124
|
+
>>> DateTime.parse('2022/1/1')
|
|
1125
|
+
DateTime(2022, 1, 1, 0, 0, 0, tzinfo=Timezone('...'))
|
|
1126
|
+
|
|
1127
|
+
Assume UTC, convert to EST
|
|
1128
|
+
>>> this_est1 = DateTime.parse('Fri, 31 Oct 2014 18:55:00').in_timezone(EST)
|
|
1129
|
+
>>> this_est1
|
|
1130
|
+
DateTime(2014, 10, 31, 14, 55, 0, tzinfo=Timezone('US/Eastern'))
|
|
1131
|
+
|
|
1132
|
+
This is actually 18:55 UTC with -4 hours applied = EST
|
|
1133
|
+
>>> this_est2 = DateTime.parse('Fri, 31 Oct 2014 14:55:00 -0400')
|
|
1134
|
+
>>> this_est2
|
|
1135
|
+
DateTime(2014, 10, 31, 14, 55, 0, tzinfo=...)
|
|
1136
|
+
|
|
1137
|
+
UTC time technically equals GMT
|
|
1138
|
+
>>> this_utc = DateTime.parse('Fri, 31 Oct 2014 18:55:00 GMT')
|
|
1139
|
+
>>> this_utc
|
|
1140
|
+
DateTime(2014, 10, 31, 18, 55, 0, tzinfo=Timezone('UTC'))
|
|
1141
|
+
|
|
1142
|
+
We can freely compare time zones
|
|
1143
|
+
>>> this_est1==this_est2==this_utc
|
|
1144
|
+
True
|
|
1145
|
+
|
|
1146
|
+
Format tests
|
|
1147
|
+
>>> DateTime.parse(1707856982).replace(tzinfo=UTC).epoch()
|
|
1148
|
+
1707856982.0
|
|
1149
|
+
>>> DateTime.parse('Jan 29 2010')
|
|
1150
|
+
DateTime(2010, 1, 29, 0, 0, 0, tzinfo=Timezone('UTC'))
|
|
1151
|
+
>>> _ = DateTime.parse('Sep 27 17:11')
|
|
1152
|
+
>>> _.month, _.day, _.hour, _.minute
|
|
1153
|
+
(9, 27, 17, 11)
|
|
1154
|
+
"""
|
|
1155
|
+
if not s:
|
|
1156
|
+
if raise_err:
|
|
1157
|
+
raise ValueError('Empty value')
|
|
1158
|
+
return
|
|
1159
|
+
|
|
1160
|
+
if not isinstance(s, str | int | float):
|
|
1161
|
+
raise TypeError(f'Invalid type for datetime parse: {s.__class__}')
|
|
1162
|
+
|
|
1163
|
+
if isinstance(s, int | float):
|
|
1164
|
+
iso = _datetime.datetime.fromtimestamp(s).isoformat()
|
|
1165
|
+
return cls.parse(iso).replace(tzinfo=LCL)
|
|
1166
|
+
|
|
1167
|
+
with contextlib.suppress(ValueError, TypeError):
|
|
1168
|
+
obj = _dateutil.parser.parse(s)
|
|
1169
|
+
return cls.instance(_pendulum.instance(obj))
|
|
1170
|
+
|
|
1171
|
+
for delim in (' ', ':'):
|
|
1172
|
+
bits = s.split(delim, 1)
|
|
1173
|
+
if len(bits) == 2:
|
|
1174
|
+
d = Date.parse(bits[0])
|
|
1175
|
+
t = Time.parse(bits[1])
|
|
1176
|
+
if d is not None and t is not None:
|
|
1177
|
+
return DateTime.combine(d, t, LCL)
|
|
1178
|
+
|
|
1179
|
+
d = Date.parse(s)
|
|
1180
|
+
if d is not None:
|
|
1181
|
+
return cls(d.year, d.month, d.day, 0, 0, 0)
|
|
1182
|
+
|
|
1183
|
+
current = Date.today()
|
|
1184
|
+
t = Time.parse(s)
|
|
1185
|
+
if t is not None:
|
|
1186
|
+
return cls.combine(current, t, LCL)
|
|
1187
|
+
|
|
1188
|
+
if raise_err:
|
|
1189
|
+
raise ValueError('Invalid date-time format: %s', s)
|
|
1190
|
+
|
|
1191
|
+
@classmethod
|
|
1192
|
+
def instance(
|
|
1193
|
+
cls,
|
|
1194
|
+
obj: _datetime.date
|
|
1195
|
+
| _datetime.time
|
|
1196
|
+
| pd.Timestamp
|
|
1197
|
+
| np.datetime64
|
|
1198
|
+
| Self
|
|
1199
|
+
| None,
|
|
1200
|
+
tz: str | _zoneinfo.ZoneInfo | _datetime.tzinfo | None = UTC,
|
|
1201
|
+
raise_err: bool = False,
|
|
1202
|
+
) -> Self | None:
|
|
1203
|
+
"""From datetime-like object
|
|
1204
|
+
|
|
1205
|
+
>>> DateTime.instance(_datetime.date(2022, 1, 1))
|
|
1206
|
+
DateTime(2022, 1, 1, 0, 0, 0, tzinfo=Timezone('...'))
|
|
1207
|
+
>>> DateTime.instance(Date(2022, 1, 1))
|
|
1208
|
+
DateTime(2022, 1, 1, 0, 0, 0, tzinfo=Timezone('...'))
|
|
1209
|
+
>>> DateTime.instance(_datetime.datetime(2022, 1, 1, 0, 0, 0))
|
|
1210
|
+
DateTime(2022, 1, 1, 0, 0, 0, tzinfo=Timezone('...'))
|
|
1211
|
+
>>> DateTime.instance(_pendulum.DateTime(2022, 1, 1, 0, 0, 0))
|
|
1212
|
+
DateTime(2022, 1, 1, 0, 0, 0, tzinfo=Timezone('...'))
|
|
1213
|
+
>>> DateTime.instance(None)
|
|
1214
|
+
|
|
1215
|
+
like Pendulum, do not add timzone if no timezone and DateTime object
|
|
1216
|
+
>>> DateTime.instance(DateTime(2022, 1, 1, 0, 0, 0))
|
|
1217
|
+
DateTime(2022, 1, 1, 0, 0, 0)
|
|
1218
|
+
>>> DateTime.instance(DateTime(2000, 1, 1))
|
|
1219
|
+
DateTime(2000, 1, 1, 0, 0, 0)
|
|
1220
|
+
|
|
1221
|
+
no tz -> UTC
|
|
1222
|
+
>>> DateTime.instance(Time(4, 4, 21))
|
|
1223
|
+
DateTime(..., 4, 4, 21, tzinfo=Timezone('UTC'))
|
|
1224
|
+
|
|
1225
|
+
tzinfo on time -> time tzinfo (precedence)
|
|
1226
|
+
>>> DateTime.instance(Time(4, 4, 21, tzinfo=UTC))
|
|
1227
|
+
DateTime(..., 4, 4, 21, tzinfo=Timezone('UTC'))
|
|
1228
|
+
>>> DateTime.instance(Time(4, 4, 21, tzinfo=LCL))
|
|
1229
|
+
DateTime(..., 4, 4, 21, tzinfo=Timezone('...'))
|
|
1230
|
+
|
|
1231
|
+
>>> DateTime.instance(np.datetime64('2000-01', 'D'))
|
|
1232
|
+
DateTime(2000, 1, 1, 0, 0, 0, tzinfo=Timezone('UTC'))
|
|
1233
|
+
|
|
1234
|
+
Convert date to datetime (will use native time zone)
|
|
1235
|
+
>>> DateTime.instance(_datetime.date(2000, 1, 1))
|
|
1236
|
+
DateTime(2000, 1, 1, 0, 0, 0, tzinfo=Timezone('...'))
|
|
1237
|
+
|
|
1238
|
+
"""
|
|
1239
|
+
if pd.isna(obj):
|
|
1240
|
+
if raise_err:
|
|
1241
|
+
raise ValueError('Empty value')
|
|
1242
|
+
return
|
|
1243
|
+
|
|
1244
|
+
if obj.__class__ == cls:
|
|
1245
|
+
return obj
|
|
1246
|
+
|
|
1247
|
+
if isinstance(obj, pd.Timestamp):
|
|
1248
|
+
obj = obj.to_pydatetime()
|
|
1249
|
+
return cls.instance(obj, tz=tz)
|
|
1250
|
+
if isinstance(obj, np.datetime64):
|
|
1251
|
+
obj = np.datetime64(obj, 'us').astype(_datetime.datetime)
|
|
1252
|
+
return cls.instance(obj, tz=tz)
|
|
1253
|
+
|
|
1254
|
+
if obj.__class__ == Time:
|
|
1255
|
+
return cls.combine(Date.today(), obj, tzinfo=tz)
|
|
1256
|
+
if isinstance(obj, _datetime.time):
|
|
1257
|
+
return cls.combine(Date.today(), obj, tzinfo=tz)
|
|
1258
|
+
|
|
1259
|
+
if obj.__class__ == Date:
|
|
1260
|
+
return cls(obj.year, obj.month, obj.day, tzinfo=tz)
|
|
1261
|
+
if isinstance(obj, _datetime.date) and not isinstance(obj, _datetime.datetime):
|
|
1262
|
+
return cls(obj.year, obj.month, obj.day, tzinfo=tz)
|
|
1263
|
+
|
|
1264
|
+
return cls(obj.year, obj.month, obj.day, obj.hour, obj.minute,
|
|
1265
|
+
obj.second, obj.microsecond, obj.tzinfo or tz)
|
|
1266
|
+
|
|
1267
|
+
|
|
1268
|
+
class IntervalError(AttributeError):
|
|
1269
|
+
pass
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
class Interval:
|
|
1273
|
+
|
|
1274
|
+
_business: bool = False
|
|
1275
|
+
_entity: type[NYSE] = NYSE
|
|
1276
|
+
|
|
1277
|
+
def __init__(self, begdate: str | Date | None = None, enddate: str | Date | None = None):
|
|
1278
|
+
self.begdate = Date.parse(begdate) if isinstance(begdate, str) else Date.instance(begdate)
|
|
1279
|
+
self.enddate = Date.parse(enddate) if isinstance(enddate, str) else Date.instance(enddate)
|
|
1280
|
+
|
|
1281
|
+
def business(self) -> Self:
|
|
1282
|
+
self._business = True
|
|
1283
|
+
if self.begdate:
|
|
1284
|
+
self.begdate.business()
|
|
1285
|
+
if self.enddate:
|
|
1286
|
+
self.enddate.business()
|
|
1287
|
+
return self
|
|
1288
|
+
|
|
1289
|
+
@property
|
|
1290
|
+
def b(self) -> Self:
|
|
1291
|
+
return self.business()
|
|
1292
|
+
|
|
1293
|
+
def entity(self, e: type[NYSE] = NYSE) -> Self:
|
|
1294
|
+
self._entity = e
|
|
1295
|
+
if self.begdate:
|
|
1296
|
+
self.enddate._entity = e
|
|
1297
|
+
if self.enddate:
|
|
1298
|
+
self.enddate._entity = e
|
|
1299
|
+
return self
|
|
1300
|
+
|
|
1301
|
+
def range(self, window=0) -> tuple[_datetime.date, _datetime.date]:
|
|
1302
|
+
"""Set date ranges based on begdate, enddate and window.
|
|
1303
|
+
|
|
1304
|
+
The combinations are as follows:
|
|
1305
|
+
|
|
1306
|
+
beg end num action
|
|
1307
|
+
--- --- --- ---------------------
|
|
1308
|
+
- - - Error, underspecified
|
|
1309
|
+
set set set Error, overspecified
|
|
1310
|
+
set set -
|
|
1311
|
+
set - - end=max date
|
|
1312
|
+
- set - beg=min date
|
|
1313
|
+
- - set end=max date, beg=end - num
|
|
1314
|
+
set - set end=beg + num
|
|
1315
|
+
- set set beg=end - num
|
|
1316
|
+
|
|
1317
|
+
>>> Interval('4/3/2014', None).b.range(3)
|
|
1318
|
+
(Date(2014, 4, 3), Date(2014, 4, 8))
|
|
1319
|
+
>>> Interval(None, Date(2014, 7, 27)).range(20)
|
|
1320
|
+
(Date(2014, 7, 7), Date(2014, 7, 27))
|
|
1321
|
+
>>> Interval(None, '2014/7/27').b.range(20)
|
|
1322
|
+
(Date(2014, 6, 27), Date(2014, 7, 27))
|
|
1323
|
+
"""
|
|
1324
|
+
begdate, enddate = self.begdate, self.enddate
|
|
1325
|
+
|
|
1326
|
+
window = abs(int(window or 0))
|
|
1327
|
+
|
|
1328
|
+
if begdate and enddate and window:
|
|
1329
|
+
raise IntervalError('Window requested and begdate and enddate provided')
|
|
1330
|
+
if not begdate and not enddate and not window:
|
|
1331
|
+
raise IntervalError('Missing begdate, enddate, and window')
|
|
1332
|
+
if not begdate and not enddate and window:
|
|
1333
|
+
raise IntervalError('Missing begdate and enddate, window specified')
|
|
1334
|
+
|
|
1335
|
+
if begdate and enddate:
|
|
1336
|
+
return (begdate.business() if begdate._business else
|
|
1337
|
+
begdate).add(days=window), \
|
|
1338
|
+
(enddate.business() if enddate._business else
|
|
1339
|
+
enddate).subtract(days=0)
|
|
1340
|
+
|
|
1341
|
+
if (not begdate and not enddate) or enddate:
|
|
1342
|
+
begdate = (enddate.business() if enddate._business else
|
|
1343
|
+
enddate).subtract(days=window)
|
|
1344
|
+
else:
|
|
1345
|
+
enddate = (begdate.business() if begdate._business else
|
|
1346
|
+
begdate).add(days=window)
|
|
1347
|
+
|
|
1348
|
+
return begdate, enddate
|
|
1349
|
+
|
|
1350
|
+
def is_business_day_series(self) -> list[bool]:
|
|
1351
|
+
"""Is business date range.
|
|
1352
|
+
|
|
1353
|
+
>>> list(Interval(Date(2018, 11, 19), Date(2018, 11, 25)).is_business_day_series())
|
|
1354
|
+
[True, True, True, False, True, False, False]
|
|
1355
|
+
>>> list(Interval(Date(2021, 11, 22),Date(2021, 11, 28)).is_business_day_series())
|
|
1356
|
+
[True, True, True, False, True, False, False]
|
|
1357
|
+
"""
|
|
1358
|
+
for thedate in self.series():
|
|
1359
|
+
yield thedate.is_business_day()
|
|
1360
|
+
|
|
1361
|
+
def series(self, window=0):
|
|
1362
|
+
"""Get a series of datetime.date objects.
|
|
1363
|
+
|
|
1364
|
+
give the function since and until wherever possible (more explicit)
|
|
1365
|
+
else pass in a window to back out since or until
|
|
1366
|
+
- Window gives window=N additional days. So `until`-`window`=1
|
|
1367
|
+
defaults to include ALL days (not just business days)
|
|
1368
|
+
|
|
1369
|
+
>>> next(Interval(Date(2014,7,16), Date(2014,7,16)).series())
|
|
1370
|
+
Date(2014, 7, 16)
|
|
1371
|
+
>>> next(Interval(Date(2014,7,12), Date(2014,7,16)).series())
|
|
1372
|
+
Date(2014, 7, 12)
|
|
1373
|
+
>>> len(list(Interval(Date(2014,7,12), Date(2014,7,16)).series()))
|
|
1374
|
+
5
|
|
1375
|
+
>>> len(list(Interval(Date(2014,7,12), None).series(window=4)))
|
|
1376
|
+
5
|
|
1377
|
+
>>> len(list(Interval(Date(2014,7,16)).series(window=4)))
|
|
1378
|
+
5
|
|
1379
|
+
|
|
1380
|
+
Weekend and a holiday
|
|
1381
|
+
>>> len(list(Interval(Date(2014,7,3), Date(2014,7,5)).b.series()))
|
|
1382
|
+
1
|
|
1383
|
+
>>> len(list(Interval(Date(2014,7,17), Date(2014,7,16)).series()))
|
|
1384
|
+
Traceback (most recent call last):
|
|
1385
|
+
...
|
|
1386
|
+
AssertionError: Begdate must be earlier or equal to Enddate
|
|
1387
|
+
|
|
1388
|
+
since != business day and want business days
|
|
1389
|
+
1/[3,10]/2015 is a Saturday, 1/7/2015 is a Wednesday
|
|
1390
|
+
>>> len(list(Interval(Date(2015,1,3), Date(2015,1,7)).b.series()))
|
|
1391
|
+
3
|
|
1392
|
+
>>> len(list(Interval(Date(2015,1,3), None).b.series(window=3)))
|
|
1393
|
+
3
|
|
1394
|
+
>>> len(list(Interval(Date(2015,1,3), Date(2015,1,10)).b.series()))
|
|
1395
|
+
5
|
|
1396
|
+
>>> len(list(Interval(Date(2015,1,3), None).b.series(window=5)))
|
|
1397
|
+
5
|
|
1398
|
+
"""
|
|
1399
|
+
window = abs(int(window))
|
|
1400
|
+
since, until = self.begdate, self.enddate
|
|
1401
|
+
_business = self._business
|
|
1402
|
+
assert until or since, 'Since or until is required'
|
|
1403
|
+
if not since and until:
|
|
1404
|
+
since = (until.business() if _business else
|
|
1405
|
+
until).subtract(days=window)
|
|
1406
|
+
elif since and not until:
|
|
1407
|
+
until = (since.business() if _business else
|
|
1408
|
+
since).add(days=window)
|
|
1409
|
+
assert since <= until, 'Since date must be earlier or equal to Until date'
|
|
1410
|
+
thedate = since
|
|
1411
|
+
while thedate <= until:
|
|
1412
|
+
if _business:
|
|
1413
|
+
if thedate.is_business_day():
|
|
1414
|
+
yield thedate
|
|
1415
|
+
else:
|
|
1416
|
+
yield thedate
|
|
1417
|
+
thedate = thedate.add(days=1)
|
|
1418
|
+
|
|
1419
|
+
def end_of_series(self, unit='month') -> list[Date]:
|
|
1420
|
+
"""Return a series between and inclusive of begdate and enddate.
|
|
1421
|
+
|
|
1422
|
+
>>> Interval(Date(2018, 1, 5), Date(2018, 4, 5)).end_of_series('month')
|
|
1423
|
+
[Date(2018, 1, 31), Date(2018, 2, 28), Date(2018, 3, 31), Date(2018, 4, 30)]
|
|
1424
|
+
>>> Interval(Date(2018, 1, 5), Date(2018, 4, 5)).end_of_series('week')
|
|
1425
|
+
[Date(2018, 1, 7), Date(2018, 1, 14), ..., Date(2018, 4, 8)]
|
|
1426
|
+
"""
|
|
1427
|
+
begdate = self.begdate.end_of(unit)
|
|
1428
|
+
enddate = self.enddate.end_of(unit)
|
|
1429
|
+
interval = _pendulum.interval(begdate, enddate)
|
|
1430
|
+
return [Date.instance(d) for d in interval.range(f'{unit}s')]
|
|
1431
|
+
|
|
1432
|
+
def days(self) -> int:
|
|
1433
|
+
"""Return days between (begdate, enddate] or negative (enddate, begdate].
|
|
1434
|
+
|
|
1435
|
+
>>> Interval(Date.parse('2018/9/6'), Date.parse('2018/9/10')).days()
|
|
1436
|
+
4
|
|
1437
|
+
>>> Interval(Date.parse('2018/9/10'), Date.parse('2018/9/6')).days()
|
|
1438
|
+
-4
|
|
1439
|
+
>>> Interval(Date.parse('2018/9/6'), Date.parse('2018/9/10')).b.days()
|
|
1440
|
+
2
|
|
1441
|
+
>>> Interval(Date.parse('2018/9/10'), Date.parse('2018/9/6')).b.days()
|
|
1442
|
+
-2
|
|
1443
|
+
"""
|
|
1444
|
+
assert self.begdate
|
|
1445
|
+
assert self.enddate
|
|
1446
|
+
if self.begdate == self.enddate:
|
|
1447
|
+
return 0
|
|
1448
|
+
if not self._business:
|
|
1449
|
+
return (self.enddate - self.begdate).days
|
|
1450
|
+
if self.begdate < self.enddate:
|
|
1451
|
+
return len(list(self.series())) - 1
|
|
1452
|
+
_reverse = Interval(self.enddate, self.begdate)
|
|
1453
|
+
_reverse._entity = self._entity
|
|
1454
|
+
_reverse._business = self._business
|
|
1455
|
+
return -len(list(_reverse.series())) + 1
|
|
1456
|
+
|
|
1457
|
+
def quarters(self):
|
|
1458
|
+
"""Return the number of quarters between two dates
|
|
1459
|
+
TODO: good enough implementation; refine rules to be heuristically precise
|
|
1460
|
+
|
|
1461
|
+
>>> round(Interval(Date(2020, 1, 1), Date(2020, 2, 16)).quarters(), 2)
|
|
1462
|
+
0.5
|
|
1463
|
+
>>> round(Interval(Date(2020, 1, 1), Date(2020, 4, 1)).quarters(), 2)
|
|
1464
|
+
1.0
|
|
1465
|
+
>>> round(Interval(Date(2020, 1, 1), Date(2020, 7, 1)).quarters(), 2)
|
|
1466
|
+
1.99
|
|
1467
|
+
>>> round(Interval(Date(2020, 1, 1), Date(2020, 8, 1)).quarters(), 2)
|
|
1468
|
+
2.33
|
|
1469
|
+
"""
|
|
1470
|
+
return 4 * self.days() / 365.0
|
|
1471
|
+
|
|
1472
|
+
def years(self, basis: int = 0):
|
|
1473
|
+
"""Years with Fractions (matches Excel YEARFRAC)
|
|
1474
|
+
|
|
1475
|
+
Adapted from https://web.archive.org/web/20200915094905/https://dwheeler.com/yearfrac/calc_yearfrac.py
|
|
1476
|
+
|
|
1477
|
+
Basis:
|
|
1478
|
+
0 = US (NASD) 30/360
|
|
1479
|
+
1 = Actual/actual
|
|
1480
|
+
2 = Actual/360
|
|
1481
|
+
3 = Actual/365
|
|
1482
|
+
4 = European 30/360
|
|
1483
|
+
|
|
1484
|
+
>>> begdate = Date(1978, 2, 28)
|
|
1485
|
+
>>> enddate = Date(2020, 5, 17)
|
|
1486
|
+
|
|
1487
|
+
Tested Against Excel
|
|
1488
|
+
>>> "{:.4f}".format(Interval(begdate, enddate).years(0))
|
|
1489
|
+
'42.2139'
|
|
1490
|
+
>>> '{:.4f}'.format(Interval(begdate, enddate).years(1))
|
|
1491
|
+
'42.2142'
|
|
1492
|
+
>>> '{:.4f}'.format(Interval(begdate, enddate).years(2))
|
|
1493
|
+
'42.8306'
|
|
1494
|
+
>>> '{:.4f}'.format(Interval(begdate, enddate).years(3))
|
|
1495
|
+
'42.2438'
|
|
1496
|
+
>>> '{:.4f}'.format(Interval(begdate, enddate).years(4))
|
|
1497
|
+
'42.2194'
|
|
1498
|
+
>>> '{:.4f}'.format(Interval(enddate, begdate).years(4))
|
|
1499
|
+
'-42.2194'
|
|
1500
|
+
|
|
1501
|
+
Excel has a known leap year bug when year == 1900 (=YEARFRAC("1900-1-1", "1900-12-1", 1) -> 0.9178)
|
|
1502
|
+
The bug originated from Lotus 1-2-3, and was purposely implemented in Excel for the purpose of backward compatibility.
|
|
1503
|
+
>>> begdate = Date(1900, 1, 1)
|
|
1504
|
+
>>> enddate = Date(1900, 12, 1)
|
|
1505
|
+
>>> '{:.4f}'.format(Interval(begdate, enddate).years(4))
|
|
1506
|
+
'0.9167'
|
|
1507
|
+
"""
|
|
1508
|
+
|
|
1509
|
+
def average_year_length(date1, date2):
|
|
1510
|
+
"""Algorithm for average year length"""
|
|
1511
|
+
days = (Date(date2.year + 1, 1, 1) - Date(date1.year, 1, 1)).days
|
|
1512
|
+
years = (date2.year - date1.year) + 1
|
|
1513
|
+
return days / years
|
|
1514
|
+
|
|
1515
|
+
def feb29_between(date1, date2):
|
|
1516
|
+
"""Requires date2.year = (date1.year + 1) or date2.year = date1.year.
|
|
1517
|
+
|
|
1518
|
+
Returns True if "Feb 29" is between the two dates (date1 may be Feb29).
|
|
1519
|
+
Two possibilities: date1.year is a leap year, and date1 <= Feb 29 y1,
|
|
1520
|
+
or date2.year is a leap year, and date2 > Feb 29 y2.
|
|
1521
|
+
"""
|
|
1522
|
+
mar1_date1_year = Date(date1.year, 3, 1)
|
|
1523
|
+
if calendar.isleap(date1.year) and (date1 < mar1_date1_year) and (date2 >= mar1_date1_year):
|
|
1524
|
+
return True
|
|
1525
|
+
mar1_date2_year = Date(date2.year, 3, 1)
|
|
1526
|
+
return bool(calendar.isleap(date2.year) and date2 >= mar1_date2_year and date1 < mar1_date2_year)
|
|
1527
|
+
|
|
1528
|
+
def appears_lte_one_year(date1, date2):
|
|
1529
|
+
"""Returns True if date1 and date2 "appear" to be 1 year or less apart.
|
|
1530
|
+
|
|
1531
|
+
This compares the values of year, month, and day directly to each other.
|
|
1532
|
+
Requires date1 <= date2; returns boolean. Used by basis 1.
|
|
1533
|
+
"""
|
|
1534
|
+
if date1.year == date2.year:
|
|
1535
|
+
return True
|
|
1536
|
+
return bool(date1.year + 1 == date2.year and (date1.month > date2.month or date1.month == date2.month and date1.day >= date2.day))
|
|
1537
|
+
|
|
1538
|
+
def basis0(date1, date2):
|
|
1539
|
+
# change day-of-month for purposes of calculation.
|
|
1540
|
+
date1day, date1month, date1year = date1.day, date1.month, date1.year
|
|
1541
|
+
date2day, date2month, date2year = date2.day, date2.month, date2.year
|
|
1542
|
+
if date1day == 31 and date2day == 31:
|
|
1543
|
+
date1day = 30
|
|
1544
|
+
date2day = 30
|
|
1545
|
+
elif date1day == 31:
|
|
1546
|
+
date1day = 30
|
|
1547
|
+
elif date1day == 30 and date2day == 31:
|
|
1548
|
+
date2day = 30
|
|
1549
|
+
# Note: If date2day==31, it STAYS 31 if date1day < 30.
|
|
1550
|
+
# Special fixes for February:
|
|
1551
|
+
elif date1month == 2 and date2month == 2 and date1 == date1.end_of('month') \
|
|
1552
|
+
and date2 == date2.end_of('month'):
|
|
1553
|
+
date1day = 30 # Set the day values to be equal
|
|
1554
|
+
date2day = 30
|
|
1555
|
+
elif date1month == 2 and date1 == date1.end_of('month'):
|
|
1556
|
+
date1day = 30 # "Illegal" Feb 30 date.
|
|
1557
|
+
daydiff360 = (date2day + date2month * 30 + date2year * 360) \
|
|
1558
|
+
- (date1day + date1month * 30 + date1year * 360)
|
|
1559
|
+
return daydiff360 / 360
|
|
1560
|
+
|
|
1561
|
+
def basis1(date1, date2):
|
|
1562
|
+
if appears_lte_one_year(date1, date2):
|
|
1563
|
+
if date1.year == date2.year and calendar.isleap(date1.year):
|
|
1564
|
+
year_length = 366.0
|
|
1565
|
+
elif feb29_between(date1, date2) or (date2.month == 2 and date2.day == 29):
|
|
1566
|
+
year_length = 366.0
|
|
1567
|
+
else:
|
|
1568
|
+
year_length = 365.0
|
|
1569
|
+
return (date2 - date1).days / year_length
|
|
1570
|
+
return (date2 - date1).days / average_year_length(date1, date2)
|
|
1571
|
+
|
|
1572
|
+
def basis2(date1, date2):
|
|
1573
|
+
return (date2 - date1).days / 360.0
|
|
1574
|
+
|
|
1575
|
+
def basis3(date1, date2):
|
|
1576
|
+
return (date2 - date1).days / 365.0
|
|
1577
|
+
|
|
1578
|
+
def basis4(date1, date2):
|
|
1579
|
+
# change day-of-month for purposes of calculation.
|
|
1580
|
+
date1day, date1month, date1year = date1.day, date1.month, date1.year
|
|
1581
|
+
date2day, date2month, date2year = date2.day, date2.month, date2.year
|
|
1582
|
+
if date1day == 31:
|
|
1583
|
+
date1day = 30
|
|
1584
|
+
if date2day == 31:
|
|
1585
|
+
date2day = 30
|
|
1586
|
+
# Remarkably, do NOT change Feb. 28 or 29 at ALL.
|
|
1587
|
+
daydiff360 = (date2day + date2month * 30 + date2year * 360) - \
|
|
1588
|
+
(date1day + date1month * 30 + date1year * 360)
|
|
1589
|
+
return daydiff360 / 360
|
|
1590
|
+
|
|
1591
|
+
begdate, enddate = self.begdate, self.enddate
|
|
1592
|
+
if enddate is None:
|
|
1593
|
+
return
|
|
1594
|
+
|
|
1595
|
+
sign = 1
|
|
1596
|
+
if begdate > enddate:
|
|
1597
|
+
begdate, enddate = enddate, begdate
|
|
1598
|
+
sign = -1
|
|
1599
|
+
if begdate == enddate:
|
|
1600
|
+
return 0.0
|
|
1601
|
+
|
|
1602
|
+
if basis == 0:
|
|
1603
|
+
return basis0(begdate, enddate) * sign
|
|
1604
|
+
if basis == 1:
|
|
1605
|
+
return basis1(begdate, enddate) * sign
|
|
1606
|
+
if basis == 2:
|
|
1607
|
+
return basis2(begdate, enddate) * sign
|
|
1608
|
+
if basis == 3:
|
|
1609
|
+
return basis3(begdate, enddate) * sign
|
|
1610
|
+
if basis == 4:
|
|
1611
|
+
return basis4(begdate, enddate) * sign
|
|
1612
|
+
|
|
1613
|
+
raise ValueError('Basis range [0, 4]. Unknown basis {basis}.')
|
|
1614
|
+
|
|
1615
|
+
|
|
1616
|
+
Range = namedtuple('Range', ['start', 'end'])
|
|
1617
|
+
|
|
1618
|
+
|
|
1619
|
+
def overlap_days(range_one, range_two, days=False):
|
|
1620
|
+
"""Test by how much two date ranges overlap
|
|
1621
|
+
if `days=True`, we return an actual day count,
|
|
1622
|
+
otherwise we just return if it overlaps True/False
|
|
1623
|
+
poached from Raymond Hettinger http://stackoverflow.com/a/9044111
|
|
1624
|
+
|
|
1625
|
+
>>> date1 = Date(2016, 3, 1)
|
|
1626
|
+
>>> date2 = Date(2016, 3, 2)
|
|
1627
|
+
>>> date3 = Date(2016, 3, 29)
|
|
1628
|
+
>>> date4 = Date(2016, 3, 30)
|
|
1629
|
+
|
|
1630
|
+
>>> assert overlap_days((date1, date3), (date2, date4))
|
|
1631
|
+
>>> assert overlap_days((date2, date4), (date1, date3))
|
|
1632
|
+
>>> assert not overlap_days((date1, date2), (date3, date4))
|
|
1633
|
+
|
|
1634
|
+
>>> assert overlap_days((date1, date4), (date1, date4))
|
|
1635
|
+
>>> assert overlap_days((date1, date4), (date2, date3))
|
|
1636
|
+
>>> overlap_days((date1, date4), (date1, date4), True)
|
|
1637
|
+
30
|
|
1638
|
+
|
|
1639
|
+
>>> assert overlap_days((date2, date3), (date1, date4))
|
|
1640
|
+
>>> overlap_days((date2, date3), (date1, date4), True)
|
|
1641
|
+
28
|
|
1642
|
+
|
|
1643
|
+
>>> assert not overlap_days((date3, date4), (date1, date2))
|
|
1644
|
+
>>> overlap_days((date3, date4), (date1, date2), True)
|
|
1645
|
+
-26
|
|
1646
|
+
"""
|
|
1647
|
+
r1 = Range(*range_one)
|
|
1648
|
+
r2 = Range(*range_two)
|
|
1649
|
+
latest_start = max(r1.start, r2.start)
|
|
1650
|
+
earliest_end = min(r1.end, r2.end)
|
|
1651
|
+
overlap = (earliest_end - latest_start).days + 1
|
|
1652
|
+
if days:
|
|
1653
|
+
return overlap
|
|
1654
|
+
return overlap >= 0
|
|
1655
|
+
|
|
1656
|
+
|
|
1657
|
+
def create_ics(begdate, enddate, summary, location):
|
|
1658
|
+
"""Create a simple .ics file per RFC 5545 guidelines."""
|
|
1659
|
+
|
|
1660
|
+
return f"""BEGIN:VCALENDAR
|
|
1661
|
+
VERSION:2.0
|
|
1662
|
+
PRODID:-//hacksw/handcal//NONSGML v1.0//EN
|
|
1663
|
+
BEGIN:VEVENT
|
|
1664
|
+
DTSTART;TZID=America/New_York:{begdate:%Y%m%dT%H%M%S}
|
|
1665
|
+
DTEND;TZID=America/New_York:{enddate:%Y%m%dT%H%M%S}
|
|
1666
|
+
SUMMARY:{summary}
|
|
1667
|
+
LOCATION:{location}
|
|
1668
|
+
END:VEVENT
|
|
1669
|
+
END:VCALENDAR
|
|
1670
|
+
"""
|
|
1671
|
+
|
|
1672
|
+
|
|
1673
|
+
# apply any missing Date functions
|
|
1674
|
+
for func in (
|
|
1675
|
+
'average',
|
|
1676
|
+
'closest',
|
|
1677
|
+
'farthest',
|
|
1678
|
+
'fromordinal',
|
|
1679
|
+
'fromtimestamp',
|
|
1680
|
+
'nth_of',
|
|
1681
|
+
'replace',
|
|
1682
|
+
):
|
|
1683
|
+
setattr(Date, func, store_entity(getattr(_pendulum.Date, func), typ=Date))
|
|
1684
|
+
|
|
1685
|
+
# apply any missing DateTime functions
|
|
1686
|
+
for func in (
|
|
1687
|
+
'astimezone',
|
|
1688
|
+
'date',
|
|
1689
|
+
'fromordinal',
|
|
1690
|
+
'fromtimestamp',
|
|
1691
|
+
'in_timezone',
|
|
1692
|
+
'in_tz',
|
|
1693
|
+
'replace',
|
|
1694
|
+
'strptime',
|
|
1695
|
+
'utcfromtimestamp',
|
|
1696
|
+
'utcnow',
|
|
1697
|
+
):
|
|
1698
|
+
setattr(DateTime, func, store_entity(getattr(_pendulum.DateTime, func), typ=DateTime))
|
|
1699
|
+
|
|
1700
|
+
|
|
1701
|
+
if __name__ == '__main__':
|
|
1702
|
+
__import__('doctest').testmod(optionflags=4 | 8 | 32)
|