regit_svi/surface.rs
1// Copyright 2026 Regit.io — Nicolas Koenig
2// SPDX-License-Identifier: Apache-2.0
3
4//! Multi-slice volatility surface assembly and interpolation.
5//!
6//! A surface is an ordered set of calibrated slices at maturities
7//! `t_1 < ... < t_n`. To evaluate total variance at an arbitrary `(k, T)`:
8//!
9//! - **Maturity inside the grid.** Locate the bracketing slices
10//! `t_j <= T < t_{j+1}` and interpolate linearly in total variance along
11//! constant `k`:
12//!
13//! ```text
14//! w(k, T) = ( (t_{j+1} - T)*w(k, t_j) + (T - t_j)*w(k, t_{j+1}) )
15//! / (t_{j+1} - t_j)
16//! ```
17//!
18//! Linear interpolation in `w` is monotone in `T`, so it introduces no
19//! calendar-spread arbitrage provided the bracketing slices are themselves
20//! ordered.
21//!
22//! - **Maturity outside the grid.** Total variance is extrapolated flat in
23//! implied volatility (constant `sigma_BS` beyond the first / last slice),
24//! the conservative market default.
25//!
26//! For an SSVI-backed surface, evaluation is direct from the closed form at
27//! the interpolated `theta_T`, and no per-`k` interpolation is needed.
28//!
29//! # References
30//!
31//! - Gatheral, J., *The Volatility Surface: A Practitioner's Guide*,
32//! Wiley (2006), Chapter 3.
33
34use crate::arbitrage::calendar_scan;
35use crate::errors::ParamError;
36use crate::raw::RawSvi;
37use crate::ssvi::Ssvi;
38
39/// The backing representation of a [`Surface`].
40#[derive(Debug, Clone, PartialEq)]
41enum Backing {
42 /// A set of raw slices, each tagged with its maturity (ascending order).
43 Slices(Vec<(f64, RawSvi)>),
44 /// An SSVI surface plus its `(maturity, theta)` term structure.
45 Ssvi {
46 /// The SSVI parametrisation.
47 ssvi: Ssvi,
48 /// `(maturity, theta)` knots, ascending in maturity.
49 term: Vec<(f64, f64)>,
50 },
51}
52
53/// An arbitrage-checked volatility surface.
54///
55/// Built from either a set of calibrated raw slices ([`Surface::from_slices`])
56/// or an SSVI parametrisation ([`Surface::from_ssvi`]). Evaluation at any
57/// `(k, T)` returns total variance ([`Surface::total_variance`]) or implied
58/// volatility ([`Surface::implied_vol`]).
59///
60/// # Examples
61///
62/// ```
63/// use regit_svi::raw::RawSvi;
64/// use regit_svi::surface::Surface;
65///
66/// let s1 = RawSvi::new(0.02, 0.3, -0.2, 0.0, 0.1).unwrap();
67/// let s2 = RawSvi::new(0.05, 0.3, -0.2, 0.0, 0.1).unwrap();
68/// let surface = Surface::from_slices(vec![(0.5, s1), (1.5, s2)]).unwrap();
69/// // Total variance at an interpolated maturity.
70/// let w = surface.total_variance(0.0, 1.0);
71/// assert!(w > s1.total_variance(0.0) && w < s2.total_variance(0.0));
72/// ```
73#[derive(Debug, Clone, PartialEq)]
74pub struct Surface {
75 backing: Backing,
76}
77
78impl Surface {
79 /// Builds a surface from `(maturity, slice)` pairs.
80 ///
81 /// The pairs are sorted into ascending maturity order. Maturities must be
82 /// strictly positive and distinct.
83 ///
84 /// # Errors
85 ///
86 /// - [`ParamError::NonFinite`] if a maturity is not finite.
87 /// - [`ParamError::NonPositiveMaturity`] if a maturity is `<= 0` or two
88 /// maturities coincide.
89 ///
90 /// # Examples
91 ///
92 /// ```
93 /// use regit_svi::raw::RawSvi;
94 /// use regit_svi::surface::Surface;
95 ///
96 /// let s = RawSvi::new(0.04, 0.3, -0.2, 0.0, 0.1).unwrap();
97 /// assert!(Surface::from_slices(vec![(1.0, s)]).is_ok());
98 /// assert!(Surface::from_slices(vec![(0.0, s)]).is_err());
99 /// ```
100 pub fn from_slices(mut slices: Vec<(f64, RawSvi)>) -> Result<Self, ParamError> {
101 if slices.is_empty() {
102 return Err(ParamError::NonPositiveMaturity { t: 0.0 });
103 }
104 for &(t, _) in &slices {
105 if !t.is_finite() {
106 return Err(ParamError::NonFinite { name: "maturity" });
107 }
108 if t <= 0.0 {
109 return Err(ParamError::NonPositiveMaturity { t });
110 }
111 }
112 slices.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(core::cmp::Ordering::Equal));
113 for pair in slices.windows(2) {
114 if (pair[1].0 - pair[0].0).abs() < 1e-12 {
115 return Err(ParamError::NonPositiveMaturity { t: pair[1].0 });
116 }
117 }
118 Ok(Self {
119 backing: Backing::Slices(slices),
120 })
121 }
122
123 /// Builds a surface from an SSVI parametrisation and its `(maturity,
124 /// theta)` term structure.
125 ///
126 /// The term structure is sorted into ascending maturity order; `theta`
127 /// must be non-decreasing (a non-decreasing ATM term structure is one of
128 /// the SSVI no-calendar conditions).
129 ///
130 /// # Errors
131 ///
132 /// - [`ParamError::NonPositiveMaturity`] if the term structure is empty or
133 /// contains a non-positive / duplicate maturity.
134 /// - [`ParamError::NonPositiveTheta`] if a `theta` is non-positive.
135 /// - [`ParamError::NonFinite`] if a knot is not finite.
136 ///
137 /// # Examples
138 ///
139 /// ```
140 /// use regit_svi::ssvi::{Phi, Ssvi};
141 /// use regit_svi::surface::Surface;
142 ///
143 /// let ssvi = Ssvi::new(-0.3, Phi::power_law(0.5, 0.5).unwrap()).unwrap();
144 /// let surface = Surface::from_ssvi(ssvi, vec![(0.5, 0.02), (1.0, 0.04)]).unwrap();
145 /// assert!(surface.total_variance(0.0, 0.75) > 0.0);
146 /// ```
147 pub fn from_ssvi(ssvi: Ssvi, mut term: Vec<(f64, f64)>) -> Result<Self, ParamError> {
148 if term.is_empty() {
149 return Err(ParamError::NonPositiveMaturity { t: 0.0 });
150 }
151 for &(t, theta) in &term {
152 if !t.is_finite() || !theta.is_finite() {
153 return Err(ParamError::NonFinite {
154 name: "term structure",
155 });
156 }
157 if t <= 0.0 {
158 return Err(ParamError::NonPositiveMaturity { t });
159 }
160 if theta <= 0.0 {
161 return Err(ParamError::NonPositiveTheta { theta });
162 }
163 }
164 term.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(core::cmp::Ordering::Equal));
165 for pair in term.windows(2) {
166 if (pair[1].0 - pair[0].0).abs() < 1e-12 {
167 return Err(ParamError::NonPositiveMaturity { t: pair[1].0 });
168 }
169 }
170 Ok(Self {
171 backing: Backing::Ssvi { ssvi, term },
172 })
173 }
174
175 /// The number of maturity knots in the surface.
176 ///
177 /// # Examples
178 ///
179 /// ```
180 /// use regit_svi::raw::RawSvi;
181 /// use regit_svi::surface::Surface;
182 ///
183 /// let s = RawSvi::new(0.04, 0.3, -0.2, 0.0, 0.1).unwrap();
184 /// let surface = Surface::from_slices(vec![(0.5, s), (1.0, s)]).unwrap();
185 /// assert_eq!(surface.len(), 2);
186 /// ```
187 #[must_use]
188 pub fn len(&self) -> usize {
189 match &self.backing {
190 Backing::Slices(s) => s.len(),
191 Backing::Ssvi { term, .. } => term.len(),
192 }
193 }
194
195 /// Returns `true` if the surface has no maturity knots.
196 ///
197 /// A [`Surface`] is always constructed with at least one knot, so this
198 /// returns `false` for every value built through the public constructors.
199 ///
200 /// # Examples
201 ///
202 /// ```
203 /// use regit_svi::raw::RawSvi;
204 /// use regit_svi::surface::Surface;
205 ///
206 /// let s = RawSvi::new(0.04, 0.3, -0.2, 0.0, 0.1).unwrap();
207 /// let surface = Surface::from_slices(vec![(1.0, s)]).unwrap();
208 /// assert!(!surface.is_empty());
209 /// ```
210 #[must_use]
211 pub fn is_empty(&self) -> bool {
212 self.len() == 0
213 }
214
215 /// Total implied variance `w(k, T)` at log-moneyness `k` and maturity `T`.
216 ///
217 /// Inside the maturity grid the value is linearly interpolated in total
218 /// variance; outside it the implied volatility is held flat (constant
219 /// `sigma_BS`). An SSVI-backed surface evaluates the closed form at the
220 /// interpolated `theta`.
221 ///
222 /// # Examples
223 ///
224 /// ```
225 /// use regit_svi::raw::RawSvi;
226 /// use regit_svi::surface::Surface;
227 ///
228 /// let s1 = RawSvi::new(0.02, 0.3, -0.2, 0.0, 0.1).unwrap();
229 /// let s2 = RawSvi::new(0.05, 0.3, -0.2, 0.0, 0.1).unwrap();
230 /// let surface = Surface::from_slices(vec![(0.5, s1), (1.5, s2)]).unwrap();
231 /// // Midpoint maturity -> midpoint total variance at constant k.
232 /// let mid = surface.total_variance(0.0, 1.0);
233 /// let expect = 0.5 * (s1.total_variance(0.0) + s2.total_variance(0.0));
234 /// assert!((mid - expect).abs() < 1e-12);
235 /// ```
236 #[must_use]
237 pub fn total_variance(&self, k: f64, t: f64) -> f64 {
238 match &self.backing {
239 Backing::Slices(slices) => Self::total_variance_slices(slices, k, t),
240 Backing::Ssvi { ssvi, term } => {
241 let theta = interpolate_theta(term, t);
242 ssvi.total_variance(k, theta)
243 }
244 }
245 }
246
247 /// Black implied volatility `sigma_BS(k, T) = sqrt(w(k, T) / T)`.
248 ///
249 /// # Errors
250 ///
251 /// Returns [`ParamError::NonPositiveMaturity`] if `T <= 0`.
252 ///
253 /// # Examples
254 ///
255 /// ```
256 /// use regit_svi::raw::RawSvi;
257 /// use regit_svi::surface::Surface;
258 ///
259 /// let s = RawSvi::new(0.04, 0.0, 0.0, 0.0, 0.1).unwrap();
260 /// let surface = Surface::from_slices(vec![(1.0, s)]).unwrap();
261 /// // Flat w = 0.04 at t = 1 -> vol = 0.2.
262 /// assert!((surface.implied_vol(0.0, 1.0).unwrap() - 0.2).abs() < 1e-12);
263 /// ```
264 pub fn implied_vol(&self, k: f64, t: f64) -> Result<f64, ParamError> {
265 if t <= 0.0 || !t.is_finite() {
266 return Err(ParamError::NonPositiveMaturity { t });
267 }
268 Ok((self.total_variance(k, t) / t).sqrt())
269 }
270
271 /// Checks the surface for calendar-spread arbitrage across adjacent
272 /// maturity knots.
273 ///
274 /// For a slice-backed surface, runs the [`calendar_scan`] on every
275 /// adjacent pair over `[k_lo, k_hi]`; the surface is calendar-free if
276 /// every pair is. For an SSVI-backed surface, the closed-form Theorem 4.1
277 /// conditions are used.
278 ///
279 /// # Examples
280 ///
281 /// ```
282 /// use regit_svi::raw::RawSvi;
283 /// use regit_svi::surface::Surface;
284 ///
285 /// let s1 = RawSvi::new(0.02, 0.3, -0.2, 0.0, 0.1).unwrap();
286 /// let s2 = RawSvi::new(0.05, 0.3, -0.2, 0.0, 0.1).unwrap();
287 /// let surface = Surface::from_slices(vec![(0.5, s1), (1.5, s2)]).unwrap();
288 /// assert!(surface.is_calendar_free(-0.5, 0.5));
289 /// ```
290 #[must_use]
291 pub fn is_calendar_free(&self, k_lo: f64, k_hi: f64) -> bool {
292 match &self.backing {
293 Backing::Slices(slices) => slices
294 .windows(2)
295 .all(|p| calendar_scan(&p[0].1, &p[1].1, k_lo, k_hi).is_free),
296 Backing::Ssvi { ssvi, term } => {
297 let thetas: Vec<f64> = term.iter().map(|&(_, theta)| theta).collect();
298 ssvi.is_calendar_free(&thetas)
299 }
300 }
301 }
302
303 /// Evaluates a slice-backed surface at `(k, t)` with maturity
304 /// interpolation and flat-vol extrapolation.
305 fn total_variance_slices(slices: &[(f64, RawSvi)], k: f64, t: f64) -> f64 {
306 let n = slices.len();
307 let (t0, first) = slices[0];
308 let (tn, last) = slices[n - 1];
309
310 if t <= t0 {
311 // Flat implied volatility below the first maturity:
312 // w(k, t) = (w(k, t0) / t0) * t.
313 return (first.total_variance(k) / t0) * t.max(0.0);
314 }
315 if t >= tn {
316 // Flat implied volatility above the last maturity.
317 return (last.total_variance(k) / tn) * t;
318 }
319
320 // Bracketing slices t_j <= t < t_{j+1}; linear interpolation in w.
321 for pair in slices.windows(2) {
322 let (tj, sj) = pair[0];
323 let (tj1, sj1) = pair[1];
324 if t >= tj && t <= tj1 {
325 let wj = sj.total_variance(k);
326 let wj1 = sj1.total_variance(k);
327 let frac = (t - tj) / (tj1 - tj);
328 return wj + frac * (wj1 - wj);
329 }
330 }
331 // Unreachable for a sorted, bracketing grid; fall back to the last.
332 last.total_variance(k)
333 }
334}
335
336/// Interpolates the `theta` term structure at maturity `t`.
337///
338/// Linear interpolation between adjacent `(maturity, theta)` knots, with flat
339/// extrapolation of `theta / t` (constant ATM implied variance) outside the
340/// grid.
341fn interpolate_theta(term: &[(f64, f64)], t: f64) -> f64 {
342 let n = term.len();
343 let (t_first, theta_first) = term[0];
344 let (t_last, theta_last) = term[n - 1];
345
346 if t <= t_first {
347 return (theta_first / t_first) * t.max(0.0);
348 }
349 if t >= t_last {
350 return (theta_last / t_last) * t;
351 }
352 for pair in term.windows(2) {
353 let (t_lo, theta_lo) = pair[0];
354 let (t_hi, theta_hi) = pair[1];
355 if t >= t_lo && t <= t_hi {
356 let frac = (t - t_lo) / (t_hi - t_lo);
357 return theta_lo + frac * (theta_hi - theta_lo);
358 }
359 }
360 theta_last
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366 use crate::ssvi::Phi;
367
368 #[test]
369 fn from_slices_sorts_and_validates() {
370 let s = RawSvi::new(0.04, 0.3, -0.2, 0.0, 0.1).unwrap();
371 let surface = Surface::from_slices(vec![(2.0, s), (0.5, s), (1.0, s)]).unwrap();
372 assert_eq!(surface.len(), 3);
373 }
374
375 #[test]
376 fn from_slices_rejects_bad_maturity() {
377 let s = RawSvi::new(0.04, 0.3, -0.2, 0.0, 0.1).unwrap();
378 assert!(Surface::from_slices(vec![(0.0, s)]).is_err());
379 assert!(Surface::from_slices(vec![(1.0, s), (1.0, s)]).is_err());
380 assert!(Surface::from_slices(vec![]).is_err());
381 }
382
383 #[test]
384 fn interpolation_is_linear_in_w() {
385 let s1 = RawSvi::new(0.02, 0.3, -0.2, 0.0, 0.1).unwrap();
386 let s2 = RawSvi::new(0.06, 0.3, -0.2, 0.0, 0.1).unwrap();
387 let surface = Surface::from_slices(vec![(1.0, s1), (3.0, s2)]).unwrap();
388 // At t = 2 (midpoint), w is the average at every k.
389 for &k in &[-0.3, 0.0, 0.3] {
390 let mid = surface.total_variance(k, 2.0);
391 let expect = 0.5 * (s1.total_variance(k) + s2.total_variance(k));
392 assert!((mid - expect).abs() < 1e-12, "k = {k}");
393 }
394 }
395
396 #[test]
397 fn interpolation_recovers_knot_values() {
398 let s1 = RawSvi::new(0.02, 0.3, -0.2, 0.0, 0.1).unwrap();
399 let s2 = RawSvi::new(0.06, 0.3, -0.2, 0.0, 0.1).unwrap();
400 let surface = Surface::from_slices(vec![(1.0, s1), (3.0, s2)]).unwrap();
401 assert!((surface.total_variance(0.1, 1.0) - s1.total_variance(0.1)).abs() < 1e-12);
402 assert!((surface.total_variance(0.1, 3.0) - s2.total_variance(0.1)).abs() < 1e-12);
403 }
404
405 #[test]
406 fn extrapolation_is_flat_in_vol() {
407 let s = RawSvi::new(0.04, 0.3, -0.2, 0.0, 0.1).unwrap();
408 let surface = Surface::from_slices(vec![(1.0, s)]).unwrap();
409 // Below: w(k, 0.5) = w(k, 1) * 0.5 (constant vol).
410 let w_half = surface.total_variance(0.0, 0.5);
411 assert!((w_half - s.total_variance(0.0) * 0.5).abs() < 1e-12);
412 // Above: w(k, 2) = w(k, 1) * 2.
413 let w_double = surface.total_variance(0.0, 2.0);
414 assert!((w_double - s.total_variance(0.0) * 2.0).abs() < 1e-12);
415 // Implied vol is constant across extrapolated maturities.
416 let v05 = surface.implied_vol(0.0, 0.5).unwrap();
417 let v20 = surface.implied_vol(0.0, 2.0).unwrap();
418 assert!((v05 - v20).abs() < 1e-12);
419 }
420
421 #[test]
422 fn implied_vol_rejects_bad_maturity() {
423 let s = RawSvi::new(0.04, 0.3, -0.2, 0.0, 0.1).unwrap();
424 let surface = Surface::from_slices(vec![(1.0, s)]).unwrap();
425 assert!(surface.implied_vol(0.0, 0.0).is_err());
426 }
427
428 #[test]
429 fn slice_surface_calendar_check() {
430 let early = RawSvi::new(0.02, 0.3, -0.2, 0.0, 0.1).unwrap();
431 let late = RawSvi::new(0.06, 0.3, -0.2, 0.0, 0.1).unwrap();
432 let ok = Surface::from_slices(vec![(0.5, early), (1.5, late)]).unwrap();
433 assert!(ok.is_calendar_free(-0.5, 0.5));
434 // Reversed: the later slice has lower variance -> arbitrage.
435 let bad = Surface::from_slices(vec![(0.5, late), (1.5, early)]).unwrap();
436 assert!(!bad.is_calendar_free(-0.5, 0.5));
437 }
438
439 #[test]
440 fn ssvi_surface_evaluates_and_interpolates() {
441 let ssvi = Ssvi::new(-0.3, Phi::power_law(0.5, 0.5).unwrap()).unwrap();
442 let surface =
443 Surface::from_ssvi(ssvi, vec![(0.5, 0.02), (1.0, 0.04), (2.0, 0.08)]).unwrap();
444 // At a knot maturity, theta is exact.
445 let w_knot = surface.total_variance(0.1, 1.0);
446 assert!((w_knot - ssvi.total_variance(0.1, 0.04)).abs() < 1e-12);
447 // Interpolated maturity is between bracketing values.
448 let w_mid = surface.total_variance(0.0, 1.5);
449 assert!(w_mid > ssvi.total_variance(0.0, 0.04));
450 assert!(w_mid < ssvi.total_variance(0.0, 0.08));
451 }
452
453 #[test]
454 fn ssvi_surface_calendar_check() {
455 let ssvi = Ssvi::new(-0.3, Phi::power_law(0.5, 0.5).unwrap()).unwrap();
456 let surface =
457 Surface::from_ssvi(ssvi, vec![(0.5, 0.02), (1.0, 0.04), (2.0, 0.08)]).unwrap();
458 assert!(surface.is_calendar_free(-0.5, 0.5));
459 }
460
461 #[test]
462 fn ssvi_surface_rejects_bad_term() {
463 let ssvi = Ssvi::new(-0.3, Phi::heston(1.0).unwrap()).unwrap();
464 assert!(Surface::from_ssvi(ssvi, vec![]).is_err());
465 assert!(Surface::from_ssvi(ssvi, vec![(1.0, 0.0)]).is_err());
466 assert!(Surface::from_ssvi(ssvi, vec![(0.0, 0.04)]).is_err());
467 }
468
469 #[test]
470 fn is_empty_is_false_for_built_surface() {
471 let s = RawSvi::new(0.04, 0.3, -0.2, 0.0, 0.1).unwrap();
472 let surface = Surface::from_slices(vec![(1.0, s)]).unwrap();
473 assert!(!surface.is_empty());
474 }
475}