pandas-market-calendars 4.4.0__py3-none-any.whl → 4.4.2__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.
Files changed (47) hide show
  1. pandas_market_calendars/__init__.py +38 -38
  2. pandas_market_calendars/calendar_registry.py +57 -53
  3. pandas_market_calendars/calendar_utils.py +262 -261
  4. pandas_market_calendars/calendars/asx.py +66 -66
  5. pandas_market_calendars/calendars/bmf.py +223 -223
  6. pandas_market_calendars/calendars/bse.py +421 -421
  7. pandas_market_calendars/calendars/cboe.py +145 -145
  8. pandas_market_calendars/calendars/cme.py +405 -402
  9. pandas_market_calendars/calendars/cme_globex_agriculture.py +172 -126
  10. pandas_market_calendars/calendars/cme_globex_base.py +119 -119
  11. pandas_market_calendars/calendars/cme_globex_crypto.py +160 -160
  12. pandas_market_calendars/calendars/cme_globex_energy_and_metals.py +216 -216
  13. pandas_market_calendars/calendars/cme_globex_equities.py +123 -123
  14. pandas_market_calendars/calendars/cme_globex_fixed_income.py +136 -136
  15. pandas_market_calendars/calendars/cme_globex_fx.py +101 -101
  16. pandas_market_calendars/calendars/eurex.py +131 -139
  17. pandas_market_calendars/calendars/eurex_fixed_income.py +98 -98
  18. pandas_market_calendars/calendars/hkex.py +429 -426
  19. pandas_market_calendars/calendars/ice.py +81 -81
  20. pandas_market_calendars/calendars/iex.py +112 -112
  21. pandas_market_calendars/calendars/jpx.py +113 -109
  22. pandas_market_calendars/calendars/lse.py +114 -114
  23. pandas_market_calendars/calendars/mirror.py +149 -130
  24. pandas_market_calendars/calendars/nyse.py +1324 -1324
  25. pandas_market_calendars/calendars/ose.py +116 -116
  26. pandas_market_calendars/calendars/sifma.py +354 -354
  27. pandas_market_calendars/calendars/six.py +132 -132
  28. pandas_market_calendars/calendars/sse.py +311 -311
  29. pandas_market_calendars/calendars/tase.py +197 -197
  30. pandas_market_calendars/calendars/tsx.py +181 -181
  31. pandas_market_calendars/holidays/cme.py +385 -385
  32. pandas_market_calendars/holidays/cme_globex.py +214 -214
  33. pandas_market_calendars/holidays/cn.py +1456 -1455
  34. pandas_market_calendars/holidays/jp.py +401 -401
  35. pandas_market_calendars/holidays/jpx_equinox.py +506 -505
  36. pandas_market_calendars/holidays/nyse.py +1531 -1531
  37. pandas_market_calendars/holidays/oz.py +63 -63
  38. pandas_market_calendars/holidays/sifma.py +350 -350
  39. pandas_market_calendars/holidays/us.py +376 -376
  40. pandas_market_calendars/market_calendar.py +922 -895
  41. {pandas_market_calendars-4.4.0.dist-info → pandas_market_calendars-4.4.2.dist-info}/METADATA +8 -7
  42. pandas_market_calendars-4.4.2.dist-info/RECORD +50 -0
  43. {pandas_market_calendars-4.4.0.dist-info → pandas_market_calendars-4.4.2.dist-info}/WHEEL +1 -1
  44. pandas_market_calendars-4.4.0.dist-info/RECORD +0 -50
  45. {pandas_market_calendars-4.4.0.dist-info → pandas_market_calendars-4.4.2.dist-info}/LICENSE +0 -0
  46. {pandas_market_calendars-4.4.0.dist-info → pandas_market_calendars-4.4.2.dist-info}/NOTICE +0 -0
  47. {pandas_market_calendars-4.4.0.dist-info → pandas_market_calendars-4.4.2.dist-info}/top_level.txt +0 -0
@@ -1,895 +1,922 @@
1
- # Fork of zipline from Quantopian. Licensed under MIT, original licence below
2
- #
3
- # Copyright 2016 Quantopian, Inc.
4
- #
5
- # Licensed under the Apache License, Version 2.0 (the "License");
6
- # you may not use this file except in compliance with the License.
7
- # You may obtain a copy of the License at
8
- #
9
- # http://www.apache.org/licenses/LICENSE-2.0
10
- #
11
- # Unless required by applicable law or agreed to in writing, software
12
- # distributed under the License is distributed on an "AS IS" BASIS,
13
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
- # See the License for the specific language governing permissions and
15
- # limitations under the License.
16
- import warnings
17
- from abc import ABCMeta, abstractmethod
18
- from datetime import time
19
-
20
- import pandas as pd
21
- from pandas.tseries.offsets import CustomBusinessDay
22
-
23
- from .class_registry import RegisteryMeta, ProtectedDict
24
-
25
- MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY = range(7)
26
-
27
-
28
- class DEFAULT:
29
- pass
30
-
31
-
32
- class MarketCalendarMeta(ABCMeta, RegisteryMeta):
33
- pass
34
-
35
-
36
- class MarketCalendar(metaclass=MarketCalendarMeta):
37
- """
38
- An MarketCalendar represents the timing information of a single market or exchange.
39
- Unless otherwise noted all times are in UTC and use Pandas data structures.
40
- """
41
-
42
- regular_market_times = {
43
- "market_open": ((None, time(0)),),
44
- "market_close": ((None, time(23)),),
45
- }
46
-
47
- open_close_map = {
48
- "market_open": True,
49
- "market_close": False,
50
- "break_start": False,
51
- "break_end": True,
52
- "pre": True,
53
- "post": False,
54
- }
55
-
56
- @staticmethod
57
- def _tdelta(t, day_offset=0):
58
- try:
59
- return pd.Timedelta(
60
- days=day_offset, hours=t.hour, minutes=t.minute, seconds=t.second
61
- )
62
- except AttributeError:
63
- t, day_offset = t
64
- return pd.Timedelta(
65
- days=day_offset, hours=t.hour, minutes=t.minute, seconds=t.second
66
- )
67
-
68
- @staticmethod
69
- def _off(tple):
70
- try:
71
- return tple[2]
72
- except IndexError:
73
- return 0
74
-
75
- @classmethod
76
- def calendar_names(cls):
77
- """All Market Calendar names and aliases that can be used in "factory"
78
- :return: list(str)
79
- """
80
- return [
81
- cal
82
- for cal in cls._regmeta_class_registry.keys()
83
- if cal not in ["MarketCalendar", "TradingCalendar"]
84
- ]
85
-
86
- @classmethod
87
- def factory(
88
- cls, name, *args, **kwargs
89
- ): # Will be set by Meta, keeping it there for tests
90
- """
91
- :param name: The name of the MarketCalendar to be retrieved.
92
- :param *args/**kwargs: passed to requested MarketCalendar.__init__
93
- :return: MarketCalendar of the desired calendar.
94
- """
95
- return
96
-
97
- def __init__(self, open_time=None, close_time=None):
98
- """
99
- :param open_time: Market open time override as datetime.time object. If None then default is used.
100
- :param close_time: Market close time override as datetime.time object. If None then default is used.
101
- """
102
-
103
- self.regular_market_times = self.regular_market_times.copy()
104
- self.open_close_map = self.open_close_map.copy()
105
- self._customized_market_times = []
106
-
107
- if open_time is not None:
108
- self.change_time("market_open", open_time)
109
-
110
- if close_time is not None:
111
- self.change_time("market_close", close_time)
112
-
113
- if not hasattr(self, "_market_times"):
114
- self._prepare_regular_market_times()
115
-
116
- @property
117
- @abstractmethod
118
- def name(self):
119
- """
120
- Name of the market
121
-
122
- :return: string name
123
- """
124
- raise NotImplementedError()
125
-
126
- @property
127
- @abstractmethod
128
- def tz(self):
129
- """
130
- Time zone for the market.
131
-
132
- :return: timezone
133
- """
134
- raise NotImplementedError()
135
-
136
- @property
137
- def market_times(self):
138
- return self._market_times
139
-
140
- def _prepare_regular_market_times(self):
141
- oc_map = self.open_close_map
142
- assert all(
143
- isinstance(x, bool) for x in oc_map.values()
144
- ), "Values in open_close_map need to be True or False"
145
-
146
- regular = self.regular_market_times
147
- discontinued = ProtectedDict()
148
- regular_tds = {}
149
-
150
- for market_time, times in regular.items():
151
- # in case a market_time has been discontinued, extend the last time
152
- # and add it to the discontinued_market_times dictionary
153
- if market_time.startswith("interruption_"):
154
- raise ValueError("'interruption_' prefix is reserved")
155
-
156
- if times[-1][1] is None:
157
- discontinued._set(market_time, times[-1][0])
158
- times = times[:-1]
159
- regular._set(market_time, times)
160
-
161
- regular_tds[market_time] = tuple(
162
- (t[0], self._tdelta(t[1], self._off(t))) for t in times
163
- )
164
-
165
- if discontinued:
166
- warnings.warn(
167
- f"{list(discontinued.keys())} are discontinued, the dictionary"
168
- f" `.discontinued_market_times` has the dates on which these were discontinued."
169
- f" The times as of those dates are incorrect, use .remove_time(market_time)"
170
- f" to ignore a market_time."
171
- )
172
-
173
- self.discontinued_market_times = discontinued
174
- self.regular_market_times = regular
175
-
176
- self._regular_market_timedeltas = regular_tds
177
- self._market_times = sorted(regular.keys(), key=lambda x: regular_tds[x][-1][1])
178
- self._oc_market_times = list(filter(oc_map.__contains__, self._market_times))
179
-
180
- def _set_time(self, market_time, times, opens):
181
- if isinstance(times, (tuple, list)): # passed a tuple
182
- if not isinstance(times[0], (tuple, list)): # doesn't have a tuple inside
183
- if times[0] is None: # seems to be a tuple indicating starting time
184
- times = (times,)
185
- else: # must be a tuple with: (time, offset)
186
- times = ((None, times[0], times[1]),)
187
- else: # should be a datetime.time object
188
- times = ((None, times),)
189
-
190
- ln = len(times)
191
- for i, t in enumerate(times):
192
- try:
193
- assert (
194
- t[0] is None
195
- or isinstance(t[0], str)
196
- or isinstance(t[0], pd.Timestamp)
197
- )
198
- assert isinstance(t[1], time) or (
199
- ln > 1 and i == ln - 1 and t[1] is None
200
- )
201
- assert isinstance(self._off(t), int)
202
- except AssertionError:
203
- raise AssertionError(
204
- "The passed time information is not in the right format, "
205
- "please consult the docs for how to set market times"
206
- )
207
-
208
- if opens is DEFAULT:
209
- opens = self.__class__.open_close_map.get(market_time, None)
210
-
211
- if opens in (True, False):
212
- self.open_close_map._set(market_time, opens)
213
-
214
- elif opens is None: # make sure it's ignored
215
- try:
216
- self.open_close_map._del(market_time)
217
- except KeyError:
218
- pass
219
- else:
220
- raise ValueError(
221
- "when you pass `opens`, it needs to be True, False, or None"
222
- )
223
-
224
- self.regular_market_times._set(market_time, times)
225
-
226
- if not self.is_custom(market_time):
227
- self._customized_market_times.append(market_time)
228
-
229
- self._prepare_regular_market_times()
230
-
231
- def change_time(self, market_time, times, opens=DEFAULT):
232
- """
233
- Changes the specified market time in regular_market_times and makes the necessary adjustments.
234
-
235
- :param market_time: the market_time to change
236
- :param times: new time information
237
- :param opens: whether the market_time is a time that closes or opens the market
238
- this is only needed if the market_time should be respected by .open_at_time
239
- True: opens
240
- False: closes
241
- None: consider it neither opening nor closing, don't add to open_close_map (ignore in .open_at_time)
242
- DEFAULT: same as None, unless the market_time is in self.__class__.open_close_map. Then it will take
243
- the default value as defined by the class.
244
- :return: None
245
- """
246
- assert market_time in self.regular_market_times, (
247
- f"{market_time} is not in regular_market_times:" f"\n{self._market_times}."
248
- )
249
- return self._set_time(market_time, times, opens)
250
-
251
- def add_time(self, market_time, times, opens=DEFAULT):
252
- """
253
- Adds the specified market time to regular_market_times and makes the necessary adjustments.
254
-
255
- :param market_time: the market_time to add
256
- :param times: the time information
257
- :param opens: see .change_time docstring
258
- :return: None
259
- """
260
- assert market_time not in self.regular_market_times, (
261
- f"{market_time} is already in regular_market_times:"
262
- f"\n{self._market_times}"
263
- )
264
-
265
- return self._set_time(market_time, times, opens)
266
-
267
- def remove_time(self, market_time):
268
- """
269
- Removes the specified market time from regular_market_times and makes the necessary adjustments.
270
-
271
- :param market_time: the market_time to remove
272
- :return: None
273
- """
274
-
275
- self.regular_market_times._del(market_time)
276
- try:
277
- self.open_close_map._del(market_time)
278
- except KeyError:
279
- pass
280
-
281
- self._prepare_regular_market_times()
282
- if self.is_custom(market_time):
283
- self._customized_market_times.remove(market_time)
284
-
285
- def is_custom(self, market_time):
286
- return market_time in self._customized_market_times
287
-
288
- @property
289
- def has_custom(self):
290
- return len(self._customized_market_times) > 0
291
-
292
- def is_discontinued(self, market_time):
293
- return market_time in self.discontinued_market_times
294
-
295
- @property
296
- def has_discontinued(self):
297
- return len(self.discontinued_market_times) > 0
298
-
299
- def get_time(self, market_time, all_times=False):
300
- try:
301
- times = self.regular_market_times[market_time]
302
- except KeyError as e:
303
- if "break_start" in market_time or "break_end" in market_time:
304
- return None # in case of no breaks
305
- elif market_time in ["market_open", "market_close"]:
306
- raise NotImplementedError("You need to set market_times")
307
- else:
308
- raise e
309
-
310
- if all_times:
311
- return times
312
- return times[-1][1].replace(tzinfo=self.tz)
313
-
314
- def get_time_on(self, market_time, date):
315
- times = self.get_time(market_time, all_times=True)
316
- if times is None:
317
- return None
318
-
319
- date = pd.Timestamp(date)
320
- for d, t in times[::-1]:
321
- if d is None or pd.Timestamp(d) < date:
322
- return t.replace(tzinfo=self.tz)
323
-
324
- def open_time_on(self, date):
325
- return self.get_time_on("market_open", date)
326
-
327
- def close_time_on(self, date):
328
- return self.get_time_on("market_close", date)
329
-
330
- def break_start_on(self, date):
331
- return self.get_time_on("break_start", date)
332
-
333
- def break_end_on(self, date):
334
- return self.get_time_on("break_end", date)
335
-
336
- @property
337
- def open_time(self):
338
- """
339
- Default open time for the market
340
-
341
- :return: time
342
- """
343
- return self.get_time("market_open")
344
-
345
- @property
346
- def close_time(self):
347
- """
348
- Default close time for the market
349
-
350
- :return: time
351
- """
352
- return self.get_time("market_close")
353
-
354
- @property
355
- def break_start(self):
356
- """
357
- Break time start. If None then there is no break
358
-
359
- :return: time or None
360
- """
361
- return self.get_time("break_start")
362
-
363
- @property
364
- def break_end(self):
365
- """
366
- Break time end. If None then there is no break
367
-
368
- :return: time or None
369
- """
370
- return self.get_time("break_end")
371
-
372
- @property
373
- def regular_holidays(self):
374
- """
375
-
376
- :return: pd.AbstractHolidayCalendar: a calendar containing the regular holidays for this calendar
377
- """
378
- return None
379
-
380
- @property
381
- def adhoc_holidays(self):
382
- """
383
-
384
- :return: list of ad-hoc holidays
385
- """
386
- return []
387
-
388
- @property
389
- def weekmask(self):
390
- return "Mon Tue Wed Thu Fri"
391
-
392
- @property
393
- def special_opens(self):
394
- """
395
- A list of special open times and corresponding AbstractHolidayCalendar.
396
-
397
- :return: List of (time, AbstractHolidayCalendar) tuples
398
- """
399
- return []
400
-
401
- @property
402
- def special_opens_adhoc(self):
403
- """
404
-
405
- :return: List of (time, DatetimeIndex) tuples that represent special opens that cannot be codified into rules.
406
- """
407
- return []
408
-
409
- @property
410
- def special_closes(self):
411
- """
412
- A list of special close times and corresponding HolidayCalendars.
413
-
414
- :return: List of (time, AbstractHolidayCalendar) tuples
415
- """
416
- return []
417
-
418
- @property
419
- def special_closes_adhoc(self):
420
- """
421
-
422
- :return: List of (time, DatetimeIndex) tuples that represent special closes that cannot be codified into rules.
423
- """
424
- return []
425
-
426
- def get_special_times(self, market_time):
427
- return getattr(self, "special_" + market_time, [])
428
-
429
- def get_special_times_adhoc(self, market_time):
430
- return getattr(self, "special_" + market_time + "_adhoc", [])
431
-
432
- def get_offset(self, market_time):
433
- return self._off(self.get_time(market_time, all_times=True)[-1])
434
-
435
- @property
436
- def open_offset(self):
437
- """
438
- :return: open offset
439
- """
440
- return self.get_offset("market_open")
441
-
442
- @property
443
- def close_offset(self):
444
- """
445
- :return: close offset
446
- """
447
- return self.get_offset("market_close")
448
-
449
- @property
450
- def interruptions(self):
451
- """
452
- This needs to be a list with a tuple for each date that had an interruption.
453
- The tuple should have this layout:
454
-
455
- (date, start_time, end_time[, start_time2, end_time2, ...])
456
-
457
- E.g.:
458
- [
459
- ("2002-02-03", (time(11), -1), time(11, 2)),
460
- ("2010-01-11", time(11), (time(11, 1), 1)),
461
- ("2010-01-13", time(9, 59), time(10), time(10, 29), time(10, 30)),
462
- ("2011-01-10", time(11), time(11, 1))
463
- ]
464
-
465
- The date needs to be a string in this format: 'yyyy-mm-dd'.
466
- Times need to be two datetime.time objects for each interruption, indicating start and end.
467
- Optionally these can be wrapped in a tuple, where the
468
- second element needs to be an integer indicating an offset.
469
- On "2010-01-13" in the example, it is shown that there can be multiple interruptions in a day.
470
- """
471
- return []
472
-
473
- def _convert(self, col: pd.Series):
474
- """
475
- col is a series indexed by dates at which interruptions occurred. The values are either the start or end times
476
- of an interruption, represented by either a timedelta or a tuple with a timedelta and day offset of the form
477
- (timedelta, offset). _convert produces a new series where the values are replaced by datetimes equal to the
478
- index of the original series plus the offset if present, at the timedelta.
479
-
480
- E.g.:
481
- >>> self._convert(
482
- pd.Series(
483
- [datetime.time(11, 2), (datetime.time(11, 1), 1), datetime.time(10, 0), None],
484
- index=pd.DatetimeIndex(['2002-02-03', '2010-01-11', '2010-01-13', '2011-01-10'])
485
- )
486
- )
487
- 2002-02-03 2002-02-03 11:02:00+00:00
488
- 2010-01-11 2010-01-12 11:01:00+00:00
489
- 2010-01-13 2010-01-13 10:00:00+00:00
490
- 2011-01-10 NaT
491
- dtype: datetime64[ns, UTC]
492
- """
493
- col = (
494
- col.dropna()
495
- ) # Python 3.8, pandas 2.0.3 cannot create time deltas from NaT
496
- try:
497
- times = col.str[0]
498
- except AttributeError: # no tuples, only offset 0
499
- return (
500
- (
501
- pd.to_timedelta(col.astype("string").fillna(""), errors="coerce")
502
- + col.index
503
- )
504
- .dt.tz_localize(self.tz)
505
- .dt.tz_convert("UTC")
506
- )
507
-
508
- return (
509
- (
510
- pd.to_timedelta(
511
- times.fillna(col).astype("string").fillna(""), errors="coerce"
512
- )
513
- + pd.to_timedelta(col.str[1].fillna(0), unit="D")
514
- + col.index
515
- )
516
- .dt.tz_localize(self.tz)
517
- .dt.tz_convert("UTC")
518
- )
519
-
520
- @staticmethod
521
- def _col_name(n: int):
522
- return (
523
- f"interruption_start_{n // 2 + 1}"
524
- if n % 2 == 1
525
- else f"interruption_end_{n // 2}"
526
- )
527
-
528
- @property
529
- def interruptions_df(self):
530
- """
531
- Will return a pd.DataFrame only containing interruptions.
532
- """
533
- if not self.interruptions:
534
- return pd.DataFrame(index=pd.DatetimeIndex([]))
535
- intr = pd.DataFrame(self.interruptions)
536
- intr.index = pd.to_datetime(intr.pop(0))
537
-
538
- intr.columns = map(self._col_name, intr.columns)
539
- intr.index.name = None
540
-
541
- return intr.apply(self._convert).sort_index()
542
-
543
- def holidays(self):
544
- """
545
- Returns the complete CustomBusinessDay object of holidays that can be used in any Pandas function that take
546
- that input.
547
-
548
- :return: CustomBusinessDay object of holidays
549
- """
550
- try:
551
- return self._holidays
552
- except AttributeError:
553
- self._holidays = CustomBusinessDay(
554
- holidays=self.adhoc_holidays,
555
- calendar=self.regular_holidays,
556
- weekmask=self.weekmask,
557
- )
558
- return self._holidays
559
-
560
- def valid_days(self, start_date, end_date, tz="UTC"):
561
- """
562
- Get a DatetimeIndex of valid open business days.
563
-
564
- :param start_date: start date
565
- :param end_date: end date
566
- :param tz: time zone in either string or pytz.timezone
567
- :return: DatetimeIndex of valid business days
568
- """
569
- return pd.date_range(
570
- start_date, end_date, freq=self.holidays(), normalize=True, tz=tz
571
- )
572
-
573
- def _get_market_times(self, start, end):
574
- mts = self._market_times
575
- return mts[mts.index(start) : mts.index(end) + 1]
576
-
577
- def days_at_time(self, days, market_time, day_offset=0):
578
- """
579
- Create an index of days at time ``t``, interpreted in timezone ``tz``. The returned index is localized to UTC.
580
-
581
- In the example below, the times switch from 13:45 to 12:45 UTC because
582
- March 13th is the daylight savings transition for US/Eastern. All the
583
- times are still 8:45 when interpreted in US/Eastern.
584
-
585
- >>> import pandas as pd; import datetime; import pprint
586
- >>> dts = pd.date_range('2016-03-12', '2016-03-14')
587
- >>> dts_at_845 = days_at_time(dts, datetime.time(8, 45), 'US/Eastern')
588
- >>> pprint.pprint([str(dt) for dt in dts_at_845])
589
- ['2016-03-12 13:45:00+00:00',
590
- '2016-03-13 12:45:00+00:00',
591
- '2016-03-14 12:45:00+00:00']
592
-
593
- :param days: DatetimeIndex An index of dates (represented as midnight).
594
- :param market_time: datetime.time The time to apply as an offset to each day in ``days``.
595
- :param day_offset: int The number of days we want to offset @days by
596
- :return: pd.Series of date with the time requested.
597
- """
598
- # Offset days without tz to avoid timezone issues.
599
- days = pd.DatetimeIndex(days).tz_localize(None).to_series()
600
-
601
- if isinstance(
602
- market_time, str
603
- ): # if string, assume its a reference to saved market times
604
- timedeltas = self._regular_market_timedeltas[market_time]
605
- datetimes = days + timedeltas[0][1]
606
- for cut_off, timedelta in timedeltas[1:]:
607
- datetimes = datetimes.where(
608
- days < pd.Timestamp(cut_off), days + timedelta
609
- )
610
-
611
- else: # otherwise, assume it is a datetime.time object
612
- datetimes = days + self._tdelta(market_time, day_offset)
613
-
614
- return datetimes.dt.tz_localize(self.tz).dt.tz_convert("UTC")
615
-
616
- def _tryholidays(self, cal, s, e):
617
- try:
618
- return cal.holidays(s, e)
619
- except ValueError:
620
- return pd.DatetimeIndex([])
621
-
622
- def _special_dates(self, calendars, ad_hoc_dates, start, end):
623
- """
624
- Union an iterable of pairs of the form (time, calendar)
625
- and an iterable of pairs of the form (time, [dates])
626
-
627
- (This is shared logic for computing special opens and special closes.)
628
- """
629
- indexes = [
630
- self.days_at_time(self._tryholidays(calendar, start, end), time_)
631
- for time_, calendar in calendars
632
- ] + [self.days_at_time(dates, time_) for time_, dates in ad_hoc_dates]
633
- if indexes:
634
- dates = pd.concat(indexes).sort_index().drop_duplicates()
635
- return dates.loc[start : end.replace(hour=23, minute=59, second=59)]
636
-
637
- return pd.Series([], dtype="datetime64[ns, UTC]", index=pd.DatetimeIndex([]))
638
-
639
- def special_dates(self, market_time, start_date, end_date, filter_holidays=True):
640
- """
641
- Calculate a datetimeindex that only contains the specail times of the requested market time.
642
-
643
- :param market_time: market_time reference
644
- :param start_date: first possible date of the index
645
- :param end_date: last possible date of the index
646
- :param filter_holidays: will filter days by self.valid_days, which can be useful when debugging
647
-
648
- :return: schedule DatetimeIndex
649
- """
650
- start_date, end_date = self.clean_dates(start_date, end_date)
651
- calendars = self.get_special_times(market_time)
652
- ad_hoc = self.get_special_times_adhoc(market_time)
653
- special = self._special_dates(calendars, ad_hoc, start_date, end_date)
654
-
655
- if filter_holidays:
656
- valid = self.valid_days(start_date, end_date, tz=None)
657
- special = special[
658
- special.index.isin(valid)
659
- ] # some sources of special times don't exclude holidays
660
- return special
661
-
662
- def schedule(
663
- self,
664
- start_date,
665
- end_date,
666
- tz="UTC",
667
- start="market_open",
668
- end="market_close",
669
- force_special_times=True,
670
- market_times=None,
671
- interruptions=False,
672
- ):
673
- """
674
- Generates the schedule DataFrame. The resulting DataFrame will have all the valid business days as the index
675
- and columns for the requested market times. The columns can be determined either by setting a range (inclusive
676
- on both sides), using `start` and `end`, or by passing a list to `market_times'. A range of market_times is
677
- derived from a list of market_times that are available to the instance, which are sorted based on the current
678
- regular time. See examples/usage.ipynb for demonstrations.
679
-
680
- All time zones are set to UTC by default. Setting the tz parameter will convert the columns to the desired
681
- timezone, such as 'America/New_York'.
682
-
683
- :param start_date: first date of the schedule
684
- :param end_date: last date of the schedule
685
- :param tz: timezone that the columns of the returned schedule are in, default: "UTC"
686
- :param start: the first market_time to include as a column, default: "market_open"
687
- :param end: the last market_time to include as a column, default: "market_close"
688
- :param force_special_times: how to handle special times.
689
- True: overwrite regular times of the column itself, conform other columns to special times of
690
- market_open/market_close if those are requested.
691
- False: only overwrite regular times of the column itself, leave others alone
692
- None: completely ignore special times
693
- :param market_times: alternative to start/end, list of market_times that are in self.regular_market_times
694
- :param interruptions: bool, whether to add interruptions to the schedule, default: False
695
- These will be added as columns to the right of the DataFrame. Any interruption on a day between
696
- start_date and end_date will be included, regardless of the market_times requested.
697
- Also, `force_special_times` does not take these into consideration.
698
- :return: schedule DataFrame
699
- """
700
- start_date, end_date = self.clean_dates(start_date, end_date)
701
- if not (start_date <= end_date):
702
- raise ValueError("start_date must be before or equal to end_date.")
703
-
704
- # Setup all valid trading days and the requested market_times
705
- _all_days = self.valid_days(start_date, end_date)
706
- if market_times is None:
707
- market_times = self._get_market_times(start, end)
708
- elif market_times == "all":
709
- market_times = self._market_times
710
-
711
- # If no valid days return an empty DataFrame
712
- if not _all_days.size:
713
- return pd.DataFrame(
714
- columns=market_times, index=pd.DatetimeIndex([], freq="C")
715
- )
716
-
717
- _adj_others = force_special_times is True
718
- _adj_col = force_special_times is not None
719
- _open_adj = _close_adj = []
720
-
721
- schedule = pd.DataFrame()
722
- for market_time in market_times:
723
- temp = self.days_at_time(_all_days, market_time).copy() # standard times
724
- if _adj_col:
725
- # create an array of special times
726
- special = self.special_dates(
727
- market_time, start_date, end_date, filter_holidays=False
728
- )
729
- # overwrite standard times
730
- specialix = special.index[
731
- special.index.isin(temp.index)
732
- ] # some sources of special times don't exclude holidays
733
- temp.loc[specialix] = special
734
-
735
- if _adj_others:
736
- if market_time == "market_open":
737
- _open_adj = specialix
738
- elif market_time == "market_close":
739
- _close_adj = specialix
740
-
741
- schedule[market_time] = temp
742
-
743
- if _adj_others:
744
- adjusted = schedule.loc[_open_adj].apply(
745
- lambda x: x.where(x.ge(x["market_open"]), x["market_open"]), axis=1
746
- )
747
- schedule.loc[_open_adj] = adjusted
748
-
749
- adjusted = schedule.loc[_close_adj].apply(
750
- lambda x: x.where(x.le(x["market_close"]), x["market_close"]), axis=1
751
- )
752
- schedule.loc[_close_adj] = adjusted
753
-
754
- if interruptions:
755
- interrs = self.interruptions_df
756
- schedule[interrs.columns] = interrs
757
- schedule = schedule.dropna(how="all", axis=1)
758
-
759
- if tz != "UTC":
760
- schedule = schedule.apply(lambda s: s.dt.tz_convert(tz))
761
-
762
- return schedule
763
-
764
- def open_at_time(self, schedule, timestamp, include_close=False, only_rth=False):
765
- """
766
- Determine if a given timestamp is during an open time for the market. If the timestamp is
767
- before the first open time or after the last close time of `schedule`, a ValueError will be raised.
768
-
769
- :param schedule: schedule DataFrame
770
- :param timestamp: the timestamp to check for. Assumed to be UTC, if it doesn't include tz information.
771
- :param include_close: if False then the timestamp that equals the closing timestamp will return False and not be
772
- considered a valid open date and time. If True then it will be considered valid and return True. Use True
773
- if using bars and would like to include the last bar as a valid open date and time. The close refers to the
774
- latest market_time available, which could be after market_close (e.g. 'post').
775
- :param only_rth: whether to ignore columns that are before market_open or after market_close. If true,
776
- include_close will be referring to market_close.
777
- :return: True if the timestamp is a valid open date and time, False if not
778
- """
779
- timestamp = pd.Timestamp(timestamp)
780
- try:
781
- timestamp = timestamp.tz_localize("UTC")
782
- except TypeError:
783
- pass
784
-
785
- cols = schedule.columns
786
- interrs = cols.str.startswith("interruption_")
787
- if not (cols.isin(self._oc_market_times) | interrs).all():
788
- raise ValueError(
789
- "You seem to be using a schedule that isn't based on the market_times, "
790
- "or includes market_times that are not represented in the open_close_map."
791
- )
792
-
793
- if only_rth:
794
- lowest, highest = "market_open", "market_close"
795
- else:
796
- cols = cols[~interrs]
797
- ix = cols.map(self._oc_market_times.index)
798
- lowest, highest = cols[ix == ix.min()][0], cols[ix == ix.max()][0]
799
-
800
- if timestamp < schedule[lowest].iat[0] or timestamp > schedule[highest].iat[-1]:
801
- raise ValueError("The provided timestamp is not covered by the schedule")
802
-
803
- day = schedule[schedule[lowest].le(timestamp)].iloc[-1].dropna().sort_values()
804
- day = day.loc[lowest:highest]
805
- day = day.index.to_series(index=day)
806
-
807
- if interrs.any():
808
- starts = day.str.startswith("interruption_start_")
809
- ends = day.str.startswith("interruption_end_")
810
- day.loc[starts] = False
811
- day.loc[ends] = True
812
-
813
- # When post follows market_close, market_close should not be considered a close
814
- day.loc[day.eq("market_close") & day.shift(-1).eq("post")] = "market_open"
815
- day = day.replace(self.open_close_map)
816
-
817
- if include_close:
818
- below = day.index < timestamp
819
- else:
820
- below = day.index <= timestamp
821
- return bool(day[below].iat[-1]) # returns numpy.bool_ if not bool(...)
822
-
823
- # need this to make is_open_now testable
824
- @staticmethod
825
- def _get_current_time():
826
- return pd.Timestamp.now(tz="UTC")
827
-
828
- def is_open_now(self, schedule, include_close=False, only_rth=False):
829
- """
830
- To determine if the current local system time (converted to UTC) is an open time for the market
831
-
832
- :param schedule: schedule DataFrame
833
- :param include_close: if False then the function will return False if the current local system time is equal to
834
- the closing timestamp. If True then it will return True if the current local system time is equal to the
835
- closing timestamp. Use True if using bars and would like to include the last bar as a valid open date
836
- and time.
837
- :param only_rth: whether to consider columns that are before market_open or after market_close
838
-
839
- :return: True if the current local system time is a valid open date and time, False if not
840
- """
841
- current_time = MarketCalendar._get_current_time()
842
- return self.open_at_time(
843
- schedule, current_time, include_close=include_close, only_rth=only_rth
844
- )
845
-
846
- def clean_dates(self, start_date, end_date):
847
- """
848
- Strips the inputs of time and time zone information
849
-
850
- :param start_date: start date
851
- :param end_date: end date
852
- :return: (start_date, end_date) with just date, no time and no time zone
853
- """
854
- start_date = pd.Timestamp(start_date).tz_localize(None).normalize()
855
- end_date = pd.Timestamp(end_date).tz_localize(None).normalize()
856
- return start_date, end_date
857
-
858
- def is_different(self, col, diff=None):
859
- if diff is None:
860
- diff = pd.Series.ne
861
- normal = self.days_at_time(col.index, col.name)
862
- return diff(col.dt.tz_convert("UTC"), normal)
863
-
864
- def early_closes(self, schedule):
865
- """
866
- Get a DataFrame of the dates that are an early close.
867
-
868
- :param schedule: schedule DataFrame
869
- :return: schedule DataFrame with rows that are early closes
870
- """
871
- return schedule[self.is_different(schedule["market_close"], pd.Series.lt)]
872
-
873
- def late_opens(self, schedule):
874
- """
875
- Get a DataFrame of the dates that are an late opens.
876
-
877
- :param schedule: schedule DataFrame
878
- :return: schedule DataFrame with rows that are late opens
879
- """
880
- return schedule[self.is_different(schedule["market_open"], pd.Series.gt)]
881
-
882
- def __getitem__(self, item):
883
- if isinstance(item, (tuple, list)):
884
- if item[1] == "all":
885
- return self.get_time(item[0], all_times=True)
886
- else:
887
- return self.get_time_on(item[0], item[1])
888
- else:
889
- return self.get_time(item)
890
-
891
- def __setitem__(self, key, value):
892
- return self.add_time(key, value)
893
-
894
- def __delitem__(self, key):
895
- return self.remove_time(key)
1
+ # Fork of zipline from Quantopian. Licensed under MIT, original licence below
2
+ #
3
+ # Copyright 2016 Quantopian, Inc.
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ import warnings
17
+ from abc import ABCMeta, abstractmethod
18
+ from datetime import time
19
+
20
+ import pandas as pd
21
+ from pandas.tseries.offsets import CustomBusinessDay
22
+
23
+ from .class_registry import RegisteryMeta, ProtectedDict
24
+
25
+ MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY = range(7)
26
+
27
+ WEEKMASK_ABBR = {
28
+ MONDAY: "Mon",
29
+ TUESDAY: "Tue",
30
+ WEDNESDAY: "Wed",
31
+ THURSDAY: "Thu",
32
+ FRIDAY: "Fri",
33
+ SATURDAY: "Sat",
34
+ SUNDAY: "Sun",
35
+ }
36
+
37
+
38
+ class DEFAULT:
39
+ pass
40
+
41
+
42
+ class MarketCalendarMeta(ABCMeta, RegisteryMeta):
43
+ pass
44
+
45
+
46
+ class MarketCalendar(metaclass=MarketCalendarMeta):
47
+ """
48
+ An MarketCalendar represents the timing information of a single market or exchange.
49
+ Unless otherwise noted all times are in UTC and use Pandas data structures.
50
+ """
51
+
52
+ regular_market_times = {
53
+ "market_open": ((None, time(0)),),
54
+ "market_close": ((None, time(23)),),
55
+ }
56
+
57
+ open_close_map = {
58
+ "market_open": True,
59
+ "market_close": False,
60
+ "break_start": False,
61
+ "break_end": True,
62
+ "pre": True,
63
+ "post": False,
64
+ }
65
+
66
+ @staticmethod
67
+ def _tdelta(t, day_offset=0):
68
+ try:
69
+ return pd.Timedelta(
70
+ days=day_offset, hours=t.hour, minutes=t.minute, seconds=t.second
71
+ )
72
+ except AttributeError:
73
+ t, day_offset = t
74
+ return pd.Timedelta(
75
+ days=day_offset, hours=t.hour, minutes=t.minute, seconds=t.second
76
+ )
77
+
78
+ @staticmethod
79
+ def _off(tple):
80
+ try:
81
+ return tple[2]
82
+ except IndexError:
83
+ return 0
84
+
85
+ @classmethod
86
+ def calendar_names(cls):
87
+ """All Market Calendar names and aliases that can be used in "factory"
88
+ :return: list(str)
89
+ """
90
+ return [
91
+ cal
92
+ for cal in cls._regmeta_class_registry.keys()
93
+ if cal not in ["MarketCalendar", "TradingCalendar"]
94
+ ]
95
+
96
+ @classmethod
97
+ def factory(
98
+ cls, name, *args, **kwargs
99
+ ): # Will be set by Meta, keeping it there for tests
100
+ """
101
+ :param name: The name of the MarketCalendar to be retrieved.
102
+ :param *args/**kwargs: passed to requested MarketCalendar.__init__
103
+ :return: MarketCalendar of the desired calendar.
104
+ """
105
+ return
106
+
107
+ def __init__(self, open_time=None, close_time=None):
108
+ """
109
+ :param open_time: Market open time override as datetime.time object. If None then default is used.
110
+ :param close_time: Market close time override as datetime.time object. If None then default is used.
111
+ """
112
+
113
+ self.regular_market_times = self.regular_market_times.copy()
114
+ self.open_close_map = self.open_close_map.copy()
115
+ self._customized_market_times = []
116
+
117
+ if open_time is not None:
118
+ self.change_time("market_open", open_time)
119
+
120
+ if close_time is not None:
121
+ self.change_time("market_close", close_time)
122
+
123
+ if not hasattr(self, "_market_times"):
124
+ self._prepare_regular_market_times()
125
+
126
+ @property
127
+ @abstractmethod
128
+ def name(self):
129
+ """
130
+ Name of the market
131
+
132
+ :return: string name
133
+ """
134
+ raise NotImplementedError()
135
+
136
+ @property
137
+ @abstractmethod
138
+ def tz(self):
139
+ """
140
+ Time zone for the market.
141
+
142
+ :return: timezone
143
+ """
144
+ raise NotImplementedError()
145
+
146
+ @property
147
+ def market_times(self):
148
+ return self._market_times
149
+
150
+ def _prepare_regular_market_times(self):
151
+ oc_map = self.open_close_map
152
+ assert all(
153
+ isinstance(x, bool) for x in oc_map.values()
154
+ ), "Values in open_close_map need to be True or False"
155
+
156
+ regular = self.regular_market_times
157
+ discontinued = ProtectedDict()
158
+ regular_tds = {}
159
+
160
+ for market_time, times in regular.items():
161
+ # in case a market_time has been discontinued, extend the last time
162
+ # and add it to the discontinued_market_times dictionary
163
+ if market_time.startswith("interruption_"):
164
+ raise ValueError("'interruption_' prefix is reserved")
165
+
166
+ if times[-1][1] is None:
167
+ discontinued._set(market_time, times[-1][0])
168
+ times = times[:-1]
169
+ regular._set(market_time, times)
170
+
171
+ regular_tds[market_time] = tuple(
172
+ (t[0], self._tdelta(t[1], self._off(t))) for t in times
173
+ )
174
+
175
+ if discontinued:
176
+ warnings.warn(
177
+ f"{list(discontinued.keys())} are discontinued, the dictionary"
178
+ f" `.discontinued_market_times` has the dates on which these were discontinued."
179
+ f" The times as of those dates are incorrect, use .remove_time(market_time)"
180
+ f" to ignore a market_time."
181
+ )
182
+
183
+ self.discontinued_market_times = discontinued
184
+ self.regular_market_times = regular
185
+
186
+ self._regular_market_timedeltas = regular_tds
187
+ self._market_times = sorted(regular.keys(), key=lambda x: regular_tds[x][-1][1])
188
+ self._oc_market_times = list(filter(oc_map.__contains__, self._market_times))
189
+
190
+ def _set_time(self, market_time, times, opens):
191
+ if isinstance(times, (tuple, list)): # passed a tuple
192
+ if not isinstance(times[0], (tuple, list)): # doesn't have a tuple inside
193
+ if times[0] is None: # seems to be a tuple indicating starting time
194
+ times = (times,)
195
+ else: # must be a tuple with: (time, offset)
196
+ times = ((None, times[0], times[1]),)
197
+ else: # should be a datetime.time object
198
+ times = ((None, times),)
199
+
200
+ ln = len(times)
201
+ for i, t in enumerate(times):
202
+ try:
203
+ assert (
204
+ t[0] is None
205
+ or isinstance(t[0], str)
206
+ or isinstance(t[0], pd.Timestamp)
207
+ )
208
+ assert isinstance(t[1], time) or (
209
+ ln > 1 and i == ln - 1 and t[1] is None
210
+ )
211
+ assert isinstance(self._off(t), int)
212
+ except AssertionError:
213
+ raise AssertionError(
214
+ "The passed time information is not in the right format, "
215
+ "please consult the docs for how to set market times"
216
+ )
217
+
218
+ if opens is DEFAULT:
219
+ opens = self.__class__.open_close_map.get(market_time, None)
220
+
221
+ if opens in (True, False):
222
+ self.open_close_map._set(market_time, opens)
223
+
224
+ elif opens is None: # make sure it's ignored
225
+ try:
226
+ self.open_close_map._del(market_time)
227
+ except KeyError:
228
+ pass
229
+ else:
230
+ raise ValueError(
231
+ "when you pass `opens`, it needs to be True, False, or None"
232
+ )
233
+
234
+ self.regular_market_times._set(market_time, times)
235
+
236
+ if not self.is_custom(market_time):
237
+ self._customized_market_times.append(market_time)
238
+
239
+ self._prepare_regular_market_times()
240
+
241
+ def change_time(self, market_time, times, opens=DEFAULT):
242
+ """
243
+ Changes the specified market time in regular_market_times and makes the necessary adjustments.
244
+
245
+ :param market_time: the market_time to change
246
+ :param times: new time information
247
+ :param opens: whether the market_time is a time that closes or opens the market
248
+ this is only needed if the market_time should be respected by .open_at_time
249
+ True: opens
250
+ False: closes
251
+ None: consider it neither opening nor closing, don't add to open_close_map (ignore in .open_at_time)
252
+ DEFAULT: same as None, unless the market_time is in self.__class__.open_close_map. Then it will take
253
+ the default value as defined by the class.
254
+ :return: None
255
+ """
256
+ assert market_time in self.regular_market_times, (
257
+ f"{market_time} is not in regular_market_times:" f"\n{self._market_times}."
258
+ )
259
+ return self._set_time(market_time, times, opens)
260
+
261
+ def add_time(self, market_time, times, opens=DEFAULT):
262
+ """
263
+ Adds the specified market time to regular_market_times and makes the necessary adjustments.
264
+
265
+ :param market_time: the market_time to add
266
+ :param times: the time information
267
+ :param opens: see .change_time docstring
268
+ :return: None
269
+ """
270
+ assert market_time not in self.regular_market_times, (
271
+ f"{market_time} is already in regular_market_times:"
272
+ f"\n{self._market_times}"
273
+ )
274
+
275
+ return self._set_time(market_time, times, opens)
276
+
277
+ def remove_time(self, market_time):
278
+ """
279
+ Removes the specified market time from regular_market_times and makes the necessary adjustments.
280
+
281
+ :param market_time: the market_time to remove
282
+ :return: None
283
+ """
284
+
285
+ self.regular_market_times._del(market_time)
286
+ try:
287
+ self.open_close_map._del(market_time)
288
+ except KeyError:
289
+ pass
290
+
291
+ self._prepare_regular_market_times()
292
+ if self.is_custom(market_time):
293
+ self._customized_market_times.remove(market_time)
294
+
295
+ def is_custom(self, market_time):
296
+ return market_time in self._customized_market_times
297
+
298
+ @property
299
+ def has_custom(self):
300
+ return len(self._customized_market_times) > 0
301
+
302
+ def is_discontinued(self, market_time):
303
+ return market_time in self.discontinued_market_times
304
+
305
+ @property
306
+ def has_discontinued(self):
307
+ return len(self.discontinued_market_times) > 0
308
+
309
+ def get_time(self, market_time, all_times=False):
310
+ try:
311
+ times = self.regular_market_times[market_time]
312
+ except KeyError as e:
313
+ if "break_start" in market_time or "break_end" in market_time:
314
+ return None # in case of no breaks
315
+ elif market_time in ["market_open", "market_close"]:
316
+ raise NotImplementedError("You need to set market_times")
317
+ else:
318
+ raise e
319
+
320
+ if all_times:
321
+ return times
322
+ return times[-1][1].replace(tzinfo=self.tz)
323
+
324
+ def get_time_on(self, market_time, date):
325
+ times = self.get_time(market_time, all_times=True)
326
+ if times is None:
327
+ return None
328
+
329
+ date = pd.Timestamp(date)
330
+ for d, t in times[::-1]:
331
+ if d is None or pd.Timestamp(d) < date:
332
+ return t.replace(tzinfo=self.tz)
333
+
334
+ def open_time_on(self, date):
335
+ return self.get_time_on("market_open", date)
336
+
337
+ def close_time_on(self, date):
338
+ return self.get_time_on("market_close", date)
339
+
340
+ def break_start_on(self, date):
341
+ return self.get_time_on("break_start", date)
342
+
343
+ def break_end_on(self, date):
344
+ return self.get_time_on("break_end", date)
345
+
346
+ @property
347
+ def open_time(self):
348
+ """
349
+ Default open time for the market
350
+
351
+ :return: time
352
+ """
353
+ return self.get_time("market_open")
354
+
355
+ @property
356
+ def close_time(self):
357
+ """
358
+ Default close time for the market
359
+
360
+ :return: time
361
+ """
362
+ return self.get_time("market_close")
363
+
364
+ @property
365
+ def break_start(self):
366
+ """
367
+ Break time start. If None then there is no break
368
+
369
+ :return: time or None
370
+ """
371
+ return self.get_time("break_start")
372
+
373
+ @property
374
+ def break_end(self):
375
+ """
376
+ Break time end. If None then there is no break
377
+
378
+ :return: time or None
379
+ """
380
+ return self.get_time("break_end")
381
+
382
+ @property
383
+ def regular_holidays(self):
384
+ """
385
+
386
+ :return: pd.AbstractHolidayCalendar: a calendar containing the regular holidays for this calendar
387
+ """
388
+ return None
389
+
390
+ @property
391
+ def adhoc_holidays(self):
392
+ """
393
+
394
+ :return: list of ad-hoc holidays
395
+ """
396
+ return []
397
+
398
+ @property
399
+ def weekmask(self):
400
+ return "Mon Tue Wed Thu Fri"
401
+
402
+ @property
403
+ def special_opens(self):
404
+ """
405
+ A list of special open times and corresponding AbstractHolidayCalendar.
406
+
407
+ :return: List of (time, AbstractHolidayCalendar) tuples
408
+ """
409
+ return []
410
+
411
+ @property
412
+ def special_opens_adhoc(self):
413
+ """
414
+
415
+ :return: List of (time, DatetimeIndex) tuples that represent special opens that cannot be codified into rules.
416
+ """
417
+ return []
418
+
419
+ @property
420
+ def special_closes(self):
421
+ """
422
+ A list of special close times and corresponding HolidayCalendars.
423
+
424
+ :return: List of (time, AbstractHolidayCalendar) tuples
425
+ """
426
+ return []
427
+
428
+ @property
429
+ def special_closes_adhoc(self):
430
+ """
431
+
432
+ :return: List of (time, DatetimeIndex) tuples that represent special closes that cannot be codified into rules.
433
+ """
434
+ return []
435
+
436
+ def get_special_times(self, market_time):
437
+ return getattr(self, "special_" + market_time, [])
438
+
439
+ def get_special_times_adhoc(self, market_time):
440
+ return getattr(self, "special_" + market_time + "_adhoc", [])
441
+
442
+ def get_offset(self, market_time):
443
+ return self._off(self.get_time(market_time, all_times=True)[-1])
444
+
445
+ @property
446
+ def open_offset(self):
447
+ """
448
+ :return: open offset
449
+ """
450
+ return self.get_offset("market_open")
451
+
452
+ @property
453
+ def close_offset(self):
454
+ """
455
+ :return: close offset
456
+ """
457
+ return self.get_offset("market_close")
458
+
459
+ @property
460
+ def interruptions(self):
461
+ """
462
+ This needs to be a list with a tuple for each date that had an interruption.
463
+ The tuple should have this layout:
464
+
465
+ (date, start_time, end_time[, start_time2, end_time2, ...])
466
+
467
+ E.g.:
468
+ [
469
+ ("2002-02-03", (time(11), -1), time(11, 2)),
470
+ ("2010-01-11", time(11), (time(11, 1), 1)),
471
+ ("2010-01-13", time(9, 59), time(10), time(10, 29), time(10, 30)),
472
+ ("2011-01-10", time(11), time(11, 1))
473
+ ]
474
+
475
+ The date needs to be a string in this format: 'yyyy-mm-dd'.
476
+ Times need to be two datetime.time objects for each interruption, indicating start and end.
477
+ Optionally these can be wrapped in a tuple, where the
478
+ second element needs to be an integer indicating an offset.
479
+ On "2010-01-13" in the example, it is shown that there can be multiple interruptions in a day.
480
+ """
481
+ return []
482
+
483
+ def _convert(self, col: pd.Series):
484
+ """
485
+ col is a series indexed by dates at which interruptions occurred. The values are either the start or end times
486
+ of an interruption, represented by either a timedelta or a tuple with a timedelta and day offset of the form
487
+ (timedelta, offset). _convert produces a new series where the values are replaced by datetimes equal to the
488
+ index of the original series plus the offset if present, at the timedelta.
489
+
490
+ E.g.:
491
+ >>> self._convert(
492
+ pd.Series(
493
+ [datetime.time(11, 2), (datetime.time(11, 1), 1), datetime.time(10, 0), None],
494
+ index=pd.DatetimeIndex(['2002-02-03', '2010-01-11', '2010-01-13', '2011-01-10'])
495
+ )
496
+ )
497
+ 2002-02-03 2002-02-03 11:02:00+00:00
498
+ 2010-01-11 2010-01-12 11:01:00+00:00
499
+ 2010-01-13 2010-01-13 10:00:00+00:00
500
+ 2011-01-10 NaT
501
+ dtype: datetime64[ns, UTC]
502
+ """
503
+ col = (
504
+ col.dropna()
505
+ ) # Python 3.8, pandas 2.0.3 cannot create time deltas from NaT
506
+ try:
507
+ times = col.str[0]
508
+ except AttributeError: # no tuples, only offset 0
509
+ return (
510
+ (
511
+ pd.to_timedelta(col.astype("string").fillna(""), errors="coerce")
512
+ + col.index
513
+ )
514
+ .dt.tz_localize(self.tz)
515
+ .dt.tz_convert("UTC")
516
+ )
517
+
518
+ return (
519
+ (
520
+ pd.to_timedelta(
521
+ times.fillna(col).astype("string").fillna(""), errors="coerce"
522
+ )
523
+ + pd.to_timedelta(col.str[1].fillna(0), unit="D")
524
+ + col.index
525
+ )
526
+ .dt.tz_localize(self.tz)
527
+ .dt.tz_convert("UTC")
528
+ )
529
+
530
+ @staticmethod
531
+ def _col_name(n: int):
532
+ return (
533
+ f"interruption_start_{n // 2 + 1}"
534
+ if n % 2 == 1
535
+ else f"interruption_end_{n // 2}"
536
+ )
537
+
538
+ @property
539
+ def interruptions_df(self):
540
+ """
541
+ Will return a pd.DataFrame only containing interruptions.
542
+ """
543
+ if not self.interruptions:
544
+ return pd.DataFrame(index=pd.DatetimeIndex([]))
545
+ intr = pd.DataFrame(self.interruptions)
546
+ intr.index = pd.to_datetime(intr.pop(0))
547
+
548
+ intr.columns = map(self._col_name, intr.columns)
549
+ intr.index.name = None
550
+
551
+ return intr.apply(self._convert).sort_index()
552
+
553
+ def holidays(self):
554
+ """
555
+ Returns the complete CustomBusinessDay object of holidays that can be used in any Pandas function that take
556
+ that input.
557
+
558
+ :return: CustomBusinessDay object of holidays
559
+ """
560
+ try:
561
+ return self._holidays
562
+ except AttributeError:
563
+ self._holidays = CustomBusinessDay(
564
+ holidays=self.adhoc_holidays,
565
+ calendar=self.regular_holidays,
566
+ weekmask=self.weekmask,
567
+ )
568
+ return self._holidays
569
+
570
+ def valid_days(self, start_date, end_date, tz="UTC"):
571
+ """
572
+ Get a DatetimeIndex of valid open business days.
573
+
574
+ :param start_date: start date
575
+ :param end_date: end date
576
+ :param tz: time zone in either string or pytz.timezone
577
+ :return: DatetimeIndex of valid business days
578
+ """
579
+ return pd.date_range(
580
+ start_date, end_date, freq=self.holidays(), normalize=True, tz=tz
581
+ )
582
+
583
+ def _get_market_times(self, start, end):
584
+ mts = self._market_times
585
+ return mts[mts.index(start) : mts.index(end) + 1]
586
+
587
+ def days_at_time(self, days, market_time, day_offset=0):
588
+ """
589
+ Create an index of days at time ``t``, interpreted in timezone ``tz``. The returned index is localized to UTC.
590
+
591
+ In the example below, the times switch from 13:45 to 12:45 UTC because
592
+ March 13th is the daylight savings transition for US/Eastern. All the
593
+ times are still 8:45 when interpreted in US/Eastern.
594
+
595
+ >>> import pandas as pd; import datetime; import pprint
596
+ >>> dts = pd.date_range('2016-03-12', '2016-03-14')
597
+ >>> dts_at_845 = days_at_time(dts, datetime.time(8, 45), 'US/Eastern')
598
+ >>> pprint.pprint([str(dt) for dt in dts_at_845])
599
+ ['2016-03-12 13:45:00+00:00',
600
+ '2016-03-13 12:45:00+00:00',
601
+ '2016-03-14 12:45:00+00:00']
602
+
603
+ :param days: DatetimeIndex An index of dates (represented as midnight).
604
+ :param market_time: datetime.time The time to apply as an offset to each day in ``days``.
605
+ :param day_offset: int The number of days we want to offset @days by
606
+ :return: pd.Series of date with the time requested.
607
+ """
608
+ # Offset days without tz to avoid timezone issues.
609
+ days = pd.DatetimeIndex(days).tz_localize(None).to_series()
610
+
611
+ if isinstance(
612
+ market_time, str
613
+ ): # if string, assume its a reference to saved market times
614
+ timedeltas = self._regular_market_timedeltas[market_time]
615
+ datetimes = days + timedeltas[0][1]
616
+ for cut_off, timedelta in timedeltas[1:]:
617
+ datetimes = datetimes.where(
618
+ days < pd.Timestamp(cut_off), days + timedelta
619
+ )
620
+
621
+ else: # otherwise, assume it is a datetime.time object
622
+ datetimes = days + self._tdelta(market_time, day_offset)
623
+
624
+ return datetimes.dt.tz_localize(self.tz).dt.tz_convert("UTC")
625
+
626
+ def _tryholidays(self, cal, s, e):
627
+ try:
628
+ return cal.holidays(s, e)
629
+ except ValueError:
630
+ return pd.DatetimeIndex([])
631
+
632
+ def _special_dates(self, calendars, ad_hoc_dates, start, end):
633
+ """
634
+ Union an iterable of pairs of the forms (time, calendar),
635
+ (time, [dates]), and (time, int). If the second item in the pair
636
+ is an int it will be interpreted as a specific day of the week.
637
+
638
+ (This is shared logic for computing special opens and special closes.)
639
+ """
640
+ indexes = []
641
+ for time_, calendar in calendars:
642
+ if isinstance(calendar, int):
643
+ day_of_week = CustomBusinessDay(weekmask=WEEKMASK_ABBR[calendar])
644
+ indexes.append(
645
+ self.days_at_time(
646
+ pd.date_range(start, end, freq=day_of_week), time_
647
+ )
648
+ )
649
+ else:
650
+ indexes.append(
651
+ self.days_at_time(self._tryholidays(calendar, start, end), time_)
652
+ )
653
+
654
+ indexes += [self.days_at_time(dates, time_) for time_, dates in ad_hoc_dates]
655
+
656
+ if indexes:
657
+ dates = pd.concat(indexes).sort_index().drop_duplicates()
658
+ return dates.loc[start : end.replace(hour=23, minute=59, second=59)]
659
+
660
+ return pd.Series([], dtype="datetime64[ns, UTC]", index=pd.DatetimeIndex([]))
661
+
662
+ def special_dates(self, market_time, start_date, end_date, filter_holidays=True):
663
+ """
664
+ Calculate a datetimeindex that only contains the specail times of the requested market time.
665
+
666
+ :param market_time: market_time reference
667
+ :param start_date: first possible date of the index
668
+ :param end_date: last possible date of the index
669
+ :param filter_holidays: will filter days by self.valid_days, which can be useful when debugging
670
+
671
+ :return: schedule DatetimeIndex
672
+ """
673
+ start_date, end_date = self.clean_dates(start_date, end_date)
674
+ calendars = self.get_special_times(market_time)
675
+ ad_hoc = self.get_special_times_adhoc(market_time)
676
+ special = self._special_dates(calendars, ad_hoc, start_date, end_date)
677
+
678
+ if filter_holidays:
679
+ valid = self.valid_days(start_date, end_date, tz=None)
680
+ special = special[
681
+ special.index.isin(valid)
682
+ ] # some sources of special times don't exclude holidays
683
+ return special
684
+
685
+ def schedule(
686
+ self,
687
+ start_date,
688
+ end_date,
689
+ tz="UTC",
690
+ start="market_open",
691
+ end="market_close",
692
+ force_special_times=True,
693
+ market_times=None,
694
+ interruptions=False,
695
+ ):
696
+ """
697
+ Generates the schedule DataFrame. The resulting DataFrame will have all the valid business days as the index
698
+ and columns for the requested market times. The columns can be determined either by setting a range (inclusive
699
+ on both sides), using `start` and `end`, or by passing a list to `market_times'. A range of market_times is
700
+ derived from a list of market_times that are available to the instance, which are sorted based on the current
701
+ regular time. See examples/usage.ipynb for demonstrations.
702
+
703
+ All time zones are set to UTC by default. Setting the tz parameter will convert the columns to the desired
704
+ timezone, such as 'America/New_York'.
705
+
706
+ :param start_date: first date of the schedule
707
+ :param end_date: last date of the schedule
708
+ :param tz: timezone that the columns of the returned schedule are in, default: "UTC"
709
+ :param start: the first market_time to include as a column, default: "market_open"
710
+ :param end: the last market_time to include as a column, default: "market_close"
711
+ :param force_special_times: how to handle special times.
712
+ True: overwrite regular times of the column itself, conform other columns to special times of
713
+ market_open/market_close if those are requested.
714
+ False: only overwrite regular times of the column itself, leave others alone
715
+ None: completely ignore special times
716
+ :param market_times: alternative to start/end, list of market_times that are in self.regular_market_times
717
+ :param interruptions: bool, whether to add interruptions to the schedule, default: False
718
+ These will be added as columns to the right of the DataFrame. Any interruption on a day between
719
+ start_date and end_date will be included, regardless of the market_times requested.
720
+ Also, `force_special_times` does not take these into consideration.
721
+ :return: schedule DataFrame
722
+ """
723
+ start_date, end_date = self.clean_dates(start_date, end_date)
724
+ if not (start_date <= end_date):
725
+ raise ValueError("start_date must be before or equal to end_date.")
726
+
727
+ # Setup all valid trading days and the requested market_times
728
+ _all_days = self.valid_days(start_date, end_date)
729
+ if market_times is None:
730
+ market_times = self._get_market_times(start, end)
731
+ elif market_times == "all":
732
+ market_times = self._market_times
733
+
734
+ # If no valid days return an empty DataFrame
735
+ if not _all_days.size:
736
+ return pd.DataFrame(
737
+ columns=market_times, index=pd.DatetimeIndex([], freq="C")
738
+ )
739
+
740
+ _adj_others = force_special_times is True
741
+ _adj_col = force_special_times is not None
742
+ _open_adj = _close_adj = []
743
+
744
+ schedule = pd.DataFrame()
745
+ for market_time in market_times:
746
+ temp = self.days_at_time(_all_days, market_time).copy() # standard times
747
+ if _adj_col:
748
+ # create an array of special times
749
+ special = self.special_dates(
750
+ market_time, start_date, end_date, filter_holidays=False
751
+ )
752
+ # overwrite standard times
753
+ specialix = special.index[
754
+ special.index.isin(temp.index)
755
+ ] # some sources of special times don't exclude holidays
756
+ temp.loc[specialix] = special
757
+
758
+ if _adj_others:
759
+ if market_time == "market_open":
760
+ _open_adj = specialix
761
+ elif market_time == "market_close":
762
+ _close_adj = specialix
763
+
764
+ schedule[market_time] = temp
765
+
766
+ if _adj_others:
767
+ adjusted = schedule.loc[_open_adj].apply(
768
+ lambda x: x.where(x.ge(x["market_open"]), x["market_open"]), axis=1
769
+ )
770
+ schedule.loc[_open_adj] = adjusted
771
+
772
+ adjusted = schedule.loc[_close_adj].apply(
773
+ lambda x: x.where(x.le(x["market_close"]), x["market_close"]), axis=1
774
+ )
775
+ schedule.loc[_close_adj] = adjusted
776
+
777
+ if interruptions:
778
+ interrs = self.interruptions_df
779
+ schedule[interrs.columns] = interrs
780
+ schedule = schedule.dropna(how="all", axis=1)
781
+
782
+ if tz != "UTC":
783
+ schedule = schedule.apply(lambda s: s.dt.tz_convert(tz))
784
+
785
+ return schedule
786
+
787
+ def open_at_time(self, schedule, timestamp, include_close=False, only_rth=False):
788
+ """
789
+ Determine if a given timestamp is during an open time for the market. If the timestamp is
790
+ before the first open time or after the last close time of `schedule`, a ValueError will be raised.
791
+
792
+ :param schedule: schedule DataFrame
793
+ :param timestamp: the timestamp to check for. Assumed to be UTC, if it doesn't include tz information.
794
+ :param include_close: if False then the timestamp that equals the closing timestamp will return False and not be
795
+ considered a valid open date and time. If True then it will be considered valid and return True. Use True
796
+ if using bars and would like to include the last bar as a valid open date and time. The close refers to the
797
+ latest market_time available, which could be after market_close (e.g. 'post').
798
+ :param only_rth: whether to ignore columns that are before market_open or after market_close. If true,
799
+ include_close will be referring to market_close.
800
+ :return: True if the timestamp is a valid open date and time, False if not
801
+ """
802
+ timestamp = pd.Timestamp(timestamp)
803
+ try:
804
+ timestamp = timestamp.tz_localize("UTC")
805
+ except TypeError:
806
+ pass
807
+
808
+ cols = schedule.columns
809
+ interrs = cols.str.startswith("interruption_")
810
+ if not (cols.isin(self._oc_market_times) | interrs).all():
811
+ raise ValueError(
812
+ "You seem to be using a schedule that isn't based on the market_times, "
813
+ "or includes market_times that are not represented in the open_close_map."
814
+ )
815
+
816
+ if only_rth:
817
+ lowest, highest = "market_open", "market_close"
818
+ else:
819
+ cols = cols[~interrs]
820
+ ix = cols.map(self._oc_market_times.index)
821
+ lowest, highest = cols[ix == ix.min()][0], cols[ix == ix.max()][0]
822
+
823
+ if timestamp < schedule[lowest].iat[0] or timestamp > schedule[highest].iat[-1]:
824
+ raise ValueError("The provided timestamp is not covered by the schedule")
825
+
826
+ day = schedule[schedule[lowest].le(timestamp)].iloc[-1].dropna().sort_values()
827
+ day = day.loc[lowest:highest]
828
+ day = day.index.to_series(index=day)
829
+
830
+ if interrs.any():
831
+ starts = day.str.startswith("interruption_start_")
832
+ ends = day.str.startswith("interruption_end_")
833
+ day.loc[starts] = False
834
+ day.loc[ends] = True
835
+
836
+ # When post follows market_close, market_close should not be considered a close
837
+ day.loc[day.eq("market_close") & day.shift(-1).eq("post")] = "market_open"
838
+ day = day.map(
839
+ lambda x: (
840
+ self.open_close_map.get(x) if x in self.open_close_map.keys() else x
841
+ )
842
+ )
843
+
844
+ if include_close:
845
+ below = day.index < timestamp
846
+ else:
847
+ below = day.index <= timestamp
848
+ return bool(day[below].iat[-1]) # returns numpy.bool_ if not bool(...)
849
+
850
+ # need this to make is_open_now testable
851
+ @staticmethod
852
+ def _get_current_time():
853
+ return pd.Timestamp.now(tz="UTC")
854
+
855
+ def is_open_now(self, schedule, include_close=False, only_rth=False):
856
+ """
857
+ To determine if the current local system time (converted to UTC) is an open time for the market
858
+
859
+ :param schedule: schedule DataFrame
860
+ :param include_close: if False then the function will return False if the current local system time is equal to
861
+ the closing timestamp. If True then it will return True if the current local system time is equal to the
862
+ closing timestamp. Use True if using bars and would like to include the last bar as a valid open date
863
+ and time.
864
+ :param only_rth: whether to consider columns that are before market_open or after market_close
865
+
866
+ :return: True if the current local system time is a valid open date and time, False if not
867
+ """
868
+ current_time = MarketCalendar._get_current_time()
869
+ return self.open_at_time(
870
+ schedule, current_time, include_close=include_close, only_rth=only_rth
871
+ )
872
+
873
+ def clean_dates(self, start_date, end_date):
874
+ """
875
+ Strips the inputs of time and time zone information
876
+
877
+ :param start_date: start date
878
+ :param end_date: end date
879
+ :return: (start_date, end_date) with just date, no time and no time zone
880
+ """
881
+ start_date = pd.Timestamp(start_date).tz_localize(None).normalize()
882
+ end_date = pd.Timestamp(end_date).tz_localize(None).normalize()
883
+ return start_date, end_date
884
+
885
+ def is_different(self, col, diff=None):
886
+ if diff is None:
887
+ diff = pd.Series.ne
888
+ normal = self.days_at_time(col.index, col.name)
889
+ return diff(col.dt.tz_convert("UTC"), normal)
890
+
891
+ def early_closes(self, schedule):
892
+ """
893
+ Get a DataFrame of the dates that are an early close.
894
+
895
+ :param schedule: schedule DataFrame
896
+ :return: schedule DataFrame with rows that are early closes
897
+ """
898
+ return schedule[self.is_different(schedule["market_close"], pd.Series.lt)]
899
+
900
+ def late_opens(self, schedule):
901
+ """
902
+ Get a DataFrame of the dates that are an late opens.
903
+
904
+ :param schedule: schedule DataFrame
905
+ :return: schedule DataFrame with rows that are late opens
906
+ """
907
+ return schedule[self.is_different(schedule["market_open"], pd.Series.gt)]
908
+
909
+ def __getitem__(self, item):
910
+ if isinstance(item, (tuple, list)):
911
+ if item[1] == "all":
912
+ return self.get_time(item[0], all_times=True)
913
+ else:
914
+ return self.get_time_on(item[0], item[1])
915
+ else:
916
+ return self.get_time(item)
917
+
918
+ def __setitem__(self, key, value):
919
+ return self.add_time(key, value)
920
+
921
+ def __delitem__(self, key):
922
+ return self.remove_time(key)