|
| 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 | +``` |
0 commit comments