# -*- coding: utf-8 -*-
# yieldcurves
# -----------
# A Python library for financial yield curves.
#
# Author: sonntagsgesicht
# Version: 0.2.6.1, copyright Monday, 14 October 2024
# Website: https://github.com/sonntagsgesicht/yieldcurves
# License: Apache License 2.0 (see LICENSE file)
from datetime import timedelta, date
from .tools import interpolation as _interpolation
from .tools import piecewise_linear
from .tools import ITERABLE
from .tools import prettyclass
from .yieldcurves import YieldCurve
_DAYS_IN_YEAR = 365.25
_BASE_DATE = date.today()
[docs]
@prettyclass
class DateCurve:
BASEDATE = None
"""default origin (if not **None** otherwise default origin will be current date)""" # noqa F401
_cache = {}
def __init__(self, curve, *, origin=None, yf=None):
"""Curve class with date type arguments
:param curve: inner curve with float arguments
:param origin: curve start date
(optional, default is |DateCurve.BASEDATE|)
:param yf: year fraction callable `yf(start: date, end: date) -> float`
to derive float argument **y** from a date **x** via
`y = yf(origin, x)`.
(optional, default is *actual/365.25*)
>>> from datetime import date
>>> from yieldcurves import DateCurve
>>> eye = lambda x: x # identity curve
>>> yc = DateCurve(eye, origin=date(2024,1,1))
>>> yc(date(2025,1,1))
1.002053388090349
>>> from businessdate import BusinessDate # date extension for finance
>>> from businessdate.daycount import get_30_360 # handles date
>>> yc = DateCurve(eye, origin=date(2024,1,1), yf=get_30_360)
>>> yc(date(2025,1,1))
1.0
>>> yc(BusinessDate(20250101)) # BusinessDate behaves like date
1.0
>>> byc = DateCurve(eye, origin=BusinessDate(20240101), yf=get_30_360)
>>> byc(BusinessDate(20250101))
1.0
""" # noqa E501
self.curve = curve
self.origin = origin
self.yf = yf
self._cache[self._cache_key] = self._cache.get(self._cache_key, {})
def __bool__(self):
return bool(self.curve)
@property
def _origin(self):
origin = self.origin
if isinstance(origin, (int, float)):
# assume time is measured in year fractions
return origin
if origin is None:
# if there is no origin use BASE_DATE
return _BASE_DATE if self.BASEDATE is None else self.BASEDATE
# match BASE_DATE type and origin type
date_type = date if self.BASEDATE is None else type(self.BASEDATE)
if not isinstance(origin, date_type):
if issubclass(date_type, date):
if isinstance(origin, int):
origin = str(origin)
origin = f"{origin[:4]}-{origin[4:6]}-{origin[6:8]}"
origin = date.fromisoformat(str(origin))
else:
origin = date_type(origin)
return origin
@property
def _cache_key(self):
return f"{self.origin} * {self.yf}"
@staticmethod
def _dyf(start, end):
r""" default year fraction function for rate period calculation
:param start: period start date $t_s$
:param end: period end date $t_e$
:returns: year fraction $\tau(t_s, t_e)$
from **start** to **end** as a float
this default function calculates the number of days
between $t_s$ and $t_e$ expressed as a fraction of a year, i.e.
$$\tau(t_s, t_e) = \frac{t_e-t_s}{365.25}$$
as an average year has nearly $365.25$ days.
Since different date packages have different concepts to derive
the number of days between two dates, **day_count** tries to adopt
at least some of them. As there are:
* dates given already as year fractions as a
`float <https://docs.python.org/3/library/functions.html?#float>`_
so $\tau(t_s, t_e) = t_e - t_s$.
* `datetime <https://docs.python.org/3/library/datetime.html>`_
the native Python package, so $\delta = t_e - t_s$ is a **timedelta**
object with attribute **days** which is used.
* `businessdate <https://pypi.org/project/businessdate/>`_
a specialised package for banking business calendar
and time period calculations,
so the **BusinessDate** object **start** has a method
**start.diff_in_days** which is used.
"""
if isinstance(start, ITERABLE):
return type(start)(DateCurve._dyf(s, end) for s in start)
if isinstance(end, ITERABLE):
return type(end)(DateCurve._dyf(start, e) for e in end)
if hasattr(start, 'diff_in_days'):
# duck typing businessdate.BusinessDate.diff_in_days
return float(start.diff_in_days(end)) / _DAYS_IN_YEAR
diff = end - start
if hasattr(diff, 'days'):
# assume datetime.date or finance.BusinessDate (else days as float)
return float(diff.days) / _DAYS_IN_YEAR
# use year fraction directly
return float(diff)
[docs]
def year_fraction(self, x):
r"""year fraction function
:param x: date argument
:return: float result
calculates year fraction **y** of period from **origin** and **x** as
`y = yf(origin, x)` with **yf** as given.
**origin** defaults to |DateCurve.BASEDATE| or current date
**yf** defaults to default year fraction function
which calculates the number of days
between $t_s$ and $t_e$ expressed as a fraction of a year, i.e.
$$\tau(t_s, t_e) = \frac{t_e-t_s}{365.25}$$
as an average year has nearly $365.25$ days.
Since different date packages have different concepts to derive
the number of days between two dates, **day_count** tries to adopt
at least some of them. As there are:
* dates given already as year fractions as a
`float <https://docs.python.org/3/library/functions.html?#float>`_
so $\tau(t_s, t_e) = t_e - t_s$.
* `datetime <https://docs.python.org/3/library/datetime.html>`_
the native Python package, so $\delta = t_e - t_s$ is a **timedelta**
object with attribute **days** which is used.
* `businessdate <https://pypi.org/project/businessdate/>`_
a specialised package for banking business calendar
and time period calculations,
so the **BusinessDate** object **start** has a method
**start.diff_in_days** which is used.
>>> from businessdate import BusinessRange, BusinessDate
>>> from businessdate.daycount import get_act_act
>>> from yieldcurves import DateCurve
>>> eye = lambda x: x # identity curve
>>> today = BusinessDate(20240101)
>>> yc = DateCurve(eye, origin=today, yf=get_act_act)
>>> yc.year_fraction(today)
0.0
>>> yc.year_fraction(today + '1w')
0.01912568306010929
>>> yc.year_fraction(today + '1m')
0.08469945355191257
>>> yc.year_fraction(today + '3m')
0.24863387978142076
>>> yc.year_fraction(today + '1y')
1.0
"""
if x is None:
return None
if isinstance(x, ITERABLE):
return type(x)(self.year_fraction(_) for _ in x)
yf = self.yf or self._dyf
origin = self._origin
date_type = type(origin)
if not isinstance(x, date_type):
x = date_type(x)
y = yf(origin, x)
self._cache[self._cache_key][y] = x
return y
[docs]
def inverse(self, y):
"""inverse function of year fraction function
:param y: float argument
:return: date result with `y = yf(origin, x)`
as year fraction functions are in general not injektiv,
i.e. have unique relation between input and output
s.th. there are dates **a** and **b** with
`yf(origin, a) = yf(origin, b)`
Hence, this method provides the smallest date **x**
s.th. `y = yf(origin, x)`.
Note, this method relies heavily on caching and caches also results of
|DateCurve().year_fraction()| which might result in conflicts to
the late minimal condition.
>>> from businessdate import BusinessDate
>>> from businessdate.daycount import get_30_360
>>> from yieldcurves import DateCurve
>>> eye = lambda x: x # identity curve
>>> today = BusinessDate(20241231)
>>> yc = DateCurve(eye, origin=today, yf=get_30_360)
>>> yc.inverse(1.7)
BusinessDate(20260912)
>>> yc.inverse(0.25)
BusinessDate(20250331)
>>> yc.inverse(1.7)
BusinessDate(20260912)
>>> y = yc.year_fraction(BusinessDate(20250331))
>>> y
0.25
>>> yc.inverse(y)
BusinessDate(20250331)
>>> yc._cache[yc._cache_key] = {} # this clears the cache
>>> y = yc.year_fraction(BusinessDate(20250101))
>>> y
0.002777777777777778
>>> yc.inverse(y)
BusinessDate(20250101)
"""
if isinstance(y, ITERABLE):
return type(y)(self.year_fraction(_) for _ in y)
if y not in self._cache[self._cache_key]:
self._cache[self._cache_key][y] = self._inverse(y)
return self._cache[self._cache_key][y]
def _inverse(self, value, step=4096):
def yf_inv(y, yf, step=1):
"""inverse of year_fraction at y"""
x = 0
step = int(step) or 1
if yf(x + step) < yf(x):
return yf_inv(y, lambda x: -1 * yf(x), step=step)
while y < yf(x - step):
step *= 2
x -= step
if y == yf(x):
return x
while 0 < step:
while yf(x + step) < y:
x += step
step //= 2
if y == yf(x):
return x
while yf(x + 1) <= y:
x += 1
return x if y - yf(x) < yf(x + 1) - y else x + 1
origin = self._origin
if isinstance(origin, (int, float)):
def yf(x):
return self.year_fraction(origin + x / _DAYS_IN_YEAR)
return origin + self._yf_inv(value, yf, step=step) / _DAYS_IN_YEAR
else:
def yf(x):
return self.year_fraction(origin + timedelta(x))
return origin + timedelta(yf_inv(value, yf, step=step))
def __call__(self, *args, **kwargs):
args = tuple(self.year_fraction(x) for x in args)
# kw = {k: self.year_fraction(y) for k, y in kwargs.items()}
return self.curve(*args, **kwargs)
def __getattr__(self, item):
if hasattr(self.curve, item):
if item.startswith('__'):
return getattr(self.curve, item)
def func(*args, **kwargs):
args = tuple(self.year_fraction(x) for x in args)
# kw = {k: self.year_fraction(y) for k, y in kwargs.items()}
return getattr(self.curve, item)(*args, **kwargs)
func.__qualname__ = self.__class__.__qualname__ + '.' + item
func.__name__ = item
func.__self__ = self
return func
msg = f"{self.__class__.__name__!r} object has no attribute {item!r}"
raise AttributeError(msg)
[docs]
@classmethod
def from_interpolation(cls, domain, curve, *, origin=None, yf=None,
interpolation=None, curve_type=None, **kwargs):
"""
:param list[date] domain: curve date list
:param list[float] curve: curve values or callable
if callable then values of curve at points of domain are used
:param date origin: curve start date
(optional, default is |DateCurve.BASEDATE|)
:param Callable yf:
year fraction callable `yf(start: date, end: date) -> float`
to derive float argument **y** from a date **x** via
`y = yf(origin, x)`.
(optional, default is *actual/365.25*)
:param interpolation: interpolation class to build
`interpolation(domain, curve)`
to give inner curve, i.e. turning **domain** and **curve**
into a callable turning **float** into **float**.
(optional, default is *piecewise linear interpolation*)
:param [str, type] curve_type: type of curve
(optional, default is |YieldCurve|)
:param dict kwargs: additional arguments for curve type creation
:return: DateCurve with inner curve of **curve_type**
builds |DateCurve| with interpolated inner curve.
>>> from businessdate import BusinessDate, BusinessRange
>>> from curves.interpolation import linear
>>> from yieldcurves import DateCurve, YieldCurve
>>> today = BusinessDate(20240101)
>>> domain = BusinessRange(today, today + '6y', '1y')
>>> values = 0.02, 0.022, 0.021, 0.019, 0.02
>>> curve_type = YieldCurve.from_short_rates
>>> yc = DateCurve.from_interpolation(domain, values, origin=today, curve_type=curve_type)
>>> yc
DateCurve(YieldCurve.from_short_rates(piecewise_linear([0.0, 1.002053388090349, 2.001368925393566, 3.0006844626967832, 4.0], [0.02, 0.022, 0.021, 0.019, 0.02])), origin=BusinessDate(20240101))
>>> yc(today + '6m')
0.020497267759562843
>>> yc.spot(today + '6m')
0.020497267759562673
""" # noqa 501
self = cls(None, origin=origin, yf=yf)
curve_type = curve_type or YieldCurve
curve_type = getattr(YieldCurve, str(curve_type), curve_type)
interpolation = interpolation or piecewise_linear
interpolation = \
getattr(_interpolation, str(interpolation), interpolation)
x_list = tuple(map(self.year_fraction, domain))
y_list = tuple(map(curve, x_list)) if callable(curve) else curve
self.curve = curve_type(interpolation(x_list, y_list), **kwargs)
return self
# --- dict like properties ---
@property
def _curve(self):
"""get innermost curve
:return: innermost curve
"""
curve = self.curve
while hasattr(curve, 'curve'):
curve = curve.curve
return curve
def __getitem__(self, item):
return self._curve[self.year_fraction(item)]
def __setitem__(self, key, value):
self._curve[self.year_fraction(key)] = value
def __delitem__(self, key):
del self._curve[self.year_fraction(key)]
def __iter__(self):
return iter(map(self.inverse, self._curve))
def __len__(self, item):
return len(self._curve)
def __contains__(self, item):
return self.year_fraction(item) in self._curve