Coverage for src/scrilla/util/dater.py: 66%
220 statements
« prev ^ index » next coverage.py v6.4.2, created at 2022-07-18 18:14 +0000
« prev ^ index » next coverage.py v6.4.2, created at 2022-07-18 18:14 +0000
1import datetime
2from datetime import date
4import math
5import holidays
6from typing import Any, List, Tuple, Union
8import dateutil.easter as easter
10from scrilla.settings import DATE_FORMAT
13def bureaucratize_date(this_date: date):
14 """
15 Returns the format expected by the US Treasury API. Because why in God's name would the US Treasury serialize a date in a universal format when it could just make one up?
17 Returns
18 -------
19 ``str`` - formatted _YYYYMM_
20 """
21 month_text = f'{this_date.month}' if this_date.month > 9 else f'0{this_date.month}'
22 return f'{this_date.year}{month_text}'
25def today() -> datetime.date:
26 """
27 Returns today's date
28 """
29 return datetime.date.today()
32def validate_order_of_dates(start_date: date, end_date: date) -> Tuple[date, date]:
33 """
34 Returns the inputted dates as an tuple ordered from earliest to latest.
35 """
36 delta = (end_date - start_date).days
37 if delta < 0:
38 return end_date, start_date
39 return start_date, end_date
42def parse(date_string: str) -> Union[date, None]:
43 """
44 Converts a date string in the 'YYYY-MM-DD' format to a Python `datetime.date`.
45 """
46 if len(date_string) < 10: 46 ↛ 47line 46 didn't jump to line 47, because the condition on line 46 was never true
47 raise ValueError(
48 'Date string does not represent a valid date in YYYY-MM-DD format')
49 return datetime.datetime.strptime(date_string[0:10], DATE_FORMAT).date()
52def validate_date(this_date: Any) -> date:
53 if isinstance(this_date, str):
54 return parse(this_date)
55 if not isinstance(this_date, date): 55 ↛ 56line 55 didn't jump to line 56, because the condition on line 55 was never true
56 raise ValueError(
57 f'{this_date} is neither date nor \'{DATE_FORMAT}\' formatted string')
58 return this_date
61def validate_date_range(start_date: Any, end_date: Any) -> Tuple[date, date]:
62 if isinstance(start_date, str):
63 start_date = parse(start_date)
64 elif not isinstance(start_date, date): 64 ↛ 65line 64 didn't jump to line 65, because the condition on line 64 was never true
65 raise ValueError(
66 f'{start_date} is neither date nor \'{DATE_FORMAT}\' formatted string')
67 if isinstance(end_date, str):
68 end_date = parse(end_date)
69 elif not isinstance(end_date, date): 69 ↛ 70line 69 didn't jump to line 70, because the condition on line 69 was never true
70 raise ValueError(
71 f'{end_date} is neither date nor \'{DATE_FORMAT}\' formatted string')
72 return validate_order_of_dates(start_date, end_date)
75def validate_date_list(dates: List[Union[datetime.date, str]]) -> Union[List[datetime.date], None]:
76 """
78 Raises
79 ------
80 1. **ValueError**
81 If the supplied list of dates contains unexpected data types, this error will be thrown.
83 """
84 verified_dates = []
85 for this_date in dates:
86 if isinstance(this_date, str):
87 verified_dates.append(parse(this_date))
88 continue
89 if isinstance(this_date, datetime.date):
90 verified_dates.append(this_date)
91 continue
92 raise ValueError(
93 f'{this_date} is neither date nor \'{DATE_FORMAT}\'formatted string')
94 return verified_dates
97def to_string(this_date: Union[date, None] = None) -> str:
98 """
99 Returns a datetime formatted as 'YYYY-MM-DD'. If no date is provided, function will return today's formatted date.
100 """
101 if this_date is None: 101 ↛ 102line 101 didn't jump to line 102, because the condition on line 101 was never true
102 return to_string(today())
103 return datetime.datetime.strftime(this_date, DATE_FORMAT)
106def is_date_today(this_date: Union[date, str]) -> bool:
107 return (validate_date(this_date) == datetime.date.today())
110def is_future_date(this_date: Union[date, str]) -> bool:
111 return (validate_date(this_date) - today()).days > 0
114def truncate_future_from_date(this_date: Union[date, str]) -> datetime.date:
115 this_date = validate_date(this_date)
116 if is_future_date(this_date): 116 ↛ 117line 116 didn't jump to line 117, because the condition on line 116 was never true
117 return today()
118 return this_date
121def last_close_date():
122 right_now = datetime.datetime.now()
123 trading_close_today = right_now.replace(hour=16)
124 if right_now > trading_close_today:
125 return right_now.date()
126 return get_previous_business_date(right_now.date())
129def is_date_weekend(this_date: Union[date, str]) -> bool:
130 return validate_date(this_date).weekday() in [5, 6]
133def is_date_holiday(this_date: Union[date, str], bond: bool = False) -> bool:
134 this_date = validate_date(this_date)
135 us_holidays = holidays.UnitedStates(years=this_date.year)
136 if not bond:
137 # generate list without columbus day and veterans day since markets are open on those day
138 trading_holidays = [
139 "Columbus Day", "Columbus Day (Observed)", "Veterans Day", "Veterans Day (Observed)"]
140 else:
141 # bond markets are closed on the above dates: https://www.aarp.org/money/investing/info-2022/stock-market-holidays.html#:~:text=In%202022%2C%20U.S.%20bond%20markets,by%20federal%20law%20last%20year.
142 trading_holidays = []
144 # markets are open
145 # see here: https://www.barrons.com/articles/stock-market-open-close-new-years-eve-monday-hours-51640891577
146 if datetime.datetime(year=this_date.year+1, month=1, day=1).weekday() in [5, 6]:
147 trading_holidays += ["New Year's Day (Observed)"]
149 custom_holidays = [that_date for that_date in list(
150 us_holidays) if us_holidays[that_date] not in trading_holidays]
152 # add good friday to list since markets are closed on good friday
153 custom_holidays.append(easter.easter(
154 year=this_date.year) - datetime.timedelta(days=2))
156 return (this_date in custom_holidays)
159def get_last_trading_date(bond: bool = False) -> date:
160 """
161 Returns
162 -------
163 The last full trading day. If today is a trading day and the time is past market close, today's date will be returned. Otherwise, the previous business day's date will be returned.
164 """
165 todays_date = datetime.datetime.now()
166 if is_date_holiday(todays_date, bond) or is_date_weekend(todays_date):
167 return get_previous_business_date(todays_date.date())
168 return last_close_date()
171def this_date_or_last_trading_date(this_date: Union[date, str, None] = None, bond: bool = False) -> date:
172 if this_date is None: 172 ↛ 173line 172 didn't jump to line 173, because the condition on line 172 was never true
173 return get_last_trading_date()
174 this_date = validate_date(this_date)
175 if is_date_holiday(this_date, bond) or is_date_weekend(this_date):
176 return get_previous_business_date(this_date)
177 if is_date_today(this_date): 177 ↛ 178line 177 didn't jump to line 178, because the condition on line 177 was never true
178 return last_close_date()
179 return this_date
182def format_date_range(start_date: date, end_date: date) -> str:
183 result = ""
184 if start_date is not None:
185 start_string = to_string(start_date)
186 result += f'From {start_string}'
187 if end_date is not None:
188 end_string = to_string(end_date)
189 result += f' Until {end_string}'
190 return result
193def is_trading_date(this_date: Union[date, str], bond: bool = False) -> bool:
194 this_date = validate_date(this_date)
195 return not is_date_weekend(this_date) and not is_date_holiday(this_date, bond)
198def intersect_with_trading_dates(date_key_dict: dict) -> dict:
199 return {this_date: date_key_dict[this_date] for this_date in date_key_dict if is_trading_date(this_date)}
202def get_holidays_between(start_date: Union[date, str], end_date: Union[date, str]) -> int:
203 if isinstance(start_date, date): 203 ↛ 205line 203 didn't jump to line 205, because the condition on line 203 was never false
204 start_date = to_string(start_date)
205 if isinstance(end_date, date): 205 ↛ 207line 205 didn't jump to line 207, because the condition on line 205 was never false
206 end_date = to_string(end_date)
207 us_holidays = holidays.UnitedStates()
208 return len(us_holidays[start_date: end_date])
210# YYYY-MM-DD
213def consecutive_trading_days(start_date: Union[date, str], end_date: Union[date, str]) -> bool:
214 """
215 Parameters
216 ----------
217 1. **start_date_string**: ``str``
218 The start date of the time period under consideration. Must be formatted "YYYY-MM-DD"
219 2. **end_date_string**: ``str``
220 The end date of the time period under consideration. Must be formatted "YYYY-MM-DD"
222 Returns
223 -------
224 True
225 if start_date_string and end_date_string are consecutive trading days, i.e. Tuesday -> Wednesday or Friday -> Monday,
226 or Tuesday -> Thursday where Wednesday is a Holiday.
227 False
228 if start_date_string and end_date_string are NOT consecutive trading days.
229 """
230 start_date, end_date = validate_date_range(start_date, end_date)
232 if is_date_weekend(start_date) or is_date_weekend(end_date): 232 ↛ 233line 232 didn't jump to line 233, because the condition on line 232 was never true
233 return False
235 delta = (end_date - start_date).days
237 if delta < 0: 237 ↛ 238line 237 didn't jump to line 238, because the condition on line 237 was never true
238 start_date, end_date = end_date, start_date
239 delta = end_date - start_date
241 holiday_count = get_holidays_between(
242 start_date=start_date, end_date=end_date)
244 if (delta - holiday_count) == 0:
245 return False
247 if (delta - holiday_count) == 1:
248 return True
250 if ((delta - holiday_count) > 1 and (delta - holiday_count) < 4):
251 start_week, end_week = start_date.isocalendar()[
252 1], end_date.isocalendar()[1]
254 if start_week == end_week: 254 ↛ 255line 254 didn't jump to line 255, because the condition on line 254 was never true
255 return False
257 return True
259 return False
262def dates_between(start_date: Union[date, str], end_date: Union[date, str]) -> List[date]:
263 """
264 Returns a list of dates between the inputted dates. "Between" is used in the inclusive sense, i.e. the list includes `start_date` and `end_date`.
266 Parameters
267 ----------
268 1. **start_date**: ``datetime.date``
269 Start date of the date range.
270 2. **end_date**: ``datetime.date``
271 End date of the date range.
272 """
273 start_date, end_date = validate_date_range(start_date, end_date)
274 return [start_date + datetime.timedelta(x) for x in range((end_date - start_date).days+1)]
277def days_between(start_date: Union[date, str], end_date: Union[date, str]) -> int:
278 start_date, end_date = validate_date_range(start_date, end_date)
279 return int((end_date - start_date).days) + 1
281# excludes start_date
284def business_dates_between(start_date: Union[date, str], end_date: Union[date, str], bond: bool = False) -> List[date]:
285 """
286 Returns a list of business dates between the inputted dates. "Between" is used in the inclusive sense, i.e. the list includes `start_date` and `dates`
288 Parameters
289 ----------
290 1. **start_date**: ``datetime.date``
291 Start date of the date range.
292 2. **end_date**: ``datetime.date``
293 End date of the date range.
294 """
295 start_date, end_date = validate_date_range(start_date, end_date)
296 dates = []
297 for x in range((end_date - start_date).days+1):
298 this_date = start_date + datetime.timedelta(x)
299 if is_trading_date(this_date, bond):
300 dates.append(this_date)
301 return dates
304def business_days_between(start_date: Union[date, str], end_date: Union[date, str], bond: bool = False) -> List[int]:
305 start_date, end_date = validate_date_range(start_date, end_date)
306 dates = business_dates_between(start_date, end_date, bond)
307 return len(dates)
310def weekends_between(start_date: Union[date, str], end_date: Union[date, str]) -> List[int]:
311 start_date, end_date = validate_date_range(start_date, end_date)
312 dates = dates_between(start_date, end_date)
313 return len([1 for day in dates if day.weekday() > 4])
316def decrement_date_by_days(start_date: Union[date, str], days: int):
317 start_date = validate_date(start_date)
318 while days > 0:
319 days -= 1
320 start_date -= datetime.timedelta(days=1)
321 return start_date
324def decrement_date_by_business_days(start_date: Union[date, str], business_days: int, bond: bool = False) -> date:
325 """
326 Subtracts `business_days`, ignoring weekends and trading holidays, from `start_date`
327 """
328 start_date = validate_date(start_date)
329 added = False
330 while business_days > 0:
331 if is_trading_date(start_date, bond):
332 business_days -= 1
333 start_date -= datetime.timedelta(days=1)
334 if business_days == 0 and not is_trading_date(start_date):
335 business_days += 1
336 added = True
337 if added and is_trading_date(start_date):
338 business_days -= 1
339 return start_date
342def increment_date_by_business_days(start_date: Union[date, str], business_days: int, bond: bool = False) -> date:
343 start_date = validate_date(start_date)
344 while business_days > 0:
345 if is_trading_date(start_date, bond):
346 business_days -= 1
347 start_date += datetime.timedelta(days=1)
348 return start_date
351def get_next_business_date(this_date: Union[date, str], bond: bool = False) -> date:
352 this_date = validate_date(this_date)
353 while not is_trading_date(this_date, bond):
354 this_date += datetime.timedelta(days=1)
355 return this_date
358def get_previous_business_date(this_date: Union[date, str], bond: bool = False) -> date:
359 this_date = decrement_date_by_days(start_date=this_date, days=1)
360 while not is_trading_date(this_date, bond):
361 this_date -= datetime.timedelta(days=1)
362 return this_date
365def get_time_to_next_month(todays_date: date = today(), trading_days: int = 252) -> float:
366 """
367 Returns the time (measured in years) from `todays_date` to first of the next month.
369 Parameters
370 ----------
371 1. **todays_date**: ``date``
372 *Optional*. Reference date for calculation.
373 2. **trading_days**: ``int``
374 *Optional*. Number of trading days in a year. Defaults to 252.
376 """
377 # TODO: what if first day of the month falls on non-trading days?
378 todays_date = datetime.date.today()
379 next_month = datetime.date(
380 year=todays_date.year, month=(todays_date.month+1), day=1)
381 return ((next_month - todays_date).days / trading_days)
384def get_time_to_next_year(todays_date: date = today(), trading_days: int = 252) -> float:
385 """
386 Returns the time (measured in years) from `todays_date` to first of the next year.
388 Parameters
389 ----------
390 1. **todays_date**: ``date``
391 *Optional*. Reference date for calculation.
392 2. **trading_days**: ``int``
393 *Optional*. Number of trading days in a year. Defaults to 252.
394 """
395 # TODO: what if first day of year falls on non-trading day?
396 # which it will, by definition. fuckwit.
397 next_year = datetime.datetime(year=todays_date.year+1, day=1, month=1)
398 return ((next_year - todays_date).days / trading_days)
401def get_time_to_next_quarter(todays_date: date = today(), trading_days: int = 252) -> float:
402 """
403 Returns the time (measured in years) from `todays_date` to first of the next quarter.
405 Parameters
406 ----------
407 1. **todays_date**: ``date``
408 *Optional*. Reference date for calculation.
409 2. **trading_days**: ``int``
410 *Optional*. Number of trading days in a year. Defaults to 252.
411 """
412 # TODO: what if first day of quarter falls on non-trading days?
414 first_q = datetime.date(year=todays_date.year, month=1, day=1)
415 second_q = datetime.date(year=todays_date.year, month=4, day=1)
416 third_q = datetime.date(year=todays_date.year, month=7, day=1)
417 fourth_q = datetime.date(year=todays_date.year, month=10, day=1)
418 next_first_q = datetime.date(year=(todays_date.year+1), month=1, day=1)
420 first_delta = (first_q - todays_date).days / trading_days
421 second_delta = (second_q - todays_date).days / trading_days
422 third_delta = (third_q - todays_date).days / trading_days
423 fourth_delta = (fourth_q - todays_date).days / trading_days
424 next_delta = (next_first_q - todays_date).days / trading_days
426 return min(i for i in [first_delta, second_delta, third_delta, fourth_delta, next_delta] if i > 0)
429def get_time_to_next_period(starting_date: Union[date, str], period: float) -> float:
430 """
431 Divides the year into segments of equal length 'period' and then calculates the time from todays_date until
432 the next period.
434 Parameters
435 ----------
436 1. **starting_date**: ``Union[date, str]``
437 Starting day of the period. Not to be confused with today. This is the point in time when the recurring event started.
438 2. **period**: float
439 Length of one period, measured in years.
440 """
441 if period is None: 441 ↛ 442line 441 didn't jump to line 442, because the condition on line 441 was never true
442 return 0
444 starting_date = validate_date(starting_date)
445 todays_date = datetime.date.today()
446 floored_days = math.floor(365*period)
448 while ((starting_date - todays_date).days < 0):
449 starting_date += datetime.timedelta(days=floored_days)
451 return float((starting_date - todays_date).days / 365)