"""Reduced-form default: Jarrow and Turnbull's hazard rates and survival curves.
Jarrow and Turnbull (1995) stopped modelling why a firm defaults and
modelled when: default is the first jump of a Poisson process with
intensity :math:`\\lambda`, so survival to :math:`t` is
:math:`Q(t) = e^{-\\int_0^t \\lambda(s)\\,ds}`. With the intensity piecewise
constant between knots this is the curve every CDS desk bootstraps. With
recovery of treasury (a fraction :math:`\\delta` of a riskless bond is paid
at default), a defaultable zero-coupon bond is worth
.. math:: P^d(0, T) = P(0, T)\\,[\\delta + (1 - \\delta) Q(T)],
and for small default rates the spread obeys the credit triangle
:math:`s \\approx \\lambda (1 - R)`.
References: R. A. Jarrow and S. M. Turnbull, "Pricing Derivatives on
Financial Securities Subject to Credit Risk," *J. Finance* 50 (1995)
53-85; D. Lando, "On Cox Processes and Credit Risky Securities," *Rev.
Derivatives Res.* 2 (1998) 99-120.
"""
from __future__ import annotations
import numpy as np
from numpy.typing import ArrayLike
from financialkit.credit.core.base import SurvivalCurve
from financialkit.options.utils.common import as_output
__all__ = ["HazardCurve", "jarrow_turnbull_zero", "hazard_from_spread", "spread_from_hazard"]
[docs]
class HazardCurve(SurvivalCurve):
"""Piecewise-constant default intensity.
The hazard is ``hazards[i]`` on ``(knots[i-1], knots[i]]`` (with
``knots[-1] = 0``) and stays at the last value beyond the last knot.
Parameters
----------
knots : array_like, shape (n,)
Increasing end times of the hazard segments (years, positive).
hazards : array_like, shape (n,)
Non-negative intensities.
Examples
--------
>>> c = HazardCurve([1.0, 3.0], [0.01, 0.03])
>>> round(c.survival(2.0), 6) == round(float(np.exp(-0.04)), 6)
True
>>> round(c.inverse_cumulative_hazard(0.04), 12)
2.0
"""
def __init__(self, knots: ArrayLike, hazards: ArrayLike):
knots = np.atleast_1d(np.asarray(knots, dtype=np.float64))
hazards = np.atleast_1d(np.asarray(hazards, dtype=np.float64))
if knots.shape != hazards.shape or knots.ndim != 1:
raise ValueError("knots and hazards must be 1-d arrays of the same length")
if knots[0] <= 0 or np.any(np.diff(knots) <= 0):
raise ValueError("knots must be positive and strictly increasing")
if np.any(hazards < 0):
raise ValueError("hazard rates must be non-negative")
self.knots, self.hazards = knots, hazards
starts = np.r_[0.0, knots[:-1]]
self._starts = starts
self._cum = np.r_[0.0, np.cumsum(hazards * (knots - starts))]
[docs]
@classmethod
def flat(cls, hazard: float) -> HazardCurve:
"""A constant intensity: exponential default time with mean ``1 / hazard``."""
return cls([1.0], [hazard])
def _segment(self, t: np.ndarray) -> np.ndarray:
return np.minimum(np.searchsorted(self.knots, t, side="left"), self.knots.size - 1)
[docs]
def hazard(self, t: ArrayLike) -> np.ndarray | float:
"""Intensity :math:`\\lambda(t)`."""
t = np.asarray(t, dtype=np.float64)
return as_output(self.hazards[self._segment(t)])
[docs]
def cumulative_hazard(self, t: ArrayLike) -> np.ndarray | float:
""":math:`\\Lambda(t)`, linear between knots."""
t = np.maximum(np.asarray(t, dtype=np.float64), 0.0)
i = self._segment(t)
return as_output(self._cum[i] + self.hazards[i] * (t - self._starts[i]))
[docs]
def inverse_cumulative_hazard(self, x: ArrayLike) -> np.ndarray | float:
"""Time at which :math:`\\Lambda` reaches ``x`` (``inf`` if it never does)."""
x = np.asarray(x, dtype=np.float64)
i = np.clip(np.searchsorted(self._cum, x, side="left") - 1, 0, self.knots.size - 1)
with np.errstate(divide="ignore", invalid="ignore"):
t = self._starts[i] + (x - self._cum[i]) / self.hazards[i]
t = np.where((self.hazards[i] == 0) & (x > self._cum[i]), np.inf, t)
return as_output(np.where(x <= 0, 0.0, t))
def __repr__(self) -> str:
return f"HazardCurve(knots={self.knots.tolist()!r}, hazards={self.hazards.tolist()!r})"
[docs]
def jarrow_turnbull_zero(T: ArrayLike, r: ArrayLike, curve: SurvivalCurve, recovery: float = 0.0) -> np.ndarray | float:
"""Defaultable zero-coupon bond under recovery of treasury.
.. math:: P^d(0, T) = e^{-rT}\\,[\\delta + (1 - \\delta) Q(T)]
Parameters
----------
T : array_like
Maturities.
r : array_like
Flat risk-free rate.
curve : SurvivalCurve
Risk-neutral survival curve.
recovery : float
Fraction :math:`\\delta` of the riskless bond paid at default.
Returns
-------
float or ndarray
Examples
--------
>>> round(jarrow_turnbull_zero(5.0, 0.05, HazardCurve.flat(0.02), 0.4), 6)
0.734333
"""
T, r = np.asarray(T, dtype=np.float64), np.asarray(r, dtype=np.float64)
Q = np.asarray(curve.survival(T), dtype=np.float64)
return as_output(np.exp(-r * T) * (recovery + (1.0 - recovery) * Q))
[docs]
def hazard_from_spread(spread: ArrayLike, recovery: float = 0.4) -> np.ndarray | float:
"""Credit triangle: the hazard rate implied by a spread, :math:`\\lambda = s / (1 - R)`.
Exact for a continuously paid CDS premium and a flat hazard and rate.
Parameters
----------
spread : array_like
recovery : float
Returns
-------
float or ndarray
Examples
--------
>>> round(hazard_from_spread(0.012, 0.4), 6)
0.02
"""
return as_output(np.asarray(spread, dtype=np.float64) / (1.0 - recovery))
[docs]
def spread_from_hazard(hazard: ArrayLike, recovery: float = 0.4) -> np.ndarray | float:
"""Inverse credit triangle, :math:`s = \\lambda (1 - R)`.
Parameters
----------
hazard : array_like
recovery : float
Returns
-------
float or ndarray
"""
return as_output(np.asarray(hazard, dtype=np.float64) * (1.0 - recovery))