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

1import datetime 

2from datetime import date 

3 

4import math 

5import holidays 

6from typing import Any, List, Tuple, Union 

7 

8import dateutil.easter as easter 

9 

10from scrilla.settings import DATE_FORMAT 

11 

12 

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? 

16 

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}' 

23 

24 

25def today() -> datetime.date: 

26 """ 

27 Returns today's date 

28 """ 

29 return datetime.date.today() 

30 

31 

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 

40 

41 

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() 

50 

51 

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 

59 

60 

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) 

73 

74 

75def validate_date_list(dates: List[Union[datetime.date, str]]) -> Union[List[datetime.date], None]: 

76 """ 

77 

78 Raises 

79 ------ 

80 1. **ValueError** 

81 If the supplied list of dates contains unexpected data types, this error will be thrown. 

82 

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 

95 

96 

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) 

104 

105 

106def is_date_today(this_date: Union[date, str]) -> bool: 

107 return (validate_date(this_date) == datetime.date.today()) 

108 

109 

110def is_future_date(this_date: Union[date, str]) -> bool: 

111 return (validate_date(this_date) - today()).days > 0 

112 

113 

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 

119 

120 

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()) 

127 

128 

129def is_date_weekend(this_date: Union[date, str]) -> bool: 

130 return validate_date(this_date).weekday() in [5, 6] 

131 

132 

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 = [] 

143 

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)"] 

148 

149 custom_holidays = [that_date for that_date in list( 

150 us_holidays) if us_holidays[that_date] not in trading_holidays] 

151 

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)) 

155 

156 return (this_date in custom_holidays) 

157 

158 

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() 

169 

170 

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 

180 

181 

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 

191 

192 

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) 

196 

197 

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)} 

200 

201 

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]) 

209 

210# YYYY-MM-DD 

211 

212 

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" 

221 

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) 

231 

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 

234 

235 delta = (end_date - start_date).days 

236 

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 

240 

241 holiday_count = get_holidays_between( 

242 start_date=start_date, end_date=end_date) 

243 

244 if (delta - holiday_count) == 0: 

245 return False 

246 

247 if (delta - holiday_count) == 1: 

248 return True 

249 

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] 

253 

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 

256 

257 return True 

258 

259 return False 

260 

261 

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`. 

265 

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)] 

275 

276 

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 

280 

281# excludes start_date 

282 

283 

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` 

287 

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 

302 

303 

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) 

308 

309 

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]) 

314 

315 

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 

322 

323 

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 

340 

341 

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 

349 

350 

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 

356 

357 

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 

363 

364 

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. 

368 

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. 

375 

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) 

382 

383 

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. 

387 

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) 

399 

400 

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. 

404 

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? 

413 

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) 

419 

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 

425 

426 return min(i for i in [first_delta, second_delta, third_delta, fourth_delta, next_delta] if i > 0) 

427 

428 

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.  

433 

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 

443 

444 starting_date = validate_date(starting_date) 

445 todays_date = datetime.date.today() 

446 floored_days = math.floor(365*period) 

447 

448 while ((starting_date - todays_date).days < 0): 

449 starting_date += datetime.timedelta(days=floored_days) 

450 

451 return float((starting_date - todays_date).days / 365)