Skip to content

Vasicek Curve

The VasicekCurve models the short rate and yield curve under the Vasicek (Ornstein-Uhlenbeck) dynamics. Historical calibration of model parameters is performed via maximum likelihood using the KalmanFilter from ta/kalman.

quantflow.rates.vasicek.VasicekCurve pydantic-model

Bases: YieldCurve

Yield curve derived from the Vasicek short-rate model.

The Vasicek model describes the short rate as a mean-reverting Ornstein-Uhlenbeck process:

\[\begin{equation} dr_t = \kappa(\theta - r_t)\, dt + \sigma\, dW_t \end{equation}\]

The model admits a closed-form discount factor; see discount_factor.

Throughout, the auxiliary quantity is:

\[\begin{equation} B(\tau) = \frac{1 - e^{-\kappa\tau}}{\kappa} \end{equation}\]

Fields:

curve_type pydantic-field

curve_type = 'vasicek_curve'

rate pydantic-field

rate = Decimal('0.05')

Initial value \(x_0\)

kappa pydantic-field

kappa = Decimal('1.0')

Mean reversion speed \(\kappa\)

theta pydantic-field

theta = Decimal('0.05')

Mean level \(\theta\)

sigma pydantic-field

sigma = Decimal('0.01')

Volatility \(\sigma\)

ref_date pydantic-field

ref_date

Reference date for the yield curve

calibrator

calibrator()

Return a VasicekCurveCalibration wrapping this curve.

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

process

process()

Return the underlying Vasicek process corresponding to this curve.

Source code in quantflow/rates/vasicek.py
def process(self) -> Vasicek:
    """Return the underlying [Vasicek][quantflow.sp.ou.Vasicek] process
    corresponding to this curve."""
    return Vasicek(
        rate=float(self.rate),
        kappa=float(self.kappa),
        theta=float(self.theta),
        bdlp=WienerProcess(sigma=float(self.sigma)),
    )

instantaneous_forward_rate

instantaneous_forward_rate(ttm)

Calculate the instantaneous forward rate for the Vasicek model.

\[\begin{equation} f(\tau) = r_0\, e^{-\kappa\tau} + \theta(1 - e^{-\kappa\tau}) - \frac{\sigma^2}{2\kappa}\, B(\tau)\, e^{-\kappa\tau} \end{equation}\]
Source code in quantflow/rates/vasicek.py
def instantaneous_forward_rate(self, ttm: FloatArrayLike) -> FloatArrayLike:
    r"""Calculate the instantaneous forward rate for the Vasicek model.

    \begin{equation}
        f(\tau) = r_0\, e^{-\kappa\tau}
            + \theta(1 - e^{-\kappa\tau})
            - \frac{\sigma^2}{2\kappa}\, B(\tau)\, e^{-\kappa\tau}
    \end{equation}
    """
    arr = np.asarray(ttm, dtype=float)
    ttma = np.maximum(arr, 0.0)
    kappa = float(self.kappa)
    theta = float(self.theta)
    sigma = float(self.sigma)
    rate = float(self.rate)
    s2 = sigma * sigma
    et = np.exp(-kappa * ttma)
    b = (1.0 - et) / kappa
    fwd = rate * et + theta * (1.0 - et) - s2 / (2.0 * kappa) * b * et
    return fwd if fwd.ndim > 0 else float(fwd)

discount_factor

discount_factor(ttm)

Calculate the discount factor using the Vasicek closed-form solution.

\[\begin{equation} D(\tau) = e^{A(\tau) - B(\tau)\, r_0} \end{equation}\]

where \(A(\tau)\) and \(B(\tau)\) are the affine coefficients given by [affine_coefficients][quantflow.rates.vasicek.VasicekCurve.discount_factor.affine_coefficients].

Source code in quantflow/rates/vasicek.py
def discount_factor(self, ttm: FloatArrayLike) -> FloatArrayLike:
    r"""Calculate the discount factor using the Vasicek closed-form solution.

    \begin{equation}
        D(\tau) = e^{A(\tau) - B(\tau)\, r_0}
    \end{equation}

    where $A(\tau)$ and $B(\tau)$ are the affine coefficients given by
    [affine_coefficients][.affine_coefficients].
    """
    arr = np.asarray(ttm, dtype=float)
    ttma = np.maximum(arr, 0.0)
    kappa = float(self.kappa)
    theta = float(self.theta)
    sigma = float(self.sigma)
    rate = float(self.rate)
    s2 = sigma * sigma
    b = (1.0 - np.exp(-kappa * ttma)) / kappa
    a = (theta - s2 / (2.0 * kappa * kappa)) * (b - ttma) + s2 * b * b / (
        4.0 * kappa
    )
    df = np.exp(a - rate * b)
    return df if df.ndim > 0 else float(df)

affine_coefficients

affine_coefficients(ttm)

Return the affine coefficients \(A(\tau)\) and \(B(\tau)\) of the log discount factor.

\[\begin{equation} \log D(\tau) = A(\tau) - B(\tau)\, r_0 \end{equation}\]

where

\[\begin{equation} \begin{aligned} B(\tau) &= \frac{1 - e^{-\kappa\tau}}{\kappa}, \\ A(\tau) &= \left(\theta - \frac{\sigma^2}{2\kappa^2}\right) \bigl(B(\tau) - \tau\bigr) + \frac{\sigma^2 B(\tau)^2}{4\kappa}. \end{aligned} \end{equation}\]
Source code in quantflow/rates/vasicek.py
def affine_coefficients(
    self, ttm: FloatArrayLike
) -> tuple[FloatArrayLike, FloatArrayLike]:
    r"""Return the affine coefficients $A(\tau)$ and $B(\tau)$
    of the log discount factor.

    \begin{equation}
        \log D(\tau) = A(\tau) - B(\tau)\, r_0
    \end{equation}

    where

    \begin{equation}
    \begin{aligned}
        B(\tau) &= \frac{1 - e^{-\kappa\tau}}{\kappa}, \\
        A(\tau) &= \left(\theta - \frac{\sigma^2}{2\kappa^2}\right)
            \bigl(B(\tau) - \tau\bigr)
            + \frac{\sigma^2 B(\tau)^2}{4\kappa}.
    \end{aligned}
    \end{equation}
    """
    arr = np.asarray(ttm, dtype=float)
    ttma = np.maximum(arr, 0.0)
    kappa = float(self.kappa)
    theta = float(self.theta)
    sigma = float(self.sigma)
    s2 = sigma * sigma
    et = np.exp(-kappa * ttma)
    b = (1.0 - et) / kappa
    a = (theta - s2 / (2.0 * kappa * kappa)) * (b - ttma) + s2 * b * b / (
        4.0 * kappa
    )
    return maybe_float(a), maybe_float(b)

jacobian

jacobian(ttm)

Analytical Jacobian of discount factors w.r.t. \([r_0, \kappa, \theta, \sigma]\). Returns shape (len(ttm), 4).

Source code in quantflow/rates/vasicek.py
def jacobian(self, ttm: FloatArrayLike) -> FloatArray | None:
    r"""Analytical Jacobian of discount factors w.r.t.
    $[r_0, \kappa, \theta, \sigma]$. Returns shape (len(ttm), 4).
    """
    arr = np.asarray(ttm, dtype=float)
    ttma = np.maximum(arr, 0.0)
    kappa = float(self.kappa)
    theta = float(self.theta)
    sigma = float(self.sigma)
    rate = float(self.rate)
    s2 = sigma * sigma
    et = np.exp(-kappa * ttma)
    b = (1.0 - et) / kappa
    a = (theta - s2 / (2.0 * kappa * kappa)) * (b - ttma) + s2 * b * b / (
        4.0 * kappa
    )
    d = np.exp(a - rate * b)

    # \partial D/\partial r0
    d_rate = -b * d

    # \partial D/\partial \kappa
    db_k = (ttma * et * kappa - (1.0 - et)) / (kappa * kappa)
    da_k = (
        s2 / (kappa**3) * (b - ttma)
        + (theta - s2 / (2.0 * kappa * kappa)) * db_k
        + s2 * b * db_k / (2.0 * kappa)
        - s2 * b * b / (4.0 * kappa * kappa)
    )
    d_kappa = d * (da_k - rate * db_k)

    # \partial D/\partial \theta
    d_theta = d * (b - ttma)

    # \partial D/\partial \sigma
    da_s = (-sigma / (kappa * kappa)) * (b - ttma) + sigma * b * b / (2.0 * kappa)
    d_sigma = d * da_s

    return np.column_stack([d_rate, d_kappa, d_theta, d_sigma])

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.instantaneous_forward_rate(0.0), -np.log(df) / ttm_
    )
    return maybe_float(result)

rates

rates(ttm, frequency=2)

Calculate zero rates compounded at the given frequency.

The continuously compounded rate \(r_c(\tau)\) is converted to a rate compounded \(m\) times per year via:

\[\begin{equation} r_m(\tau) = m\,(e^{r_c(\tau)/m} - 1) \end{equation}\]

When frequency=0 the result is continuously compounded (same as continuously_compounded_rate).

PARAMETER DESCRIPTION
ttm

Time to maturity in years

TYPE: ArrayLike

frequency

Compounding periods per year (e.g. 2 for semi-annual). Pass 0 for continuously compounded.

TYPE: int DEFAULT: 2

Source code in quantflow/rates/yield_curve.py
def rates(
    self,
    ttm: Annotated[ArrayLike, Doc("Time to maturity in years")],
    frequency: Annotated[
        int,
        Doc(
            "Compounding periods per year (e.g. 2 for semi-annual). "
            "Pass 0 for continuously compounded."
        ),
    ] = 2,
) -> FloatArrayLike:
    r"""Calculate zero rates compounded at the given frequency.

    The continuously compounded rate $r_c(\tau)$ is converted to a
    rate compounded $m$ times per year via:

    \begin{equation}
        r_m(\tau) = m\,(e^{r_c(\tau)/m} - 1)
    \end{equation}

    When ``frequency=0`` the result is continuously compounded (same as
    [continuously_compounded_rate][..continuously_compounded_rate]).
    """
    rc = self.continuously_compounded_rate(ttm)
    if frequency <= 0:
        return rc
    return frequency * np.expm1(rc / frequency)

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.

The registry key is the curve_type discriminator value rather than the class name, so the two can be named independently.

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.

    The registry key is the ``curve_type`` discriminator value rather than
    the class name, so the two can be named independently.
    """
    for curve_cls in curve_classes:
        name = curve_cls.model_fields["curve_type"].default
        if not isinstance(name, str):
            raise TypeError(
                f"{curve_cls.__name__} must define a string curve_type default"
            )
        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.vasicek.VasicekCurveCalibration pydantic-model

Bases: YieldCurveCalibration[VasicekCurve]

Calibration wrapper for a VasicekCurve yield curve.

Fields:

filtered_short_rate property

filtered_short_rate

Kalman-filtered short rate at each observation date.

Populated by [calibrate_historical_rates][quantflow.rates.vasicek.VasicekCurveCalibration.filtered_short_rate.calibrate_historical_rates]; accessing it before a historical fit raises an error.

yield_curve pydantic-field

yield_curve

Yield curve to be calibrated

get_params

get_params()
Source code in quantflow/rates/vasicek.py
def get_params(self) -> FloatArray:
    c = self.yield_curve
    return np.array([float(c.rate), float(c.kappa), float(c.theta), float(c.sigma)])

set_params

set_params(params)
Source code in quantflow/rates/vasicek.py
def set_params(self, params: FloatArray) -> None:
    rate, kappa, theta, sigma = params
    self.yield_curve.rate = Decimal(str(round(float(rate), 10)))
    self.yield_curve.kappa = Decimal(str(round(float(kappa), 10)))
    self.yield_curve.theta = Decimal(str(round(float(theta), 10)))
    self.yield_curve.sigma = Decimal(str(round(float(sigma), 10)))

get_bounds

get_bounds()
Source code in quantflow/rates/vasicek.py
def get_bounds(self) -> Bounds:
    return Bounds([-1.0, 1e-4, -1.0, 0.0], [1.0, 1000.0, 1.0, 1.0])

calibrate

calibrate(ttm, rates)

Fit the Vasicek curve to continuously compounded rates via least squares.

PARAMETER DESCRIPTION
ttm

Times to maturity in years.

TYPE: ArrayLike

rates

Continuously compounded rates, same length as ttm.

TYPE: ArrayLike

Source code in quantflow/rates/vasicek.py
def calibrate(
    self,
    ttm: Annotated[ArrayLike, Doc("Times to maturity in years.")],
    rates: Annotated[
        ArrayLike, Doc("Continuously compounded rates, same length as ttm.")
    ],
) -> VasicekCurve:
    """Fit the Vasicek curve to continuously compounded rates via least squares."""
    ttm_arr = np.asarray(ttm, dtype=float)
    rates_arr = np.asarray(rates, dtype=float)

    def residuals(params: np.ndarray) -> np.ndarray:
        self.set_params(params)
        df = np.asarray(self.yield_curve.discount_factor(ttm_arr), dtype=float)
        return -np.log(df) / ttm_arr - rates_arr

    def jac(params: np.ndarray) -> FloatArray:
        self.set_params(params)
        df = np.asarray(self.yield_curve.discount_factor(ttm_arr), dtype=float)
        jac_d = np.asarray(self.yield_curve.jacobian(ttm_arr), dtype=float)
        return -jac_d / (df * ttm_arr)[:, None]

    x0 = np.array([rates_arr[0], 1.0, rates_arr[-1], 0.01])
    result = least_squares(
        residuals,
        jac=jac,
        x0=x0,
        bounds=([-1.0, 1e-4, -1.0, 0.0], [1.0, 1000.0, 1.0, 1.0]),
    )
    self.set_params(result.x)
    return self.yield_curve

calibrate_historical_rates

calibrate_historical_rates(ttm, rates, dt)

Fit Vasicek by maximum likelihood with a Kalman filter on the panel.

The short rate \(r_t\) is the latent state of a LinearGaussianModel. Under a uniform time step \(\Delta t\) the exact discretization of the Ornstein-Uhlenbeck dynamics is the Gaussian AR(1):

\[\begin{equation} \begin{aligned} r_t &= \theta(1 - \phi) + \phi\, r_{t-1} + \varepsilon_t \\ \phi &= e^{-\kappa \Delta t} \\ \varepsilon_t &\sim N\left(0, \tfrac{\sigma^2}{2\kappa}(1 - \phi^2)\right) \end{aligned} \end{equation}\]

Centring the state on \(\theta\) (filtering \(z_t = r_t - \theta\)) cancels the AR(1) drift, and the affine yields \(y_i = (B_i r_t - A_i)/\tau_i\) become linear in \(z_t\) once their constant part is subtracted. The plain zero-intercept linear-Gaussian filter then applies.

The negative log-likelihood is minimised over \((\kappa, \theta, \sigma, h)\), where \(h\) is the observation noise standard deviation, then the curve's rate is set to the final filtered short rate.

PARAMETER DESCRIPTION
ttm

Times to maturity in years, shape (n,).

TYPE: FloatArray

rates

Continuously compounded rates, shape (T, n) (time by maturity).

TYPE: FloatArray

dt

Per-step time increments in years, shape (T-1,); assumed uniform.

TYPE: FloatArray

Source code in quantflow/rates/vasicek.py
def calibrate_historical_rates(
    self,
    ttm: Annotated[FloatArray, Doc("Times to maturity in years, shape (n,).")],
    rates: Annotated[
        FloatArray,
        Doc("Continuously compounded rates, shape (T, n) (time by maturity)."),
    ],
    dt: Annotated[
        FloatArray,
        Doc("Per-step time increments in years, shape (T-1,); assumed uniform."),
    ],
) -> VasicekCurve:
    r"""Fit Vasicek by maximum likelihood with a Kalman filter on the panel.

    The short rate $r_t$ is the latent state of a
    [LinearGaussianModel][quantflow.ta.kalman.LinearGaussianModel]. Under a
    uniform time step $\Delta t$ the exact discretization of the
    Ornstein-Uhlenbeck dynamics is the Gaussian AR(1):

    \begin{equation}
        \begin{aligned}
            r_t &= \theta(1 - \phi) + \phi\, r_{t-1} + \varepsilon_t \\
            \phi &= e^{-\kappa \Delta t} \\
            \varepsilon_t &\sim N\left(0,
                \tfrac{\sigma^2}{2\kappa}(1 - \phi^2)\right)
        \end{aligned}
    \end{equation}

    Centring the state on $\theta$ (filtering $z_t = r_t - \theta$) cancels
    the AR(1) drift, and the affine yields $y_i = (B_i r_t - A_i)/\tau_i$
    become linear in $z_t$ once their constant part is subtracted. The plain
    zero-intercept linear-Gaussian filter then applies.

    The negative log-likelihood is minimised over
    $(\kappa, \theta, \sigma, h)$, where $h$ is the observation noise
    standard deviation, then the curve's rate is set to the final filtered
    short rate.
    """
    rates = np.asarray(rates, dtype=float)
    ttm = np.asarray(ttm, dtype=float)
    dt = np.asarray(dt, dtype=float)
    if dt.size and not np.allclose(dt, dt[0], rtol=1e-2):
        raise ValueError(
            "calibrate_historical_rates assumes a uniform time step; "
            "the observation dates are not equally spaced"
        )
    step = float(dt[0]) if dt.size else 1.0

    def filtered(
        kappa: float,
        theta: float,
        sigma: float,
        h: float,
    ) -> KalmanFilter:
        self.set_params(np.array([0.0, kappa, theta, sigma]))
        a, b = self.yield_curve.affine_coefficients(ttm)
        A, B = np.asarray(a, dtype=float), np.asarray(b, dtype=float)
        phi = np.exp(-kappa * step)
        q = sigma * sigma * (1.0 - phi * phi) / (2.0 * kappa)
        offset = (B * theta - A) / ttm  # constant part of the affine yields
        model = LinearGaussianModel(
            F=np.array([[phi]]),
            Q=np.array([[q]]),
            H=B / ttm,
            R=h * h * np.eye(len(ttm)),
            mu0=np.zeros(1),
            cov0=np.array([[sigma * sigma / (2.0 * kappa)]]),
        )
        return model.kalman_filter(rates - offset)

    theta0 = float(np.mean(rates))
    short = rates[:, int(np.argmin(ttm))]
    sigma0 = max(float(np.std(np.diff(short)) / np.sqrt(step)), 1e-4)
    h0 = max(float(np.std(rates - rates.mean(axis=0))) / 10.0, 1e-4)
    x0 = np.array([np.log(0.5), theta0, np.log(sigma0), np.log(h0)])

    def neg_loglik(x: np.ndarray) -> float:
        return -filtered(
            float(np.exp(x[0])),
            float(x[1]),
            float(np.exp(x[2])),
            float(np.exp(x[3])),
        ).filter()

    result = minimize(neg_loglik, x0, method="Nelder-Mead")
    kappa = float(np.exp(result.x[0]))
    theta = float(result.x[1])
    sigma = float(np.exp(result.x[2]))
    h = float(np.exp(result.x[3]))
    kf = filtered(kappa, theta, sigma, h)
    kf.filter()
    states = kf.states
    # filtered short rate path: z_t + theta
    short_rate = theta + np.array([float(s.mean.item()) for s in states])
    self._filtered_short_rate = short_rate
    self.set_params(np.array([float(short_rate[-1]), kappa, theta, sigma]))
    return self.yield_curve

calibrate_df

calibrate_df(ttm, target)

Fit the yield curve to target discount factors.

Converts discount factors to continuously compounded rates then calls calibrate.

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/calibration.py
def calibrate_df(
    self,
    ttm: Annotated[ArrayLike, Doc("Times to maturity in years.")],
    target: Annotated[
        ArrayLike, Doc("Target discount factors, same length as ttm.")
    ],
) -> Y:
    """Fit the yield curve to target discount factors.

    Converts discount factors to continuously compounded rates then calls
    [calibrate][..calibrate].
    """
    ttm_ = np.asarray(ttm, dtype=float)
    rates = -np.log(np.asarray(target, dtype=float)) / ttm_
    return self.calibrate(ttm_, rates)

calibrate_historical_rates_dataframe

calibrate_historical_rates_dataframe(rates, frequency=None)

Fit the yield curve from a historical panel of rates.

Tenor column labels are parsed into times to maturity, per-step time increments are inferred from the DatetimeIndex (irregular spacing supported), and rates are converted to continuously compounded if a finite frequency is supplied. The actual fit is delegated to [calibrate_historical_rates][quantflow.rates.vasicek.calibrate_historical_rates], which subclasses override.

PARAMETER DESCRIPTION
rates

Historical zero rates with a DatetimeIndex and tenor column labels parsed by [ccy.Period][ccy.dates.period.Period] (e.g. '6m', '1y').

TYPE: DataFrame

frequency

Compounding periods per year of the input rates. None (default) means continuously compounded.

TYPE: int | None DEFAULT: None

Source code in quantflow/rates/calibration.py
def calibrate_historical_rates_dataframe(
    self,
    rates: Annotated[
        pd.DataFrame,
        Doc(
            "Historical zero rates with a DatetimeIndex and tenor column "
            "labels parsed by [ccy.Period][ccy.dates.period.Period] "
            "(e.g. ``'6m'``, ``'1y'``)."
        ),
    ],
    frequency: Annotated[
        int | None,
        Doc(
            "Compounding periods per year of the input rates. ``None`` "
            "(default) means continuously compounded."
        ),
    ] = None,
) -> Y:
    """Fit the yield curve from a historical panel of rates.

    Tenor column labels are parsed into times to maturity, per-step
    time increments are inferred from the DatetimeIndex (irregular
    spacing supported), and rates are converted to continuously
    compounded if a finite ``frequency`` is supplied. The actual fit
    is delegated to [calibrate_historical_rates][...calibrate_historical_rates],
    which subclasses override.
    """
    ttm = np.array([tenor_to_years(str(c)) for c in rates.columns], dtype=float)
    rates_arr = _to_continuous(np.asarray(rates.values, dtype=float), frequency)
    dt = _dt_array(rates.index)
    return self.calibrate_historical_rates(ttm, rates_arr, dt)