All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
4.0.0 - 2026-08-23
types::DisplacedVol— the displaced-diffusion vol parameter, which is a Black vol only at β = 1 and was being returned as one at every β.SmileSection::default_scan_config()— the gridis_arbitrage_free()scans. A model whose approximation stops short of the wings states its own domain once instead of overridingis_arbitrage_free()to pass a grid, and an implementor outside this crate no longer silently inherits SVI's.ArbitrageScanConfig::default(), soArbitrageScanConfig { n_points: 500, ..Default::default() }compiles.SplineSmile::calibrateandcalibrate_with_config— fit a spline from(strike, vol)quotes like SVI and SABR do.new()takes sorted strikes and total variances, and every caller was writing that conversion itself.calibrate_with_configtakes noWeightingScheme: a spline passes through every surviving quote, so no residual bears a weight. Both reach the Python bindings asSplineSmile.calibrateandSplineSmile.calibrate_with_config; WASM exposes no spline smile type, so there is no JS counterpart.- Accessors for parameters that could previously only be read back through
serde:
SviSmile::a/b/m/sigmaandSplineSmile::strikes/variances. VolSurface::forward(expiry)— reads the forward directly instead of building a whole smile section for it.DupireLocalVolneeds three forwards and no vols per query; on aPiecewiseSurfacethat used to cost ~150 variance evaluations, three spline solves, and three allocations. Thelocal_vol/dupire_piecewise_single_querybenchmark goes from 6.75 µs to 180 ns, a 37× speedup.VolSurface::calendar_violations()— calendar spread checks reachable through&dyn VolSurface, with a grid-scanning default.SsviSurfaceoverrides it with the exact∂w/∂θtest.smile::SmileCalibrator— the per-tenor calibration contract the models already shared informally.SurfaceBuilder::calibrator()accepts any implementation, so a model defined outside this crate can be built into a surface on the same footing as theSmileModelvariants. Itsvalidate()method (defaultOk(())) checks parameters fixed at construction;SurfaceBuilder::build()calls it before reading any tenor data, so a misconfigured model reports its own error rather than whatever the first tenor trips over.validate_in_rangeandvalidate_open_unit_intervalbehind the ρ, β, and γ checks, so those messages are uniform across models.
-
BREAKING:
black_price,normal_priceanddisplaced_pricetake the vol newtype their extractor returns —Vol,NormalVolandDisplacedVolrespectively — rather than a baref64. A Bachelier vol of20.0and a Black vol of0.20were interchangeable at the one call site where the two units meet. Wrap the argument:black_price(f, k, Vol(0.2), t, ty), or pass an extractor's output straight through. The Python and WASM bindings still take and return bare floats. -
BREAKING:
DisplacedImpliedVol::computereturnsDisplacedVolrather thanVol. Both are tuple newtypes, so.0still reaches thef64. -
BREAKING:
ArbitrageScanConfig::svi_default()andsabr_default()arewide()andnarrow(). They describe grid width, not models —svi_default()was also the SSVI default and the trait-wide fallback.Defaultreturnswide(). Renamed in the Python and WASM bindings too. -
BREAKING:
DataFilter,WeightingSchemeandArbitrageScanConfigare passed by value, not by reference, on everycalibrate_with_config,is_arbitrage_free_with,diagnostics_with,apply_filterandSmileCalibrator::calibrate. All three areCopy, andSurfaceBuilder::data_filteralready took one by value. Drop the&. -
BREAKING: surface calibration takes
(tenors, forwards, market_data)rather than(market_data, tenors, forwards), matching the smile layer's coordinates-then-quotes order. AffectsSsviSurface::calibrate*,EssviSurface::calibrate*andEssviSurface::fit_per_tenor*, in the Python and WASM bindings as well.market_datahas a distinct type, so a call left in the old order fails to compile rather than mis-binding. -
BREAKING:
EssviSurface::rho(theta)isrho_at(theta)andEssviSurface::a()isrho_exponent().rho()everywhere else in the crate reads a stored parameter; onEssviSurfacealone it evaluated a function.ais the exponent in ρ(θ) = ρ₀ + (ρₘ − ρ₀)(θ/θ_max)^a — still the name of the constructor argument, which follows the paper. Theahalf lands in the bindings too: the Python getterEssviSurface.aand the WASM getterWasmEssviSurface.aarerho_exponent, and unlike the Rust rename those fail at runtime —essvi.araisesAttributeErrorin Python and isundefinedin JS. (rho_atis not exposed in either binding.) EveryEssviSurfaceaccessor now carries a doc comment. -
BREAKING:
EssviSurface::calendar_check_structural()iscalendar_violations_structural(), matching theVolSurface::calendar_violations()it sits beside. -
PiecewiseSurface::smile_at()returns the stored smile on an exact tenor match rather than a cubic-spline resampling of it. The section now keeps its model identity (model_name()reports"SVI", not"CubicSpline"), its analytic density, and its wing behaviour — previouslysmile_at(T).vol(K)andblack_vol(T, K)disagreed outside[0.5F, 2F]on the same surface. Off-grid expiries are still resampled onto a spline. -
On
SmileModel::CubicSpline,SurfaceBuilder::build()now validates each quote before applying theDataFilter, so a non-finite or non-positive vol errors withInvalidInputinstead of being filtered out and fitted around. The spline arm routes throughSplineSmile::calibrate_with_config, which aligns it with the SVI and SABR arms. A build with amin_volfilter and one zero or NaN vol in a chain that previously succeeded on the surviving quotes now fails that tenor. -
ArbitrageScanConfigis re-exported at the crate root, alongsideDataFilter. -
SVI's calibration is split into named stages (weighting, vol-cliff filter, ATM interpolation, multi-start search, ATM sanity check) that are unit-tested directly. The fit itself is unchanged.
-
Error message wording, on the same
VolSurfErrorvariants as before. Match on the variant, not the string:- Bad tenors or forwards passed to
SsviSurface::calibrate*orEssviSurface::fit_per_tenor*now read"tenors must be positive and finite, got tenors[0]=0"rather than"tenors[0] must be positive and finite, got 0". Both surfaces route through the shared slice validator, so they no longer disagree. - An out-of-range SABR
betareads"SABR beta must be in [0, 1], got NaN"rather than"SABR beta must be in [0, 1] and finite, got NaN";NaNandinfare still rejected.SabrSmile::new/calibrate_with_configname the parameter the same way (was"beta must be in [0, 1]"), and the Python and WASM bindings raise the core message instead of their own copy of it. SurfaceBuilder::build()'s min-strikes error names the model bymodel_name()—"(model: SABR)"— instead of debug-formatting theSmileModel, so the message no longer carriesbeta.
- Bad tenors or forwards passed to
-
WASM errors from
InvalidInputandNumericalErrornow carry the bare message rather than theDisplayform, matching the Python bindings: a JS caller sees"tenors must be positive and finite, got tenors[0]=0", not"invalid input: tenors must be positive and finite, got tenors[0]=0". This applies to every message on those two variants, not just SABRbeta. It also makesInvalidInputandNumericalErrorindistinguishable to a JS caller;CalibrationErrorstill carries its"{model}: {message}"prefix. These bindings throw plain strings rather than a typed value, so they offer no equivalent of the Python bindings'ValueError/RuntimeErrorsplit. -
SplineSmilenow overridesis_arbitrage_free_withinstead ofis_arbitrage_free, so both entry points scan the same domain. They previously disagreed:is_arbitrage_free()ran a hand-rolled scan over the knot range with a hardcoded point count and tolerance and ignoredArbitrageScanConfigentirely, whileis_arbitrage_free_with(config)fell through to the trait default and scanned log-moneyness[k_min, k_max]. The configured range is now honoured but clipped to the knot range, stepping in from the boundary knots. Outside[K₀, Kₙ]the spline flat-extrapolates, where the finite-difference density is cancellation noise, andwis only C⁰ at the boundary itself — on a convex 5-knot smile the unclipped scan reported six spurious violations, two of them at magnitude ~13. Aconfigthat does not overlap the knot range now returnsInvalidInputrather than silently reporting a clean scan it never performed. -
SviSmile::bandSviSmile::rhodocument that neither is identified whenmfalls outside the quoted log-moneyness range. The total-variance curve goes linear there and the two trade off along a ray, so a low RMSE does not imply a determinedb, andrho's sign stops tracking which wing is steeper.
Breaking. Each has a drop-in replacement on the VolSurface trait — bring it
into scope with use volsurf::surface::VolSurface:
SsviSurface::calendar_arb_analytical()→SsviSurface::calendar_violations(), which now returnsResult<Vec<CalendarViolation>>.- The inherent
SsviSurface::tenors()andEssviSurface::tenors(), which shadowed the identical trait method.
Breaking, Python bindings only:
- Python 3.9 support.
requires-pythonis now>=3.10and CI tests 3.10 and 3.14. 3.9 reached end of life in October 2025, and supporting it held the test matrix on a pytest release predating the CVE-2025-71176 tmpdir fix, which needs 3.10+. The Rust crate is unaffected.
3.0.0 - 2026-08-22
A major bump for the API contract, not for new capability. Three public items changed shape and several calls that used to degrade silently now return an error instead (PAN-135).
Migrating:
conventions::StickyKindis gone. Nothing consumed it, so there is no replacement to adopt — remove the import.NormalImpliedVol::computereturnstypes::NormalVolrather thanVol. Both are tuple newtypes, so.0still reaches thef64.ButterflyViolation::magnitudeis a method:v.magnitudebecomesv.magnitude().- Calibrations that relied on a
DataFilterquietly falling back to the unfiltered data now fail with aCalibrationError. Widen the filter, or lowermin_points, to get the previous fit. - Butterfly scans that could not evaluate every grid point returned a clean
report; they now return a
NumericalError. A report means the whole grid was covered.
The Python and WASM bindings keep their existing signatures — both already
returned bare floats, and both wrappers absorb the magnitude change.
- BREAKING:
conventions::StickyKind. The enum had no consumer anywhere in the crate — nothing accepted it and nothing returned it — so the sticky-strike / sticky-delta choice its docs described could not actually be applied. It will return alongside an API that honours it.
- BREAKING:
NormalImpliedVol::computereturns the newtypes::NormalVolrather thanVol. Bachelier volatility is quoted in price units per √year whileVolis an annualized proportion, so the newtype that exists to stop unit mixing was itself mixing units. The Python and WASM bindings are unaffected — both return a bare float. - BREAKING:
ButterflyViolation::magnitudeis now a method rather than a public field, and is computed asdensity.abs(). As two public fields they could disagree. Serialized violations lose the redundantmagnitudekey as a result.
- BREAKING: a
DataFilterthat leaves fewer points than the model needs is now aCalibrationErrorinstead of a silent fallback to the unfiltered data. Calibrating on the full set gave a fit the caller never asked for and had no way to detect. Affects SVI, SABR, SSVI, eSSVI and the builder's cubic-spline path; the remedy is to widen the filter, and the error reports how many points survived out of how many. The same now holds forDataFilter::vol_cliff_filter, which is on by default for SVI and for the per-tenor SVI stage of SSVI/eSSVI: a cliff that leaves fewer than five points on the retained side is aCalibrationErrorrather than a fit across the cliff. - BREAKING:
PiecewiseSurface::newnow rejects a smile whoseexpiry()disagrees with the tenor it is paired with, including a non-finiteexpiry(). Queries locate smiles by the tenor grid, so a mismatched pair was evaluated at the wrong maturity. SurfaceBuilder::buildno longer requiresspotandratewhen every tenor was added throughadd_tenor_with_forward. Futures-options surfaces, where the forward is the futures price and there is no spot to quote, previously had to pass placeholder values — andspothad to be positive, so even that was awkward.- BREAKING: butterfly arbitrage scans no longer skip grid points whose density
cannot be evaluated.
is_arbitrage_free()andis_arbitrage_free_with()now returnNumericalErrornaming the offending strike, so a returned report always covers the whole configured grid. Previously a smile with non-positive total variance reported as arbitrage-free — and in the g-function path (SVI, SSVI, eSSVI) the skipped points were violations that had already been detected. Behaviour is unchanged for models built through their validated constructors. - The release workflow now skips
cargo publishwhen the tagged version is already on crates.io, so tagging a version that was published by hand no longer fails the run. It also selects the workspace package by name rather than by position when checking the tag against the manifest.
- PyO3 0.28 → 0.29 and
rust-numpy0.28 → 0.29, clearing RUSTSEC advisories for an out-of-bounds read inPyList/PyTuplenth/nth_backand a missingSyncbound onPyCFunction::new_closure. The two move together becauserust-numpypins PyO3 and PyO3 setslinks = "python", so the graph admits one version. Neither advisory affects the publishedvolsurfcrate, which has no PyO3 dependency; only thevolsurf-pythonbinding is impacted. - Require pytest ≥ 9.0.3 on Python 3.10+ for the tmpdir advisory
2.4.0 - 2026-08-19
First release published to crates.io since 2.1.0. Versions 2.2.0 and 2.3.0
were tagged and changelogged but never uploaded, so volsurf = "2.3" did not
resolve; they remain git-only releases and their contents ship here (PAN-134).
- Python
DupireLocalVolacceptsSsviSurfaceandEssviSurfacedirectly, not only the opaque surface handle - CI job running the Python binding's pytest suite against Python 3.9 and 3.14
via
uv sync --locked+uv run pytest, with pytest declared in adevdependency group (PAN-129) - CI job running the WASM tests via
wasm-pack test --node(PAN-41)
- Consolidated volatility model internals across smile, surface and implied-vol modules — shared validation, optimizer and arbitrage-scan helpers, with no change to the public Rust API (−77 net lines across 18 files)
homepagemetadata inCargo.toml— the site it pointed at no longer exists; crates.io falls back torepository
2.3.0 - 2026-06-06
- WASM binding parity with the Python crate (PAN-28):
- Implied vol:
blackPrice/normalPrice/displacedPriceundiscounted pricing fns,WasmBlackImpliedVol/WasmNormalImpliedVol(staticcompute) andWasmDisplacedImpliedVol(instance, withbeta) for Black/Normal/displaced-diffusion IV extraction, and aWasmOptionType(Call/Put) enum - Conventions:
logMoneyness,moneyness,forwardPricehelpers - Local vol:
WasmDupireLocalVolandWasmBoundaryLocalVol(the v2.2 PAN-25 small-time boundary adapter), reachable from any surface viadupireLocalVol(bumpSize?)/dupireLocalVolWithBoundary(bumpSize?)onWasmSsviSurface,WasmEssviSurface, andWasmPiecewiseSurface - WASM smoke tests covering price→IV round-trips, convention known-values, flat-surface
σ_loc ≡ σ, and thet = 0boundary rescue
- Implied vol:
- Lockstep version bump of all three crates (core,
volsurf-python,volsurf-wasm) to 2.3.0; coresrc/is unchanged in this release
2.2.0 - 2026-06-06
BoundaryLocalVol<L: LocalVol>— opt-in adapter that smooths the Dupire small-time boundary: a query att ≤ floorevaluates the inner local vol att = floor(keeping total variancew = σ²·Taway from the singular1/w,k²/w²terms in the Gatheral denominator asT → 0);t > floordelegates exactly to the strict path (PAN-25)DupireLocalVol::with_boundary()— wraps aDupireLocalVolin aBoundaryLocalVolwhosefloordefaults to the finite-differencebump_sizeBoundaryLocalVolandDupireLocalVolre-exported at the crate root
2.1.0 "API Polish" - 2026-03-25
SmileSection::model_name()— returns the model identifier ("SVI","SABR","CubicSpline","SSVI","eSSVI")VolSurface::tenors()— accessor returning the surface's tenors as&[f64]expiryfield onArbitrageReportfor per-tenor attribution- Configurable arbitrage scanning via
ArbitrageScanConfig:SmileSection::is_arbitrage_free_with(config)andVolSurface::diagnostics_with(config) - Configurable calibration:
DataFilter,WeightingScheme, and warm-starting viacalibrate_with_configon smile models Clone+PartialEqonVolSurfError
- BREAKING:
is_arbitrage_free()is now computed on demand from the smile/surface rather than stored at construction - SVI warm-start falls back to grid search when the seeded optimization diverges
2.0.0 "Type-Safe Inputs" - 2026-03-13
- BREAKING: All trait method inputs now use
Strike/Tenornewtypes instead of baref64—SmileSection::vol(Strike),VolSurface::black_vol(Tenor, Strike),LocalVol::local_vol(Tenor, Strike), etc. - Python and WASM bindings unchanged — FFI boundary wraps
f64 → Strike/Tenorinternally - Updated
types.rsmodule docs to reflect input newtypes strategy - Extracted
impl_wasm_smile_methods!macro to DRY SmileSection wrappers in WASM crate
- NaN/Infinity rejection tests for all smile models, slices, and surfaces
1.0.0 "Stable" - 2026-02-25
volsurf-wasmcrate — WebAssembly bindings viawasm-bindgenfor SVI, SABR, SSVI, eSSVI, and SurfaceBuilder with 27 smoke testsvolsurf-pythoncrate — PyO3 bindings with NumPy integration, serde round-trip support, and 207 tests- WASM CI job (build + clippy) in GitHub Actions
- eSSVI Stage 2/3 calibration optimizations: precomputed
ln(xs)/ln(theta_ratio)forexpinstead ofpowf, adaptive rho grid, 21-point quadratica-scan - API stability review: sealed internal modules, documented all public types, ensured
Send + Sync + Debugon all traits
- README updated with v1.0 version numbers, bindings section, and changelog
0.4.0 - 2026-02-22
- Tracing fields in calibration diagnostics:
rmsrenamed torms_implied_vol(SABR) andrms_total_variance(SSVI/eSSVI) to clarify the metric space
VolSurfError::ArbitrageViolationvariant — was unused;VolSurfErroris#[non_exhaustive]so downstream wildcard matches are unaffected, but code referencing this variant by name will need updating
- Non-uniform strike calibration round-trip tests for SVI and SABR
- 12 coverage gap tests across SVI, SABR, SSVI, and arbitrage modules
0.3.0 "Production Grade" - 2026-02-22
EssviSurface— Hendriks-Martini (2019) extended SSVI with tenor-dependent rho for calendar arbitrage freedomEssviSlice— zero-cost newtype overSsviSlicewith baked-in rho(theta)EssviSurface::calibrate()— 3-stage calibration: per-tenor SVI, rho(theta) regression, global (eta, gamma) optimization with Eq. 5.7 constraint enforcementSurfaceBuilder::dividend_yield()for forward calculation via F = S*exp((r-q)*T)SurfaceBuilder::add_tenor_with_forward()to bypass forward computation with market-observed forwardslog_moneyness(),moneyness(),forward_price()now returnResult<f64>with input validation- Parallel surface construction via
rayonfeature inSurfaceBuilder::build() - Dupire local vol benchmarks validating NFR performance targets
- SECURITY.md with private vulnerability reporting via GitHub Security Advisories
- Better error messages when calibration produces non-monotone ATM total variances
- Integration tests use non-constant forwards for realistic DJX scenarios
0.2.1 - 2026-02-17
NormalImpliedVol— Bachelier implied vol extraction via Jäckel (2017) rational approximation withnormal_price()standalone pricing functionDisplacedImpliedVol— displaced diffusion model with beta-blended Black/Normal pricing and IV extraction; delegates to pure Black (β=1) or Normal (β=0) at boundariesDupireLocalVol— local volatility extraction from anyVolSurfacevia Gatheral (2006) Eq. 1.10 using finite differences on total implied variance, with forward-adjusted time derivatives at constant log-moneyness- GitHub Actions CI workflow (test, clippy, fmt, doc)
- Apache-2.0 LICENSE file
- README with badges, quick-start guide, benchmarks, and architecture overview
- crates.io publish metadata (keywords, categories, repository, homepage)
- Serde deserialization now validates all smile/surface types via
#[serde(try_from)]—SsviSurface,SsviSlice,SabrSmile,SviSmile,SplineSmile - Black IV accuracy claim corrected from "3 ULP" to "near-machine-precision" in module docs
- Normal IV accuracy claim corrected from "2 ULP" to "near-machine-precision" in module docs
- 14 edge case tests added from implied vol paper audits (5 black, 5 normal, 4 displaced)
0.2.0 "Market Ready" - 2026-02-16
SabrSmile— Hagan (2002) SABR implied vol with unified code path, Taylor expansion for small z, and 12-digit accuracy against reference valuesSabrSmile::calibrate()— analytic alpha via Newton on ATM cubic, rho/nu optimization via Nelder-Mead in transformed parameter space with 15x15 grid initializationSsviSurface— Gatheral-Jacquier (2014) global SSVI parameterization with power-law phi function, theta interpolation, and flat-vol extrapolationSsviSlice— lightweight single-tenor SSVI evaluator with analytical first and second derivatives for g-function butterfly detectionSsviSurface::calibrate()— two-stage calibration: per-tenor SVI to extract theta/rho, then global (eta, gamma) optimizationSsviSurface::calendar_arb_analytical()— analytical calendar arbitrage detection via dw/dtheta derivativeArbitrageReport::merge()andworst_violation()for combining and summarizing multi-tenor diagnostic resultsSmileModel::Sabr { beta }variant forSurfaceBuilderintegration (minimum 4 strikes per tenor)examples/sabr_smile.rs— SABR calibration and smile evaluationexamples/ssvi_surface.rs— SSVI surface construction and querying- Runnable doc examples on 8 core public API items
0.1.0 "First Light" - 2026-02-15
- Domain newtypes:
Strike,Tenor,Vol,Variance,OptionTypewithCopy,Debug,Serdesupport VolSurfErrorenum withthiserror,#[non_exhaustive], and 4 structured variants:CalibrationError,InvalidInput,NumericalError,ArbitrageViolation(removed in v0.4.0)SmileSectiontrait (Send + Sync + Debug) for single-tenor smile evaluation withvol(),variance(),density(),forward(),expiry(),is_arbitrage_free()VolSurfacetrait (Send + Sync + Debug) for multi-tenor surfaces withblack_vol(),black_variance(),smile_at(),diagnostics()LocalVoltrait for future Dupire local vol extractionBlackImpliedVol— Black-Scholes implied vol extraction via Jackel rational approximation with round-trip accuracy < 1e-12black_price()— undiscounted Black-Scholes call/put pricingSviSmile— SVI parameterization (a, b, rho, m, sigma) with Gatheral-Jacquier validation, analytical density via g-function, and butterfly arbitrage detectionSviSmile::calibrate()— Zeliade (2009) quasi-explicit method with linear least-squares, 15x15 grid search, and Nelder-Mead refinementSplineSmile— natural cubic spline on variance with Thomas algorithm, binary search, and flat extrapolationPiecewiseSurface— per-tenorSmileSectionstorage with linear variance interpolation, arbitrary-tenorsmile_at(), and calendar + butterfly diagnosticsSurfaceBuilder— fluent API for surface construction:.spot(),.rate(),.tenor(),.model(),.build()with forward price computation and auto-sorting by expirySmileModelenum — selector forSurfaceBuilder:Svi(default, 5+ strikes) andCubicSpline(3+ strikes)- Default
density()onSmileSectionvia numerical Breeden-Litzenberger StickyKindenum,log_moneyness(),moneyness(),forward_price()utilitiesArbitrageReport,ButterflyViolation,SurfaceDiagnostics,CalendarViolationdiagnostic typesparallelCargo feature for optional rayon supportloggingCargo feature for optional tracing instrumentation- Examples:
basic_surface,smile_models,implied_vol