Skip to content

Release 4.0.0: SmileCalibrator, VolSurface::forward, spline calibration - #138

Merged
pandashark merged 41 commits into
mainfrom
v4.0.0
Aug 23, 2026
Merged

Release 4.0.0: SmileCalibrator, VolSurface::forward, spline calibration#138
pandashark merged 41 commits into
mainfrom
v4.0.0

Conversation

@pandashark

@pandashark pandashark commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

All post-3.0.0 work, which had accumulated locally on main and was blocked
from a direct push by the pre-push hook.

Scope

33 commits. The [Unreleased] changelog section covers them in full; the
headlines:

Added

  • smile::SmileCalibrator — the per-tenor calibration contract the models
    already shared informally. SurfaceBuilder::calibrator() accepts any
    implementation, so a model defined outside this crate builds into a surface
    on the same footing as the SmileModel variants.
  • VolSurface::forward(expiry) — reads the forward without building a smile
    section. The local_vol/dupire_piecewise_single_query benchmark goes from
    6.75 µs to 180 ns (37×).
  • VolSurface::calendar_violations(), reachable through &dyn VolSurface,
    with SsviSurface overriding it with the exact ∂w/∂θ test.
  • SplineSmile::calibrate / calibrate_with_config, plus their Python
    bindings.
  • SmileSection::default_scan_config(), ArbitrageScanConfig::default(),
    types::DisplacedVol, and parameter accessors on SviSmile / SplineSmile.

Breaking (7 in Changed, 2 in Removed)

  • Surface calibration takes (tenors, forwards, market_data).
  • DataFilter, WeightingScheme and ArbitrageScanConfig pass by value.
  • black_price / normal_price / displaced_price take the vol unit their
    extractor returns; DisplacedImpliedVol::compute returns DisplacedVol.
  • EssviSurface::rho(theta) is rho_at(theta);
    calendar_check_structural() renamed.
  • ArbitrageScanConfig::svi_default() / sabr_default() reworked.
  • Removed SsviSurface::calendar_arb_analytical() and the inherent
    tenors() on SsviSurface / EssviSurface, which shadowed the identical
    trait method. Each has a drop-in replacement on the VolSurface trait.

Verification

  • cargo test — 1065 tests pass across the seven suites, doctests included.
  • cargo clippy --all-targets -- -D warnings — clean.
  • cargo doc --all-features --no-deps under -D warnings — clean.
  • cargo fmt --check — clean.
  • Downstream numeraire (path dependency on this crate) still builds.

CI covers the binding test suites: the WASM job runs wasm-pack test --node wasm/, and the Python jobs run uv run pytest tests/ -q on 3.9 and 3.14.

Release

Version bumped to 4.0.0 across the root, python/, and wasm/ manifests,
with [Unreleased] promoted to a dated [4.0.0] header. The breaking surface
above makes this a major, not a 3.1.

Python 3.9 is dropped in the same commit: it reached end of life in October
2025 and held the test matrix on pytest 8.x, which predates the
CVE-2025-71176 tmpdir fix (that fix needs 3.10+). requires-python is now
>=3.10, CI tests 3.10 and 3.14, and relocking removes pytest 8.4.2 from
python/uv.lock, closing the last open Dependabot alert. The Rust crate is
unaffected.

Remaining after merge: tag v4.0.0 and publish.

…bration

Adds two VolSurface methods, opens the builder to out-of-crate models, and
decomposes SVI's calibration. Both trait additions have defaults, so external
implementors are unaffected; two public inherent methods are removed.

Layer 5 — forward lookups

DupireLocalVol read three forwards per query by materializing whole smile
sections and discarding everything but .forward(). On a PiecewiseSurface each
of those sampled 51 strikes and solved a cubic spline. VolSurface::forward()
returns it directly: dupire_piecewise_single_query drops 6.75 us -> 180 ns.

Layer 3 — smile identity

PiecewiseSurface::smile_at() resampled onto a spline even on an exact tenor
match, so model_name() reported "CubicSpline" for a calibrated SVI, density
became spline-derived, and the section disagreed with black_variance() in the
wings. Smiles are stored behind Arc and handed back as-is on a match.

Calendar checks reachable through the trait

VolSurface::calendar_violations() has a grid-scanning default; SsviSurface
overrides it with the exact dw/dtheta test that was previously only an
inherent method. eSSVI's structural check stays as model-specific reporting.

Calibration contract

SmileCalibrator captures the pipeline the four models shared informally.
SmileModel implements it, and SurfaceBuilder::calibrator() accepts any
implementation, so a model defined elsewhere builds into a surface on the same
footing as the built-ins. SVI's 350-line fit splits into named stages —
weighting, vol-cliff filter, ATM interpolation, multi-start search, ATM
sanity check — now unit-tested directly. The fit itself is unchanged.

Also: validate_in_range/validate_open_unit_interval behind the rho, beta, and
gamma checks; ArbitrageScanConfig re-exported at the crate root; the inherent
tenors() shadows and the strike_grid forwarder deleted; implied/ module docs
scope the Bachelier and displaced branches as standalone utilities.

Removed (breaking, replacements on VolSurface):
- SsviSurface::calendar_arb_analytical() -> calendar_violations()
- inherent SsviSurface::tenors() / EssviSurface::tenors()
Three loose ends from the calibration refactor.

eSSVI's per-tenor fit still carried the inline tenor/forward loops that SSVI
now routes through validate_positive_slice, so the same failure reported two
different strings depending on which surface you calibrated. Both go through
the shared validator.

SurfaceBuilder::build() lost its eager SABR beta check when min_strikes moved
onto SmileCalibrator, pushing a bad beta behind the per-tenor expiry and
strike-count checks. SmileCalibrator gains a defaulted validate() for
parameters fixed at construction; SmileModel overrides it for Sabr and build()
calls it before reading any tenor data.

SharedSmile's two arbitrage methods had no coverage. Added a test that scans a
butterfly-violating SVI through smile_at() and matches it against the model's
own scan, where a spline resampling would disagree.

CHANGELOG records the three message changes; the error variants are unchanged.
The bindings each carried a private copy of the beta range check with
pre-refactor wording, so Python and WASM callers never saw the message the
CHANGELOG documents. Both now call SmileCalibrator::validate() and surface
the core error through to_py_err/to_js_err.

Inside the crate, SabrSmile::new and calibrate_with_config said "beta" while
the builder said "SABR beta" for the same rejection; both now use "SABR beta".

Add a fit_per_tenor test for zero/NaN tenors and NaN forwards — without the
validate_positive_slice calls those fall through to SviSmile and return
CalibrationError instead of InvalidInput, a variant swap nothing caught.

Document on build() that the calibrator's own parameters are checked before
any tenor data.
to_js_err now maps InvalidInput to the bare message like to_py_err does, so
both bindings surface the same string and JS callers stop seeing the
"invalid input: " prefix the Display fallback added.

The serde path for SmileModel::Sabr delegates to SmileCalibrator::validate()
instead of its own bounds check, removing the last private copy of the beta
range, and a new test asserts SabrSmile::new, calibrate_with_config, validate,
and deserialization all produce identical wording.
to_js_err now matches to_py_err on all three concrete variants; NumericalError
was still falling through to Display while InvalidInput did not. The WASM SABR
beta test asserts the exact JS-visible string so the mapping arm is covered at
the FFI boundary, and CHANGELOG records that the prefix drop applies to every
message on those two variants, not just beta.
…riant tag

The `NumericalError → bare message` arm added in 5f5a33c had no test — the
pinned assertion in smoke.rs exercises `InvalidInput`, which was already
mapped that way, so deleting the new arm left the suite green. Assert on
`log_moneyness(MIN_POSITIVE, MAX)`, whose ln underflow is the reachable
`NumericalError` path through the bindings.

With all three concrete variants now reaching JS as plain strings, a JS
caller can no longer tell an invalid-input rejection from a numerical
failure. Python still separates them by exception class; note the gap in
the CHANGELOG and wasm/README.md rather than leaving it implicit.
… variants

CalibrationError still reaches JS with its "{model}: {message}" prefix, so
only InvalidInput and NumericalError are indistinguishable. State that these
bindings throw plain strings rather than that JS lacks a typed alternative.
The seven validated models each restated their field list three times — in
the `*Raw` shadow struct, in `TryFrom<Raw>`, and in `From<Model>`. A field
added to a model but missed in the hand-written `From` would have dropped
silently out of the serialized form. `validated_serde!` emits all three from
one list, so that can no longer happen.

`EssviSlice` keeps a hand-written `From` (it wraps `SsviSlice`, whose fields
are private to `ssvi.rs`) and uses the macro's `@shadow` arm for the rest.

`displaced.rs` was the only implied-vol model not routed through
`validate_implied_inputs` / `validate_pricing_inputs`; it inlined the same
calls in the same order. Its two `beta` range checks now use
`validate_in_range`, which produces the identical message.

No behavior change: error types, messages, and ordering are unchanged.
`scan_density` and `scan_g` each carried their own copy of the grid index
arithmetic and the density-evaluation error wrapper. Both now walk `k_grid`
and evaluate through `violation_at`, so the grid formula and the failure
message exist once.

`SsviSlice::vol`/`variance` and `SplineSmile::vol`/`variance` were each the
same eight-line preamble twice, differing only in the return wrapper; both
pairs now go through a `checked_variance` helper. `SsviSlice::density` keeps
its own guard — it rejects `w == 0` as well, where the density's `1/(K·√w)`
factor is undefined, and reports that with its own message.

No behavior change: every error message and tolerance is preserved.
Benchmarked: ssvi_slice_vol_query 12.3ns, unchanged against the <100ns target.
Both surfaces ran the same outer procedure and had drifted into three
byte-identical blocks differing only in the model tag: the tenor/forward
length-and-positivity check, the θ-monotonicity check, and the
grid-search → Nelder-Mead → clamp → RMS pass over (η, γ). All three now live
in `surface::calib`, which also owns the shared `GRID_N`.

Only the scaffolding moves. The objectives stay in their own files, and the
query paths are untouched — eSSVI threads a maturity-dependent ρ(θ) through
every evaluation, so unifying those would put a virtual call in
`black_variance`.

No behavior change: messages, bounds, step sizes, and clamps are unchanged.
`PiecewiseSurface::locate_tenor` and `interp::interpolate_theta_forward`
each carried their own copy of the exact-match scan, the before/after
bounds checks, and the `partition_point` bracket. `TenorPosition` and a free
`locate_tenor` now live in `interp`, so the two extrapolation policies are
verifiably driven by the same locator.

`diagnostics` and `diagnostics_with` differed only in which
`is_arbitrage_free` variant each smile was asked for; both now go through
`diagnostics_via`. They stay separate methods rather than delegating, since
per-smile defaults differ (SABR scans [-2, 2]).

No behavior change. Benchmarked: piecewise_vol_query 20.3ns, no regression.
`SplineSmile` overrode `is_arbitrage_free`, which ran a hand-rolled scan over
the knot range with a hardcoded 200 points and a hardcoded `-1e-8` — a literal
restatement of `DENSITY_NEG_TOL`, which it did not import — and ignored
`ArbitrageScanConfig` entirely. `is_arbitrage_free_with(config)` meanwhile fell
through to the trait default and scanned log-moneyness `[k_min, k_max]`. The
same model answered the same question two different ways.

Now it overrides `is_arbitrage_free_with`, so both entry points share one code
path and one tolerance, and the configured range is honoured.

The range is clipped to the knot span rather than taken as given. Deleting the
override outright — the obvious consolidation — is wrong: outside `[K₀, Kₙ]`
the spline flat-extrapolates, so the finite-difference density there is
cancellation noise, and on a convex 5-knot smile the unclipped scan reports
four spurious violations at strikes 5.6-7.4 against a knot range of 80-120.
The clip also steps in from the boundary knots, where `w` is only C⁰ and a
stencil straddling the kink reads ~-13. Interior knots need no inset; a natural
cubic spline is C² there.

A config disjoint from the knot range now errors rather than reporting a clean
scan it never performed.

Tests: both entry points agree, a narrow config is not widened, a disjoint
config errors, a sharp variance dip is still detected, and a spline-built
surface's `diagnostics()` stays clean end-to-end.
`synthetic_ssvi_data` and `synthetic_essvi_data` were character-identical
apart from the surface type, retyped because each `mod tests` is private.
They collapse into one `synthetic_surface_data` generic over `VolSurface` in
a new `test_support` module.

The uniform strike ladder that feeds it was spelled out as a four-line
iterator chain at 25 sites across the two files, varying only in count, floor,
and step; `strike_ladder` takes those as arguments. Kept as a ladder builder
rather than folding it into the sampler because 11 of the 20 eSSVI sites go on
to use `strikes` for their assertions.

Test count is unchanged at 937 — this is a transcription of the same fixtures,
not a re-scoping of what they cover.
…g it

`SplineSmile::is_arbitrage_free_with` narrows the caller's config to its
usable knot range, which the trait doc promised it would not do: a caller
passing `svi_default()` to a spline built by `PiecewiseSurface::smile_at`
got `is_free()` for ~23% of the requested range with nothing to say so.
The clipping is right; the promise was wrong, so amend the trait doc and
name `SplineSmile` as the case in point.

Test the non-overlap case explicitly rather than letting it fall out of
`ArbitrageScanConfig::validate` on the clipped config, which reported a
`k_max` the caller never supplied and blamed the config even when a knot
span narrower than `2 · KNOT_EDGE_INSET` was the real cause. The new
error names both ranges; a tight-ATM-ladder test covers the second case.

Also make the narrow-config test discriminating (it asserted a property
the fixture had regardless), assert the three unverified `SsviSlice`
fields off-ATM, and correct the doc drift left by the recent extractions:
`checked_variance` carried `eval_variance`'s docs, `optimize_eta_gamma`
stated its seed range as a return guarantee, and a deleted fixture left
its doc comment on the following test. Add the preconditions the three
extracted helpers lost with their locality.
A knot span narrower than 2·KNOT_EDGE_INSET shared the "does not overlap"
message with the genuinely disjoint case, printing an inverted usable range
that the scan window did in fact contain. Split the branch so the empty span
names its own cause, and assert on that wording in the test that claimed to
check it. Switch both_arbitrage_entry_points_agree to the dip fixture so the
compared counts are nonzero, and point diagnostics_with at the per-smile
narrowing caveat.
The empty-span error printed strike endpoints against a log-moneyness
threshold named only by a crate-private constant, so a caller could not
check the claim from the text. Print the span itself alongside the
threshold as plain numbers, as the sibling disjoint-range message
already does.
…error

The empty-knot-span message said "knot range" like the disjoint-config
message but printed strikes, not log-moneyness; it said "less than" a
guard that fires on equality; and it re-derived the span from the
strikes instead of the lo/hi the guard tested.
The vol-cliff filter guarded on `!has_rise || !has_drop`, but `cliff_idx` is
only ever set inside the branch that sets `has_drop`, so `!has_drop` was
always false where it was read and the guard reduced to `!has_rise`. Removing
the disjunct lets `has_drop` go too, which folds its branch into the
`cliff_idx.is_none()` test. `vol_cliff_filter_leaves_v_shaped_smiles_alone`
already pins the drop-and-rise input this could have affected, and it passes
unchanged.

The 0.999 correlation bound and the 1e-14 flat-rho epsilon each appeared at
several sites across ssvi and essvi with the reason for the value written
down nowhere. Both now live in the calib module that the two surfaces already
share, so the SSVI radical's degeneracy at |rho| = 1 and the Eq. 5.7 bound's
division by rho_m - rho_0 are stated once. The values are unchanged, so
calibration output is bit-identical.

Also bind the builder's tenor iterator once per cfg instead of duplicating
the whole map/collect under each arm.
rho()'s doc hardcoded (-0.999, 0.999) while the body clamps to
RHO_CLAMP, so changing the constant would silently make the public
doc wrong. Name the constant instead of the number; RHO_CLAMP is
pub(crate), so it is plain text rather than an intra-doc link.
…urns

black_price, normal_price and displaced_price took a bare f64 vol, so a
Bachelier vol of 20.0 and a Black vol of 0.20 were interchangeable at the one
call site where the two units actually meet. Each now takes the newtype its
inverse produces, and DisplacedImpliedVol::compute returns a new DisplacedVol
rather than smuggling a third unit through Vol.

types.rs no longer claims newtypes cover every input — it says which two jobs
they do and that model parameters stay bare f64.
…ts own

svi_default() was the trait fallback and the SSVI default as well as SVI's, so
its name described the wrong thing. It is now wide(), sabr_default() is
narrow(), and Default returns wide() — so `ArbitrageScanConfig { n_points: 500,
..Default::default() }` compiles.

is_arbitrage_free() now scans default_scan_config(), a new trait method. A
model whose approximation stops short of the wings says so once, in one place,
instead of overriding is_arbitrage_free to pass its own grid: SABR returns
narrow(), and SVI and SSVI drop overrides that only repeated the default. An
implementor outside the crate can do the same rather than silently inheriting
SVI's grid.
Every EssviSurface accessor was undocumented, against the crate's own rule,
while SSVI's equivalents all carry docs. Two also read wrong: rho(theta)
evaluated a function where rho() elsewhere in the crate reads a stored
parameter, and a() gave no clue it is the exponent in
rho(theta) = rho_0 + (rho_m - rho_0)(theta/theta_max)^a. They are now rho_at()
and rho_exponent().

calendar_check_structural() becomes calendar_violations_structural(), matching
the VolSurface::calendar_violations() it sits beside.
SviSmile handed back rho() and nothing else, so reading a fit's a, b, m or
sigma meant a serde round-trip; SplineSmile exposed nothing at all. Both now
have accessors, matching SABR and SSVI.

SplineSmile also gains calibrate/calibrate_with_config taking (strike, vol)
quotes like SVI and SABR do. new() still wants sorted strikes and total
variances, which every caller had to produce for itself — SurfaceBuilder's
CubicSpline arm now calls the new entry point instead of doing the conversion
inline.
DataFilter, WeightingScheme and ArbitrageScanConfig are all Copy, and
SurfaceBuilder::data_filter already took one by value, but every
calibrate_with_config, is_arbitrage_free_with and diagnostics_with wanted a
reference — so 51 call sites, most of them inside the crate, wrote
&DataFilter::default(). They now take the configs by value.
Smile calibration reads (forward, expiry, market_vols) — coordinates, then
quotes. Surface calibration read (market_data, tenors, forwards), the reverse,
so the two layers could not be scanned together. SsviSurface::calibrate,
EssviSurface::calibrate and fit_per_tenor (both with_config variants included)
now take (tenors, forwards, market_data), and the Python and WASM bindings
follow.
Docs that drifted from the reordered/renamed signatures:

- `python/README.md` passed `fit_per_tenor` the old
  `(market_data, tenors, forwards)` order one line under prose stating the
  new one, so the snippet raised `TypeError` on the `Vec<f64>` extraction.
- `EssviSurface::fit_per_tenor` and `::calibrate` still listed `market_data`
  first in their `# Arguments` blocks, contradicting the signatures beneath.
- `wasm/README.md` was camelCase throughout — wasm-bindgen keeps the Rust
  snake_case names and `wasm/src/` sets no `js_name`, so every member in the
  file was `undefined` in JS. Renamed against the generated `.d.ts` rather
  than only the three flagged lines; leaving the rest would have made the
  file more self-contradictory, not less.

Behaviour:

- `SharedSmile` did not forward `default_scan_config`, so a SABR smile pulled
  off a `PiecewiseSurface` at an exact tenor fell through to the trait default
  `wide()` and scanned the Hagan expansion's own wing breakdown as arbitrage.
  This was also the one composition point silently dropping an out-of-crate
  implementor's override.

Changelog omissions:

- The `rho_exponent` rename also lands in both bindings, where it fails at
  runtime rather than at compile time.
- Routing the builder's `CubicSpline` arm through `calibrate_with_config`
  validates each quote *before* the `DataFilter`, so quotes that were
  previously dropped and fitted around now abort the tenor.

Coverage and the git-ignored crate:

- The three surface-level `*_with_config` entry points had no direct test;
  their smile-level equivalents did.
- `SplineSmile::calibrate` rejects duplicate strikes — a call and a put on one
  strike — from inside `new`, naming an index the caller never built. Now
  documented and tested.
- `PySplineSmile` gained `calibrate`, so Python callers stop converting to
  total variance and sorting by hand.
- `tools/chain-prep` no longer compiled against the new argument order and
  by-value configs.
The Rust addition landed without its binding, changelog note, or example,
and its duplicate-strike error pointed at an index the caller cannot map
back to their input.

- `PySplineSmile::calibrate_with_config` takes an optional `DataFilter`,
  matching `SviSmile` and `SabrSmile`. A `min_vol` or `max_log_moneyness`
  band on a spline no longer has to be applied by hand.
- `calibrate_with_config` detects a shared strike after its own sort and
  names the strike, rather than letting `new` report a post-sort position.
  `new` keeps its index message for callers who supplied an ordered vector.
- The changelog entry states the binding impact, as the entries around it
  do; the Python README calibrates the `SplineSmile` it was importing and
  never using.
`test_defaults_match_calibrate` compared `vol(100.0)`, a quoted strike. A
spline interpolates every surviving knot exactly, so that assertion held for
any filter that kept the 100.0 quote — including one that silently dropped
60/170. Probe 150.0 instead, where the value depends on the wings surviving.

Also correct the builder's stale duplicate-strike comment (the check now lives
in `calibrate_with_config`, before `new` sees the knots) and document
`SplineSmile.calibrate_with_config`'s filter-only signature in the Python
README.
Report strike/vol length mismatches per tenor instead of packing a
truncated or NaN-filled buffer and surfacing a bare "calibration failed".

Plotly v2 dropped `titlefont`, so every axis title was rendering with the
default font; move the config under `title.font` and merge it into the
per-chart title overrides.

The empty-term-structure branch handed Plotly the shared `LAYOUT_BASE`,
which it mutates with computed ranges — pass a clone.

Derive each mini-chart's x range from that tenor's own strikes; the fixed
85–115 window clipped the wider long-dated quotes while they still drove
the y range.

Fix the skew readout: the 25d span is 0.50, so vol points per delta point
is `* 100 / 50`, not `/ 50 * 1000` — the old number was 10x. Label the unit.

Also add an SRI hash to the Plotly CDN tag, drop the Google Fonts import
in favour of the local stacks already listed, delete the unused
`WasmSurfaceBuilder` import and `strikeAtDelta`, and document the demo's
build-and-serve prerequisite in the README.
The tenor guard only compared list lengths, so a blank or non-numeric
field passed whenever the two lists happened to match. apply_filter then
dropped the quote for a non-finite vol or ln(K/F) and the page still read
"ready". Number('') is 0 rather than NaN, so parsing has to reject blank
fields before the finiteness test can see them.

Plotly assigns the layout it is handed to gd.layout and a GUI relayout
writes through it, so the four charts sharing LAYOUT_BASE's legend, font,
margin and shapes leaked state between each other -- dragging the density
legend moved the delta-smile legend on the next build. Clone the base per
plot instead of spreading it.

Collapse the repeated three-level title spread into an axis() helper so a
future addition to AXIS_COMMON.title survives without a manual merge at
each call site.
calibrate validates raw strike/vol pairs before any filtering, so a
non-finite quote errors rather than being silently dropped.
cargo doc --all-features fails under -D warnings. SmileCalibrator::calibrate
linked crate::calibration::prepare_market_vols, which is pub(crate), so an
out-of-crate implementor cannot reach the helper the sentence told it to route
through. State the filtering contract as behaviour instead.

Drop the two redundant explicit link targets rustdoc flagged alongside it.
The calibrate contract told implementors to treat filter starvation as an
error but its # Errors block only mapped CalibrationError to a failed fit, so
an out-of-crate model could reasonably return InvalidInput instead and callers
matching on the variant would see starvation classified per-model. In-crate
models all return CalibrationError via prepare_market_vols; say so.

Reflow the two paragraphs left short-wrapped by the previous commit.
Promote [Unreleased] to a dated 4.0.0. The accumulated breaking surface —
seven Changed entries plus the removed SsviSurface::calendar_arb_analytical
and the inherent tenors() methods — makes this a major, not a 3.1.

Drop Python 3.9 alongside it. It reached end of life in October 2025 and held
the test matrix on pytest 8.x, which predates the CVE-2025-71176 tmpdir fix;
that fix needs 3.10+. requires-python is now >=3.10, CI tests 3.10 and 3.14,
and relocking drops pytest 8.4.2 from python/uv.lock. The Rust crate is
unaffected.
@pandashark pandashark changed the title Post-3.0.0 work: SmileCalibrator, VolSurface::forward, spline calibration Release 4.0.0: SmileCalibrator, VolSurface::forward, spline calibration Aug 23, 2026
The release commit bumped the manifests but left both READMEs advertising
the previous version: the crate install snippets said 3.0 while the
examples below them use 4.0-only API, the recent-releases table had no
v4.0 row, and the Python README still promised 3.9 support that
requires-python and the CI matrix had just dropped.
The 4.0 README pass fixed the version pins and the release table but left
the prose at 3.0: DisplacedVol was missing from the newtype bullet and the
module tree, SmileCalibrator was absent everywhere except the release-table
row that advertises it, and the Python section said pip install for a crate
that is not on PyPI.
VolSurface::forward and SplineSmile::calibrate appeared only in the v4.0
release row; both are now named where a reader looks for them. Give the
surface module tree node its trait so it reads like its siblings.
VolSurface::forward's default implementation goes through smile_at(), so
the no-smile claim is a property of the built-in surfaces that override
it, not of the trait method.
Both accessors read as wing measurements, and on a real corpus they often
are not. Where m falls outside the quoted log-moneyness range the hyperbola
degenerates to a line over the observed strikes, only the product b(1 − ρ)
stays pinned, and b and ρ slide along a ray — b moving an order of magnitude
while the curve shifts less than the fit's own residual. ρ's sign goes with
it: a slice with a visibly steeper put wing can fit ρ > 0 because the whole
ladder sits on one branch.

Neither is a fitter bug, and RMSE cannot distinguish the cases, so the docs
have to. b is newly public in this release; ρ was not, but reads the same way.
@pandashark
pandashark merged commit f6c6b4d into main Aug 23, 2026
7 checks passed
@pandashark
pandashark deleted the v4.0.0 branch August 23, 2026 23:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant