Skip to content

Commit c2ea59b

Browse files
authored
docs: bring README up to v2.3 and add the Python binding README (#125)
Adds v2.1-v2.3 coverage: ArbitrageScanConfig scan grids, DataFilter / WeightingScheme / warm-start calibration controls, and the BoundaryLocalVol small-time adapter. Install snippets move to 2.3, the module tree gains the calibration module, and the changelog table lists v2.1-v2.3 with pre-1.0 history deferred to CHANGELOG.md. Drops the "production-ready" framing and restates the query benchmark as "20 ns or less", matching the Criterion table. python/README.md is new; the Bindings section now links to it directly.
1 parent 3896605 commit c2ea59b

5 files changed

Lines changed: 273 additions & 19 deletions

File tree

README.md

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
[![docs.rs](https://docs.rs/volsurf/badge.svg)](https://docs.rs/volsurf)
66
[![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
77

8-
Production-ready volatility surface construction for equity and FX derivatives.
8+
Volatility surface construction for equity and FX derivatives.
99

10-
`volsurf` builds implied volatility surfaces from market data, calibrates parametric smile models (SVI, SABR, SSVI), detects butterfly and calendar arbitrage, and provides sub-20ns vol queries suitable for real-time pricing engines.
10+
`volsurf` builds implied volatility surfaces from market data, calibrates parametric smile models (SVI, SABR, SSVI), and detects butterfly and calendar arbitrage. Vol queries take 20 ns or less, fast enough for real-time pricing engines.
1111

1212
## Features
1313

@@ -28,6 +28,12 @@ Production-ready volatility surface construction for equity and FX derivatives.
2828
- Calendar spread arbitrage via cross-tenor variance monotonicity
2929
- Analytical calendar arbitrage for SSVI surfaces
3030
- Combined `SurfaceDiagnostics` report
31+
- Configurable scan grids via `ArbitrageScanConfig` on `is_arbitrage_free_with()` and `diagnostics_with()`
32+
33+
**Calibration Control**
34+
- `DataFilter` drops wing strikes, sub-floor vols, and vol cliffs before fitting
35+
- `WeightingScheme` weights the least-squares fit by vega or uniformly (Zeliade 2009, Hagan 2002)
36+
- Warm-starting from prior parameters; SVI falls back to grid search when a seeded fit diverges
3137

3238
**Implied Volatility**
3339
- **Black** (lognormal) implied vol via Jackel rational approximation (near-machine-precision)
@@ -36,6 +42,7 @@ Production-ready volatility surface construction for equity and FX derivatives.
3642

3743
**Local Volatility**
3844
- **Dupire** local vol extraction (Dupire 1994) from any `VolSurface` via finite differences
45+
- **Boundary adapter** -- `with_boundary()` evaluates short expiries at a floor, avoiding the Dupire denominator blow-up as total variance goes to zero
3946

4047
**Design**
4148
- No global state -- evaluation date is a parameter, not a singleton
@@ -49,13 +56,13 @@ Production-ready volatility surface construction for equity and FX derivatives.
4956

5057
```toml
5158
[dependencies]
52-
volsurf = "2.0"
59+
volsurf = "2.3"
5360
```
5461

5562
Optional features:
5663

5764
```toml
58-
volsurf = { version = "2.0", features = ["parallel", "logging"] }
65+
volsurf = { version = "2.3", features = ["parallel", "logging"] }
5966
```
6067

6168
| Feature | Description |
@@ -171,6 +178,8 @@ use volsurf::OptionType;
171178
let vol = BlackImpliedVol::compute(10.45, 100.0, 100.0, 1.0, OptionType::Call)?;
172179
```
173180

181+
Runnable programs for every layer live in [`examples/`](examples/). Run one with `cargo run --example local_vol`.
182+
174183
## Architecture
175184

176185
Five-layer pipeline following the natural domain flow:
@@ -184,6 +193,7 @@ Option Prices -> Implied Vol -> Smile -> Surface -> Local Vol
184193

185194
```
186195
volsurf
196+
├── calibration DataFilter, WeightingScheme, apply_filter
187197
├── conventions StickyKind, log_moneyness, forward_price
188198
├── error VolSurfError, Result<T>
189199
├── implied
@@ -194,14 +204,14 @@ volsurf
194204
│ ├── svi SviSmile (Gatheral 2006)
195205
│ ├── sabr SabrSmile (Hagan 2002)
196206
│ ├── spline SplineSmile (cubic on variance)
197-
│ └── arbitrage ArbitrageReport, ButterflyViolation
207+
│ └── arbitrage ArbitrageReport, ButterflyViolation, ArbitrageScanConfig
198208
├── surface
199209
│ ├── ssvi SsviSurface (Gatheral-Jacquier 2014)
200210
│ ├── essvi EssviSurface, EssviSlice (Hendriks-Martini 2019)
201211
│ ├── piecewise PiecewiseSurface (per-tenor interpolation)
202212
│ ├── builder SurfaceBuilder, SmileModel
203213
│ └── arbitrage SurfaceDiagnostics, CalendarViolation
204-
├── local_vol LocalVol trait, DupireLocalVol (Dupire 1994)
214+
├── local_vol LocalVol trait, DupireLocalVol (Dupire 1994), BoundaryLocalVol
205215
└── types Strike, Tenor, Vol, Variance, OptionType
206216
```
207217

@@ -246,27 +256,27 @@ Measured with Criterion.rs on Apple Silicon. All performance targets exceeded.
246256
pip install volsurf
247257
```
248258

249-
Built with PyO3. See [`python/`](python/) for usage and API docs.
259+
Built with PyO3. See [`python/README.md`](python/README.md) for the API and usage examples.
250260

251261
### WebAssembly
252262

253263
```bash
254264
wasm-pack build wasm/ --target web
255265
```
256266

257-
Built with wasm-bindgen. See [`wasm/README.md`](wasm/README.md) for JavaScript API and usage examples.
267+
Built with wasm-bindgen. v2.3 added implied vol, conventions, and local vol, so the WASM bindings now cover all five layers. See [`wasm/README.md`](wasm/README.md) for the JavaScript API and usage examples.
258268

259269
## Changelog
260270

271+
See [CHANGELOG.md](CHANGELOG.md) for the full history. Recent releases:
272+
261273
| Version | Name | Key Features |
262274
|---------|------|--------------|
263-
| **v2.0** | **Type-Safe Inputs** | **Strike/Tenor newtypes for all API inputs** |
275+
| **v2.3** | **WASM Parity** | **Implied vol, conventions, and local vol in the WASM bindings** |
276+
| v2.2 | Local Vol Boundary | `BoundaryLocalVol` small-time adapter, `with_boundary()` |
277+
| v2.1 | API Polish | `model_name()`, `tenors()`, configurable arbitrage scans and calibration |
278+
| v2.0 | Type-Safe Inputs | `Strike`/`Tenor` newtypes for all API inputs |
264279
| v1.0 | Stable | API stability, PyO3 bindings, WASM target |
265-
| v0.4 | Hardening | Coverage gaps, tracing diagnostics, dead code removal |
266-
| v0.3 | Production Grade | eSSVI surface + calibration, parallel construction, dividend yield |
267-
| v0.2.1 | | Normal/Displaced IV, Dupire local vol, serde validation, CI |
268-
| v0.2 | Market Ready | SABR, SSVI, calendar arbitrage, surface diagnostics |
269-
| v0.1 | First Light | SVI smile, cubic spline, ragged grid surface, builder API |
270280

271281
## References
272282

python/README.md

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
# volsurf (Python)
2+
3+
Python bindings for [volsurf](https://github.com/volsurf-rs/volsurf), a volatility
4+
surface library for equity and FX derivatives. Built with PyO3.
5+
6+
## Install
7+
8+
```bash
9+
pip install volsurf
10+
```
11+
12+
Requires Python 3.9 or later. NumPy is the only runtime dependency.
13+
14+
To build from source:
15+
16+
```bash
17+
maturin develop --release -m python/Cargo.toml
18+
```
19+
20+
## Build a surface
21+
22+
`SurfaceBuilder` sets its options through method calls rather than chaining, so
23+
call each setter on its own line.
24+
25+
```python
26+
from volsurf import SurfaceBuilder, SmileModel
27+
28+
strikes = [80.0, 90.0, 95.0, 100.0, 105.0, 110.0, 120.0]
29+
vols = [0.28, 0.24, 0.22, 0.20, 0.22, 0.24, 0.28]
30+
31+
b = SurfaceBuilder()
32+
b.spot(100.0)
33+
b.rate(0.05)
34+
b.add_tenor(0.25, strikes, vols)
35+
b.add_tenor(1.00, strikes, vols)
36+
surface = b.build()
37+
38+
surface.black_vol(0.5, 100.0) # vol at any (expiry, strike)
39+
surface.black_variance(0.5, 100.0) # total variance
40+
surface.tenors() # [0.25, 1.0]
41+
```
42+
43+
Pick a smile model with `b.model(...)`. `SmileModel.svi()` is the default and
44+
needs 5 strikes per tenor, `SmileModel.cubic_spline()` needs 3, and
45+
`SmileModel.sabr(beta)` needs 4.
46+
47+
```python
48+
b.model(SmileModel.sabr(0.5))
49+
b.dividend_yield(0.02)
50+
b.add_tenor_with_forward(0.25, strikes, vols, 101.2)
51+
```
52+
53+
## Query a smile
54+
55+
`smile_at()` returns the calibrated section for one tenor.
56+
57+
```python
58+
smile = surface.smile_at(0.5)
59+
smile.vol(105.0)
60+
smile.density(105.0) # risk-neutral density (Breeden-Litzenberger)
61+
smile.forward # a property, as are smile.expiry and smile.model_name
62+
```
63+
64+
Sections taken from a `SurfaceBuilder` surface report `model_name == "CubicSpline"`.
65+
The builder fits your chosen model per tenor, then hands back a spline through the
66+
fitted variances. Construct a model directly if you need its own parameters.
67+
68+
Construct smiles directly when you already have parameters, or calibrate one
69+
tenor at a time:
70+
71+
```python
72+
from volsurf import SviSmile, SabrSmile, SplineSmile
73+
74+
svi = SviSmile(100.0, 1.0, 0.04, 0.4, -0.4, 0.0, 0.2) # forward, expiry, a, b, rho, m, sigma
75+
sabr = SabrSmile(100.0, 1.0, 0.20, 0.5, -0.3, 0.4) # forward, expiry, alpha, beta, rho, nu
76+
77+
market = [(80.0, 0.28), (90.0, 0.24), (100.0, 0.20), (110.0, 0.24), (120.0, 0.28)]
78+
fitted = SviSmile.calibrate(100.0, 1.0, market)
79+
```
80+
81+
## Global surfaces
82+
83+
`SsviSurface` and `EssviSurface` parameterize the whole surface at once. Both
84+
take parameters directly or calibrate from per-tenor market data.
85+
86+
```python
87+
from volsurf import SsviSurface, EssviSurface
88+
89+
ssvi = SsviSurface(
90+
rho=-0.3, eta=0.5, gamma=0.5,
91+
tenors=[0.25, 0.5, 1.0],
92+
forwards=[100.0, 100.0, 100.0],
93+
thetas=[0.04, 0.08, 0.16], # ATM total variance
94+
)
95+
96+
essvi = EssviSurface.calibrate(
97+
[market_3m, market_1y], # per-tenor [(strike, vol), ...]
98+
[0.25, 1.0], # tenors
99+
[100.0, 100.0], # forwards
100+
)
101+
```
102+
103+
To see how well each tenor fit, run the first stage on its own.
104+
`EssviSurface.fit_per_tenor(market_data, tenors, forwards)` returns a list of
105+
`PerTenorFit` — each with `rms_error`, `theta`, and the fitted `svi` slice — and
106+
`EssviSurface.from_per_tenor(fits)` turns that list into the surface.
107+
108+
```python
109+
fits = EssviSurface.fit_per_tenor([market_3m, market_1y], [0.25, 1.0], [100.0, 100.0])
110+
print([f.rms_error for f in fits])
111+
surface = EssviSurface.from_per_tenor(fits)
112+
```
113+
114+
## NumPy grids
115+
116+
Every surface exposes `vol_grid(expiries, strikes)`, which returns a
117+
`(len(expiries), len(strikes))` array of `float64`. Smiles expose
118+
`vol_array(strikes)`.
119+
120+
```python
121+
import numpy as np
122+
123+
vols = surface.vol_grid(np.array([0.25, 0.5, 1.0]), np.array([90.0, 100.0, 110.0]))
124+
vols.shape # (3, 3)
125+
```
126+
127+
Grid values match the scalar calls to within 1e-14, so use whichever fits the
128+
calling code.
129+
130+
## Arbitrage checks
131+
132+
```python
133+
report = smile.is_arbitrage_free()
134+
report.is_free # property
135+
report.butterfly_violations # property, a list
136+
report.worst_violation() # method
137+
138+
diag = surface.diagnostics()
139+
for v in diag.calendar_violations:
140+
print(v)
141+
```
142+
143+
`is_arbitrage_free_with(config)` and `diagnostics_with(config)` take an
144+
`ArbitrageScanConfig` when you want a denser or sparser scan grid than the
145+
default.
146+
147+
## Calibration control
148+
149+
`DataFilter` drops points before the fit. `WeightingScheme` sets how the
150+
remaining points are weighted.
151+
152+
```python
153+
from volsurf import DataFilter, WeightingScheme
154+
155+
f = DataFilter(max_log_moneyness=0.5, min_vol=0.01) # drop far wings and sub-floor quotes
156+
157+
b.data_filter(f)
158+
b.weighting(WeightingScheme.vega())
159+
```
160+
161+
The same options reach single-tenor fits through
162+
`SviSmile.calibrate_with_config(forward, expiry, market_vols, filter, weighting, seed)`.
163+
Pass `seed` to warm-start from prior parameters.
164+
165+
## Implied volatility
166+
167+
Prices are undiscounted and quoted on the forward.
168+
169+
```python
170+
from volsurf import BlackImpliedVol, NormalImpliedVol, DisplacedImpliedVol
171+
from volsurf import black_price, normal_price, displaced_price, OptionType
172+
173+
price = black_price(100.0, 100.0, 0.20, 1.0, OptionType.Call)
174+
vol = BlackImpliedVol.compute(price, 100.0, 100.0, 1.0, OptionType.Call)
175+
```
176+
177+
Use `NormalImpliedVol` (Bachelier) when forwards can go negative, and
178+
`DisplacedImpliedVol(beta)` to interpolate between normal (`beta=0`) and Black
179+
(`beta=1`).
180+
181+
Conventions helpers: `log_moneyness(strike, forward)`, `moneyness(strike, forward)`,
182+
and `forward_price(spot, rate, dividend_yield, expiry)`.
183+
184+
## Local volatility
185+
186+
`DupireLocalVol` wraps a `Surface`, `SsviSurface`, or `EssviSurface` and extracts
187+
local vol by finite differences.
188+
189+
```python
190+
from volsurf import DupireLocalVol
191+
192+
lv = DupireLocalVol(surface) # or DupireLocalVol(surface, bump_size=1e-3)
193+
lv.local_vol(0.5, 100.0)
194+
```
195+
196+
## Serialization
197+
198+
Model structs round-trip through JSON with `to_json()` and `from_json()`.
199+
200+
```python
201+
s = ssvi.to_json()
202+
restored = SsviSurface.from_json(s)
203+
```
204+
205+
## Errors
206+
207+
Invalid inputs raise `ValueError` with the underlying message: negative vols,
208+
zero or negative forwards, non-finite values, too few strikes for the chosen
209+
model, and calibration failures.
210+
211+
## Tests
212+
213+
```bash
214+
pytest python/tests
215+
```

python/src/local_vol.rs

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
use std::sync::Arc;
22

3+
use pyo3::exceptions::PyTypeError;
34
use pyo3::prelude::*;
5+
use pyo3::types::PyAny;
46
use volsurf::local_vol::{DupireLocalVol, LocalVol};
5-
use volsurf::{Strike, Tenor};
7+
use volsurf::{Strike, Tenor, VolSurface};
68

79
use crate::error::to_py_err;
10+
use crate::surface::{PyEssviSurface, PySsviSurface};
811
use crate::types::PySurface;
912

1013
#[pyclass(frozen, name = "DupireLocalVol")]
@@ -16,8 +19,21 @@ pub struct PyDupireLocalVol {
1619
impl PyDupireLocalVol {
1720
#[new]
1821
#[pyo3(signature = (surface, bump_size=None))]
19-
fn new(surface: &PySurface, bump_size: Option<f64>) -> PyResult<Self> {
20-
let mut lv = DupireLocalVol::new(Arc::clone(&surface.inner));
22+
fn new(surface: &Bound<'_, PyAny>, bump_size: Option<f64>) -> PyResult<Self> {
23+
let surface: Arc<dyn VolSurface> =
24+
if let Ok(surface) = surface.extract::<PyRef<PySurface>>() {
25+
Arc::clone(&surface.inner)
26+
} else if let Ok(surface) = surface.extract::<PyRef<PySsviSurface>>() {
27+
Arc::new(surface.inner.clone())
28+
} else if let Ok(surface) = surface.extract::<PyRef<PyEssviSurface>>() {
29+
Arc::new(surface.inner.clone())
30+
} else {
31+
return Err(PyTypeError::new_err(
32+
"surface must be a Surface, SsviSurface, or EssviSurface",
33+
));
34+
};
35+
36+
let mut lv = DupireLocalVol::new(surface);
2137
if let Some(h) = bump_size {
2238
lv = lv.with_bump_size(h).map_err(to_py_err)?;
2339
}

python/src/surface.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ macro_rules! impl_vol_grid {
4646

4747
#[pyclass(frozen, name = "SsviSurface")]
4848
pub struct PySsviSurface {
49-
inner: SsviSurface,
49+
pub(crate) inner: SsviSurface,
5050
}
5151

5252
#[pymethods]
@@ -208,7 +208,7 @@ impl PyPerTenorFit {
208208

209209
#[pyclass(frozen, name = "EssviSurface")]
210210
pub struct PyEssviSurface {
211-
inner: EssviSurface,
211+
pub(crate) inner: EssviSurface,
212212
}
213213

214214
#[pymethods]

0 commit comments

Comments
 (0)