Calibration pipeline

All iterative models calibrate via L-BFGS-B (bounded quasi-Newton) with automatic Nelder-Mead fallback; DirectSVI solves a closed-form eigenvalue problem instead.

Calibration controls

Every iterative model accepts the same optional keyword arguments through calibrate / calibrate_slice:

params = calibrate_slice(
    df_slice, model,
    objective="vega_weighted",       # residual space
    loss="soft_l1",                  # robust aggregation
    initialization="multi_start",    # start strategy
)

Objectives (residual spaces)

  • "total_variance" (default) — residuals in \(w\); the historical behaviour.

  • "implied_vol" — residuals in total volatility \(\sqrt{w}\) (equivalent to implied-vol residuals up to a per-slice constant).

  • "price" — residuals in forward-normalized undiscounted Black call prices.

  • "vega_weighted" — volatility residuals weighted by Black vega computed from the market quotes (normalized to mean one); approximates price errors while staying in vol space, and is usually the best practical default for real chains.

  • "bid_ask" — zero loss inside the quoted band, distance outside it. Pass the band as total-variance arrays: w_bid = iv_bid**2 * T, w_ask = iv_ask**2 * T.

Robust losses

loss="l2" (default), "huber", "soft_l1", or "cauchy", following the scipy.optimize.least_squares convention: contribution \(= f^2 \rho(r/f)\) with scale \(f\). When f_scale is not given, it defaults to \(1.4826 \times \mathrm{MAD}\) of the residuals at a pilot l2 fit, floored at \(10^{-6}\) of the data scale — so genuine outliers stand out against the fitted noise level, and on clean data the robust losses reduce to l2. A single corrupted far-wing quote that visibly distorts an l2 SVI fit is largely ignored under soft_l1 or cauchy.

Initialisation

  • initialization="default" — the per-model heuristics (unchanged behaviour).

  • "jump_wings" (SVI and NaturalSVI) — a data-driven start read off the quotes: wing slopes from the outer 20% of strikes on each side, skew from their asymmetry, vertex from the minimum-variance strike.

  • "multi_start" — a deterministic grid of starts (the default start plus skew and width variations, 16 total); each runs L-BFGS-B to tight tolerance and the result with the lowest objective value wins. The default start is additionally run under the default path’s own settings, so multi_start can never return a worse fit than default with the same controls. Raw SVI’s landscape has genuine bad basins that a single start can fall into — multi-start is the recommended setting whenever fit quality matters more than the last millisecond, and it is cheap under the numba backend.

Temporal stability: prior-anchored fits

Two nearly identical markets can produce materially different parameters — the optimizer lands in an equivalent basin, and raw SVI coordinates are not equally meaningful. Every iterative model (and the surface fitters) accepts:

params_today = model.calibrate(k, w, prior=params_yesterday, anchor=1.0)
surface_today = VolSurface.fit(df, prior=surface_yesterday, anchor=1.0)

prior warm-starts from yesterday’s parameters (with multi_start it becomes the base start of the grid); anchor > 0 adds a Tikhonov pull anchor * mean((w(k) - w_prior(k))^2) on the penalty grid — anchoring the surface shape, not the raw parameters, so near-degenerate parameter directions are not pinned arbitrarily. anchor=0 (the default) leaves the objective untouched, and identical quotes plus an identical prior give identical parameters.

When any control is active the optimizer runs with tight tolerances (ftol=1e-15); the plain default path keeps scipy’s defaults for backward-compatible fits.

Failure semantics: strict / warn / lenient

The ingestion and fit entry points (OptionChain.from_dataframe, chain.fit, VolSurface.fit, calibrate_surface) take mode:

  • "strict" — the first bad input raises with its location: an invalid quote row (with its input row index), an expiry that cannot form a forward, a mid quote whose implied vol will not invert, a slice too thin to calibrate or that fails to converge (with its maturity).

  • "warn" (default) — problems are logged and recorded; the previous behaviour.

  • "lenient" — problems are filtered silently, but still recorded.

Nothing disappears without a count: rejected quotes, failed inversions, and skipped expiries land on chain.rejections and flow onto the surface’s fit report (n_rejected_quotes, n_failed_inversions, n_skipped_expiries, plus the per-slice quote accounting that was already there), in every mode. Schema errors — an unrecognized cp value, an unknown mode — raise in every mode: they are caller bugs, not bad data.

The library installs no blanket warning suppression, and implied-vol inversion catches only the specific py_vollib failure exceptions (below intrinsic, above maximum, and numerical-domain errors); anything else propagates.

Per-slice pipeline

The pipeline for a single maturity slice is:

  1. prepare_slice: extracts \(T\), \(F\), computes \(k = \log(K/F)\) and \(w = \sigma_{\mathrm{mkt}}^2 T\), filters invalid data, clips extreme moneyness.

  2. model.calibrate: minimises the mean squared error between model and market total variance, plus penalty terms:

\[\min_{\text{params}}\; \mathrm{MSE}\big(w_{\mathrm{model}}(k),\, w_{\mathrm{target}}\big) + \text{penalties}\]
  1. apply_slice: evaluates the fitted surface and computes fitted vols and residuals:

\[\sigma_{\mathrm{fit}}(k) = \sqrt{\frac{w(k)}{T}}\]

Input preparation helpers

If you’re starting from raw option prices rather than implied vols:

Implied volatilities

compute_ivs_vectorized computes Black-Scholes-Merton implied vols from option mid-prices via py_vollib. Failures (e.g. below-intrinsic prices) come back as NaN. Under the hood py_vollib inverts with Jäckel’s “Let’s Be Rational” algorithm (full machine precision in ~two price evaluations); see Real-data examples for a survey of inversion methods, including Schadner’s explicit Volfi inverse.

Implied forwards

calculate_implied_forward estimates the forward price from put-call parity:

\[F = K + e^{rT}(C - P)\]

The rate (and every rate input in the library) accepts a flat float, an interest_rate_models.DiscountCurve, an interest-rate model from that package (its implied zero curve from today is used), or any callable \(T \mapsto r(T)\) such as a cubic spline over curve pillars.

OTM leg selection

choose_leg selects the OTM leg for cleaner vol quotes — calls for \(K \geq F\), puts for \(K < F\). OTM options have higher liquidity and no intrinsic-value noise, which improves calibration stability.

Slice validation

prepare_slice rejects slices with fewer than min_points (default 5) valid strikes after cleaning, and clips log-moneyness to \([-10, 10]\) to prevent optimizer divergence from wing noise.