pandas-market-calendars 4.3.1__py3-none-any.whl → 4.3.3__py3-none-any.whl

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