Source code for merton.calibration.vassalou_xing

"""Vassalou-Xing (2004) iterative MLE calibration.

When supplied a *single snapshot* of equity value + equity volatility, this
calibrator coincides with :func:`jmr_iterative`.

When supplied an equity *time series*, it runs the canonical Vassalou-Xing
loop:

1. Initialise ``σ_A^(0) = σ_E · E / (E + D)`` (naive proxy).
2. Given ``σ_A^(k)``, invert the BSM call equation at each ``t`` to obtain
   ``A_t`` from the observed ``E_t``.
3. Compute log-returns ``r_{A,t} = ln(A_t / A_{t-1})`` and update
   ``σ_A^(k+1) = std(r_A) · √252``.
4. Repeat until ``|σ_A^(k+1) - σ_A^(k)| < tol`` or ``k == max_iter``.

References
----------
Vassalou & Xing (2004). *Default Risk in Equity Returns*. Journal of
Finance 59 (2), 831-868.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

import numpy as np
from scipy.optimize import brentq

from .._backend._numpy import equity_value as _equity_value_kernel
from .._typing import FloatArray
from ..exceptions import (
    CalibrationConvergenceError,
    InsufficientDataError,
    MertonInputError,
)
from ._solvers import solve_two_equation
from .base import CalibrationResult, Calibrator

if TYPE_CHECKING:
    from ..core.firm import Firm

ANNUALIZATION = 252.0


def _invert_equity_for_assets(
    equity_series: FloatArray,
    sigma_A: float,
    debt: float,
    rf: float,
    T: float,
    q: float,
) -> FloatArray:
    """Solve E = BSCall(A; σ_A, D, r, T) for ``A`` at each ``t``."""
    A_series = np.empty_like(equity_series, dtype=np.float64)
    for i, E in enumerate(equity_series):

        def f(A: float, E_val: float = float(E)) -> float:
            return float(_equity_value_kernel(A, sigma_A, debt, rf, T, q) - E_val)

        # Bracket: A must exceed the discounted debt at minimum and lie below
        # several multiples of equity + debt at maximum.
        lo = max(E + debt * np.exp(-rf * T), 1e-9) * 0.5
        hi = (E + debt) * 5.0
        # Expand brackets if f doesn't change sign.
        f_lo, f_hi = f(lo), f(hi)
        attempts = 0
        while f_lo * f_hi > 0 and attempts < 20:
            hi *= 2.0
            f_hi = f(hi)
            attempts += 1
        if f_lo * f_hi > 0:
            raise CalibrationConvergenceError(
                "could not bracket asset value during VX inversion",
                suggested_fix="Inspect equity series for outliers / zeros.",
            )
        A_series[i] = brentq(f, lo, hi, xtol=1e-10)
    return A_series


[docs] def vassalou_xing( *, equity: float | FloatArray, debt: float, rf: float, T: float, equity_vol: float | None = None, dividend_yield: float = 0.0, tol: float = 1e-6, max_iter: int = 200, annualization: float = ANNUALIZATION, ) -> CalibrationResult: """Vassalou-Xing iterative MLE calibration. Either pass: - ``equity`` as a scalar with ``equity_vol`` — this collapses to the JMR two-equation solve. - ``equity`` as a 1-D array (equity series) — full iterative VX. In this case ``equity_vol`` is optional; if omitted we estimate it from the sample stdev of equity log-returns. Returns ------- CalibrationResult For the array case, ``asset_value`` is a full array of inferred ``A_t`` and ``asset_vol`` is the converged scalar ``σ_A``. """ eq_arr = np.atleast_1d(np.asarray(equity, dtype=np.float64)) if eq_arr.ndim != 1: raise MertonInputError("equity must be scalar or 1-D") if eq_arr.size == 1: # Single snapshot ⇒ delegate to JMR. if equity_vol is None: raise MertonInputError( "scalar Vassalou-Xing requires equity_vol", suggested_fix="Pass equity_vol, or provide an equity time series.", ) a, sa, n_iter, conv = solve_two_equation( E=float(eq_arr[0]), sigma_E=float(equity_vol), D=float(debt), r=float(rf), T=float(T), q=float(dividend_yield), tol=tol, max_iter=max_iter, ) return CalibrationResult( asset_value=a, asset_vol=sa, n_iter=n_iter, converged=conv, method="vassalou_xing", diagnostics={"mode": "snapshot"}, ) # Series mode. if eq_arr.size < 10: raise InsufficientDataError( "Vassalou-Xing series mode needs at least 10 observations", suggested_fix="Use a longer equity history or method='naive'.", ) eq_log_returns = np.diff(np.log(eq_arr)) sigma_E0 = ( float(equity_vol) if equity_vol is not None else float(eq_log_returns.std(ddof=1) * np.sqrt(annualization)) ) sigma_A = sigma_E0 * float(eq_arr[-1]) / (float(eq_arr[-1]) + float(debt)) history: list[float] = [sigma_A] converged = False for k in range(max_iter): A_series = _invert_equity_for_assets(eq_arr, sigma_A, debt, rf, T, dividend_yield) ret_A = np.diff(np.log(A_series)) sigma_A_new = float(ret_A.std(ddof=1) * np.sqrt(annualization)) if not np.isfinite(sigma_A_new) or sigma_A_new <= 0: raise CalibrationConvergenceError( f"VX iteration produced non-finite σ_A at step {k}", ) history.append(sigma_A_new) if abs(sigma_A_new - sigma_A) < tol: sigma_A = sigma_A_new converged = True break sigma_A = sigma_A_new if not converged: raise CalibrationConvergenceError( f"Vassalou-Xing did not converge in {max_iter} iterations", suggested_fix=f"Relax tol (now {tol}) or increase max_iter.", ) A_series = _invert_equity_for_assets(eq_arr, sigma_A, debt, rf, T, dividend_yield) asset_drift = float(np.mean(np.diff(np.log(A_series))) * annualization) return CalibrationResult( asset_value=A_series, asset_vol=sigma_A, asset_drift=asset_drift, n_iter=len(history) - 1, converged=True, method="vassalou_xing", diagnostics={ "mode": "series", "history": history, "sigma_E_used": sigma_E0, "n_obs": int(eq_arr.size), }, )
[docs] class VassalouXingCalibrator(Calibrator): """OO wrapper around :func:`vassalou_xing`."""
[docs] method = "vassalou_xing"
def __init__(self, *, tol: float = 1e-6, max_iter: int = 200) -> None:
[docs] self.tol = tol
[docs] self.max_iter = max_iter
[docs] def fit(self, firm: Firm) -> CalibrationResult: equity = np.asarray(firm.equity, dtype=np.float64) debt = ( float(np.asarray(firm.default_point_value()).item()) if np.ndim(firm.default_point_value()) == 0 else float(np.mean(firm.default_point_value())) ) return vassalou_xing( equity=equity if equity.ndim > 0 and equity.size > 1 else float(equity), equity_vol=float(firm.equity_vol) if firm.equity_vol is not None else None, debt=debt, rf=float(np.mean(np.asarray(firm.rf, dtype=np.float64))), T=float(firm.horizon), dividend_yield=float(np.mean(np.asarray(firm.dividend_yield, dtype=np.float64))), tol=self.tol, max_iter=self.max_iter, )
__all__ = ["VassalouXingCalibrator", "vassalou_xing"]