Skip to content

Nelson Siegel Curve

quantflow.rates.nelson_siegel.NelsonSiegel pydantic-model

Bases: YieldCurve

Class representing a Nelson-Siegel yield curve

The Nelson-Siegel model is a popular parametric model for fitting the term structure of interest rates. It is defined by the following formula for the instantaneous forward rate:

\[\begin{equation} f(\tau) = \beta_1 + \beta_2 e^{-\lambda \tau} + \beta_3 \lambda \tau e^{-\lambda \tau} \end{equation}\]

where \(\tau\) is the time to maturity, \(\beta_1\) is the level parameter, \(\beta_2\) is the slope parameter, \(\beta_3\) is the curvature parameter and \(\lambda\) is the decay factor.

Fields:

curve_type pydantic-field

curve_type = 'nelson_siegel'

beta1 pydantic-field

beta1 = Decimal(0)

Level parameter

beta2 pydantic-field

beta2 = Decimal(0)

Slope parameter

beta3 pydantic-field

beta3 = Decimal(0)

Curvature parameter

lambda_ pydantic-field

lambda_ = Decimal(1)

Decay factor

ref_date pydantic-field

ref_date

Reference date for the yield curve

calibrator

calibrator()

Return a NelsonSiegelCalibration wrapping this curve.

Source code in quantflow/rates/nelson_siegel.py
def calibrator(self) -> NelsonSiegelCalibration:
    """Return a [NelsonSiegelCalibration][...NelsonSiegelCalibration] wrapping
    this curve."""
    return NelsonSiegelCalibration(yield_curve=self)

instanteous_forward_rate

instanteous_forward_rate(ttm)
Source code in quantflow/rates/nelson_siegel.py
def instanteous_forward_rate(self, ttm: FloatArrayLike) -> FloatArrayLike:
    b1, b2, b3, lam = (
        float(self.beta1),
        float(self.beta2),
        float(self.beta3),
        float(self.lambda_),
    )
    ttm_ = np.maximum(np.asarray(ttm, dtype=float), 0.0)
    lt = lam * ttm_
    et = np.exp(-lt)
    return maybe_float(b1 + b2 * et + b3 * lt * et)

discount_factor

discount_factor(ttm)

Calculate the discount factor for a given time to maturity.

The discount factor is calculated using the formula:

\[\begin{align*} D(\tau) &= e^{-r(\tau) \tau} \\ r(\tau) &= \beta_1 + \beta_2 \frac{1 - e^{-\lambda \tau}} {\lambda \tau} + \beta_3 \left(\frac{1 - e^{-\lambda \tau}}{\lambda \tau} - e^{-\lambda \tau}\right) \end{align*}\]
Source code in quantflow/rates/nelson_siegel.py
def discount_factor(self, ttm: FloatArrayLike) -> FloatArrayLike:
    r"""Calculate the discount factor for a given time to maturity.

    The discount factor is calculated using the formula:

    \begin{align*}
        D(\tau) &= e^{-r(\tau) \tau} \\
        r(\tau) &= \beta_1 + \beta_2 \frac{1 - e^{-\lambda \tau}}
        {\lambda \tau} + \beta_3
        \left(\frac{1 - e^{-\lambda \tau}}{\lambda \tau}
          - e^{-\lambda \tau}\right)
    \end{align*}
    """
    ttma = np.maximum(np.asarray(ttm, dtype=float), 0.0)
    tt = ttma * float(self.lambda_)
    et = np.exp(-tt)
    with np.errstate(divide="ignore", invalid="ignore"):
        ett = np.where(tt > 1e-10, (1 - et) / tt, 1.0)
    zero_coupon_rate = (
        float(self.beta1) + float(self.beta2) * ett + float(self.beta3) * (ett - et)
    )
    df = np.exp(-zero_coupon_rate * ttma)
    return maybe_float(df)

jacobian

jacobian(ttm)

Analytical Jacobian of discount factors w.r.t. params.

Params order: \([\beta_1, \beta_2, \beta_3, \lambda]\). Shape: (len(ttm), 4).

Source code in quantflow/rates/nelson_siegel.py
def jacobian(self, ttm: FloatArrayLike) -> FloatArray:
    r"""Analytical Jacobian of discount factors w.r.t. params.

    Params order: $[\beta_1, \beta_2, \beta_3, \lambda]$. Shape: (len(ttm), 4).
    """
    b2, b3, lam = float(self.beta2), float(self.beta3), float(self.lambda_)
    ttma = np.maximum(np.asarray(ttm, dtype=float), 0.0)
    lt = lam * ttma
    et = np.exp(-lt)
    with np.errstate(divide="ignore", invalid="ignore"):
        ett = np.where(lt > 1e-10, (1.0 - et) / lt, 1.0 - lt / 2.0)
    a_term = ttma * ett
    b_term = a_term - ttma * et
    zero_rate = float(self.beta1) + b2 * ett + b3 * (ett - et)
    df = np.exp(-zero_rate * ttma)
    with np.errstate(divide="ignore", invalid="ignore"):
        da_dlam = np.where(lam > 1e-10, ttma * et / lam - a_term / lam, 0.0)
    db_dlam = da_dlam + ttma**2 * et
    return np.column_stack(
        [
            -ttma * df,
            -a_term * df,
            -b_term * df,
            -df * (b2 * da_dlam + b3 * db_dlam),
        ]
    )

calibrate classmethod

calibrate(ttm, rates, lambda_bounds=(0.01, 10.0))

Fit a Nelson-Siegel curve to observed continuously compounded rates.

Uses a profile OLS approach: for each candidate \(\lambda\) the betas are solved exactly via least squares, so only a 1-D scalar minimisation over \(\lambda\) is needed.

Observations whose rates deviate by more than 3 robust standard deviations (MAD-scaled) from the median are excluded before fitting, making the result robust to a small number of bad parity observations.

PARAMETER DESCRIPTION
ttm

times to maturity in years (1-D, length >= 3)

TYPE: ArrayLike

rates

observed continuously compounded rates, same length as ttm

TYPE: ArrayLike

lambda_bounds

search bounds for the decay parameter \(\lambda\)

TYPE: tuple[float, float] DEFAULT: (0.01, 10.0)

Source code in quantflow/rates/nelson_siegel.py
@classmethod
def calibrate(
    cls,
    ttm: Annotated[
        ArrayLike,
        Doc("times to maturity in years (1-D, length >= 3)"),
    ],
    rates: Annotated[
        ArrayLike, Doc("observed continuously compounded rates, same length as ttm")
    ],
    lambda_bounds: Annotated[
        tuple[float, float],
        Doc("search bounds for the decay parameter $\\lambda$"),
    ] = (0.01, 10.0),
) -> Self:
    r"""Fit a Nelson-Siegel curve to observed continuously compounded rates.

    Uses a profile OLS approach: for each candidate $\lambda$ the betas are
    solved exactly via least squares, so only a 1-D scalar minimisation over
    $\lambda$ is needed.

    Observations whose rates deviate by more than 3 robust standard deviations
    (MAD-scaled) from the median are excluded before fitting, making the result
    robust to a small number of bad parity observations.
    """
    ttm_arr = np.asarray(ttm, dtype=float)
    rates_arr = np.asarray(rates, dtype=float)
    mask = _mad_filter(rates_arr)
    fit_ttm = ttm_arr[mask] if mask.sum() >= 3 else ttm_arr
    fit_rates = rates_arr[mask] if mask.sum() >= 3 else rates_arr
    # Grid search to avoid local minima, then refine with bounded minimisation
    lo, hi = lambda_bounds
    grid = np.linspace(lo, hi, 50)
    rss_values = [_rss(lam, fit_ttm, fit_rates) for lam in grid]
    best_idx = int(np.argmin(rss_values))
    # Refine around the best grid point
    refine_lo = grid[max(best_idx - 1, 0)]
    refine_hi = grid[min(best_idx + 1, len(grid) - 1)]
    result = minimize_scalar(
        _rss,
        bounds=(refine_lo, refine_hi),
        method="bounded",
        args=(fit_ttm, fit_rates),
    )
    lam: float = result.x
    b1, b2, b3 = _ols_betas(fit_ttm, fit_rates, lam)
    return cls(
        beta1=Decimal(str(round(b1, 10))),
        beta2=Decimal(str(round(b2, 10))),
        beta3=Decimal(str(round(b3, 10))),
        lambda_=Decimal(str(round(lam, 10))),
    )

continuously_compounded_rate

continuously_compounded_rate(ttm)

Calculate the continuously compounded rate for a given time to maturity.

The continuously compounded rate is related to the discount factor by the following formula:

\[\begin{equation} r(\tau) = -\frac{\ln D(\tau)}{\tau} \end{equation}\]

where \(D(\tau)\) is the discount factor for a given time to maturity \(\tau\).

Accepts a scalar float or a float array. Returns a scalar float for scalar input and a numpy float array for array input.

PARAMETER DESCRIPTION
ttm

Time to maturity in years

TYPE: ArrayLike

Source code in quantflow/rates/yield_curve.py
def continuously_compounded_rate(
    self, ttm: Annotated[ArrayLike, Doc("Time to maturity in years")]
) -> FloatArrayLike:
    r"""Calculate the continuously compounded rate for a given time to maturity.

    The continuously compounded rate is related to the discount factor
    by the following formula:

    \begin{equation}
        r(\tau) = -\frac{\ln D(\tau)}{\tau}
    \end{equation}

    where $D(\tau)$ is the discount factor for a given time to maturity $\tau$.

    Accepts a scalar float or a float array. Returns a scalar float for scalar
    input and a numpy float array for array input.
    """
    ttm_ = np.asarray(ttm, dtype=float)
    df = np.asarray(self.discount_factor(ttm_), dtype=float)
    result = np.where(
        ttm_ <= 0, self.instanteous_forward_rate(0.0), -np.log(df) / ttm_
    )
    return maybe_float(result)

plot

plot(ttm_max=10.0, n=200, **kwargs)

Plot the continuously compounded rate vs time to maturity.

Requires plotly to be installed.

PARAMETER DESCRIPTION
ttm_max

Maximum time to maturity in years

TYPE: float DEFAULT: 10.0

n

Number of points to evaluate

TYPE: int DEFAULT: 200

Source code in quantflow/rates/yield_curve.py
def plot(
    self,
    ttm_max: Annotated[float, Doc("Maximum time to maturity in years")] = 10.0,
    n: Annotated[int, Doc("Number of points to evaluate")] = 200,
    **kwargs: Any,
) -> Any:
    """Plot the continuously compounded rate vs time to maturity.

    Requires plotly to be installed.
    """
    return plot.plot_yield_curve(self, ttm_max=ttm_max, n=n, **kwargs)

register_curve_types classmethod

register_curve_types(*curve_classes)

Register a yield curve subclass for deserialization.

Source code in quantflow/rates/yield_curve.py
@classmethod
def register_curve_types(cls, *curve_classes: type[YieldCurve]) -> None:
    """Register a yield curve subclass for deserialization."""
    for curve_cls in curve_classes:
        name = snake_case(curve_cls.__name__)
        if current_type := _CURVE_TYPES.pop(name, None):
            _TYPES_TO_NAMES.pop(current_type, None)
        _CURVE_TYPES[name] = curve_cls
        _TYPES_TO_NAMES[curve_cls] = name

curve_types classmethod

curve_types()

Return the registered curve types.

Source code in quantflow/rates/yield_curve.py
@classmethod
def curve_types(cls) -> tuple[str, ...]:
    """Return the registered curve types."""
    return tuple(sorted(_CURVE_TYPES))

get_curve_class classmethod

get_curve_class(curve_type)

Get the yield curve class for a given curve type.

Source code in quantflow/rates/yield_curve.py
@classmethod
def get_curve_class(cls, curve_type: str) -> type[YieldCurve] | None:
    """Get the yield curve class for a given curve type."""
    return _CURVE_TYPES.get(curve_type)

quantflow.rates.nelson_siegel.NelsonSiegelCalibration pydantic-model

Bases: YieldCurveCalibration[NelsonSiegel]

Calibration wrapper for a Nelson-Siegel yield curve.

Fields:

beta_bounds pydantic-field

beta_bounds = (-0.5, 0.5)

Lower and upper bounds for beta parameters

lambda_bounds pydantic-field

lambda_bounds = (0.01, 10.0)

Lower and upper bounds for the decay parameter

yield_curve pydantic-field

yield_curve

Yield curve to be calibrated

get_params

get_params()
Source code in quantflow/rates/nelson_siegel.py
def get_params(self) -> FloatArray:
    ns = self.yield_curve
    return np.array(
        [float(ns.beta1), float(ns.beta2), float(ns.beta3), float(ns.lambda_)]
    )

set_params

set_params(params)
Source code in quantflow/rates/nelson_siegel.py
def set_params(self, params: FloatArray) -> None:
    b1, b2, b3, lam = params
    self.yield_curve.beta1 = Decimal(str(round(float(b1), 10)))
    self.yield_curve.beta2 = Decimal(str(round(float(b2), 10)))
    self.yield_curve.beta3 = Decimal(str(round(float(b3), 10)))
    self.yield_curve.lambda_ = Decimal(str(round(float(lam), 10)))

get_bounds

get_bounds()
Source code in quantflow/rates/nelson_siegel.py
def get_bounds(self) -> Bounds:
    lo, hi = self.beta_bounds
    lam_lo, lam_hi = self.lambda_bounds
    return Bounds([lo, lo, lo, lam_lo], [hi, hi, hi, lam_hi])

calibrate

calibrate(ttm, target)

Fit the curve using the fast profile-OLS solver.

Drop times to maturity <= 1 day (if any) before fitting, as these are often dominated by noise and can cause instability in the fit.

PARAMETER DESCRIPTION
ttm

Times to maturity in years.

TYPE: ArrayLike

target

Target discount factors, same length as ttm.

TYPE: ArrayLike

Source code in quantflow/rates/nelson_siegel.py
def calibrate(
    self,
    ttm: Annotated[ArrayLike, Doc("Times to maturity in years.")],
    target: Annotated[
        ArrayLike, Doc("Target discount factors, same length as ttm.")
    ],
) -> NelsonSiegel:
    """Fit the curve using the fast profile-OLS solver.

    Drop times to maturity <= 1 day (if any) before fitting, as these are often
    dominated by noise and can cause instability in the fit.
    """
    ttm_ = np.asarray(ttm, dtype=float)
    mask = ttm_ >= 1 / 365
    rates = -np.log(np.asarray(target, dtype=float)[mask]) / ttm_[mask]
    ns = NelsonSiegel.calibrate(ttm_[mask], rates, lambda_bounds=self.lambda_bounds)
    self.yield_curve.beta1 = ns.beta1
    self.yield_curve.beta2 = ns.beta2
    self.yield_curve.beta3 = ns.beta3
    self.yield_curve.lambda_ = ns.lambda_
    return self.yield_curve