Skip to main content

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}