Skip to content
All library documents

Calibrating Bass Local Volatility with Multiple Marginals

Article Quant Q&A · Author: K. Roman

Summary

The document presents a numerical calibration problem for the Bass construction with multiple marginals, a local volatility approach. It outlines an initialization based on a linearized fixed-point equation, followed by repeated application of a fixed-point operator until successive estimates are close in the maximum norm. The implementation represents functions with interpolation and evaluates heat-kernel convolutions using Gauss–Hermite quadrature. A lognormal marginal case provides an exact solution for comparison with the numerical output.

The author reports convergence and accumulated error problems, including when repeatedly applying the operator to the known exact solution. They describe checks of the initialization formula and numerical integration, but do not identify a definitive cause or fix. The supplied implementation details make the issue reproducible in principle, yet the document remains an open debugging question rather than a validated calibration recipe. Its evidence is diagnostic plots and checks described by the author; no quantified error analysis or final resolution is given.

Key ideas

  • The calibration alternates a linearized initial estimate with fixed-point operator iterations.
  • The stopping criterion compares successive estimates in the maximum norm.
  • Interpolation and Gauss–Hermite quadrature approximate functions and heat-kernel convolutions.
  • A lognormal case supplies an exact solution for checking numerical behavior.
  • The reported error accumulation remains unresolved, so the implementation is not presented as a settled method.

Tags

Full text
# Bass Local Volatility (Bass construction with multi-marginals) Calibration Problem


# Bass Local Volatility (Bass construction with multi-marginals) Calibration Problem












I'm trying to implement the Bass Local Volatility model (Bass construction with multi-marginals) but have encountered a problem.

References:

- Antoine Conze and Henry-Labordere, Bass Construction with Multi-Marginals: Lightspeed Computation in a New Local Volatility Model: https://papers.ssrn.com/sol3/papers.cfm?abstract_id=3853085

- Antoine Conze and Henry-Labordere, A new fast local volatility model: https://www.risk.net/media/download/1079736/download

I will refer to formulas from the second article.

The code that I found and adapted is from: https://github.com/igudav/Bass-Local-Volatility

Problem: The main problem is convergence of numerical calibration algorithm for exact case - lognormal model.

Numerical calibration algorithm:

- Initial guess $F^{(0)}_{W_{T_i}}$: use solution of the linearised fixed-point equation (equation (12) in the article): $$F^{-1}(u) = \sqrt{\frac{\Delta}{2}} \int_{\frac{1}{2}}^{u} dy \sqrt{\frac{G_2'(y)}{\int_{0}^{y} (G_1(z) - G_2(z)) dz}},$$ where $\Delta := T_{2}-T_{1}$ , $G_{i}(y) := F^{-1}_{\mu_{i}}(y)$, $i=1,2$.

- Iterate until convergence (in $L^\infty$ - norm) the equation: $$F^{(p+1)}_{W_{T_i}} = \mathcal{A}_i F^{(p)}_{W_{T_i}},$$ where $\mathcal{A}F := F_{\mu_{1}}\circ\big(K_{T_{2}-T_{1}}\star\big(F^{-1}_{\mu_{2}}\circ(K_{T_{2}-T_{1}}\star F)\big)\big)$, convolution $\star$ with heat kernel $K_{t}(x):=e^{-\frac{x^{2}}{2t}}/\sqrt{2\pi t}$ and $F_{\mu_i}$ is $T_i$-marginal cdf.

- All one-dimensional functions are stored and evaluated as linear or spline interpolations, and convolution with heat kernel is done using a Gauss-Hermite quadrature.

In lognormal case the exact solution is $F_{W_{T_1}} = N(\cdot / \sqrt{T_1})$. For interpolation scipy.interpolate.PchipInterpolator is used. Results showing the problem for tenors $T_1=2.0$, $T_2=3.0$ and $T_1=2.0$, $T_2=7.0$:

Figure 1.

Figure 2.

Debug Firstly I thought problem is implementation of solution (equation (12)) of the linearised fixed-point equation (equation (4) in the article). I checked implementation by substituting the solution into linearised fixed-point equation and it appears to be correct ($G_{i}(y) := F^{-1}_{\mu_{i}}(y)$):

Figure 3.

I also checked how the exact solution transforms after N successive applications of the operator $A$ , expecting the equality $F=AF$ (Theorem 1.) to be satisfied. I found an error accumulation after successive application of convolution and interpolation of the obtained result, but even if this is what causes the error, I don't understand how it can be fixed

Figure 4.

Below is a minimal code snippet to reproduce the issue:

```
import numpy as np  
  
from scipy.stats import norm  
  
from typing import Union, List, Callable  
from numpy.typing import NDArray  
from numpy.polynomial.hermite import hermgauss  
from scipy.interpolate import PchipInterpolator, CubicSpline  
  
FloatOrVectorType = Union[float, List[float], NDArray[float]]  
FloatVectorType = Union[List[float], NDArray[float]]  
EPS = np.finfo(float).eps  
  
  
def convolutionWithGaussHermiteQuadrature(  
        t: float,  
        func: Callable[[NDArray], NDArray],  
) -> Callable[[NDArray], NDArray]:  
    nodes, weights = hermgauss(61)  
    def f(x: FloatOrVectorType):  
        shape = (-1, *[1] * x.ndim)  
        newVariable = x[None] - np.sqrt(2 * t) * nodes.reshape(shape)    
        return 1 / np.sqrt(np.pi) * np.sum(  
            weights.reshape(shape) * func(newVariable),  
            axis=0  
        )  
  
    return f  
  
  
class LogNormalMarginal:  
  
    def __init__(self, sigma: float, tenor: float):  
        self._sigma = sigma  
        self._tenor = tenor  
  
    @property  
    def tenor(self):  
        return self._tenor  
  
    @property  
    def sigma(self):  
        return self._sigma  
  
    def inverseCdf(self, u: FloatOrVectorType) -> FloatOrVectorType:  
        return self._inverseCdf(np.clip(u, a_min=EPS, a_max=1 - EPS))  
  
    def cdf(self, x: FloatOrVectorType) -> FloatOrVectorType:  
        return np.clip(self._cdf(x), a_min=EPS, a_max=1 - EPS)  
  
    def pdf(self, x: FloatOrVectorType) -> FloatOrVectorType:  
        return 1 / (x * self._sigma * np.sqrt(self.tenor)) \  
            * norm.pdf(  
                (np.log(x) + 0.5 * self._sigma ** 2 * self.tenor) \  
                / self._sigma / np.sqrt(self.tenor)  
            )  
  
    def _cdf(self, x: FloatOrVectorType) -> FloatOrVectorType:  
        return norm.cdf(  
            (np.log(x) + 0.5 * self._sigma ** 2 * self.tenor) \  
            / self._sigma / np.sqrt(self.tenor)  
        )  
  
    def _inverseCdf(self, u: FloatOrVectorType) -> FloatOrVectorType:  
        return np.exp(  
            - 0.5 * self._sigma ** 2 * self.tenor \  
            + self._sigma * np.sqrt(self.tenor) * norm.ppf(u)  
        )  
  
    def integralOfInverseCdf(self, u: FloatOrVectorType) -> FloatOrVectorType:  
        d_1 = (-np.log(self._inverseCdf(u)) + 0.5 * self._sigma ** 2 * self.tenor) \  
            / self._sigma / np.sqrt(self.tenor)  
        return norm.cdf(-d_1)  
  
    def derivativeOfInverseCdf(self, u: FloatOrVectorType) -> FloatOrVectorType:  
        return 1 / self.pdf(self._inverseCdf(u))  
  
  
class FixedPointEquation:  
  
    @staticmethod  
    def getInitialIterationAsSolutionOfLinearisedFixedPointEquation(  
            marginal1: LogNormalMarginal,  
            marginal2: LogNormalMarginal,  
            gridPoints: int = 2001,  
            bounds: float = 5.  
    ) -> PchipInterpolator:  
        # eq. (12)  
        uGrid = norm.cdf(np.linspace(-bounds, bounds, gridPoints))  
  
        integrand = np.sqrt(  
            marginal2.derivativeOfInverseCdf(uGrid) / (  
                    marginal1.integralOfInverseCdf(uGrid)  
                    - marginal2.integralOfInverseCdf(uGrid) + EPS  
            )  
        )  
        integral = CubicSpline(uGrid, integrand).antiderivative()  
        inverseCdfValues = np.sqrt(  
            (marginal2.tenor - marginal1.tenor) / 2  
        ) * (integral(uGrid) - integral(1 / 2))  
  
        return PchipInterpolator(inverseCdfValues, uGrid)  
  
    @staticmethod  
    def getMappingFunction(  
            solution: PchipInterpolator,  
            marginal1: LogNormalMarginal,  
            marginal2: LogNormalMarginal  
    ) -> Callable[[FloatVectorType], FloatVectorType]:  
        # eq. (3) 
        internalConvolution = \  
            convolutionWithGaussHermiteQuadrature(  
                t=marginal2.tenor - marginal1.tenor,  
                func=solution  
        )  
  
        def applySecondMarginalInverseCdf(x):  
            u = internalConvolution(x)  
            return marginal2.inverseCdf(u)  
  
        externalConvolution = \  
            convolutionWithGaussHermiteQuadrature(  
                t=marginal2.tenor - marginal1.tenor,  
                func=applySecondMarginalInverseCdf  
        )  
        return externalConvolution  
  
    @classmethod  
    def applyOperatorA(  
            cls,  
            solution: PchipInterpolator,  
            marginal1: LogNormalMarginal,  
            marginal2: LogNormalMarginal  
    ) -> Callable[[FloatVectorType], FloatVectorType]:  
  
        def applyFirstMarginalCdf(x):  
            mappingValues = cls.getMappingFunction(  
                solution=solution,  
                marginal1=marginal1,  
                marginal2=marginal2  
            )(x)  
            return marginal1.cdf(mappingValues)  
  
        return applyFirstMarginalCdf  
  
    @staticmethod  
    def LInfinityNorm(  
            sequence1: FloatVectorType,  
            sequence2: FloatVectorType  
    ) -> float:  
        return np.max(np.abs(sequence1 - sequence2))  
  
    @classmethod  
    def solveFixedPointEquation(  
            cls,  
            marginal1: LogNormalMarginal,  
            marginal2: LogNormalMarginal,  
            maxIter: int = 61,  
            tol: float = 1e-4  
    ):  
        wGrid = np.linspace(-5, 5, 2001) * np.sqrt(marginal1.tenor)  
        solutionIteration = \  
            cls.getInitialIterationAsSolutionOfLinearisedFixedPointEquation(  
                marginal1=marginal1,  
                marginal2=marginal2,  
        )  
  
        for iterationNumber in range(maxIter):  
            solutionNextIteration = PchipInterpolator(  
                x=wGrid,  
                y=cls.applyOperatorA(  
                    solution=solutionIteration,  
                    marginal1=marginal1,  
                    marginal2=marginal2  
                )(wGrid)  
            )  
  
            lInfty = cls.LInfinityNorm(  
                sequence1=solutionNextIteration(wGrid),  
                sequence2=solutionIteration(wGrid)  
            )  
            solutionIteration = solutionNextIteration  
            if lInfty < tol:  
                break  
            if iterationNumber == (maxIter - 1):  
                raise Exception(  
                    f'Convergence error: current tol: {tol}, reach {lInfty}, iter: {iterationNumber}'  
                )  
  
        print(f'Current tol: {tol}, reach: {lInfty}, iter: {iterationNumber}')  
        return solutionIteration  
  
  
if __name__ == '__main__':  
    # Let S_0 = 1.  
    import matplotlib  
    import matplotlib.pyplot as plt  
  
    expiries = [2., 3.]  
    vols = [0.2] * 2  
  
    marginal1, marginal2 = [  
        LogNormalMarginal(sigma=sigma, tenor=T)  
        for sigma, T in zip(vols, expiries)  
    ]  
  
    exactSolutionOfFixedPointEq = lambda x: norm.cdf(x / np.sqrt(marginal1.tenor))  
  
    numericalSolution = FixedPointEquation().solveFixedPointEquation(  
        marginal1=marginal1,  
        marginal2=marginal2,  
        maxIter=61,  
        tol=2e-4  
    )  
    x = np.linspace(-5 * np.sqrt(marginal1.tenor), 5 * np.sqrt(marginal1.tenor), 1000)  
    print(np.abs(numericalSolution(x) - exactSolutionOfFixedPointEq(x)).max())  
  
    print(numericalSolution(0.))  
  
    _, (generalAxis, diffAxis) = plt.subplots(nrows=1, ncols=2, figsize=(15, 8), dpi=120)  
    generalAxis.plot(x, numericalSolution(x), label='numericalSolution')  
    generalAxis.plot(x, exactSolutionOfFixedPointEq(x), label='exact')  
    generalAxis.set_title(  
        f"Solutions of fixed point equation, marginal tenors = {expiries}"  
    )  
    generalAxis.legend()  
    generalAxis.grid()  
  
    diffAxis.plot(x, numericalSolution(x) - exactSolutionOfFixedPointEq(x), label='diff')  
    diffAxis.legend()  
    diffAxis.set_title('Difference between the numerical solution and the exact')  
    diffAxis.grid()  
    plt.show()
```

I tried change interpolation methods, grids, verify analytical formulas and double check it by numerical integration and taking the derivative, but I'm completely stuck and don't know how to fix the error, any suggestions would be greatly appreciated.

Shown in full with attribution under the source's licence. Licence: CC BY-SA 4.0 (Stack Exchange)

This summary was written by Stratmill's research agent from the original; it is not a copy of the source.