Skip to content

Commit 11ce405

Browse files
Tobias BrandtTobias Brandt
Tobias Brandt
authored and
Tobias Brandt
committed
ENH: Experimental CustomBusinessDay DateOffset class. Fixes GH2301.
ENH: Added check for required version of Numpy. ENH: Added holidays and weekmask arguments to cdate_range as suggested by @jreback. Also improved docstrings.
1 parent fd34eab commit 11ce405

File tree

4 files changed

+190
-18
lines changed

4 files changed

+190
-18
lines changed

pandas/core/datetools.py

+6
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@
88
day = DateOffset()
99
bday = BDay()
1010
businessDay = bday
11+
try:
12+
cday = CDay()
13+
customBusinessDay = CustomBusinessDay()
14+
except ImportError:
15+
# Don't create CustomBusinessDay instances when not available
16+
pass
1117
monthEnd = MonthEnd()
1218
yearEnd = YearEnd()
1319
yearBegin = YearBegin()

pandas/tseries/frequencies.py

+23-15
Original file line numberDiff line numberDiff line change
@@ -114,14 +114,17 @@ def _get_freq_str(base, mult=1):
114114
# Offset names ("time rules") and related functions
115115

116116

117-
from pandas.tseries.offsets import (Day, BDay, Hour, Minute, Second, Milli,
118-
Week, Micro, MonthEnd, MonthBegin,
119-
BMonthBegin, BMonthEnd, YearBegin, YearEnd,
120-
BYearBegin, BYearEnd, QuarterBegin,
121-
QuarterEnd, BQuarterBegin, BQuarterEnd)
117+
from pandas.tseries.offsets import (Micro, Milli, Second, Minute, Hour,
118+
Day, BDay, CDay, Week, MonthBegin,
119+
MonthEnd, BMonthBegin, BMonthEnd,
120+
QuarterBegin, QuarterEnd, BQuarterBegin,
121+
BQuarterEnd, YearBegin, YearEnd,
122+
BYearBegin, BYearEnd,
123+
)
122124

123125
_offset_map = {
124126
'D': Day(),
127+
'C': CDay(),
125128
'B': BDay(),
126129
'H': Hour(),
127130
'T': Minute(),
@@ -278,6 +281,7 @@ def _get_freq_str(base, mult=1):
278281
'BAS': 'A',
279282
'MS': 'M',
280283
'D': 'D',
284+
'C': 'C',
281285
'B': 'B',
282286
'T': 'T',
283287
'S': 'S',
@@ -1004,15 +1008,17 @@ def is_subperiod(source, target):
10041008
if _is_quarterly(source):
10051009
return _quarter_months_conform(_get_rule_month(source),
10061010
_get_rule_month(target))
1007-
return source in ['D', 'B', 'M', 'H', 'T', 'S']
1011+
return source in ['D', 'C', 'B', 'M', 'H', 'T', 'S']
10081012
elif _is_quarterly(target):
1009-
return source in ['D', 'B', 'M', 'H', 'T', 'S']
1013+
return source in ['D', 'C', 'B', 'M', 'H', 'T', 'S']
10101014
elif target == 'M':
1011-
return source in ['D', 'B', 'H', 'T', 'S']
1015+
return source in ['D', 'C', 'B', 'H', 'T', 'S']
10121016
elif _is_weekly(target):
1013-
return source in [target, 'D', 'B', 'H', 'T', 'S']
1017+
return source in [target, 'D', 'C', 'B', 'H', 'T', 'S']
10141018
elif target == 'B':
10151019
return source in ['B', 'H', 'T', 'S']
1020+
elif target == 'C':
1021+
return source in ['C', 'H', 'T', 'S']
10161022
elif target == 'D':
10171023
return source in ['D', 'H', 'T', 'S']
10181024
elif target == 'H':
@@ -1055,17 +1061,19 @@ def is_superperiod(source, target):
10551061
smonth = _get_rule_month(source)
10561062
tmonth = _get_rule_month(target)
10571063
return _quarter_months_conform(smonth, tmonth)
1058-
return target in ['D', 'B', 'M', 'H', 'T', 'S']
1064+
return target in ['D', 'C', 'B', 'M', 'H', 'T', 'S']
10591065
elif _is_quarterly(source):
1060-
return target in ['D', 'B', 'M', 'H', 'T', 'S']
1066+
return target in ['D', 'C', 'B', 'M', 'H', 'T', 'S']
10611067
elif source == 'M':
1062-
return target in ['D', 'B', 'H', 'T', 'S']
1068+
return target in ['D', 'C', 'B', 'H', 'T', 'S']
10631069
elif _is_weekly(source):
1064-
return target in [source, 'D', 'B', 'H', 'T', 'S']
1070+
return target in [source, 'D', 'C', 'B', 'H', 'T', 'S']
10651071
elif source == 'B':
1066-
return target in ['D', 'B', 'H', 'T', 'S']
1072+
return target in ['D', 'C', 'B', 'H', 'T', 'S']
1073+
elif source == 'C':
1074+
return target in ['D', 'C', 'B', 'H', 'T', 'S']
10671075
elif source == 'D':
1068-
return target in ['D', 'B', 'H', 'T', 'S']
1076+
return target in ['D', 'C', 'B', 'H', 'T', 'S']
10691077
elif source == 'H':
10701078
return target in ['H', 'T', 'S']
10711079
elif source == 'T':

pandas/tseries/index.py

+47-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from pandas.tseries.frequencies import (
1212
infer_freq, to_offset, get_period_alias,
1313
Resolution, get_reso_string)
14-
from pandas.tseries.offsets import DateOffset, generate_range, Tick
14+
from pandas.tseries.offsets import DateOffset, generate_range, Tick, CDay
1515
from pandas.tseries.tools import parse_time_string, normalize_date
1616
from pandas.util.decorators import cache_readonly
1717
import pandas.core.common as com
@@ -1740,6 +1740,52 @@ def bdate_range(start=None, end=None, periods=None, freq='B', tz=None,
17401740
freq=freq, tz=tz, normalize=normalize, name=name)
17411741

17421742

1743+
def cdate_range(start=None, end=None, periods=None, freq='C', tz=None,
1744+
normalize=True, name=None, **kwargs):
1745+
"""
1746+
Return a fixed frequency datetime index, with CustomBusinessDay as the
1747+
default frequency
1748+
1749+
Parameters
1750+
----------
1751+
start : string or datetime-like, default None
1752+
Left bound for generating dates
1753+
end : string or datetime-like, default None
1754+
Right bound for generating dates
1755+
periods : integer or None, default None
1756+
If None, must specify start and end
1757+
freq : string or DateOffset, default 'C' (CustomBusinessDay)
1758+
Frequency strings can have multiples, e.g. '5H'
1759+
tz : string or None
1760+
Time zone name for returning localized DatetimeIndex, for example
1761+
Asia/Beijing
1762+
normalize : bool, default False
1763+
Normalize start/end dates to midnight before generating date range
1764+
name : str, default None
1765+
Name for the resulting index
1766+
weekmask : str, Default 'Mon Tue Wed Thu Fri'
1767+
weekmask of valid business days, passed to ``numpy.busdaycalendar``
1768+
holidays : list
1769+
list/array of dates to exclude from the set of valid business days,
1770+
passed to ``numpy.busdaycalendar``
1771+
1772+
Notes
1773+
-----
1774+
2 of start, end, or periods must be specified
1775+
1776+
Returns
1777+
-------
1778+
rng : DatetimeIndex
1779+
"""
1780+
1781+
if freq=='C':
1782+
holidays = kwargs.pop('holidays', [])
1783+
weekmask = kwargs.pop('weekmask', 'Mon Tue Wed Thu Fri')
1784+
freq = CDay(holidays=holidays, weekmask=weekmask)
1785+
return DatetimeIndex(start=start, end=end, periods=periods, freq=freq,
1786+
tz=tz, normalize=normalize, name=name, **kwargs)
1787+
1788+
17431789
def _to_m8(key, tz=None):
17441790
'''
17451791
Timestamp-like => dt64

pandas/tseries/offsets.py

+114-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from datetime import date, datetime, timedelta
2+
import numpy as np
23

34
from pandas.tseries.tools import to_datetime
45

@@ -7,7 +8,7 @@
78
import pandas.lib as lib
89
import pandas.tslib as tslib
910

10-
__all__ = ['Day', 'BusinessDay', 'BDay',
11+
__all__ = ['Day', 'BusinessDay', 'BDay', 'CustomBusinessDay', 'CDay',
1112
'MonthBegin', 'BMonthBegin', 'MonthEnd', 'BMonthEnd',
1213
'YearBegin', 'BYearBegin', 'YearEnd', 'BYearEnd',
1314
'QuarterBegin', 'BQuarterBegin', 'QuarterEnd', 'BQuarterEnd',
@@ -100,7 +101,8 @@ def _should_cache(self):
100101

101102
def _params(self):
102103
attrs = [(k, v) for k, v in vars(self).iteritems()
103-
if k not in ['kwds', '_offset', 'name', 'normalize']]
104+
if k not in ['kwds', '_offset', 'name', 'normalize',
105+
'busdaycalendar']]
104106
attrs.extend(self.kwds.items())
105107
attrs = sorted(set(attrs))
106108

@@ -359,6 +361,115 @@ def onOffset(cls, dt):
359361
return dt.weekday() < 5
360362

361363

364+
class CustomBusinessDay(BusinessDay):
365+
"""
366+
DateOffset subclass representing possibly n business days excluding
367+
holidays
368+
369+
Parameters
370+
----------
371+
n : int, default 1
372+
offset : timedelta, default timedelta(0)
373+
normalize : bool, default False
374+
Normalize start/end dates to midnight before generating date range
375+
weekmask : str, Default 'Mon Tue Wed Thu Fri'
376+
weekmask of valid business days, passed to ``numpy.busdaycalendar``
377+
holidays : list
378+
list/array of dates to exclude from the set of valid business days,
379+
passed to ``numpy.busdaycalendar``
380+
"""
381+
382+
_cacheable = False
383+
384+
def __init__(self, n=1, **kwds):
385+
# Check we have the required numpy version
386+
from distutils.version import LooseVersion
387+
if LooseVersion(np.__version__) < '1.7.0':
388+
raise ImportError("CustomBusinessDay requires numpy >= 1.7.0. "
389+
"Current version: "+np.__version__)
390+
391+
self.n = int(n)
392+
self.kwds = kwds
393+
self.offset = kwds.get('offset', timedelta(0))
394+
self.normalize = kwds.get('normalize', False)
395+
self.weekmask = kwds.get('weekmask', 'Mon Tue Wed Thu Fri')
396+
397+
holidays = kwds.get('holidays', [])
398+
holidays = [self._to_dt64(dt, dtype='datetime64[D]') for dt in
399+
holidays]
400+
self.holidays = tuple(sorted(holidays))
401+
self.kwds['holidays'] = self.holidays
402+
self._set_busdaycalendar()
403+
404+
def _set_busdaycalendar(self):
405+
holidays = np.array(self.holidays, dtype='datetime64[D]')
406+
self.busdaycalendar = np.busdaycalendar(holidays=holidays,
407+
weekmask=self.weekmask)
408+
409+
def __getstate__(self):
410+
""""Return a pickleable state"""
411+
state = self.__dict__.copy()
412+
del state['busdaycalendar']
413+
return state
414+
415+
def __setstate__(self, state):
416+
"""Reconstruct an instance from a pickled state"""
417+
self.__dict__ = state
418+
self._set_busdaycalendar()
419+
420+
@property
421+
def rule_code(self):
422+
return 'C'
423+
424+
@staticmethod
425+
def _to_dt64(dt, dtype='datetime64'):
426+
if isinstance(dt, (datetime, basestring)):
427+
dt = np.datetime64(dt, dtype=dtype)
428+
if isinstance(dt, np.datetime64):
429+
dt = dt.astype(dtype)
430+
else:
431+
raise TypeError('dt must be datestring, datetime or datetime64')
432+
return dt
433+
434+
def apply(self, other):
435+
if isinstance(other, datetime):
436+
dtype = type(other)
437+
elif isinstance(other, np.datetime64):
438+
dtype = other.dtype
439+
elif isinstance(other, (timedelta, Tick)):
440+
return BDay(self.n, offset=self.offset + other,
441+
normalize=self.normalize)
442+
else:
443+
raise TypeError('Only know how to combine trading day with '
444+
'datetime, datetime64 or timedelta!')
445+
dt64 = self._to_dt64(other)
446+
447+
day64 = dt64.astype('datetime64[D]')
448+
time = dt64 - day64
449+
450+
if self.n<=0:
451+
roll = 'forward'
452+
else:
453+
roll = 'backward'
454+
455+
result = np.busday_offset(day64, self.n, roll=roll,
456+
busdaycal=self.busdaycalendar)
457+
458+
if not self.normalize:
459+
result = result + time
460+
461+
result = result.astype(dtype)
462+
463+
if self.offset:
464+
result = result + self.offset
465+
466+
return result
467+
468+
def onOffset(self, dt):
469+
day64 = self._to_dt64(dt).astype('datetime64[D]')
470+
return np.is_busday(day64, busdaycal=self.busdaycalendar)
471+
472+
362473
class MonthEnd(DateOffset, CacheableOffset):
363474
"""DateOffset of one month end"""
364475

@@ -1169,6 +1280,7 @@ class Nano(Tick):
11691280
BDay = BusinessDay
11701281
BMonthEnd = BusinessMonthEnd
11711282
BMonthBegin = BusinessMonthBegin
1283+
CDay = CustomBusinessDay
11721284

11731285

11741286
def _get_firstbday(wkday):

0 commit comments

Comments
 (0)