Arbitrage freeness¶
Arbitrage handling has two halves: enforcement during calibration (penalty flags, below) and verification of a fitted result (diagnostics). Constrained calibration is a soft penalty — always verify a fit you intend to rely on.
Verifying a fit¶
check_slice_arbitrage inspects one calibrated slice; check_arbitrage inspects a set of slices across maturities:
from pysvi import check_slice_arbitrage, check_arbitrage
report = check_slice_arbitrage(model, params, maturity=0.25)
report.ok # single verdict
print(report)
# Slice (T=0.25):
# Butterfly arbitrage: none (min g = 2.452e-01 at k = -2.0000)
# Lee wing bounds: satisfied (left slope = 0.1920, right slope = 0.0480, bound = 2, asymptotic)
report = check_arbitrage(model, {0.25: params_1, 0.5: params_2})
print(report) # adds the calendar check between adjacent maturities
The reports carry numerical evidence, not just verdicts:
Domain validity: grid points where \(w(k)\) is non-finite or non-positive (or the density non-finite) are counted as invalid, and any invalid point fails the check — freedom from arbitrage is never certified on a region that could not be evaluated.
Butterfly: minimum of the density factor \(g(k)\) over the grid and the log-moneyness where it occurs (butterfly-free iff \(g(k) \geq 0\)).
Lee wing bounds: the Lee moment formula requires \(\limsup_{|k|\to\infty} w(k)/|k| \leq 2\) on each wing. For the SVI family the closed-form asymptotic slopes are used (raw SVI: \(b(1 \mp \rho)\)); for SABR and DirectSVI the slope is measured at the grid edge — a proxy that understates the asymptote, so widen the grid for wide smiles (the report records which method was used).
Calendar: minimum of \(w(k, T_{i+1}) - w(k, T_i)\) over adjacent maturity pairs and the \((k, T_i, T_{i+1})\) where it is attained (calendar-free iff non-negative). Duplicate maturities are rejected.
Pass your observed log-moneyness values as k_data to evaluate on the same domain the calibration penalties used (\([\min k - 0.5, \max k + 0.5]\)) — recommended over the default fixed \([-2, 2]\) grid, especially for DirectSVI, whose conic is meaningless far outside the fitted strike range. For SABR and DirectSVI the density carries finite-difference noise well above the default tol=1e-8; use a looser tolerance (e.g. 1e-4) when judging marginal fits.
Diagnostics build on the public derivative API: every parametrization exposes derivatives(k, params) returning \((w, w', w'')\) in one call, plus dw_dk, d2w_dk2 and density(k, params) — analytic for the SVI family (raw SVI, SSVI, eSSVI, jump-wings), central finite differences (step model.fd_step) for SABR and DirectSVI.
Enforcement during calibration¶
Every parametrization accepts an arbitrage_condition argument controlling how strictly no-arbitrage is enforced during calibration. The options are flags that can be combined with |:
from pysvi import get_model, ArbitrageFreedom
# Default: soft parameter bounds only
model = get_model("svi") # ArbitrageFreedom.QUASI
# Enforce no butterfly arbitrage (non-negative density)
model = get_model("svi", ArbitrageFreedom.NO_BUTTERFLY)
# Enforce no calendar spread arbitrage (non-decreasing total variance in T)
model = get_model("ssvi", ArbitrageFreedom.NO_CALENDAR)
# Enforce both
model = get_model("svi", ArbitrageFreedom.NO_BUTTERFLY | ArbitrageFreedom.NO_CALENDAR)
QUASI (default)¶
Soft parameter-bound constraints only: \(b > 0\), \(|\rho| < 1\), \(\sigma > 0\). Enforced via bounded optimisation and penalty terms. Fast, and usually sufficient for liquid underlyings.
NO_BUTTERFLY¶
Enforces non-negative call price density \(g(k) \geq 0\) across strikes, where:
Butterfly arbitrage exists whenever \(g(k) < 0\) for some \(k\). The calibrator evaluates \(g\) on a fine grid and penalises violations.
Model-specific notes:
SSVI / eSSVI already guarantee \(g(k) \geq 0\) by their functional form; this flag adds an explicit numerical check.
SABR uses finite-difference derivatives for \(g\) (the Hagan expansion has no tractable closed-form \(w''\)); the check is a numerical guard, not a structural guarantee.
DirectSVI does not support this flag (closed-form fit, nothing to penalise).
NO_CALENDAR¶
Enforces non-decreasing total variance in maturity: \(w(k, T_2) \geq w(k, T_1)\) for \(T_2 > T_1\) at every \(k\). This is a cross-slice condition. Pass the prior (shorter-maturity) slice’s total variance via the w_prev keyword argument to calibrate:
# After calibrating the first slice:
w_prev = model.total_variance(k_grid, params_first_slice)
# Calibrate the next slice with calendar constraint:
params_next = model.calibrate(k, w_target, w_prev=w_prev)
Economic vs mathematical arbitrage¶
The diagnostics report mathematical evidence (minimum density, Lee slopes, calendar margins). classify_arbitrage(surface, panel=...) adds the economic layer — what that evidence means:
extrapolation_risk— the violation sits outside the slice’s quoted strike range: a property of the model’s wings, not a constructible trade.executable— inside the quoted range and a static butterfly built from the panel’s bid/ask quotes around the violation has negative worst-case cost (buy the wings at ask, sell the body at bid): an actual arbitrage at quoted prices.quote_consistent— inside the quoted range, but the worst-case butterfly cost is non-negative: the violation lives within bid/ask uncertainty.mathematical— inside the quoted range with no bid/ask panel supplied to decide executability.
A tiny violation in an untradeable wing is not an executable arbitrage, and the classification keeps the two from being conflated in reports.