clockwork 0.1.0__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.
clockwork/clockwork.py ADDED
@@ -0,0 +1,780 @@
1
+ import calendar
2
+ import holidays
3
+ import pandas as pd
4
+ import numpy as np
5
+ import re
6
+ import datetime
7
+ import time
8
+ from textwrap import wrap as wrap_text
9
+ from dateutil.relativedelta import relativedelta
10
+
11
+
12
+
13
+
14
+ #+---------------------------------------------------------------------------+
15
+ # Freestanding functions
16
+ #+---------------------------------------------------------------------------+
17
+
18
+ def Date(arg=None, normalize=False, week_offset=0):
19
+ '''
20
+ Description
21
+ ----------
22
+ assigns new date instances to the correct class polymorphism
23
+
24
+ Parameters
25
+ ----------
26
+ arg : str | object
27
+ object to convert to DateBase object. Currently supported formats include:
28
+ • None (the current date and time will be used)
29
+ • pandas._libs.tslibs.timestamps.Timestamp
30
+ • datetime.datetime
31
+ • datetime.date
32
+ • integer (in seconds)
33
+ • string
34
+ -> day of the week fully spelled out or first 3 letters (not case sensitive)
35
+ (e.g. 'Monday', 'monday', 'mon')
36
+ -> quarter in #QYY or YYYYQ# format
37
+ -> any string format supported by pd.to_datetime
38
+ • DateBase object or DateBase polymorphism
39
+ normalize : bool
40
+ if True, only the year, month, and day are retained (hours, minutes, seconds, microseconds are set to zero)
41
+ week_offset : int
42
+ by default, if a day of the week is supplied (e.g. 'Monday') then the date returned will be that day of the week
43
+ for the current week. This argument is used to override this behavior by shifting the week backwards or forwards
44
+ (e.g. if arg='Monday' and week_offset=-1 then the Monday of last week will be returned).
45
+
46
+ Returns
47
+ ----------
48
+ out : DateBase | DateBase polymorphism
49
+ DateBase object or polymorphism
50
+ '''
51
+
52
+ qtr_map = {k: i for i,k in enumerate([(3,31), (6,30), (9,30), (12,31)], 1)}
53
+
54
+ def qtr_label_to_dt(x):
55
+ ''' attempts to convert quarter expressed as string to datetime. Suported formats include #QYY and YYYYQ# '''
56
+ try:
57
+ qtr, year = re.findall(r'(\d{1})Q(\d{2})', x)[0]
58
+ except:
59
+ try:
60
+ year, qtr = re.findall(r'(\d{4})Q(\d{1})', x)[0]
61
+ except:
62
+ return
63
+
64
+ inverse = {v: k for k,v in qtr_map.items()}
65
+ month, day = inverse[int(qtr)]
66
+ out = DateBase.to_datetime(f'{month}-{day}-{year}')
67
+ return out
68
+
69
+
70
+ if arg is None:
71
+ dt = datetime.datetime.now()
72
+ elif hasattr(arg, 'to_pydatetime'): # pandas
73
+ dt = arg.to_pydatetime()
74
+ elif isinstance(arg, datetime.datetime):
75
+ dt = arg
76
+ elif isinstance(arg, datetime.date):
77
+ dt = datetime.datetime(arg.year, arg.month, arg.day)
78
+ elif isinstance(arg, int): # is timestamp expressed in seconds
79
+ dt = datetime.datetime.fromtimestamp(arg)
80
+ elif isinstance(arg, str):
81
+ desired_day = DateBase.weekdays.get(arg.title())
82
+ if desired_day is None:
83
+ dt = qtr_label_to_dt(arg) or DateBase.to_datetime(arg)
84
+ else:
85
+ normalize, now = True, datetime.datetime.now()
86
+ dt = now - datetime.timedelta(days=now.weekday()) + \
87
+ datetime.timedelta(days=desired_day, weeks=week_offset)
88
+ elif isinstance(arg, DateBase):
89
+ dt = arg.dt
90
+ else:
91
+ raise ValueError(f'date argument {arg} of type {type(arg)} is not supported.')
92
+
93
+ if normalize: dt = datetime.datetime(dt.year, dt.month, dt.day)
94
+
95
+ if dt.day == n_days_in_month(dt.year, dt.month):
96
+ qtr = qtr_map.get((dt.month, dt.day))
97
+ return QuarterEnd(dt, qtr) if qtr else MonthEnd(dt)
98
+ else:
99
+ return DateBase(dt)
100
+
101
+
102
+
103
+ def add_border(text, width=100, fixed_width=False, align='left'):
104
+ '''
105
+ Description
106
+ ----------
107
+ Adds a border around text.
108
+
109
+ Parameters
110
+ ----------
111
+ text : str
112
+ text to encase
113
+ width : int
114
+ wrap_text width argument. If width=1, the text is printed vertically.
115
+ fixed_width : bool
116
+ • True -> the width of the border will equal the 'width' argument value.
117
+ • False -> the width of the border is capped at the length of the longest
118
+ line in the text.
119
+ align : str
120
+ • 'left' -> aligns text along the left margin
121
+ • 'center' -> aligns text in the center between the left and right margins
122
+ • 'right' -> aligns text along the right margin
123
+
124
+ Returns
125
+ ----------
126
+ out : str
127
+ text encased within a border
128
+ '''
129
+ lines = wrap_text(' '.join(text.split()), width)
130
+ max_width = width if fixed_width else len(max(lines, key=len))
131
+ border = ('-' * (max_width + 2)).join(['+'] * 2)
132
+
133
+ if align in 'left':
134
+ content = [('| ' + line + ''.join([' '] * (max_width - len(line))) + ' |') for line in lines]
135
+ elif align == 'right':
136
+ content = [('| ' + ''.join([' '] * (max_width - len(line))) + line + ' |') for line in lines]
137
+ elif align == 'center':
138
+ content = [('| ' + ''.join([' '] * ((max_width - len(line)) // 2)) + line +
139
+ ''.join([' '] * ((max_width - len(line) + 1) // 2)) + ' |') for line in lines]
140
+
141
+ out = '\n'.join([border, '\n'.join(content), border])
142
+ return out
143
+
144
+
145
+
146
+ def elapsed_time(seconds):
147
+ '''
148
+ Parameters
149
+ ----------
150
+ seconds : float
151
+ seconds
152
+ '''
153
+ out = []
154
+ for k,v in [('days', 86400), ('hours', 3600), ('minutes', 60)]:
155
+ count = int(seconds / v)
156
+ if count > 0:
157
+ out.append(f'{count} {k}')
158
+ seconds -= count * v
159
+ out.append(f'{round(seconds, 2)} seconds')
160
+ return ', '.join(out)
161
+
162
+
163
+
164
+ def action_timer(func):
165
+
166
+ def wrapper(*args, **kwargs):
167
+ start_time = time.time()
168
+ print(add_border(func.__name__, width=75))
169
+ print()
170
+ out = func(*args, **kwargs)
171
+ print(add_border(f'{func.__name__} complete in {elapsed_time(time.time() - start_time)}', width=75))
172
+ print()
173
+ return out
174
+
175
+ return wrapper
176
+
177
+
178
+
179
+ def n_days_in_month(year, month):
180
+ '''
181
+ Description
182
+ ----------
183
+ Returns the # of days in a month for a given year.
184
+ For example, if year=2024 and month=2, 29 days is returned since it's a leap year
185
+
186
+ Parameters
187
+ ----------
188
+ year : int
189
+ year in YYYY format
190
+ month : int
191
+ month
192
+
193
+ Returns
194
+ ----------
195
+ out : int
196
+ number of days
197
+ '''
198
+ out = calendar.monthrange(year, month)[1]
199
+ return out
200
+
201
+
202
+
203
+ def last_day_of_month(year, month):
204
+ '''
205
+ Description
206
+ ----------
207
+ Returns date of the last day of the month for a given year and month
208
+
209
+ Parameters
210
+ ----------
211
+ year : int
212
+ year in YYYY format
213
+ month : int
214
+ month
215
+
216
+ Returns
217
+ ----------
218
+ clockwork.{MonthEnd}|{QuarterEnd}
219
+ '''
220
+ day = n_days_in_month(year, month)
221
+ return Date(datetime.datetime(year, month, day))
222
+
223
+
224
+
225
+ def month_year_iter(start_month, start_year, end_month=None, end_year=None, rng=None, step=0):
226
+
227
+ start = 12 * start_year + start_month - 1
228
+
229
+ if not any((end_month, end_year, rng)):
230
+ while True:
231
+ y,m = divmod(start, 12)
232
+ start += 1
233
+ yield y, m + 1
234
+ else:
235
+ if all((end_month, end_year)):
236
+ end = 12 * end_year + end_month
237
+ elif rng:
238
+ rng += (1 if rng > 0 else -1)
239
+ end = start + rng
240
+ else:
241
+ raise ValueError('end_month and end_year arguments must both be provided.')
242
+
243
+ if end < start:
244
+ if step == 0: step = -1
245
+ if step > 0: step *= -1
246
+
247
+ month_range = range(start,end,step) if step else range(start, end)
248
+
249
+ for x in month_range:
250
+ y,m = divmod(x, 12)
251
+ yield y, m + 1
252
+
253
+
254
+
255
+ def day_of_week(day, delta=0):
256
+ ''' see DateBase.first_last decorator for documentation '''
257
+ return Date(normalize=True).last(day, delta)
258
+
259
+
260
+
261
+ def month_end(delta=0):
262
+ '''
263
+ Description
264
+ ----------
265
+ Returns month end date
266
+
267
+ Parameters
268
+ ----------
269
+ delta : int | str | DateBase
270
+ Offset value +/- from the most recent month end (e.g. 0 is the most recent month end and -1 is the 2nd most recent month end).
271
+
272
+ Returns
273
+ ----------
274
+ clockwork.MonthEnd object
275
+ '''
276
+ if isinstance(delta, (DateBase, str)): return Date(delta)
277
+ y, m = divmod(datetime.date.today().year * 12 + datetime.date.today().month + delta - 1, 12)
278
+ if m == 0:
279
+ y -= 1
280
+ m = 12
281
+ return MonthEnd(datetime.datetime(y, m, 1))
282
+
283
+
284
+
285
+ def quarter_end(delta=0, scheme=None):
286
+ '''
287
+ Description
288
+ ----------
289
+ Returns quarter end date
290
+
291
+ Parameters
292
+ ----------
293
+ delta : int | str | DateBase
294
+ clockwork.date() -> 'date' argument.
295
+ Integer values are treated as offsets +/- from the most recent quarter end (e.g. 0 is the most recent quarter
296
+ end and -1 is the 2nd most recent quarter end).
297
+ String values represent specific quarters. Acceptable formats include: '#QYY' or 'YYYYQ#'
298
+ scheme : tuple
299
+ Tuple listing the quarter end months. Defaults to calendar year-end.
300
+
301
+ Returns
302
+ ----------
303
+ clockwork.QuarterEnd object
304
+ '''
305
+ if isinstance(delta, (DateBase, str)): return Date(delta)
306
+ if scheme is not None: raise NotImplementedError
307
+ scheme = (3, 6, 9, 12)
308
+ today = datetime.datetime.now()
309
+ cy, cm, cd = today.year, today.month, today.day
310
+ if cd <= n_days_in_month(cy, cm): cm -= 1
311
+ candidates = [((cy * 12) + m) + (delta * 3) - 1 for m in scheme]
312
+ candidates.insert(0,((cy - 1) * 12) + scheme[-1] + (delta * 3) - 1)
313
+ y, m = divmod(candidates[np.digitize(((cy * 12) + cm + (delta * 3)), candidates, right=True) - 1], 12)
314
+ m += 1
315
+ return QuarterEnd(dt=datetime.datetime(y, m, 1), qtr=scheme.index(m) + 1)
316
+
317
+
318
+
319
+ def year_end(delta=0, **kwargs):
320
+ ''' returns year end date as a QuarterEnd object '''
321
+ quarter = quarter_end().qtr
322
+ delta = (4 * delta) + (4 - quarter)
323
+ return quarter_end(delta, **kwargs)
324
+
325
+
326
+
327
+ #+---------------------------------------------------------------------------+
328
+ # Classes
329
+ #+---------------------------------------------------------------------------+
330
+
331
+ class DateBase(object):
332
+ '''
333
+ Description
334
+ --------------------
335
+ user-friendly object-oriented representation of a date
336
+
337
+ Class Attributes
338
+ --------------------
339
+ weekdays : dict
340
+ dictionary where keys are the days of the week and values are the corresponding index values
341
+ holidays : holidays.countries.united_states.UnitedStates
342
+ comprehensive list of U.S. holidays
343
+
344
+ Instance Attributes
345
+ --------------------
346
+ datetime : datetime.datetime
347
+ date and time (if applicable)
348
+ '''
349
+
350
+ weekdays = {'Monday': 0, 'Tuesday': 1, 'Wednesday': 2, 'Thursday': 3, 'Friday': 4, 'Saturday': 5, 'Sunday': 6}
351
+ weekdays.update({k[:3]: v for k,v in weekdays.items()})
352
+ holidays = holidays.UnitedStates()
353
+
354
+
355
+ def __init__(self, arg=None):
356
+ self.datetime = arg
357
+
358
+
359
+ #+---------------------------------------------------------------------------+
360
+ # Static Methods
361
+ #+---------------------------------------------------------------------------+
362
+
363
+ @staticmethod
364
+ def to_timestamp(arg, **kwargs):
365
+ ''' converts date in any format to timestamp '''
366
+ if not isinstance(arg, datetime.datetime):
367
+ arg = DateBase.to_datetime(arg, **kwargs)
368
+ return int(time.mktime(arg.timetuple()))
369
+
370
+
371
+ @staticmethod
372
+ def to_datetime(arg, **kwargs):
373
+ ''' converts date in any format to datetime.datetime '''
374
+ return pd.to_datetime(arg, **kwargs).to_pydatetime()
375
+
376
+
377
+
378
+ #+---------------------------------------------------------------------------+
379
+ # Classes
380
+ #+---------------------------------------------------------------------------+
381
+
382
+ class Decorators(object):
383
+
384
+ @classmethod
385
+ def other_to_dt(cls, func):
386
+ ''' decorator converts other argument used in magic methods to datetime '''
387
+ def wrapper(self, other):
388
+ if hasattr(other, 'dt'):
389
+ other = other.dt
390
+ else:
391
+ other = DateBase.to_datetime(other)
392
+ return func(self, other)
393
+ return wrapper
394
+
395
+ @classmethod
396
+ def arithmetic_other(cls, func):
397
+ ''' decorator converts other argument used in magic methods to datetime '''
398
+ def wrapper(self, other):
399
+ try:
400
+ other = datetime.timedelta(float(other))
401
+ return Date(func(self, other))
402
+ except:
403
+ other = Date(other).dt
404
+ return func(self, other)
405
+
406
+ return wrapper
407
+
408
+
409
+ @classmethod
410
+ def next_last(cls, func):
411
+ ''' decorator performs next/last logic '''
412
+ def wrapper(self, weekday, delta=0):
413
+ '''
414
+ returns DateBase object representing the next or last day of the week relative to self. For example self.next('Mon')
415
+ would return the date of the following monday.
416
+
417
+ Attributes
418
+ -----------------------
419
+ weekday : str
420
+ Day of the week either fully spelled out or the first three characters (e.g. 'Friday' or 'Fri') not case-sensitive.
421
+ delta : int
422
+ Offset value +/- from the current week (e.g. 0 is the current week and -1 is last week).
423
+ '''
424
+ day, desired_day = self.dt.weekday(), self.weekdays[weekday.title()]
425
+ delta += func(self, day - desired_day)
426
+ return self.minus(days=day).plus(days=desired_day, weeks=delta)
427
+ return wrapper
428
+
429
+
430
+
431
+ #+---------------------------------------------------------------------------+
432
+ # Class Methods
433
+ #+---------------------------------------------------------------------------+
434
+ # None
435
+
436
+
437
+ #+---------------------------------------------------------------------------+
438
+ # Properties
439
+ #+---------------------------------------------------------------------------+
440
+
441
+ @property
442
+ def dt(self):
443
+ ''' datetime alias '''
444
+ return self.datetime
445
+
446
+ @property
447
+ def pandas(self):
448
+ ''' pandas format '''
449
+ return pd.to_datetime(self.dt)
450
+
451
+ @property
452
+ def sql_server(self):
453
+ ''' string in YYYY-MM-DD format '''
454
+ return self.str('%Y-%m-%d')
455
+
456
+ @property
457
+ def sqlsvr(self):
458
+ ''' sql_server alias '''
459
+ return self.sql_server
460
+
461
+ @property
462
+ def oracle(self):
463
+ ''' string in DD-%b-YY (e.g. 30-Sep-19) format '''
464
+ return self.str('%d-%b-%y')
465
+
466
+ @property
467
+ def timestamp(self):
468
+ ''' integer '''
469
+ return self.to_timestamp(self.dt)
470
+
471
+ @property
472
+ def year(self):
473
+ return self.dt.year
474
+
475
+ @property
476
+ def month(self):
477
+ return self.dt.month
478
+
479
+ @property
480
+ def month_name(self):
481
+ return self.dt.strftime('%B')
482
+
483
+ @property
484
+ def day(self):
485
+ return self.dt.day
486
+
487
+ @property
488
+ def yesterday(self):
489
+ return self - 1
490
+
491
+ @property
492
+ def tomorrow(self):
493
+ return self + 1
494
+
495
+ @property
496
+ def month_start(self):
497
+ ''' return date object representing the first day of the month '''
498
+ return Date(datetime.datetime(self.year, self.month, 1))
499
+
500
+ @property
501
+ def month_end(self):
502
+ ''' last day of the month as a MonthEnd instance '''
503
+ return last_day_of_month(self.year, self.month)
504
+
505
+ @property
506
+ def last_business_day_of_month(self):
507
+ dt = self.month_end
508
+ dt -= {'Saturday': 1, 'Sunday': 2}.get(dt.weekday, 0)
509
+ return dt
510
+
511
+ @property
512
+ def weekday(self):
513
+ out = {
514
+ 0: 'Monday',
515
+ 1: 'Tuesday',
516
+ 2: 'Wednesday',
517
+ 3: 'Thursday',
518
+ 4: 'Friday',
519
+ 5: 'Saturday',
520
+ 6: 'Sunday'
521
+ }[self.dt.weekday()]
522
+ return out
523
+
524
+ @property
525
+ def is_weekend(self):
526
+ ''' returns True if date does not fall on a weekend '''
527
+ return self.weekday in ('Saturday','Sunday')
528
+
529
+ @property
530
+ def is_holiday(self):
531
+ ''' returns True if date is a U.S. holiday '''
532
+ return self.sqlsvr in self.holidays
533
+
534
+ @property
535
+ def is_business_day(self):
536
+ ''' returns True if date does not fall on a weekend '''
537
+ return not (self.is_weekend or self.is_holiday)
538
+
539
+ @property
540
+ def is_business_hours(self):
541
+ ''' returns True if date is within business hours (8am - 9pm) '''
542
+ set_hour = lambda hour: datetime.datetime(self.year, self.month, self.day, hour)
543
+ out = self.is_business_day and self.dt >= set_hour(8) and self.dt <= set_hour(21)
544
+ return out
545
+
546
+ @property
547
+ def is_today(self):
548
+ ''' returns True if date is the current day '''
549
+ return self.sqlsvr == Date().sqlsvr
550
+
551
+ @property
552
+ def holiday(self):
553
+ ''' returns the current holiday, if applicable '''
554
+ return self.holidays.get(self.sqlsvr)
555
+
556
+ @property
557
+ def is_quarter_end(self):
558
+ ''' returns True if the date is a quarter end date '''
559
+ return isinstance(Date(self.sqlsvr), QuarterEnd)
560
+
561
+ @property
562
+ def is_month_end(self):
563
+ '''' returns True if the date is a month end date '''
564
+ return isinstance(Date(self.sqlsvr), MonthEnd)
565
+
566
+
567
+
568
+ #+---------------------------------------------------------------------------+
569
+ # Magic Methods
570
+ #+---------------------------------------------------------------------------+
571
+
572
+ def __repr__(self):
573
+ return str(self)
574
+
575
+ def __str__(self):
576
+ components = ['%Y-%m-%d']
577
+ if self.dt.hour + self.dt.second + self.dt.microsecond > 0:
578
+ components.append('%I:%M:%S.%f %p')
579
+ return self.str(' '.join(components))
580
+
581
+ def __int__(self):
582
+ return self.timestamp
583
+
584
+ @Decorators.other_to_dt
585
+ def __eq__(self, other):
586
+ return self.dt == other
587
+
588
+ @Decorators.other_to_dt
589
+ def __ne__(self, other):
590
+ return self.dt != other
591
+
592
+ @Decorators.other_to_dt
593
+ def __lt__(self, other):
594
+ return self.dt < other
595
+
596
+ @Decorators.other_to_dt
597
+ def __gt__(self, other):
598
+ return self.dt > other
599
+
600
+ @Decorators.other_to_dt
601
+ def __le__(self, other):
602
+ return self.dt <= other
603
+
604
+ @Decorators.other_to_dt
605
+ def __ge__(self, other):
606
+ return self.dt >= other
607
+
608
+ @Decorators.arithmetic_other
609
+ def __add__(self, other):
610
+ ''' if other is date-like then implements default behavior for adding datetimes otherwise
611
+ other is treated as timedelta '''
612
+ return self.dt + other
613
+
614
+ @Decorators.arithmetic_other
615
+ def __sub__(self, other):
616
+ ''' if other is date-like then implements default behavior for subtracting datetimes otherwise
617
+ other is treated as timedelta '''
618
+ return self.dt - other
619
+
620
+ @Decorators.other_to_dt
621
+ def __contains__(self, item):
622
+ return self.normalize(item).dt <= self.dt < (self.normalize(item) + 1).dt
623
+
624
+
625
+
626
+ #+---------------------------------------------------------------------------+
627
+ # Instance Methods
628
+ #+---------------------------------------------------------------------------+
629
+
630
+ def skip_weekend(self, forward=True):
631
+ x = 2 if forward else -1
632
+ if self.weekday == 'Saturday':
633
+ return self + x
634
+ elif self.weekday == 'Sunday':
635
+ return self + (x - 1)
636
+ else:
637
+ return self
638
+
639
+
640
+ def business_day_delta(self, delta):
641
+ out = Date(self.dt)
642
+ sign = np.sign(delta)
643
+
644
+ counter = 0
645
+ while counter < abs(delta):
646
+ out += sign
647
+ while not out.is_business_day:
648
+ out += sign
649
+ counter += 1
650
+
651
+ return out
652
+
653
+
654
+ def skip_holiday(self, forward=True):
655
+ if self.is_holiday:
656
+ return (self + (1 if forward else -1)).skip_holiday(forward=forward)
657
+ else:
658
+ return self
659
+
660
+ def shift(self, **kwargs):
661
+ return Date(self.dt + relativedelta(**kwargs))
662
+
663
+ def replace(self, **kwargs):
664
+ return Date(self.dt.replace(**kwargs))
665
+
666
+ def str(self, fmt):
667
+ ''' strftime shortcut '''
668
+ return self.dt.strftime(fmt)
669
+
670
+ def plus(self, **kwargs):
671
+ ''' timedelta kwargs = weeks, days, hours, minutes, seconds, seconds, etc '''
672
+ return Date(self.dt + datetime.timedelta(**kwargs))
673
+
674
+ def minus(self, **kwargs):
675
+ ''' timedelta kwargs = weeks, days, hours, minutes, seconds, seconds, etc '''
676
+ return Date(self.dt - datetime.timedelta(**kwargs))
677
+
678
+ @Decorators.next_last
679
+ def next(self, x):
680
+ ''' see decorator for documentation '''
681
+ return +1 if x >= 0 else 0
682
+
683
+
684
+ @Decorators.next_last
685
+ def last(self, x):
686
+ ''' see decorator for documentation '''
687
+ return -1 if x <= 0 else 0
688
+
689
+
690
+ def normalize(self, inplace=False):
691
+ ''' the time component (hours, minutes, seconds, microseconds) is set to zero (midnight) '''
692
+ out = Date(datetime.datetime(self.year, self.month, self.day))
693
+ if inplace:
694
+ self = out
695
+ else:
696
+ return out
697
+
698
+
699
+
700
+ class MonthEnd(DateBase):
701
+
702
+ def __init__(self, dt):
703
+ super().__init__(dt.replace(day=n_days_in_month(dt.year, dt.month)))
704
+
705
+ @property
706
+ def label(self):
707
+ return self.str('%Y%b')
708
+
709
+ @property
710
+ def short(self):
711
+ return self.str('%b-%y')
712
+
713
+ @property
714
+ def mid(self):
715
+ return self.str('%b%y')
716
+
717
+ @property
718
+ def last_quarter_end(self):
719
+ ''' returns most recent quarter end '''
720
+ delta = 0
721
+ while True:
722
+ dt = self.offset(delta=delta)
723
+ if isinstance(dt, QuarterEnd): return dt
724
+ delta -= 1
725
+
726
+ def offset(self, delta):
727
+ ''' returns the month end 'delta' months away from the instance '''
728
+ if not isinstance(delta, int):
729
+ raise TypeError("'delta' argument must be an integer")
730
+ if delta == 0: return self
731
+ dt = self.shift(months=delta).dt
732
+ return MonthEnd(dt)
733
+
734
+
735
+
736
+ class QuarterEnd(MonthEnd):
737
+
738
+ def __init__(self, dt, qtr, *args, **kwargs):
739
+ super().__init__(dt)
740
+ self.qtr = qtr
741
+
742
+ @property
743
+ def label(self):
744
+ return f'{self.year}Q{self.qtr}'
745
+
746
+ @property
747
+ def strqtr(self):
748
+ return self.label
749
+
750
+ @property
751
+ def quarter(self):
752
+ return self.qtr
753
+
754
+ @property
755
+ def short(self):
756
+ return f'Q{self.qtr}'
757
+
758
+ @property
759
+ def mid(self):
760
+ return f'{self.qtr}Q{str(self.year)[2:]}'
761
+
762
+ def to_month_end(self):
763
+ return MonthEnd(self.dt)
764
+
765
+ def offset(self, delta):
766
+ ''' returns the quarter end 'delta' quarters away from the instance '''
767
+ if not isinstance(delta, int):
768
+ raise TypeError("'delta' argument must be an integer")
769
+ if delta == 0: return self
770
+ dt = self.shift(months=delta * 3).dt
771
+ qtr = ((self.qtr - 1 + delta) % 4) + 1
772
+ return QuarterEnd(dt, qtr)
773
+
774
+
775
+
776
+
777
+
778
+
779
+ if __name__ == '__main__':
780
+ pass