Skip to content

Vol Model Calibration

quantflow.options.calibration.base.OptionEntry pydantic-model

Bases: BaseModel

Entry for a single option in the calibration dataset.

Each entry corresponds to a unique (maturity, strike) pair and holds the bid and ask sides as separate OptionPrice objects.

Fields:

ttm pydantic-field

ttm

Time to maturity in years

log_strike pydantic-field

log_strike

Log-strike as log(K/F)

options pydantic-field

options

Bid and ask option prices for this entry

implied_vol_range

implied_vol_range()

Get the range of implied volatilities across bid and ask

Source code in quantflow/options/calibration/base.py
def implied_vol_range(self) -> Bounds:
    """Get the range of implied volatilities across bid and ask"""
    implied_vols = tuple(option.implied_vol for option in self.options)
    return Bounds(min(implied_vols), max(implied_vols))

mid_price

mid_price()

Mid price as the average of bid and ask call prices

Source code in quantflow/options/calibration/base.py
def mid_price(self) -> float:
    """Mid price as the average of bid and ask call prices"""
    prices = tuple(float(option.call_price) for option in self.options)
    return sum(prices) / len(prices)

mid_iv

mid_iv()

Mid implied volatility as the average of bid and ask

Source code in quantflow/options/calibration/base.py
def mid_iv(self) -> float:
    """Mid implied volatility as the average of bid and ask"""
    ivs = tuple(option.implied_vol for option in self.options)
    return sum(ivs) / len(ivs)

quantflow.options.calibration.base.VolModelCalibration pydantic-model

Bases: BaseModel, ABC, Generic[M]

Abstract base class for calibration of a stochastic volatility model.

Subclasses must implement get_params, set_params, and get_bounds.

The two-stage fit method is provided here and works for any subclass:

  • Stage 1: Nelder-Mead on the scalar cost_function to find a good basin.
  • Stage 2: Levenberg-Marquardt (TRF) on the residuals vector for precise convergence with bound constraints.

Fields:

pricer pydantic-field

pricer

The OptionPricerBase for the model

vol_surface pydantic-field

vol_surface

The VolSurface to calibrate the model with

residual_kind pydantic-field

residual_kind = PRICE

Kind of residual used by the calibration cost function. price (default) measures the residual on forward-space option prices and applies the moneyness_weight cost weights; iv measures it on Black implied volatilities by inverting the model price. The iv residual is already in vol units and is naturally well-scaled across moneyness, so the moneyness_weight cost weights are not applied in that mode.

moneyness_weight pydantic-field

moneyness_weight = 0.0

Coefficient that up-weights wing options in the cost function. Applied as min(exp(moneyness_weight * moneyness**2), max_cost_weight), with moneyness = log(K/F) / sqrt(ttm). The quadratic form mimics the gaussian shape of 1/vega and puts wing residuals on the same footing as ATM ones. A value of 0 applies no moneyness weighting; typical values are in [0.1, 0.5].

max_cost_weight pydantic-field

max_cost_weight = 10.0

Hard cap on the per-option cost weight, to prevent a single deep-wing option from dominating the loss when moneyness_weight is large.

options pydantic-field

options

The options to calibrate

model property

model

ref_date property

ref_date

implied_vols property

implied_vols

get_params abstractmethod

get_params()

Current model parameters as a flat array (starting point for fit)

Source code in quantflow/options/calibration/base.py
@abstractmethod
def get_params(self) -> np.ndarray:
    """Current model parameters as a flat array (starting point for fit)"""

set_params abstractmethod

set_params(params)

Apply a flat parameter array back to the model

Source code in quantflow/options/calibration/base.py
@abstractmethod
def set_params(self, params: np.ndarray) -> None:
    """Apply a flat parameter array back to the model"""

get_bounds abstractmethod

get_bounds()

Parameter bounds for the optimiser

Source code in quantflow/options/calibration/base.py
@abstractmethod
def get_bounds(self) -> Bounds:
    """Parameter bounds for the optimiser"""

implied_vol_range

implied_vol_range()

Range of implied volatilities across all calibration options

Source code in quantflow/options/calibration/base.py
def implied_vol_range(self) -> Bounds:
    """Range of implied volatilities across all calibration options"""
    return Bounds(
        min(option.implied_vol_range().lb for option in self.options.values()),
        max(option.implied_vol_range().ub for option in self.options.values()),
    )

fit

fit()

Two-stage fit: Nelder-Mead basin search then LM refinement.

Stage 1 (Nelder-Mead): gradient-free minimisation of cost_function to reach the right basin of attraction.

Stage 2 (TRF/LM): scipy.optimize.least_squares on the residuals vector with parameter bounds for precise convergence.

Source code in quantflow/options/calibration/base.py
def fit(self) -> OptimizeResult:
    """Two-stage fit: Nelder-Mead basin search then LM refinement.

    Stage 1 (Nelder-Mead): gradient-free minimisation of `cost_function`
    to reach the right basin of attraction.

    Stage 2 (TRF/LM): `scipy.optimize.least_squares` on the `residuals`
    vector with parameter bounds for precise convergence.
    """
    bounds = self.get_bounds()
    stage1 = minimize(
        self.cost_function,
        self.get_params(),
        method="L-BFGS-B",
        bounds=list(zip(bounds.lb, bounds.ub)),
    )
    result = least_squares(
        self.residuals,
        np.clip(stage1.x, bounds.lb, bounds.ub),
        method="trf",
        bounds=(bounds.lb, bounds.ub),
        ftol=1e-10,
        xtol=1e-10,
        gtol=1e-10,
        max_nfev=10000,
    )
    self.set_params(result.x)
    return result

cost_weight

cost_weight(ttm, log_strike)

Weight for a given time to maturity and log-strike.

Up-weights wing options via exp(moneyness_weight * moneyness**2), capped at max_cost_weight. The quadratic form mimics 1/vega.

Source code in quantflow/options/calibration/base.py
def cost_weight(self, ttm: float, log_strike: float) -> float:
    """Weight for a given time to maturity and log-strike.

    Up-weights wing options via `exp(moneyness_weight * moneyness**2)`,
    capped at `max_cost_weight`. The quadratic form mimics `1/vega`.
    """
    moneyness = log_strike / np.sqrt(ttm)
    weight = np.exp(self.moneyness_weight * moneyness * moneyness)
    return float(min(weight, self.max_cost_weight))

cost_weights

cost_weights()

Vector of cost weights for all calibration options

Source code in quantflow/options/calibration/base.py
def cost_weights(self) -> np.ndarray:
    """Vector of cost weights for all calibration options"""
    weights = np.exp(self.moneyness_weight * self._moneyness * self._moneyness)
    return np.minimum(weights, self.max_cost_weight)

penalize

penalize()

Additional scalar penalty added to the cost function (default: 0)

Source code in quantflow/options/calibration/base.py
def penalize(self) -> float:
    """Additional scalar penalty added to the cost function (default: 0)"""
    return 0.0

residuals

residuals(params)

Weighted residuals per option, in price or implied-vol space.

Controlled by residual_kind:

  • price: weight * (model_price - mid_price)
  • iv: weight * (model_iv - mid_iv), where model_iv is the Black implied volatility of the model price.
Source code in quantflow/options/calibration/base.py
def residuals(self, params: np.ndarray) -> np.ndarray:
    """Weighted residuals per option, in price or implied-vol space.

    Controlled by `residual_kind`:

    - `price`: `weight * (model_price - mid_price)`
    - `iv`: `weight * (model_iv - mid_iv)`, where `model_iv` is the
      Black implied volatility of the model price.
    """
    self.set_params(params)
    self.pricer.reset()
    with np.errstate(all="ignore"):
        try:
            model_prices = self.pricer.call_prices(self._ttms, self._log_strikes)
        except ValueError:
            return np.full(self._log_strikes.shape, 1e6)
        if self.residual_kind is ResidualKind.IV:
            implied = implied_black_volatility(
                self._log_strikes,
                model_prices,
                self._ttms,
                initial_sigma=self._mid_ivs,
                call_put=1,
            )
            # Fourier pricers can return prices outside the no-arb band
            # for deep-wing strikes, where Newton fails to invert. Mask
            # those points out (zero residual). If fewer than half of the
            # options invert successfully the parameter set is treated as
            # invalid and a large penalty is returned.
            ok = implied.converged
            if 2 * int(ok.sum()) < ok.size:
                return np.full(self._log_strikes.shape, 1e6)
            r = np.where(ok, implied.values - self._mid_ivs, 0.0)
        else:
            r = self.cost_weights() * (model_prices - self._mid_prices)
    return np.where(np.isfinite(r), r, 1e6)

cost_function

cost_function(params)

Scalar cost: sum of squared residuals plus any penalty

Source code in quantflow/options/calibration/base.py
def cost_function(self, params: np.ndarray) -> float:
    """Scalar cost: sum of squared residuals plus any penalty"""
    r = self.residuals(params)
    return float(np.dot(r, r)) + self.penalize()

plot

plot(index=0, *, max_moneyness=1.0, support=51, **kwargs)

Plot implied volatility for market and model prices

Source code in quantflow/options/calibration/base.py
def plot(
    self,
    index: int = 0,
    *,
    max_moneyness: float = 1.0,
    support: int = 51,
    **kwargs: Any,
) -> Any:
    """Plot implied volatility for market and model prices"""
    cross = self.vol_surface.maturities[index]
    options = tuple(self.vol_surface.option_prices(index=index, converged=True))
    return plot.plot_vol_surface(
        pd.DataFrame([d.info_dict() for d in options]),
        model=self._model_grid(cross.ttm(self.ref_date), max_moneyness, support),
        **kwargs,
    )

plot_maturities

plot_maturities(*, max_moneyness=1.0, support=51, cols=2, row_height=400, showlegend=False, **kwargs)

Plot implied volatility for all maturities as a subplot grid

Source code in quantflow/options/calibration/base.py
def plot_maturities(
    self,
    *,
    max_moneyness: float = 1.0,
    support: int = 51,
    cols: int = 2,
    row_height: int = 400,
    showlegend: bool = False,
    **kwargs: Any,
) -> Any:
    """Plot implied volatility for all maturities as a subplot grid"""
    plot.check_plotly()
    n = len(self.vol_surface.maturities)
    rows = (n + cols - 1) // cols
    titles = [
        cross.maturity.strftime("%Y-%m-%d") for cross in self.vol_surface.maturities
    ]
    fig = plot.make_subplots(rows=rows, cols=cols, subplot_titles=titles)
    fig.update_layout(height=rows * row_height, showlegend=showlegend)
    for i, cross in enumerate(self.vol_surface.maturities):
        row = i // cols + 1
        col = i % cols + 1
        options = tuple(self.vol_surface.option_prices(index=i, converged=True))
        plot.plot_vol_surface(
            pd.DataFrame([d.info_dict() for d in options]),
            model=self._model_grid(
                cross.ttm(self.ref_date), max_moneyness, support
            ),
            fig=fig,
            fig_params={"row": row, "col": col},
            **kwargs,
        )
    return fig

quantflow.options.calibration.heston.HestonCalibration pydantic-model

Bases: VolModelCalibration[H], Generic[H]

Calibration of the Heston model.

Also serves as the base class for Heston-with-jumps calibration, providing the Feller condition penalty and the core variance-process parameter handling.

Fields:

feller_penalize pydantic-field

feller_penalize = 1000.0

Penalty weight for violating the Feller condition \(2\kappa\theta \geq \sigma^2\). Applied during the Nelder-Mead stage. Set to 0 to disable.

pricer pydantic-field

pricer

The OptionPricerBase for the model

vol_surface pydantic-field

vol_surface

The VolSurface to calibrate the model with

residual_kind pydantic-field

residual_kind = PRICE

Kind of residual used by the calibration cost function. price (default) measures the residual on forward-space option prices and applies the moneyness_weight cost weights; iv measures it on Black implied volatilities by inverting the model price. The iv residual is already in vol units and is naturally well-scaled across moneyness, so the moneyness_weight cost weights are not applied in that mode.

moneyness_weight pydantic-field

moneyness_weight = 0.0

Coefficient that up-weights wing options in the cost function. Applied as min(exp(moneyness_weight * moneyness**2), max_cost_weight), with moneyness = log(K/F) / sqrt(ttm). The quadratic form mimics the gaussian shape of 1/vega and puts wing residuals on the same footing as ATM ones. A value of 0 applies no moneyness weighting; typical values are in [0.1, 0.5].

max_cost_weight pydantic-field

max_cost_weight = 10.0

Hard cap on the per-option cost weight, to prevent a single deep-wing option from dominating the loss when moneyness_weight is large.

options pydantic-field

options

The options to calibrate

model property

model

ref_date property

ref_date

implied_vols property

implied_vols

get_bounds

get_bounds()
Source code in quantflow/options/calibration/heston.py
def get_bounds(self) -> Bounds:
    vol_range = self.implied_vol_range()
    vol_lb = 0.5 * vol_range.lb[0]
    vol_ub = 1.5 * vol_range.ub[0]
    return Bounds(
        [vol_lb**2, vol_lb**2, 0.0, 0.0, -0.9],
        [vol_ub**2, vol_ub**2, np.inf, np.inf, 0.0],
    )

get_params

get_params()
Source code in quantflow/options/calibration/heston.py
def get_params(self) -> np.ndarray:
    return np.asarray(
        [
            self.model.variance_process.rate,
            self.model.variance_process.theta,
            self.model.variance_process.kappa,
            self.model.variance_process.sigma,
            self.model.rho,
        ]
    )

set_params

set_params(params)
Source code in quantflow/options/calibration/heston.py
def set_params(self, params: np.ndarray) -> None:
    self.model.variance_process.rate = params[0]
    self.model.variance_process.theta = params[1]
    self.model.variance_process.kappa = params[2]
    self.model.variance_process.sigma = params[3]
    self.model.rho = params[4]

penalize

penalize()

Penalty for violating the Feller condition

Source code in quantflow/options/calibration/heston.py
def penalize(self) -> float:
    """Penalty for violating the Feller condition"""
    neg = min(self.model.variance_process.feller_condition, 0.0)
    return self.feller_penalize * neg * neg

implied_vol_range

implied_vol_range()

Range of implied volatilities across all calibration options

Source code in quantflow/options/calibration/base.py
def implied_vol_range(self) -> Bounds:
    """Range of implied volatilities across all calibration options"""
    return Bounds(
        min(option.implied_vol_range().lb for option in self.options.values()),
        max(option.implied_vol_range().ub for option in self.options.values()),
    )

fit

fit()

Two-stage fit: Nelder-Mead basin search then LM refinement.

Stage 1 (Nelder-Mead): gradient-free minimisation of cost_function to reach the right basin of attraction.

Stage 2 (TRF/LM): scipy.optimize.least_squares on the residuals vector with parameter bounds for precise convergence.

Source code in quantflow/options/calibration/base.py
def fit(self) -> OptimizeResult:
    """Two-stage fit: Nelder-Mead basin search then LM refinement.

    Stage 1 (Nelder-Mead): gradient-free minimisation of `cost_function`
    to reach the right basin of attraction.

    Stage 2 (TRF/LM): `scipy.optimize.least_squares` on the `residuals`
    vector with parameter bounds for precise convergence.
    """
    bounds = self.get_bounds()
    stage1 = minimize(
        self.cost_function,
        self.get_params(),
        method="L-BFGS-B",
        bounds=list(zip(bounds.lb, bounds.ub)),
    )
    result = least_squares(
        self.residuals,
        np.clip(stage1.x, bounds.lb, bounds.ub),
        method="trf",
        bounds=(bounds.lb, bounds.ub),
        ftol=1e-10,
        xtol=1e-10,
        gtol=1e-10,
        max_nfev=10000,
    )
    self.set_params(result.x)
    return result

cost_weight

cost_weight(ttm, log_strike)

Weight for a given time to maturity and log-strike.

Up-weights wing options via exp(moneyness_weight * moneyness**2), capped at max_cost_weight. The quadratic form mimics 1/vega.

Source code in quantflow/options/calibration/base.py
def cost_weight(self, ttm: float, log_strike: float) -> float:
    """Weight for a given time to maturity and log-strike.

    Up-weights wing options via `exp(moneyness_weight * moneyness**2)`,
    capped at `max_cost_weight`. The quadratic form mimics `1/vega`.
    """
    moneyness = log_strike / np.sqrt(ttm)
    weight = np.exp(self.moneyness_weight * moneyness * moneyness)
    return float(min(weight, self.max_cost_weight))

cost_weights

cost_weights()

Vector of cost weights for all calibration options

Source code in quantflow/options/calibration/base.py
def cost_weights(self) -> np.ndarray:
    """Vector of cost weights for all calibration options"""
    weights = np.exp(self.moneyness_weight * self._moneyness * self._moneyness)
    return np.minimum(weights, self.max_cost_weight)

residuals

residuals(params)

Weighted residuals per option, in price or implied-vol space.

Controlled by residual_kind:

  • price: weight * (model_price - mid_price)
  • iv: weight * (model_iv - mid_iv), where model_iv is the Black implied volatility of the model price.
Source code in quantflow/options/calibration/base.py
def residuals(self, params: np.ndarray) -> np.ndarray:
    """Weighted residuals per option, in price or implied-vol space.

    Controlled by `residual_kind`:

    - `price`: `weight * (model_price - mid_price)`
    - `iv`: `weight * (model_iv - mid_iv)`, where `model_iv` is the
      Black implied volatility of the model price.
    """
    self.set_params(params)
    self.pricer.reset()
    with np.errstate(all="ignore"):
        try:
            model_prices = self.pricer.call_prices(self._ttms, self._log_strikes)
        except ValueError:
            return np.full(self._log_strikes.shape, 1e6)
        if self.residual_kind is ResidualKind.IV:
            implied = implied_black_volatility(
                self._log_strikes,
                model_prices,
                self._ttms,
                initial_sigma=self._mid_ivs,
                call_put=1,
            )
            # Fourier pricers can return prices outside the no-arb band
            # for deep-wing strikes, where Newton fails to invert. Mask
            # those points out (zero residual). If fewer than half of the
            # options invert successfully the parameter set is treated as
            # invalid and a large penalty is returned.
            ok = implied.converged
            if 2 * int(ok.sum()) < ok.size:
                return np.full(self._log_strikes.shape, 1e6)
            r = np.where(ok, implied.values - self._mid_ivs, 0.0)
        else:
            r = self.cost_weights() * (model_prices - self._mid_prices)
    return np.where(np.isfinite(r), r, 1e6)

cost_function

cost_function(params)

Scalar cost: sum of squared residuals plus any penalty

Source code in quantflow/options/calibration/base.py
def cost_function(self, params: np.ndarray) -> float:
    """Scalar cost: sum of squared residuals plus any penalty"""
    r = self.residuals(params)
    return float(np.dot(r, r)) + self.penalize()

plot

plot(index=0, *, max_moneyness=1.0, support=51, **kwargs)

Plot implied volatility for market and model prices

Source code in quantflow/options/calibration/base.py
def plot(
    self,
    index: int = 0,
    *,
    max_moneyness: float = 1.0,
    support: int = 51,
    **kwargs: Any,
) -> Any:
    """Plot implied volatility for market and model prices"""
    cross = self.vol_surface.maturities[index]
    options = tuple(self.vol_surface.option_prices(index=index, converged=True))
    return plot.plot_vol_surface(
        pd.DataFrame([d.info_dict() for d in options]),
        model=self._model_grid(cross.ttm(self.ref_date), max_moneyness, support),
        **kwargs,
    )

plot_maturities

plot_maturities(*, max_moneyness=1.0, support=51, cols=2, row_height=400, showlegend=False, **kwargs)

Plot implied volatility for all maturities as a subplot grid

Source code in quantflow/options/calibration/base.py
def plot_maturities(
    self,
    *,
    max_moneyness: float = 1.0,
    support: int = 51,
    cols: int = 2,
    row_height: int = 400,
    showlegend: bool = False,
    **kwargs: Any,
) -> Any:
    """Plot implied volatility for all maturities as a subplot grid"""
    plot.check_plotly()
    n = len(self.vol_surface.maturities)
    rows = (n + cols - 1) // cols
    titles = [
        cross.maturity.strftime("%Y-%m-%d") for cross in self.vol_surface.maturities
    ]
    fig = plot.make_subplots(rows=rows, cols=cols, subplot_titles=titles)
    fig.update_layout(height=rows * row_height, showlegend=showlegend)
    for i, cross in enumerate(self.vol_surface.maturities):
        row = i // cols + 1
        col = i % cols + 1
        options = tuple(self.vol_surface.option_prices(index=i, converged=True))
        plot.plot_vol_surface(
            pd.DataFrame([d.info_dict() for d in options]),
            model=self._model_grid(
                cross.ttm(self.ref_date), max_moneyness, support
            ),
            fig=fig,
            fig_params={"row": row, "col": col},
            **kwargs,
        )
    return fig

quantflow.options.calibration.heston.HestonJCalibration pydantic-model

Bases: HestonCalibration[HestonJ[D]], Generic[D]

Calibration of the HestonJ model with jumps.

Extends HestonCalibration by appending jump parameters to the parameter vector and bounds.

Fields:

pricer pydantic-field

pricer

The OptionPricerBase for the model

vol_surface pydantic-field

vol_surface

The VolSurface to calibrate the model with

residual_kind pydantic-field

residual_kind = PRICE

Kind of residual used by the calibration cost function. price (default) measures the residual on forward-space option prices and applies the moneyness_weight cost weights; iv measures it on Black implied volatilities by inverting the model price. The iv residual is already in vol units and is naturally well-scaled across moneyness, so the moneyness_weight cost weights are not applied in that mode.

moneyness_weight pydantic-field

moneyness_weight = 0.0

Coefficient that up-weights wing options in the cost function. Applied as min(exp(moneyness_weight * moneyness**2), max_cost_weight), with moneyness = log(K/F) / sqrt(ttm). The quadratic form mimics the gaussian shape of 1/vega and puts wing residuals on the same footing as ATM ones. A value of 0 applies no moneyness weighting; typical values are in [0.1, 0.5].

max_cost_weight pydantic-field

max_cost_weight = 10.0

Hard cap on the per-option cost weight, to prevent a single deep-wing option from dominating the loss when moneyness_weight is large.

options pydantic-field

options

The options to calibrate

model property

model

ref_date property

ref_date

implied_vols property

implied_vols

feller_penalize pydantic-field

feller_penalize = 1000.0

Penalty weight for violating the Feller condition \(2\kappa\theta \geq \sigma^2\). Applied during the Nelder-Mead stage. Set to 0 to disable.

get_bounds

get_bounds()
Source code in quantflow/options/calibration/heston.py
def get_bounds(self) -> Bounds:
    base = super().get_bounds()
    vol_range = self.implied_vol_range()
    vol_lb = 0.5 * vol_range.lb[0]
    vol_ub = 1.5 * vol_range.ub[0]
    lower = list(base.lb) + [1.0, (0.01 * vol_lb) ** 2]
    upper = list(base.ub) + [np.inf, (0.5 * vol_ub) ** 2]
    try:
        self.model.jumps.jumps.asymmetry()
        lower.append(-2.0)
        upper.append(2.0)
    except NotImplementedError:
        pass
    return Bounds(lower, upper)

get_params

get_params()
Source code in quantflow/options/calibration/heston.py
def get_params(self) -> np.ndarray:
    params = list(super().get_params()) + [
        self.model.jumps.intensity,
        self.model.jumps.jumps.variance(),
    ]
    try:
        params.append(self.model.jumps.jumps.asymmetry())
    except NotImplementedError:
        pass
    return np.asarray(params)

set_params

set_params(params)
Source code in quantflow/options/calibration/heston.py
def set_params(self, params: np.ndarray) -> None:
    super().set_params(params)
    self.model.jumps.intensity = params[5]
    self.model.jumps.jumps.set_variance(params[6])
    try:
        self.model.jumps.jumps.set_asymmetry(params[7])
    except IndexError:
        pass

implied_vol_range

implied_vol_range()

Range of implied volatilities across all calibration options

Source code in quantflow/options/calibration/base.py
def implied_vol_range(self) -> Bounds:
    """Range of implied volatilities across all calibration options"""
    return Bounds(
        min(option.implied_vol_range().lb for option in self.options.values()),
        max(option.implied_vol_range().ub for option in self.options.values()),
    )

fit

fit()

Two-stage fit: Nelder-Mead basin search then LM refinement.

Stage 1 (Nelder-Mead): gradient-free minimisation of cost_function to reach the right basin of attraction.

Stage 2 (TRF/LM): scipy.optimize.least_squares on the residuals vector with parameter bounds for precise convergence.

Source code in quantflow/options/calibration/base.py
def fit(self) -> OptimizeResult:
    """Two-stage fit: Nelder-Mead basin search then LM refinement.

    Stage 1 (Nelder-Mead): gradient-free minimisation of `cost_function`
    to reach the right basin of attraction.

    Stage 2 (TRF/LM): `scipy.optimize.least_squares` on the `residuals`
    vector with parameter bounds for precise convergence.
    """
    bounds = self.get_bounds()
    stage1 = minimize(
        self.cost_function,
        self.get_params(),
        method="L-BFGS-B",
        bounds=list(zip(bounds.lb, bounds.ub)),
    )
    result = least_squares(
        self.residuals,
        np.clip(stage1.x, bounds.lb, bounds.ub),
        method="trf",
        bounds=(bounds.lb, bounds.ub),
        ftol=1e-10,
        xtol=1e-10,
        gtol=1e-10,
        max_nfev=10000,
    )
    self.set_params(result.x)
    return result

cost_weight

cost_weight(ttm, log_strike)

Weight for a given time to maturity and log-strike.

Up-weights wing options via exp(moneyness_weight * moneyness**2), capped at max_cost_weight. The quadratic form mimics 1/vega.

Source code in quantflow/options/calibration/base.py
def cost_weight(self, ttm: float, log_strike: float) -> float:
    """Weight for a given time to maturity and log-strike.

    Up-weights wing options via `exp(moneyness_weight * moneyness**2)`,
    capped at `max_cost_weight`. The quadratic form mimics `1/vega`.
    """
    moneyness = log_strike / np.sqrt(ttm)
    weight = np.exp(self.moneyness_weight * moneyness * moneyness)
    return float(min(weight, self.max_cost_weight))

cost_weights

cost_weights()

Vector of cost weights for all calibration options

Source code in quantflow/options/calibration/base.py
def cost_weights(self) -> np.ndarray:
    """Vector of cost weights for all calibration options"""
    weights = np.exp(self.moneyness_weight * self._moneyness * self._moneyness)
    return np.minimum(weights, self.max_cost_weight)

penalize

penalize()

Penalty for violating the Feller condition

Source code in quantflow/options/calibration/heston.py
def penalize(self) -> float:
    """Penalty for violating the Feller condition"""
    neg = min(self.model.variance_process.feller_condition, 0.0)
    return self.feller_penalize * neg * neg

residuals

residuals(params)

Weighted residuals per option, in price or implied-vol space.

Controlled by residual_kind:

  • price: weight * (model_price - mid_price)
  • iv: weight * (model_iv - mid_iv), where model_iv is the Black implied volatility of the model price.
Source code in quantflow/options/calibration/base.py
def residuals(self, params: np.ndarray) -> np.ndarray:
    """Weighted residuals per option, in price or implied-vol space.

    Controlled by `residual_kind`:

    - `price`: `weight * (model_price - mid_price)`
    - `iv`: `weight * (model_iv - mid_iv)`, where `model_iv` is the
      Black implied volatility of the model price.
    """
    self.set_params(params)
    self.pricer.reset()
    with np.errstate(all="ignore"):
        try:
            model_prices = self.pricer.call_prices(self._ttms, self._log_strikes)
        except ValueError:
            return np.full(self._log_strikes.shape, 1e6)
        if self.residual_kind is ResidualKind.IV:
            implied = implied_black_volatility(
                self._log_strikes,
                model_prices,
                self._ttms,
                initial_sigma=self._mid_ivs,
                call_put=1,
            )
            # Fourier pricers can return prices outside the no-arb band
            # for deep-wing strikes, where Newton fails to invert. Mask
            # those points out (zero residual). If fewer than half of the
            # options invert successfully the parameter set is treated as
            # invalid and a large penalty is returned.
            ok = implied.converged
            if 2 * int(ok.sum()) < ok.size:
                return np.full(self._log_strikes.shape, 1e6)
            r = np.where(ok, implied.values - self._mid_ivs, 0.0)
        else:
            r = self.cost_weights() * (model_prices - self._mid_prices)
    return np.where(np.isfinite(r), r, 1e6)

cost_function

cost_function(params)

Scalar cost: sum of squared residuals plus any penalty

Source code in quantflow/options/calibration/base.py
def cost_function(self, params: np.ndarray) -> float:
    """Scalar cost: sum of squared residuals plus any penalty"""
    r = self.residuals(params)
    return float(np.dot(r, r)) + self.penalize()

plot

plot(index=0, *, max_moneyness=1.0, support=51, **kwargs)

Plot implied volatility for market and model prices

Source code in quantflow/options/calibration/base.py
def plot(
    self,
    index: int = 0,
    *,
    max_moneyness: float = 1.0,
    support: int = 51,
    **kwargs: Any,
) -> Any:
    """Plot implied volatility for market and model prices"""
    cross = self.vol_surface.maturities[index]
    options = tuple(self.vol_surface.option_prices(index=index, converged=True))
    return plot.plot_vol_surface(
        pd.DataFrame([d.info_dict() for d in options]),
        model=self._model_grid(cross.ttm(self.ref_date), max_moneyness, support),
        **kwargs,
    )

plot_maturities

plot_maturities(*, max_moneyness=1.0, support=51, cols=2, row_height=400, showlegend=False, **kwargs)

Plot implied volatility for all maturities as a subplot grid

Source code in quantflow/options/calibration/base.py
def plot_maturities(
    self,
    *,
    max_moneyness: float = 1.0,
    support: int = 51,
    cols: int = 2,
    row_height: int = 400,
    showlegend: bool = False,
    **kwargs: Any,
) -> Any:
    """Plot implied volatility for all maturities as a subplot grid"""
    plot.check_plotly()
    n = len(self.vol_surface.maturities)
    rows = (n + cols - 1) // cols
    titles = [
        cross.maturity.strftime("%Y-%m-%d") for cross in self.vol_surface.maturities
    ]
    fig = plot.make_subplots(rows=rows, cols=cols, subplot_titles=titles)
    fig.update_layout(height=rows * row_height, showlegend=showlegend)
    for i, cross in enumerate(self.vol_surface.maturities):
        row = i // cols + 1
        col = i % cols + 1
        options = tuple(self.vol_surface.option_prices(index=i, converged=True))
        plot.plot_vol_surface(
            pd.DataFrame([d.info_dict() for d in options]),
            model=self._model_grid(
                cross.ttm(self.ref_date), max_moneyness, support
            ),
            fig=fig,
            fig_params={"row": row, "col": col},
            **kwargs,
        )
    return fig

quantflow.options.calibration.heston.DoubleHestonCalibration pydantic-model

Bases: VolModelCalibration[DH], Generic[DH]

Calibration of the DoubleHeston model.

The parameter vector is [rate1, theta1, kappa_delta, sigma1, rho1, rate2, theta2, kappa2, sigma2, rho2] where kappa1 = kappa2 + kappa_delta with kappa_delta > 0, enforcing that the first (short-maturity) process always mean-reverts faster than the second.

The Feller penalty is applied independently to both variance processes. A warm start fits each process independently to its natural maturity range before the joint optimisation.

Fields:

feller_penalize pydantic-field

feller_penalize = 1000.0

Penalty weight for violating the Feller condition \(2\kappa\theta \geq \sigma^2\). Applied during the L-BFGS-B stage. Set to 0 to disable.

ttm_split pydantic-field

ttm_split = None

TTM threshold in years separating short-maturity options (fitted to heston1) from long-maturity options (fitted to heston2) during warm start. Defaults to the median TTM across all calibration options.

pricer pydantic-field

pricer

The OptionPricerBase for the model

vol_surface pydantic-field

vol_surface

The VolSurface to calibrate the model with

residual_kind pydantic-field

residual_kind = PRICE

Kind of residual used by the calibration cost function. price (default) measures the residual on forward-space option prices and applies the moneyness_weight cost weights; iv measures it on Black implied volatilities by inverting the model price. The iv residual is already in vol units and is naturally well-scaled across moneyness, so the moneyness_weight cost weights are not applied in that mode.

moneyness_weight pydantic-field

moneyness_weight = 0.0

Coefficient that up-weights wing options in the cost function. Applied as min(exp(moneyness_weight * moneyness**2), max_cost_weight), with moneyness = log(K/F) / sqrt(ttm). The quadratic form mimics the gaussian shape of 1/vega and puts wing residuals on the same footing as ATM ones. A value of 0 applies no moneyness weighting; typical values are in [0.1, 0.5].

max_cost_weight pydantic-field

max_cost_weight = 10.0

Hard cap on the per-option cost weight, to prevent a single deep-wing option from dominating the loss when moneyness_weight is large.

options pydantic-field

options

The options to calibrate

model property

model

ref_date property

ref_date

implied_vols property

implied_vols

maturity_split

maturity_split()

TTM split to use for warm start: explicit value or median of option TTMs.

Source code in quantflow/options/calibration/heston.py
def maturity_split(self) -> float:
    """TTM split to use for warm start: explicit value or median of option TTMs."""
    if self.ttm_split is not None:
        return self.ttm_split
    ttms = sorted({v.ttm for v in self.options.values()})
    return ttms[len(ttms) // 2]

get_bounds

get_bounds()
Source code in quantflow/options/calibration/heston.py
def get_bounds(self) -> Bounds:
    vol_range = self.implied_vol_range()
    vol_lb = 0.5 * vol_range.lb[0]
    vol_ub = 1.5 * vol_range.ub[0]
    v2 = vol_lb**2
    v2u = vol_ub**2
    return Bounds(
        [v2, v2, 1e-4, 0.0, -0.9, v2, v2, 0.0, 0.0, -0.9],
        [v2u, v2u, np.inf, np.inf, 0.0, v2u, v2u, 5.0, np.inf, 0.0],
    )

get_params

get_params()
Source code in quantflow/options/calibration/heston.py
def get_params(self) -> np.ndarray:
    vp1 = self.model.heston1.variance_process
    vp2 = self.model.heston2.variance_process
    kappa_delta = max(vp1.kappa - vp2.kappa, 1e-4)
    return np.asarray(
        [
            vp1.rate,
            vp1.theta,
            kappa_delta,
            vp1.sigma,
            self.model.heston1.rho,
            vp2.rate,
            vp2.theta,
            vp2.kappa,
            vp2.sigma,
            self.model.heston2.rho,
        ]
    )

set_params

set_params(params)
Source code in quantflow/options/calibration/heston.py
def set_params(self, params: np.ndarray) -> None:
    vp1 = self.model.heston1.variance_process
    vp1.rate = params[0]
    vp1.theta = params[1]
    vp1.sigma = params[3]
    self.model.heston1.rho = params[4]
    vp2 = self.model.heston2.variance_process
    vp2.rate = params[5]
    vp2.theta = params[6]
    vp2.kappa = params[7]
    vp2.sigma = params[8]
    self.model.heston2.rho = params[9]
    vp1.kappa = vp2.kappa + params[2]  # kappa2 + kappa_delta

feller_residuals

feller_residuals()

Extra residual terms penalising Feller violations for both processes.

Appended to the main residual vector so the TRF stage also sees the constraint, not just the L-BFGS-B stage.

Source code in quantflow/options/calibration/heston.py
def feller_residuals(self) -> list[float]:
    """Extra residual terms penalising Feller violations for both processes.

    Appended to the main residual vector so the TRF stage also sees the
    constraint, not just the L-BFGS-B stage.
    """
    w = self.feller_penalize**0.5
    neg1 = min(self.model.heston1.variance_process.feller_condition, 0.0)
    neg2 = min(self.model.heston2.variance_process.feller_condition, 0.0)
    return [w * neg1, w * neg2]

penalize

penalize()

Feller penalty applied independently to both variance processes

Source code in quantflow/options/calibration/heston.py
def penalize(self) -> float:
    """Feller penalty applied independently to both variance processes"""
    neg1 = min(self.model.heston1.variance_process.feller_condition, 0.0)
    neg2 = min(self.model.heston2.variance_process.feller_condition, 0.0)
    return self.feller_penalize * (neg1 * neg1 + neg2 * neg2)

residuals

residuals(params)
Source code in quantflow/options/calibration/heston.py
def residuals(self, params: np.ndarray) -> np.ndarray:
    return np.append(super().residuals(params), self.feller_residuals())

warm_start

warm_start()

Sequential single-Heston fits to initialise the joint optimisation.

Fits heston2 to long-dated options (ttm > split) then heston1 to short-dated options (ttm <= split), where the split defaults to the median TTM across all calibration options.

Source code in quantflow/options/calibration/heston.py
def warm_start(self) -> None:
    """Sequential single-Heston fits to initialise the joint optimisation.

    Fits heston2 to long-dated options (ttm > split) then heston1 to
    short-dated options (ttm <= split), where the split defaults to the
    median TTM across all calibration options.
    """
    split = self.maturity_split()
    long_options = {k: v for k, v in self.options.items() if v.ttm > split}
    short_options = {k: v for k, v in self.options.items() if v.ttm <= split}
    if long_options:
        h2 = Heston(
            variance_process=self.model.heston2.variance_process.model_copy(),
            rho=self.model.heston2.rho,
        )
        HestonCalibration(
            pricer=OptionPricer(model=h2),
            vol_surface=self.vol_surface,
            options=long_options,
        ).fit()
        self.model.heston2.variance_process = h2.variance_process
        self.model.heston2.rho = h2.rho
    if short_options:
        h1 = Heston(
            variance_process=self.model.heston1.variance_process.model_copy(),
            rho=self.model.heston1.rho,
        )
        HestonCalibration(
            pricer=OptionPricer(model=h1),
            vol_surface=self.vol_surface,
            options=short_options,
        ).fit()
        self.model.heston1.variance_process = h1.variance_process
        self.model.heston1.rho = h1.rho
    vp1 = self.model.heston1.variance_process
    vp2 = self.model.heston2.variance_process
    vp1.kappa = max(vp1.kappa, vp2.kappa + 1e-4)

fit

fit()

Warm-start then joint two-stage fit.

Source code in quantflow/options/calibration/heston.py
def fit(self) -> OptimizeResult:
    """Warm-start then joint two-stage fit."""
    self.warm_start()
    return super().fit()

implied_vol_range

implied_vol_range()

Range of implied volatilities across all calibration options

Source code in quantflow/options/calibration/base.py
def implied_vol_range(self) -> Bounds:
    """Range of implied volatilities across all calibration options"""
    return Bounds(
        min(option.implied_vol_range().lb for option in self.options.values()),
        max(option.implied_vol_range().ub for option in self.options.values()),
    )

cost_weight

cost_weight(ttm, log_strike)

Weight for a given time to maturity and log-strike.

Up-weights wing options via exp(moneyness_weight * moneyness**2), capped at max_cost_weight. The quadratic form mimics 1/vega.

Source code in quantflow/options/calibration/base.py
def cost_weight(self, ttm: float, log_strike: float) -> float:
    """Weight for a given time to maturity and log-strike.

    Up-weights wing options via `exp(moneyness_weight * moneyness**2)`,
    capped at `max_cost_weight`. The quadratic form mimics `1/vega`.
    """
    moneyness = log_strike / np.sqrt(ttm)
    weight = np.exp(self.moneyness_weight * moneyness * moneyness)
    return float(min(weight, self.max_cost_weight))

cost_weights

cost_weights()

Vector of cost weights for all calibration options

Source code in quantflow/options/calibration/base.py
def cost_weights(self) -> np.ndarray:
    """Vector of cost weights for all calibration options"""
    weights = np.exp(self.moneyness_weight * self._moneyness * self._moneyness)
    return np.minimum(weights, self.max_cost_weight)

cost_function

cost_function(params)

Scalar cost: sum of squared residuals plus any penalty

Source code in quantflow/options/calibration/base.py
def cost_function(self, params: np.ndarray) -> float:
    """Scalar cost: sum of squared residuals plus any penalty"""
    r = self.residuals(params)
    return float(np.dot(r, r)) + self.penalize()

plot

plot(index=0, *, max_moneyness=1.0, support=51, **kwargs)

Plot implied volatility for market and model prices

Source code in quantflow/options/calibration/base.py
def plot(
    self,
    index: int = 0,
    *,
    max_moneyness: float = 1.0,
    support: int = 51,
    **kwargs: Any,
) -> Any:
    """Plot implied volatility for market and model prices"""
    cross = self.vol_surface.maturities[index]
    options = tuple(self.vol_surface.option_prices(index=index, converged=True))
    return plot.plot_vol_surface(
        pd.DataFrame([d.info_dict() for d in options]),
        model=self._model_grid(cross.ttm(self.ref_date), max_moneyness, support),
        **kwargs,
    )

plot_maturities

plot_maturities(*, max_moneyness=1.0, support=51, cols=2, row_height=400, showlegend=False, **kwargs)

Plot implied volatility for all maturities as a subplot grid

Source code in quantflow/options/calibration/base.py
def plot_maturities(
    self,
    *,
    max_moneyness: float = 1.0,
    support: int = 51,
    cols: int = 2,
    row_height: int = 400,
    showlegend: bool = False,
    **kwargs: Any,
) -> Any:
    """Plot implied volatility for all maturities as a subplot grid"""
    plot.check_plotly()
    n = len(self.vol_surface.maturities)
    rows = (n + cols - 1) // cols
    titles = [
        cross.maturity.strftime("%Y-%m-%d") for cross in self.vol_surface.maturities
    ]
    fig = plot.make_subplots(rows=rows, cols=cols, subplot_titles=titles)
    fig.update_layout(height=rows * row_height, showlegend=showlegend)
    for i, cross in enumerate(self.vol_surface.maturities):
        row = i // cols + 1
        col = i % cols + 1
        options = tuple(self.vol_surface.option_prices(index=i, converged=True))
        plot.plot_vol_surface(
            pd.DataFrame([d.info_dict() for d in options]),
            model=self._model_grid(
                cross.ttm(self.ref_date), max_moneyness, support
            ),
            fig=fig,
            fig_params={"row": row, "col": col},
            **kwargs,
        )
    return fig

quantflow.options.calibration.heston.DoubleHestonJCalibration pydantic-model

Bases: DoubleHestonCalibration[DoubleHestonJ[D]], Generic[D]

Calibration of the DoubleHestonJ model.

Extends DoubleHestonCalibration by appending the jump parameters of heston1 to the parameter vector and bounds.

Overrides warm_start to fit a full HestonJCalibration to the short-dated options, so that the jump parameters are also initialised before the joint optimisation.

Fields:

pricer pydantic-field

pricer

The OptionPricerBase for the model

vol_surface pydantic-field

vol_surface

The VolSurface to calibrate the model with

residual_kind pydantic-field

residual_kind = PRICE

Kind of residual used by the calibration cost function. price (default) measures the residual on forward-space option prices and applies the moneyness_weight cost weights; iv measures it on Black implied volatilities by inverting the model price. The iv residual is already in vol units and is naturally well-scaled across moneyness, so the moneyness_weight cost weights are not applied in that mode.

moneyness_weight pydantic-field

moneyness_weight = 0.0

Coefficient that up-weights wing options in the cost function. Applied as min(exp(moneyness_weight * moneyness**2), max_cost_weight), with moneyness = log(K/F) / sqrt(ttm). The quadratic form mimics the gaussian shape of 1/vega and puts wing residuals on the same footing as ATM ones. A value of 0 applies no moneyness weighting; typical values are in [0.1, 0.5].

max_cost_weight pydantic-field

max_cost_weight = 10.0

Hard cap on the per-option cost weight, to prevent a single deep-wing option from dominating the loss when moneyness_weight is large.

options pydantic-field

options

The options to calibrate

model property

model

ref_date property

ref_date

implied_vols property

implied_vols

feller_penalize pydantic-field

feller_penalize = 1000.0

Penalty weight for violating the Feller condition \(2\kappa\theta \geq \sigma^2\). Applied during the L-BFGS-B stage. Set to 0 to disable.

ttm_split pydantic-field

ttm_split = None

TTM threshold in years separating short-maturity options (fitted to heston1) from long-maturity options (fitted to heston2) during warm start. Defaults to the median TTM across all calibration options.

get_bounds

get_bounds()
Source code in quantflow/options/calibration/heston.py
def get_bounds(self) -> Bounds:
    base = super().get_bounds()
    vol_range = self.implied_vol_range()
    vol_lb = 0.5 * vol_range.lb[0]
    vol_ub = 1.5 * vol_range.ub[0]
    lower = list(base.lb) + [1.0, (0.01 * vol_lb) ** 2]
    upper = list(base.ub) + [np.inf, (0.5 * vol_ub) ** 2]
    try:
        self.model.heston1.jumps.jumps.asymmetry()
        lower.append(-2.0)
        upper.append(2.0)
    except NotImplementedError:
        pass
    return Bounds(lower, upper)

get_params

get_params()
Source code in quantflow/options/calibration/heston.py
def get_params(self) -> np.ndarray:
    params = list(super().get_params()) + [
        self.model.heston1.jumps.intensity,
        self.model.heston1.jumps.jumps.variance(),
    ]
    try:
        params.append(self.model.heston1.jumps.jumps.asymmetry())
    except NotImplementedError:
        pass
    return np.asarray(params)

set_params

set_params(params)
Source code in quantflow/options/calibration/heston.py
def set_params(self, params: np.ndarray) -> None:
    super().set_params(params)
    self.model.heston1.jumps.intensity = params[10]
    self.model.heston1.jumps.jumps.set_variance(params[11])
    try:
        self.model.heston1.jumps.jumps.set_asymmetry(params[12])
    except IndexError:
        pass

implied_vol_range

implied_vol_range()

Range of implied volatilities across all calibration options

Source code in quantflow/options/calibration/base.py
def implied_vol_range(self) -> Bounds:
    """Range of implied volatilities across all calibration options"""
    return Bounds(
        min(option.implied_vol_range().lb for option in self.options.values()),
        max(option.implied_vol_range().ub for option in self.options.values()),
    )

fit

fit()

Warm-start then joint two-stage fit.

Source code in quantflow/options/calibration/heston.py
def fit(self) -> OptimizeResult:
    """Warm-start then joint two-stage fit."""
    self.warm_start()
    return super().fit()

cost_weight

cost_weight(ttm, log_strike)

Weight for a given time to maturity and log-strike.

Up-weights wing options via exp(moneyness_weight * moneyness**2), capped at max_cost_weight. The quadratic form mimics 1/vega.

Source code in quantflow/options/calibration/base.py
def cost_weight(self, ttm: float, log_strike: float) -> float:
    """Weight for a given time to maturity and log-strike.

    Up-weights wing options via `exp(moneyness_weight * moneyness**2)`,
    capped at `max_cost_weight`. The quadratic form mimics `1/vega`.
    """
    moneyness = log_strike / np.sqrt(ttm)
    weight = np.exp(self.moneyness_weight * moneyness * moneyness)
    return float(min(weight, self.max_cost_weight))

cost_weights

cost_weights()

Vector of cost weights for all calibration options

Source code in quantflow/options/calibration/base.py
def cost_weights(self) -> np.ndarray:
    """Vector of cost weights for all calibration options"""
    weights = np.exp(self.moneyness_weight * self._moneyness * self._moneyness)
    return np.minimum(weights, self.max_cost_weight)

penalize

penalize()

Feller penalty applied independently to both variance processes

Source code in quantflow/options/calibration/heston.py
def penalize(self) -> float:
    """Feller penalty applied independently to both variance processes"""
    neg1 = min(self.model.heston1.variance_process.feller_condition, 0.0)
    neg2 = min(self.model.heston2.variance_process.feller_condition, 0.0)
    return self.feller_penalize * (neg1 * neg1 + neg2 * neg2)

residuals

residuals(params)
Source code in quantflow/options/calibration/heston.py
def residuals(self, params: np.ndarray) -> np.ndarray:
    return np.append(super().residuals(params), self.feller_residuals())

cost_function

cost_function(params)

Scalar cost: sum of squared residuals plus any penalty

Source code in quantflow/options/calibration/base.py
def cost_function(self, params: np.ndarray) -> float:
    """Scalar cost: sum of squared residuals plus any penalty"""
    r = self.residuals(params)
    return float(np.dot(r, r)) + self.penalize()

plot

plot(index=0, *, max_moneyness=1.0, support=51, **kwargs)

Plot implied volatility for market and model prices

Source code in quantflow/options/calibration/base.py
def plot(
    self,
    index: int = 0,
    *,
    max_moneyness: float = 1.0,
    support: int = 51,
    **kwargs: Any,
) -> Any:
    """Plot implied volatility for market and model prices"""
    cross = self.vol_surface.maturities[index]
    options = tuple(self.vol_surface.option_prices(index=index, converged=True))
    return plot.plot_vol_surface(
        pd.DataFrame([d.info_dict() for d in options]),
        model=self._model_grid(cross.ttm(self.ref_date), max_moneyness, support),
        **kwargs,
    )

plot_maturities

plot_maturities(*, max_moneyness=1.0, support=51, cols=2, row_height=400, showlegend=False, **kwargs)

Plot implied volatility for all maturities as a subplot grid

Source code in quantflow/options/calibration/base.py
def plot_maturities(
    self,
    *,
    max_moneyness: float = 1.0,
    support: int = 51,
    cols: int = 2,
    row_height: int = 400,
    showlegend: bool = False,
    **kwargs: Any,
) -> Any:
    """Plot implied volatility for all maturities as a subplot grid"""
    plot.check_plotly()
    n = len(self.vol_surface.maturities)
    rows = (n + cols - 1) // cols
    titles = [
        cross.maturity.strftime("%Y-%m-%d") for cross in self.vol_surface.maturities
    ]
    fig = plot.make_subplots(rows=rows, cols=cols, subplot_titles=titles)
    fig.update_layout(height=rows * row_height, showlegend=showlegend)
    for i, cross in enumerate(self.vol_surface.maturities):
        row = i // cols + 1
        col = i % cols + 1
        options = tuple(self.vol_surface.option_prices(index=i, converged=True))
        plot.plot_vol_surface(
            pd.DataFrame([d.info_dict() for d in options]),
            model=self._model_grid(
                cross.ttm(self.ref_date), max_moneyness, support
            ),
            fig=fig,
            fig_params={"row": row, "col": col},
            **kwargs,
        )
    return fig

maturity_split

maturity_split()

TTM split to use for warm start: explicit value or median of option TTMs.

Source code in quantflow/options/calibration/heston.py
def maturity_split(self) -> float:
    """TTM split to use for warm start: explicit value or median of option TTMs."""
    if self.ttm_split is not None:
        return self.ttm_split
    ttms = sorted({v.ttm for v in self.options.values()})
    return ttms[len(ttms) // 2]

feller_residuals

feller_residuals()

Extra residual terms penalising Feller violations for both processes.

Appended to the main residual vector so the TRF stage also sees the constraint, not just the L-BFGS-B stage.

Source code in quantflow/options/calibration/heston.py
def feller_residuals(self) -> list[float]:
    """Extra residual terms penalising Feller violations for both processes.

    Appended to the main residual vector so the TRF stage also sees the
    constraint, not just the L-BFGS-B stage.
    """
    w = self.feller_penalize**0.5
    neg1 = min(self.model.heston1.variance_process.feller_condition, 0.0)
    neg2 = min(self.model.heston2.variance_process.feller_condition, 0.0)
    return [w * neg1, w * neg2]

warm_start

warm_start()

Sequential single-Heston fits to initialise the joint optimisation.

Fits heston2 to long-dated options (ttm > split) then heston1 to short-dated options (ttm <= split), where the split defaults to the median TTM across all calibration options.

Source code in quantflow/options/calibration/heston.py
def warm_start(self) -> None:
    """Sequential single-Heston fits to initialise the joint optimisation.

    Fits heston2 to long-dated options (ttm > split) then heston1 to
    short-dated options (ttm <= split), where the split defaults to the
    median TTM across all calibration options.
    """
    split = self.maturity_split()
    long_options = {k: v for k, v in self.options.items() if v.ttm > split}
    short_options = {k: v for k, v in self.options.items() if v.ttm <= split}
    if long_options:
        h2 = Heston(
            variance_process=self.model.heston2.variance_process.model_copy(),
            rho=self.model.heston2.rho,
        )
        HestonCalibration(
            pricer=OptionPricer(model=h2),
            vol_surface=self.vol_surface,
            options=long_options,
        ).fit()
        self.model.heston2.variance_process = h2.variance_process
        self.model.heston2.rho = h2.rho
    if short_options:
        h1 = Heston(
            variance_process=self.model.heston1.variance_process.model_copy(),
            rho=self.model.heston1.rho,
        )
        HestonCalibration(
            pricer=OptionPricer(model=h1),
            vol_surface=self.vol_surface,
            options=short_options,
        ).fit()
        self.model.heston1.variance_process = h1.variance_process
        self.model.heston1.rho = h1.rho
    vp1 = self.model.heston1.variance_process
    vp2 = self.model.heston2.variance_process
    vp1.kappa = max(vp1.kappa, vp2.kappa + 1e-4)

quantflow.options.calibration.bns.BNSCalibration pydantic-model

Bases: VolModelCalibration[B], Generic[B]

Calibration of the BNS stochastic volatility model.

The parameter vector is [v0, theta, kappa, beta, rho] where

  • v0 is the initial variance (\(v_0 = \text{variance\_process.rate}\))
  • theta is the long-run variance (\(\theta = \lambda / \beta\))
  • kappa is the mean-reversion speed of the variance process
  • beta is the exponential decay of the BDLP jump-size distribution
  • rho is the leverage parameter (correlation between jumps in variance and jumps in log-price)

The BDLP intensity is set as \(\lambda = \theta \beta\) so that the stationary mean of the variance process equals \(\theta\), mirroring the Heston parameterisation. The Gamma-OU variance process is positive by construction, so no Feller-style penalty is needed.

Fields:

pricer pydantic-field

pricer

The OptionPricerBase for the model

vol_surface pydantic-field

vol_surface

The VolSurface to calibrate the model with

residual_kind pydantic-field

residual_kind = PRICE

Kind of residual used by the calibration cost function. price (default) measures the residual on forward-space option prices and applies the moneyness_weight cost weights; iv measures it on Black implied volatilities by inverting the model price. The iv residual is already in vol units and is naturally well-scaled across moneyness, so the moneyness_weight cost weights are not applied in that mode.

moneyness_weight pydantic-field

moneyness_weight = 0.0

Coefficient that up-weights wing options in the cost function. Applied as min(exp(moneyness_weight * moneyness**2), max_cost_weight), with moneyness = log(K/F) / sqrt(ttm). The quadratic form mimics the gaussian shape of 1/vega and puts wing residuals on the same footing as ATM ones. A value of 0 applies no moneyness weighting; typical values are in [0.1, 0.5].

max_cost_weight pydantic-field

max_cost_weight = 10.0

Hard cap on the per-option cost weight, to prevent a single deep-wing option from dominating the loss when moneyness_weight is large.

options pydantic-field

options

The options to calibrate

model property

model

ref_date property

ref_date

implied_vols property

implied_vols

get_bounds

get_bounds()
Source code in quantflow/options/calibration/bns.py
def get_bounds(self) -> Bounds:
    vol_range = self.implied_vol_range()
    vol_lb = 0.5 * vol_range.lb[0]
    vol_ub = 1.5 * vol_range.ub[0]
    v2 = vol_lb**2
    v2u = vol_ub**2
    return Bounds(
        [v2, v2, 1e-3, 1.0, -0.9],
        [v2u, v2u, np.inf, np.inf, 0.9],
    )

get_params

get_params()
Source code in quantflow/options/calibration/bns.py
def get_params(self) -> np.ndarray:
    vp = self.model.variance_process
    theta = vp.intensity / vp.beta
    return np.asarray([vp.rate, theta, vp.kappa, vp.beta, self.model.rho])

set_params

set_params(params)
Source code in quantflow/options/calibration/bns.py
def set_params(self, params: np.ndarray) -> None:
    vp = self.model.variance_process
    vp.rate = params[0]
    vp.kappa = params[2]
    vp.bdlp.jumps.decay = params[3]
    vp.bdlp.intensity = params[1] * params[3]
    self.model.rho = params[4]

implied_vol_range

implied_vol_range()

Range of implied volatilities across all calibration options

Source code in quantflow/options/calibration/base.py
def implied_vol_range(self) -> Bounds:
    """Range of implied volatilities across all calibration options"""
    return Bounds(
        min(option.implied_vol_range().lb for option in self.options.values()),
        max(option.implied_vol_range().ub for option in self.options.values()),
    )

fit

fit()

Two-stage fit: Nelder-Mead basin search then LM refinement.

Stage 1 (Nelder-Mead): gradient-free minimisation of cost_function to reach the right basin of attraction.

Stage 2 (TRF/LM): scipy.optimize.least_squares on the residuals vector with parameter bounds for precise convergence.

Source code in quantflow/options/calibration/base.py
def fit(self) -> OptimizeResult:
    """Two-stage fit: Nelder-Mead basin search then LM refinement.

    Stage 1 (Nelder-Mead): gradient-free minimisation of `cost_function`
    to reach the right basin of attraction.

    Stage 2 (TRF/LM): `scipy.optimize.least_squares` on the `residuals`
    vector with parameter bounds for precise convergence.
    """
    bounds = self.get_bounds()
    stage1 = minimize(
        self.cost_function,
        self.get_params(),
        method="L-BFGS-B",
        bounds=list(zip(bounds.lb, bounds.ub)),
    )
    result = least_squares(
        self.residuals,
        np.clip(stage1.x, bounds.lb, bounds.ub),
        method="trf",
        bounds=(bounds.lb, bounds.ub),
        ftol=1e-10,
        xtol=1e-10,
        gtol=1e-10,
        max_nfev=10000,
    )
    self.set_params(result.x)
    return result

cost_weight

cost_weight(ttm, log_strike)

Weight for a given time to maturity and log-strike.

Up-weights wing options via exp(moneyness_weight * moneyness**2), capped at max_cost_weight. The quadratic form mimics 1/vega.

Source code in quantflow/options/calibration/base.py
def cost_weight(self, ttm: float, log_strike: float) -> float:
    """Weight for a given time to maturity and log-strike.

    Up-weights wing options via `exp(moneyness_weight * moneyness**2)`,
    capped at `max_cost_weight`. The quadratic form mimics `1/vega`.
    """
    moneyness = log_strike / np.sqrt(ttm)
    weight = np.exp(self.moneyness_weight * moneyness * moneyness)
    return float(min(weight, self.max_cost_weight))

cost_weights

cost_weights()

Vector of cost weights for all calibration options

Source code in quantflow/options/calibration/base.py
def cost_weights(self) -> np.ndarray:
    """Vector of cost weights for all calibration options"""
    weights = np.exp(self.moneyness_weight * self._moneyness * self._moneyness)
    return np.minimum(weights, self.max_cost_weight)

penalize

penalize()

Additional scalar penalty added to the cost function (default: 0)

Source code in quantflow/options/calibration/base.py
def penalize(self) -> float:
    """Additional scalar penalty added to the cost function (default: 0)"""
    return 0.0

residuals

residuals(params)

Weighted residuals per option, in price or implied-vol space.

Controlled by residual_kind:

  • price: weight * (model_price - mid_price)
  • iv: weight * (model_iv - mid_iv), where model_iv is the Black implied volatility of the model price.
Source code in quantflow/options/calibration/base.py
def residuals(self, params: np.ndarray) -> np.ndarray:
    """Weighted residuals per option, in price or implied-vol space.

    Controlled by `residual_kind`:

    - `price`: `weight * (model_price - mid_price)`
    - `iv`: `weight * (model_iv - mid_iv)`, where `model_iv` is the
      Black implied volatility of the model price.
    """
    self.set_params(params)
    self.pricer.reset()
    with np.errstate(all="ignore"):
        try:
            model_prices = self.pricer.call_prices(self._ttms, self._log_strikes)
        except ValueError:
            return np.full(self._log_strikes.shape, 1e6)
        if self.residual_kind is ResidualKind.IV:
            implied = implied_black_volatility(
                self._log_strikes,
                model_prices,
                self._ttms,
                initial_sigma=self._mid_ivs,
                call_put=1,
            )
            # Fourier pricers can return prices outside the no-arb band
            # for deep-wing strikes, where Newton fails to invert. Mask
            # those points out (zero residual). If fewer than half of the
            # options invert successfully the parameter set is treated as
            # invalid and a large penalty is returned.
            ok = implied.converged
            if 2 * int(ok.sum()) < ok.size:
                return np.full(self._log_strikes.shape, 1e6)
            r = np.where(ok, implied.values - self._mid_ivs, 0.0)
        else:
            r = self.cost_weights() * (model_prices - self._mid_prices)
    return np.where(np.isfinite(r), r, 1e6)

cost_function

cost_function(params)

Scalar cost: sum of squared residuals plus any penalty

Source code in quantflow/options/calibration/base.py
def cost_function(self, params: np.ndarray) -> float:
    """Scalar cost: sum of squared residuals plus any penalty"""
    r = self.residuals(params)
    return float(np.dot(r, r)) + self.penalize()

plot

plot(index=0, *, max_moneyness=1.0, support=51, **kwargs)

Plot implied volatility for market and model prices

Source code in quantflow/options/calibration/base.py
def plot(
    self,
    index: int = 0,
    *,
    max_moneyness: float = 1.0,
    support: int = 51,
    **kwargs: Any,
) -> Any:
    """Plot implied volatility for market and model prices"""
    cross = self.vol_surface.maturities[index]
    options = tuple(self.vol_surface.option_prices(index=index, converged=True))
    return plot.plot_vol_surface(
        pd.DataFrame([d.info_dict() for d in options]),
        model=self._model_grid(cross.ttm(self.ref_date), max_moneyness, support),
        **kwargs,
    )

plot_maturities

plot_maturities(*, max_moneyness=1.0, support=51, cols=2, row_height=400, showlegend=False, **kwargs)

Plot implied volatility for all maturities as a subplot grid

Source code in quantflow/options/calibration/base.py
def plot_maturities(
    self,
    *,
    max_moneyness: float = 1.0,
    support: int = 51,
    cols: int = 2,
    row_height: int = 400,
    showlegend: bool = False,
    **kwargs: Any,
) -> Any:
    """Plot implied volatility for all maturities as a subplot grid"""
    plot.check_plotly()
    n = len(self.vol_surface.maturities)
    rows = (n + cols - 1) // cols
    titles = [
        cross.maturity.strftime("%Y-%m-%d") for cross in self.vol_surface.maturities
    ]
    fig = plot.make_subplots(rows=rows, cols=cols, subplot_titles=titles)
    fig.update_layout(height=rows * row_height, showlegend=showlegend)
    for i, cross in enumerate(self.vol_surface.maturities):
        row = i // cols + 1
        col = i % cols + 1
        options = tuple(self.vol_surface.option_prices(index=i, converged=True))
        plot.plot_vol_surface(
            pd.DataFrame([d.info_dict() for d in options]),
            model=self._model_grid(
                cross.ttm(self.ref_date), max_moneyness, support
            ),
            fig=fig,
            fig_params={"row": row, "col": col},
            **kwargs,
        )
    return fig

quantflow.options.calibration.bns.BNS2Calibration pydantic-model

Bases: VolModelCalibration[B2], Generic[B2]

Calibration of the BNS2 two-factor BNS model.

Following the BNS superposition-of-OU construction, both factors share the same Gamma stationary marginal: only the mean-reversion timescales and the leverage parameters differ between the fast and slow factors. The parameter vector has nine entries:

[v01, v02, theta, beta, kappa2, kappa_delta, rho1, rho2, w]

Symbol Description
v01, v02 Initial variances of the two factors
theta Long-run variance shared by both factors (\(\theta = \lambda / \beta\))
beta Exponential decay of the BDLP jump-size distribution (shared)
kappa2 Mean-reversion speed of the slow factor
kappa_delta Excess speed of the fast factor (\(\kappa_1 - \kappa_2\))
rho1, rho2 Leverage of the two factors, free in \([-0.9, 0.9]\)
w Weight of the first variance factor in the convex combination

Tying \((\theta, \beta)\) removes the degeneracy between the two marginal-distribution parameters and the timescales: the long-dated smile pins down a single stationary variance distribution, while the term structure of vol identifies the two relaxation speeds. The leverages \(\rho_1, \rho_2\) stay independent because the empirical equity skew flattens with maturity, which a single shared leverage cannot reproduce.

The user-supplied initial model still seeds the fit: pick distinct timescales for bns1 and bns2 (and consider opposite-sign leverages) so the optimiser starts away from the single-factor collapse. Any difference in (theta, beta) between the two seed factors is averaged when building the starting parameter vector.

Fields:

pricer pydantic-field

pricer

The OptionPricerBase for the model

vol_surface pydantic-field

vol_surface

The VolSurface to calibrate the model with

residual_kind pydantic-field

residual_kind = PRICE

Kind of residual used by the calibration cost function. price (default) measures the residual on forward-space option prices and applies the moneyness_weight cost weights; iv measures it on Black implied volatilities by inverting the model price. The iv residual is already in vol units and is naturally well-scaled across moneyness, so the moneyness_weight cost weights are not applied in that mode.

moneyness_weight pydantic-field

moneyness_weight = 0.0

Coefficient that up-weights wing options in the cost function. Applied as min(exp(moneyness_weight * moneyness**2), max_cost_weight), with moneyness = log(K/F) / sqrt(ttm). The quadratic form mimics the gaussian shape of 1/vega and puts wing residuals on the same footing as ATM ones. A value of 0 applies no moneyness weighting; typical values are in [0.1, 0.5].

max_cost_weight pydantic-field

max_cost_weight = 10.0

Hard cap on the per-option cost weight, to prevent a single deep-wing option from dominating the loss when moneyness_weight is large.

options pydantic-field

options

The options to calibrate

model property

model

ref_date property

ref_date

implied_vols property

implied_vols

get_bounds

get_bounds()
Source code in quantflow/options/calibration/bns.py
def get_bounds(self) -> Bounds:
    vol_range = self.implied_vol_range()
    vol_lb = 0.5 * vol_range.lb[0]
    vol_ub = 1.5 * vol_range.ub[0]
    v2 = vol_lb**2
    v2u = vol_ub**2
    return Bounds(
        #  v01, v02, theta, beta, kappa2, kappa_delta, rho1, rho2, w
        [v2, v2, v2, 1.0, 1e-3, 1e-4, -0.9, -0.9, 0.0],
        [v2u, v2u, v2u, np.inf, 5.0, np.inf, 0.9, 0.9, 1.0],
    )

get_params

get_params()
Source code in quantflow/options/calibration/bns.py
def get_params(self) -> np.ndarray:
    vp1 = self.model.bns1.variance_process
    vp2 = self.model.bns2.variance_process
    theta1 = vp1.intensity / vp1.beta
    theta2 = vp2.intensity / vp2.beta
    theta = 0.5 * (theta1 + theta2)
    beta = 0.5 * (vp1.beta + vp2.beta)
    kappa_delta = max(vp1.kappa - vp2.kappa, 1e-4)
    return np.asarray(
        [
            vp1.rate,
            vp2.rate,
            theta,
            beta,
            vp2.kappa,
            kappa_delta,
            self.model.bns1.rho,
            self.model.bns2.rho,
            self.model.weight,
        ]
    )

set_params

set_params(params)
Source code in quantflow/options/calibration/bns.py
def set_params(self, params: np.ndarray) -> None:
    v01, v02, theta, beta, kappa2, kappa_delta, rho1, rho2, w = params
    vp1 = self.model.bns1.variance_process
    vp2 = self.model.bns2.variance_process
    vp1.rate = v01
    vp2.rate = v02
    vp1.bdlp.jumps.decay = beta
    vp2.bdlp.jumps.decay = beta
    intensity = theta * beta
    vp1.bdlp.intensity = intensity
    vp2.bdlp.intensity = intensity
    vp2.kappa = kappa2
    vp1.kappa = kappa2 + kappa_delta
    self.model.bns1.rho = rho1
    self.model.bns2.rho = rho2
    self.model.weight = w

implied_vol_range

implied_vol_range()

Range of implied volatilities across all calibration options

Source code in quantflow/options/calibration/base.py
def implied_vol_range(self) -> Bounds:
    """Range of implied volatilities across all calibration options"""
    return Bounds(
        min(option.implied_vol_range().lb for option in self.options.values()),
        max(option.implied_vol_range().ub for option in self.options.values()),
    )

fit

fit()

Two-stage fit: Nelder-Mead basin search then LM refinement.

Stage 1 (Nelder-Mead): gradient-free minimisation of cost_function to reach the right basin of attraction.

Stage 2 (TRF/LM): scipy.optimize.least_squares on the residuals vector with parameter bounds for precise convergence.

Source code in quantflow/options/calibration/base.py
def fit(self) -> OptimizeResult:
    """Two-stage fit: Nelder-Mead basin search then LM refinement.

    Stage 1 (Nelder-Mead): gradient-free minimisation of `cost_function`
    to reach the right basin of attraction.

    Stage 2 (TRF/LM): `scipy.optimize.least_squares` on the `residuals`
    vector with parameter bounds for precise convergence.
    """
    bounds = self.get_bounds()
    stage1 = minimize(
        self.cost_function,
        self.get_params(),
        method="L-BFGS-B",
        bounds=list(zip(bounds.lb, bounds.ub)),
    )
    result = least_squares(
        self.residuals,
        np.clip(stage1.x, bounds.lb, bounds.ub),
        method="trf",
        bounds=(bounds.lb, bounds.ub),
        ftol=1e-10,
        xtol=1e-10,
        gtol=1e-10,
        max_nfev=10000,
    )
    self.set_params(result.x)
    return result

cost_weight

cost_weight(ttm, log_strike)

Weight for a given time to maturity and log-strike.

Up-weights wing options via exp(moneyness_weight * moneyness**2), capped at max_cost_weight. The quadratic form mimics 1/vega.

Source code in quantflow/options/calibration/base.py
def cost_weight(self, ttm: float, log_strike: float) -> float:
    """Weight for a given time to maturity and log-strike.

    Up-weights wing options via `exp(moneyness_weight * moneyness**2)`,
    capped at `max_cost_weight`. The quadratic form mimics `1/vega`.
    """
    moneyness = log_strike / np.sqrt(ttm)
    weight = np.exp(self.moneyness_weight * moneyness * moneyness)
    return float(min(weight, self.max_cost_weight))

cost_weights

cost_weights()

Vector of cost weights for all calibration options

Source code in quantflow/options/calibration/base.py
def cost_weights(self) -> np.ndarray:
    """Vector of cost weights for all calibration options"""
    weights = np.exp(self.moneyness_weight * self._moneyness * self._moneyness)
    return np.minimum(weights, self.max_cost_weight)

penalize

penalize()

Additional scalar penalty added to the cost function (default: 0)

Source code in quantflow/options/calibration/base.py
def penalize(self) -> float:
    """Additional scalar penalty added to the cost function (default: 0)"""
    return 0.0

residuals

residuals(params)

Weighted residuals per option, in price or implied-vol space.

Controlled by residual_kind:

  • price: weight * (model_price - mid_price)
  • iv: weight * (model_iv - mid_iv), where model_iv is the Black implied volatility of the model price.
Source code in quantflow/options/calibration/base.py
def residuals(self, params: np.ndarray) -> np.ndarray:
    """Weighted residuals per option, in price or implied-vol space.

    Controlled by `residual_kind`:

    - `price`: `weight * (model_price - mid_price)`
    - `iv`: `weight * (model_iv - mid_iv)`, where `model_iv` is the
      Black implied volatility of the model price.
    """
    self.set_params(params)
    self.pricer.reset()
    with np.errstate(all="ignore"):
        try:
            model_prices = self.pricer.call_prices(self._ttms, self._log_strikes)
        except ValueError:
            return np.full(self._log_strikes.shape, 1e6)
        if self.residual_kind is ResidualKind.IV:
            implied = implied_black_volatility(
                self._log_strikes,
                model_prices,
                self._ttms,
                initial_sigma=self._mid_ivs,
                call_put=1,
            )
            # Fourier pricers can return prices outside the no-arb band
            # for deep-wing strikes, where Newton fails to invert. Mask
            # those points out (zero residual). If fewer than half of the
            # options invert successfully the parameter set is treated as
            # invalid and a large penalty is returned.
            ok = implied.converged
            if 2 * int(ok.sum()) < ok.size:
                return np.full(self._log_strikes.shape, 1e6)
            r = np.where(ok, implied.values - self._mid_ivs, 0.0)
        else:
            r = self.cost_weights() * (model_prices - self._mid_prices)
    return np.where(np.isfinite(r), r, 1e6)

cost_function

cost_function(params)

Scalar cost: sum of squared residuals plus any penalty

Source code in quantflow/options/calibration/base.py
def cost_function(self, params: np.ndarray) -> float:
    """Scalar cost: sum of squared residuals plus any penalty"""
    r = self.residuals(params)
    return float(np.dot(r, r)) + self.penalize()

plot

plot(index=0, *, max_moneyness=1.0, support=51, **kwargs)

Plot implied volatility for market and model prices

Source code in quantflow/options/calibration/base.py
def plot(
    self,
    index: int = 0,
    *,
    max_moneyness: float = 1.0,
    support: int = 51,
    **kwargs: Any,
) -> Any:
    """Plot implied volatility for market and model prices"""
    cross = self.vol_surface.maturities[index]
    options = tuple(self.vol_surface.option_prices(index=index, converged=True))
    return plot.plot_vol_surface(
        pd.DataFrame([d.info_dict() for d in options]),
        model=self._model_grid(cross.ttm(self.ref_date), max_moneyness, support),
        **kwargs,
    )

plot_maturities

plot_maturities(*, max_moneyness=1.0, support=51, cols=2, row_height=400, showlegend=False, **kwargs)

Plot implied volatility for all maturities as a subplot grid

Source code in quantflow/options/calibration/base.py
def plot_maturities(
    self,
    *,
    max_moneyness: float = 1.0,
    support: int = 51,
    cols: int = 2,
    row_height: int = 400,
    showlegend: bool = False,
    **kwargs: Any,
) -> Any:
    """Plot implied volatility for all maturities as a subplot grid"""
    plot.check_plotly()
    n = len(self.vol_surface.maturities)
    rows = (n + cols - 1) // cols
    titles = [
        cross.maturity.strftime("%Y-%m-%d") for cross in self.vol_surface.maturities
    ]
    fig = plot.make_subplots(rows=rows, cols=cols, subplot_titles=titles)
    fig.update_layout(height=rows * row_height, showlegend=showlegend)
    for i, cross in enumerate(self.vol_surface.maturities):
        row = i // cols + 1
        col = i % cols + 1
        options = tuple(self.vol_surface.option_prices(index=i, converged=True))
        plot.plot_vol_surface(
            pd.DataFrame([d.info_dict() for d in options]),
            model=self._model_grid(
                cross.ttm(self.ref_date), max_moneyness, support
            ),
            fig=fig,
            fig_params={"row": row, "col": col},
            **kwargs,
        )
    return fig