The VolSurface object¶
VolSurface is the fitted-surface abstraction: model → calibration → surface. It owns calibrated slices across maturities and exposes evaluation, diagnostics, and pricing — the object quant work actually consumes, rather than raw parameter dicts.
Fitting¶
from pysvi import VolSurface
surface = VolSurface.fit(df, model="svi", r=0.02)
df is a multi-expiry panel in the calibrate_slice schema (strike, iv, maturity, implied_forward). Every parametrization works through the same call — per-slice extras are derived automatically (\(\theta\) per slice for SSVI and eSSVI — plus, for eSSVI only, \(\theta_{\mathrm{ref}}\) defaulting to the median across slices — \(T\) for jump-wings, \(T\)/\(F\) for SABR with \(\beta\) overridable via kwargs), and the calibration controls (objective, loss, initialization, …) pass through to every slice:
surface = VolSurface.fit(df, model="ssvi", loss="soft_l1", initialization="multi_start")
With objective="bid_ask", panels carrying iv_bid/iv_ask columns (as OptionChain produces) get their per-slice w_bid/w_ask bands derived automatically — rows with a missing or crossed band degenerate to fit-to-mid. Slices that fail to calibrate are skipped with a warning. A surface can also be assembled directly from calibrated slices: VolSurface(model, {T: params, ...}), where each params dict carries 'forward' (as calibrate_slice returns).
Calendar-aware fitting¶
VolSurface.fit calibrates slices independently. calibrate_surface owns the cross-maturity logic:
from pysvi import calibrate_surface
surface = calibrate_surface(df, model="ssvi", enforce_calendar=True)
expiries are ordered and calibrated oldest-first, with each fitted slice evaluated on the next slice’s penalty grid and passed as
w_prevautomatically — the manual chaining the per-slice API requires;enforce_calendaraddsNO_CALENDARto the arbitrage condition, and for SSVI/eSSVI clips the per-slice ATM total variances \(\theta(T)\) to be non-decreasing before fitting;for eSSVI the global term structure \((\rho_0, \rho_1, \alpha, \eta)\) is fitted jointly across all slices against the shared \(\theta_{\mathrm{ref}}\) — every returned slice carries identical shape parameters, which is the point of the model;
after fitting, SSVI-form slices are checked against the Gatheral-Jacquier sufficient no-butterfly bounds (\(\theta\varphi(1+|\rho|) \leq 4\) and \(\theta\varphi^2(1+|\rho|) \leq 4\)) and one aggregated warning is logged for slices outside the proven-safe region (the bounds are conservative — verify with
check_arbitrage).
Calibration controls pass through to every slice. DirectSVI is rejected when enforce_calendar is set (its closed-form fit has no penalty to enforce).
Interpolation in maturity¶
Between fitted maturities the surface interpolates; beyond the fitted range it raises (no extrapolation). Two methods, chosen at construction via interp_method:
"total_variance"(default, any model) — linear interpolation of \(w(k)\) in \(T\) at fixed log-moneyness. Model-agnostic, exact at fitted maturities, and calendar-free between two slices whenever they are ordered (\(w\) of the blend lies between them at every \(k\))."theta"(SSVI/eSSVI) — interpolates the ATM total variance \(\theta(T)\) and shape parameters, yielding a genuine parametric slice at any maturity;surface.slice_at(T)returns its params dict. Under a joint eSSVI fit the shape parameters are shared, so only \(\theta\) actually interpolates."monotone_cubic"(any model, at least two slices) — a shape-preserving cubic (PCHIP, Fritsch–Carlson) in \(T\) at fixed log-moneyness across all fitted slices. Exact at fitted maturities, monotone in \(T\) wherever the fitted slices are (calendar-free slices stay calendar-free between expiries), and continuously differentiable in maturity — see Differentiability below.
Forwards interpolate log-linearly in \(T\) (piecewise-constant forward rate). All evaluation and pricing methods (iv, total_variance, price, Greeks, atm_vol, skew, curvature) accept any maturity in range; params(T) remains exact-slice-only, and slice_at(T) between slices requires the "theta" method.
surface = calibrate_surface(df, model="ssvi")
surface.iv(100.0, 1.37) # interpolated maturity
surface.price(95.0, 1.37, "put")
Differentiability and Dupire-readiness¶
surface.regularity declares the smoothness guarantee in maturity: "C0" for the linear blend and the theta method (continuous, but \(\partial w/\partial T\) jumps at every fitted slice), "C1" for "monotone_cubic". A C0 surface is pricing-ready — implied vols, prices, and sticky-strike Greeks are all well defined — but not Dupire-ready: local volatility, forward variance, and PDE coefficients consume \(\partial w/\partial T\), which would be discontinuous exactly at the traded expiries.
With interp_method="monotone_cubic" the maturity derivative exists and is continuous everywhere in the fitted range, exposed directly:
surface = VolSurface.fit(df, model="svi", interp_method="monotone_cubic")
surface.regularity # "C1"
surface.dw_dT(k, T) # the Dupire numerator, any T in range
dw_dT on a C0 surface raises rather than return a one-sided number that would be silently wrong at the knots. Smoothness in strike comes from the model itself and is analytic for the SVI family under every method.
Evaluation¶
surface.maturities # fitted maturities, ascending
surface.iv(strike, T) # implied vol at absolute strikes
surface.total_variance(k, T) # w(k) in log-moneyness
surface.forward(T)
surface.atm_vol(T)
surface.skew(T) # dw/dk at k = 0
surface.curvature(T) # d2w/dk2 at k = 0
surface.params(T) # per-slice parameter dict
All strike/moneyness inputs are vectorized; scalar in, scalar out. Any maturity inside the fitted range works (see Interpolation below); maturities outside it raise.
Variance events¶
Real term structures contain known jumps — earnings, FOMC, CPI, elections. Generic maturity interpolation smooths across them, silently asserting all term-structure curvature is continuous variance. VarianceEvent(time, variance, label) makes the jump explicit:
from pysvi import VarianceEvent, implied_event_variances
surface = VolSurface.fit(df, events=[VarianceEvent(0.4, 0.006, "earnings")])
Total variance decomposes as \(w(k,T) = w_{cont}(k,T) + \sum_{t_e \le T} v_e\): fitting subtracts each expiry’s cumulative event variance (slices store the continuous component; events inconsistent with the quoted variance are rejected), interpolation acts on the continuous component, and evaluation adds the events back on the correct side of each event time — so the surface reproduces the jump exactly instead of smearing it, and the calendar diagnostics raise no false violation across an event. Events serialize with the surface; dw_dT reports the continuous derivative (the jump is a jump). implied_event_variances(df, events) reports the quoted ATM-variance jump straddling each event as an upper bound on its variance.
Evaluation status¶
Every slice records its quoted strike range on the fit report, and evaluation can say whether a number is market information or model wing:
iv, status = surface.iv(K, T, return_status=True)
# status per point: "observed" | "interpolated" | "extrapolated"
"observed" means a fitted maturity inside that slice’s quoted range; "interpolated" sits between fitted maturities inside the bracketing slices’ joint quoted range; everything else is "extrapolated" — the model’s wings, not the market. See also the economic arbitrage classification on the arbitrage page.
Fit reports and diagnostics¶
A surface is the output of a calibration process, and the process carries the evidence needed to trust the output. fit records that evidence on the surface:
surface.fit_report # per-slice status, quote accounting, residuals, settings, provenance
print(surface.diagnose()) # fit report + arbitrage diagnostics in one formatted block
fit_report lists every slice of the input panel — including slices that failed to calibrate or were rejected for insufficient data — with quote counts (in vs used), implied-vol RMSE and max residual, and the quoted log-moneyness range. The report also records the calibration settings (objective, loss, initialization, backend) and provenance (svi-py version, fit timestamp). report.ok is False whenever any slice of the panel did not make it into the surface, so partial fits cannot pass silently.
diagnose() combines the fit report with check_arbitrage, run by default on the quoted strike range (the surface’s domain of validity) rather than the wide default grid, and renders a single scikit-learn-style result block; every field remains individually accessible on the returned object. Surfaces constructed directly from parameter dicts have fit_report=None and diagnose() reports arbitrage only.
Verifying¶
report = surface.check_arbitrage(k_data=k_observed)
report.ok
print(report)
forwards to the arbitrage diagnostics across all slices (butterfly, Lee wing bounds, calendar). Prefer k_data so verification covers the quoted strike range: an unconstrained fit extrapolated far outside its data can legitimately fail the butterfly check out in the wings.
Pricing and Greeks¶
A Black-76 layer on the slice forward, with a flat continuously compounded rate r set at construction:
surface.price(K, T, cp="call")
surface.delta(K, T, cp="call") # forward delta, e^{-rT} N(d1)
surface.gamma(K, T)
surface.vega(K, T) # per unit volatility
surface.theta(K, T, cp="call") # per year of calendar time
Conventions: Greeks hold the implied volatility fixed (sticky-strike); delta and gamma are with respect to the forward; vega is per 1.00 of volatility; theta is annualized. Prices are verified against py_vollib and the Greeks against finite differences of the Black price in the test suite.
Deliberately out of scope here: American exercise, exotics, rate sensitivity, curve-aware discounting (curves are a later release; r is flat), and extrapolation beyond the fitted maturity range.