# -*- coding: utf-8 -*-
# businessdate
# ------------
# Python library for generating business dates for fast date operations
# and rich functionality.
#
# Author: sonntagsgesicht, based on a fork of Deutsche Postbank [pbrisk]
# Version: 0.5, copyright Wednesday, 18 September 2019
# Website: https://github.com/sonntagsgesicht/businessdate
# License: Apache License 2.0 (see LICENSE file)
from datetime import date, datetime, timedelta
from . import conventions
from . import daycount
from .ymd import is_leap_year, days_in_year, days_in_month, \
end_of_quarter_month
from .basedate import BaseDateFloat, BaseDateDatetimeDate
from .businessholidays import TargetHolidays
from .businessperiod import BusinessPeriod
[docs]class BusinessDate(BaseDateDatetimeDate):
ADJUST = 'No'
BASE_DATE = None
DATE_FORMAT = '%Y%m%d'
DAY_COUNT = 'act_36525'
DEFAULT_CONVENTION = conventions.adjust_no
DEFAULT_HOLIDAYS = TargetHolidays()
DEFAULT_DAY_COUNT = daycount.get_act_36525
_adj_func = {
'no': conventions.adjust_no,
'previous': conventions.adjust_previous,
'prev': conventions.adjust_previous,
'prv': conventions.adjust_previous,
'mod_previous': conventions.adjust_mod_previous,
'modprevious': conventions.adjust_mod_previous,
'modprev': conventions.adjust_mod_previous,
'modprv': conventions.adjust_mod_previous,
'follow': conventions.adjust_follow,
'flw': conventions.adjust_follow,
'modified': conventions.adjust_mod_follow,
'mod_follow': conventions.adjust_mod_follow,
'modfollow': conventions.adjust_mod_follow,
'modflw': conventions.adjust_mod_follow,
'start_of_month': conventions.adjust_start_of_month,
'startofmonth': conventions.adjust_start_of_month,
'som': conventions.adjust_start_of_month,
'end_of_month': conventions.adjust_end_of_month,
'endofmonth': conventions.adjust_end_of_month,
'eom': conventions.adjust_end_of_month,
'imm': conventions.adjust_imm,
'cds_imm': conventions.adjust_cds_imm,
'cdsimm': conventions.adjust_cds_imm,
'cds': conventions.adjust_cds_imm,
}
_dc_func = {
'30_360': daycount.get_30_360,
'30360': daycount.get_30_360,
'thirty360': daycount.get_30_360,
'30e_360': daycount.get_30e_360,
'30e360': daycount.get_30e_360,
'thirtye360': daycount.get_30e_360,
'30e_360_i': daycount.get_30e_360i,
'30e360i': daycount.get_30e_360i,
'thirtye360i': daycount.get_30e_360i,
'act_360': daycount.get_act_360,
'act360': daycount.get_act_360,
'act_365': daycount.get_act_365,
'act365': daycount.get_act_365,
'act_36525': daycount.get_act_36525,
'act_365.25': daycount.get_act_36525,
'act36525': daycount.get_act_36525,
'act_act': daycount.get_act_act,
'actact': daycount.get_act_act,
}
def __new__(cls, year=None, month=0, day=0,
convention=None, holidays=None, day_count=None):
""" date class to perform calculations coming from financial businesses
:param year: number of year or some other input value t
o create :class:`BusinessDate` instance.
When applying other input, this can be either
:class:`int`, :class:`float`, :class:`datetime.date` or :class:`string`
which will be parsed and transformed into equivalent
:class:`tuple` of :class:`int` items `(year,month,day)`
(See :doc:`tutorial <tutorial>` for details).
:param int month: number of month in year 1 ... 12
(default: 0, required to be 0 when other input of year is used)
:param int days: number of day in month 1 ... 31
(default: 0, required to be 0 when other input of year is used)
For all input arguments exits read only properties.
"""
'''
:param str convention: keyword to select a business day adjustment convention
which is used as default for :meth:`BusinessDate.adjust`.
For more details on the conventions see module :mod:`businessdate.conventions`.
:param list holidays: container containing items of type :class:`datetime.date`
which is used as default for :meth:`BusinessDate.adjust`.
'''
if year is None:
base_date = date.today() if cls.BASE_DATE is None else cls.BASE_DATE
return cls(base_date, convention=convention, holidays=holidays,
day_count=day_count)
if isinstance(year, (list, tuple)):
kwargs = ({'year': y,
'convention': convention,
'holidays': holidays,
'day_count': day_count} for y in year)
return year.__class__((BusinessDate(**kw) for kw in kwargs))
if isinstance(year, str):
year, month, day = \
cls._parse_date_string(year, default=(year, month, day))
if isinstance(year, (date, BaseDateFloat,
BaseDateDatetimeDate, BusinessDate)):
if convention is None:
convention = getattr(year, 'convention', None)
if holidays is None:
holidays = getattr(year, 'holidays', None)
if day_count is None:
day_count = getattr(year, 'day_count', None)
year, month, day = year.year, year.month, year.day
if isinstance(year, (int, float)) and 10000101 <= year:
# start 20191231 representation from 1000 a.d.
ymd = str(year)
year, month, day = int(ymd[:4]), int(ymd[4:6]), int(ymd[6:])
if isinstance(year, int) and month and day:
if 12 < month:
year += int(month // 12)
month = int(month % 12)
if issubclass(cls, BaseDateFloat):
new = cls.from_ymd(year, month, day)
else:
new = super(BusinessDate, cls).__new__(cls, year, month, day)
elif isinstance(year, (int, float)) and 1 < year < 10000101:
# excel representation before 1000 a.d.
if issubclass(cls, BaseDateDatetimeDate):
new = cls.from_float(year)
else:
new = super(BusinessDate, cls).__new__(cls, year)
else:
if isinstance(year, timedelta):
year = '%sD' % year.days
# try to split complex or period input,
# e.g. '0B1D2BMOD20191231' or '3Y2M1D' or '-2B'
new = cls._from_complex_input(str(year))
# set additional properties
new.convention = convention
new.holidays = holidays
new.day_count = day_count
return new
@classmethod
def _parse_date_string(cls, date_str, default=None):
date_str = str(date_str)
if date_str.count('-'):
str_format = '%Y-%m-%d'
elif date_str.count('.'):
str_format = '%d.%m.%Y'
elif date_str.count('/'):
str_format = '%m/%d/%Y'
elif len(date_str) == 8 and date_str.isdigit():
str_format = '%Y%m%d'
else:
str_format = ''
if str_format:
date_date = datetime.strptime(date_str, str_format)
return date_date.year, date_date.month, date_date.day
if default is None:
raise ValueError("The input %s has not the right format for %s" % (
date_str, cls.__name__))
return default
@classmethod
def _from_complex_input(cls, date_str):
date_str = str(date_str).upper()
convention, origin, holidays = None, None, None
# first, extract origin
if len(date_str) > 8:
try:
datetime.strptime(date_str[-8:], '%Y%m%d')
origin = date_str[-8:]
date_str = date_str[:-8]
except ValueError:
# no date found a the end of the string
pass
# second, extract convention
for a in sorted(cls._adj_func.keys(), key=len, reverse=True):
if date_str.find(a.upper()) >= 0:
convention = a
date_str = date_str[:-len(a)]
break
if not date_str:
date_str = '0B'
# third, parse spot, period and final
pfields = date_str.strip('0123456789+-B')
spot, period, final = date_str, '', ''
if pfields:
spot, period, final = '', '', ''
x = pfields[-1]
period, final = date_str.split(x, 1)
period += x
if period.find('B') >= 0:
spot, period = period.split('B', 1)
spot += 'B'
# third, build BusinessDate and adjust by conventions to periods
res = cls(origin)
if spot:
if convention:
res = res.adjust(convention, holidays)
res = res.add_period(spot, holidays)
if period:
res = res.add_period(period, holidays)
if final:
if convention:
res = res.adjust(convention, holidays)
res = res.add_period(final, holidays)
return res
[docs] @classmethod
def is_businessdate(cls, d):
""" checks whether the provided input can be a date """
if not isinstance(d, (date, BaseDateFloat, BaseDateDatetimeDate)):
try: # to be removed
cls(d)
except ValueError:
return False
return True
def __copy__(self):
return self.__deepcopy__()
def __deepcopy__(self, memodict={}):
return BusinessDate(str(self),
convention=self.convention,
holidays=self.holidays,
day_count=self.day_count)
# --- operator methods ---------------------------------------------------
def __add__(self, other):
"""
addition of BusinessDate.
:param other: can be BusinessPeriod or
any thing that might be casted to it. Or a list of them.
"""
if isinstance(other, (list, tuple)):
return [self + pd for pd in other]
if BusinessPeriod.is_businessperiod(other):
return self.add_period(other)
raise TypeError(
'addition of BusinessDates cannot handle objects of type %s.' % other.__class__.__name__)
def __sub__(self, other):
"""
subtraction of BusinessDate.
:param other: can be other BusinessDate, BusinessPeriod or
any thing that might be casted to those. Or a list of them.
"""
if isinstance(other, (list, tuple)):
return [self - pd for pd in other]
if BusinessPeriod.is_businessperiod(other):
return self + (-1 * BusinessPeriod(other))
if BusinessDate.is_businessdate(other):
y, m, d = BusinessDate(other).diff_in_ymd(self)
return BusinessPeriod(years=y, months=m, days=d)
raise TypeError(
'subtraction of BusinessDates cannot handle objects of type %s.' % other.__class__.__name__)
def __str__(self):
date_format = self.__class__.DATE_FORMAT
return self.to_date().strftime(date_format)
def __repr__(self):
return self.__class__.__name__ + "(%s)" % str(self)
# --- validation and information methods ------------------------
[docs] def is_leap_year(self):
""" returns `True` for leap year and False otherwise """
return is_leap_year(self.year)
[docs] def days_in_year(self):
""" returns number of days in the given calendar year """
return days_in_year(self.year)
[docs] def days_in_month(self):
""" returns number of days for the month """
return days_in_month(self.year, self.month)
[docs] def end_of_month(self):
""" returns the day of the end of the month as :class:`BusinessDate` object"""
return BusinessDate(self.year, self.month, self.days_in_month())
[docs] def end_of_quarter(self):
""" returns the day of the end of the quarter as :class:`BusinessDate` object"""
return BusinessDate(self.year, end_of_quarter_month(self.month),
0o1).end_of_month()
[docs] def is_business_day(self, holidays=None):
""" returns `True` if date falls neither on weekend
nor is in holidays (if given as container object) """
holidays = self.holidays if holidays is None else holidays
holidays = self.DEFAULT_HOLIDAYS if holidays is None else holidays
return conventions.is_business_day(self, holidays)
# --- calculation methods --------------------------------------------
def _add_business_days(self, days_int, holidays=None):
res = self.__deepcopy__()
if days_int >= 0:
count = 0
while count < days_int:
res = res._add_days(1)
if res.is_business_day(holidays):
count += 1
else:
count = 0
while count > days_int:
res = res._add_days(-1)
if res.is_business_day(holidays):
count -= 1
return res
def _add_ymd(self, years=0, months=0, days=0):
y = self.year + years
m = self.month + months
while m < 1:
m += 12
y -= 1
som = self.__class__(y, m, 1)
d = min(self.day, som.days_in_month()) - 1 + days
new = som._add_days(d)
new.convention = self.convention
new.holidays = self.holidays
new.day_count = self.day_count
return new
[docs] def add_period(self, period_obj, holidays=None):
""" adds a :class:`BusinessPeriod` object
or anythings that create one and returns :class:`BusinessDate` object.
It is simply adding the number of `years`, `months` and `days` or
if `businessdays` given the number of business days,
i.e. days neither weekend nor in holidays (see also :meth:`BusinessDate.is_business_day`)
"""
p = BusinessPeriod(period_obj)
res = self
res = res._add_business_days(p.businessdays, holidays)
res = res._add_ymd(p.years, p.months, p.days)
return res
[docs] def diff_in_days(self, end_date):
""" calculates the distance to a :class:`BusinessDate` in days """
return int(self._diff_in_days(end_date))
[docs] def diff_in_ymd(self, end_date):
if end_date < self:
y, m, d = 0, 0, 0
while end_date < self._add_ymd(y, 0, 0):
y -= 1
while end_date < self._add_ymd(y + 1, m, 0):
m -= 1
while end_date < self._add_ymd(y + 1, m + 1, d):
d -= 1
return y + 1, m + 1, d
y = end_date.year - self.year
m = end_date.month - self.month
d = end_date.day - self.day
while m < 0:
y -= 1
m += 12
while d < 0:
m -= 1
if m < 0:
y -= 1
m += 12
d = self._add_ymd(y, m, 0)._diff_in_days(end_date)
return int(y), int(m), int(d)
# --- business day adjustment and day count fraction methods ----------
[docs] def get_day_count(self, end=None, day_count=None):
""" counts the days as a year fraction
to given date following the specified convention.
For more details on the conventions
see module :mod:`businessdate.daycount`.
"""
day_count = self.day_count if day_count is None else day_count
day_count = self.DEFAULT_DAY_COUNT \
if day_count is None else day_count
if isinstance(day_count, str):
dc_func = self.__class__._dc_func[day_count.lower()]
return dc_func(self, BusinessDate(end))
else:
return day_count(BusinessDate(end))
[docs] def get_year_fraction(self, end=None, day_count=None):
""" wrapper for :meth:`BusinessDate.get_day_count`
method for different naming preferences """
return self.get_day_count(end, day_count)
[docs] def adjust(self, convention=None, holidays=None):
""" returns an adjusted :class:`BusinessDate`
if it was not a business day following the specified convention.
For details on business days see :meth:`BusinessDate.is_business_day`.
For more details on the conventions
see module :mod:`businessdate.conventions`
"""
convention = self.convention if convention is None else convention
convention = self.DEFAULT_CONVENTION \
if convention is None else convention
holidays = self.holidays if holidays is None else holidays
holidays = self.DEFAULT_HOLIDAYS if holidays is None else holidays
if isinstance(convention, str):
adj_func = self.__class__._adj_func[convention.lower()]
return BusinessDate(adj_func(self, holidays))
else:
return convention(holidays)
def __getattr__(self, item):
if item.startswith('adjust_'):
return lambda h=None: self.adjust(item.replace('adjust_', ''), h)
if item.startswith('get_'):
return lambda e: self.get_year_fraction(e,
item.replace('get_', ''))
raise AttributeError("'%s' object has no attribute '%s'" % (
self.__class__.__name__, item))
# add additional __doc__ at runtime (during import)
try:
s = '\n' \
' In order to get the year fraction according a day count convention \n' \
' provide one of the following convention key words: \n\n'
for k, v in BusinessDate._dc_func.items():
s += ' * ' + (":code:`%s`" % k).ljust(
16) + '' + v.__doc__ + '\n\n'
BusinessDate.get_day_count.__doc__ += s
s = '\n' \
' In order to adjust according a business day convention \n' \
' provide one of the following convention key words: \n\n'
for k, v in BusinessDate._adj_func.items():
s += ' * ' + (":code:`%s`" % k).ljust(
16) + '' + v.__doc__ + '\n\n'
BusinessDate.adjust.__doc__ += s
del s
del k
del v
except AttributeError:
pass