Skip to main content

rustyqlib/equity/
svi.rs

1//! SVI and SSVI implied-volatility parameterizations (Gatheral 2004;
2//! Gatheral & Jacquier 2014).
3//!
4//! **SVI** (raw form) parameterizes one expiry's total variance in
5//! log-moneyness `k = ln(K/F)`:
6//!
7//! ```text
8//! w(k) = a + b [ rho (k - m) + sqrt((k - m)^2 + sigma^2) ]
9//! ```
10//!
11//! five parameters per smile: level `a`, wing slope `b`, skew `rho`,
12//! shift `m`, ATM curvature `sigma`. Wings are asymptotically linear
13//! with slopes `b(1 - rho)` (put side) and `b(1 + rho)` (call side).
14//!
15//! **SSVI** parameterizes the whole surface from the ATM total-variance
16//! term structure `theta_t` and three global parameters `(rho, eta,
17//! gamma)` through the power-law curvature
18//! `phi(theta) = eta / (theta^gamma (1 + theta)^(1-gamma))`:
19//!
20//! ```text
21//! w(k, t) = theta_t/2 [ 1 + rho phi k + sqrt((phi k + rho)^2 + 1 - rho^2) ]
22//! ```
23//!
24//! Both calibrate by Levenberg-Marquardt
25//! ([`core::optimization`](crate::core::optimization)) in transformed
26//! parameter spaces, the same pattern as
27//! [`heston::calibrate`](crate::equity::heston::calibrate). Butterfly
28//! arbitrage is checked through the Gatheral-Jacquier `g(k)` density
29//! condition (SVI) and the power-law sufficient conditions (SSVI), and
30//! fitted smiles sample into the pricing
31//! [`VolSurface`](crate::core::vols::VolSurface) via
32//! [`Ssvi::to_vol_surface`].
33
34use chrono::NaiveDate;
35
36use crate::core::curves::Tenor;
37use crate::core::daycount::DayCountConvention;
38use crate::core::optimization::{levenberg_marquardt, OptimConfig};
39use crate::core::vols::{VolError, VolSurface};
40use crate::core::errors::RustyQLibError;
41
42// ── SVI: one expiry ─────────────────────────────────────────────────────
43
44/// Raw SVI parameters for a single expiry.
45#[derive(Debug, Clone, Copy, PartialEq)]
46pub struct SviParams {
47    pub a: f64,
48    pub b: f64,
49    pub rho: f64,
50    pub m: f64,
51    pub sigma: f64,
52}
53
54/// Result of an SVI smile calibration.
55#[derive(Debug, Clone)]
56pub struct SviFit {
57    pub params: SviParams,
58    /// Root-mean-square error in implied vol.
59    pub rmse: f64,
60    pub iterations: usize,
61    pub converged: bool,
62}
63
64impl SviParams {
65    /// Total variance `w(k)` at log-moneyness `k = ln(K/F)`.
66    pub fn total_variance(&self, k: f64) -> f64 {
67        let d = k - self.m;
68        self.a + self.b * (self.rho * d + (d * d + self.sigma * self.sigma).sqrt())
69    }
70
71    /// Implied vol at log-moneyness `k` for expiry `t`.
72    pub fn vol(&self, k: f64, t: f64) -> f64 {
73        (self.total_variance(k).max(0.0) / t).sqrt()
74    }
75
76    /// Static parameter constraints: `b >= 0`, `|rho| < 1`, `sigma > 0`
77    /// and non-negative minimum variance `a + b sigma sqrt(1 - rho^2)`.
78    pub fn validate(&self) -> Result<(), RustyQLibError> {
79        if self.b < 0.0 {
80            return Err(RustyQLibError::invalid_input("svi params", "b must be non-negative"));
81        }
82        if !(-1.0..1.0).contains(&self.rho) && self.rho != -1.0 {
83            return Err(RustyQLibError::invalid_input("svi params", "rho must be in (-1, 1)"));
84        }
85        if self.sigma <= 0.0 {
86            return Err(RustyQLibError::invalid_input("svi params", "sigma must be positive"));
87        }
88        if self.a + self.b * self.sigma * (1.0 - self.rho * self.rho).sqrt() < 0.0 {
89            return Err(RustyQLibError::invalid_input("svi params", "minimum total variance is negative"));
90        }
91        Ok(())
92    }
93
94    /// The Gatheral-Jacquier butterfly function
95    /// `g(k) = (1 - k w'/(2w))^2 - (w'^2/4)(1/w + 1/4) + w''/2`,
96    /// which must stay non-negative for an arbitrage-free density.
97    pub fn butterfly_g(&self, k: f64) -> f64 {
98        let d = k - self.m;
99        let root = (d * d + self.sigma * self.sigma).sqrt();
100        let w = self.a + self.b * (self.rho * d + root);
101        let w1 = self.b * (self.rho + d / root);
102        let w2 = self.b * self.sigma * self.sigma / (root * root * root);
103        (1.0 - k * w1 / (2.0 * w)).powi(2) - (w1 * w1 / 4.0) * (1.0 / w + 0.25) + w2 / 2.0
104    }
105
106    /// Minimum of `g(k)` over a wide log-moneyness scan; negative means
107    /// the smile carries butterfly arbitrage.
108    pub fn min_butterfly_g(&self) -> f64 {
109        (0..=800)
110            .map(|i| self.butterfly_g(-2.0 + i as f64 * 0.005))
111            .fold(f64::INFINITY, f64::min)
112    }
113
114    pub fn has_butterfly_arbitrage(&self) -> bool {
115        self.min_butterfly_g() < 0.0
116    }
117
118    /// Calibrate to one expiry's quotes `(k, implied vol)` by
119    /// Levenberg-Marquardt on total-variance residuals, with `b` and
120    /// `sigma` in log space and `rho` through `tanh` so every trial is
121    /// admissible.
122    pub fn calibrate(quotes: &[(f64, f64)], t: f64) -> SviFit {
123        assert!(quotes.len() >= 5, "SVI has five parameters; need at least five quotes");
124        assert!(t > 0.0);
125        let w_target: Vec<(f64, f64)> =
126            quotes.iter().map(|&(k, v)| (k, v * v * t)).collect();
127        let (w_min, w_max) = w_target
128            .iter()
129            .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &(_, w)| (lo.min(w), hi.max(w)));
130        let k_at_min = w_target
131            .iter()
132            .fold((0.0, f64::INFINITY), |acc, &(k, w)| if w < acc.1 { (k, w) } else { acc })
133            .0;
134        let k_span = quotes.iter().map(|q| q.0).fold(f64::NEG_INFINITY, f64::max)
135            - quotes.iter().map(|q| q.0).fold(f64::INFINITY, f64::min);
136        // start: level at the observed floor, gentle wings, no skew
137        let x0 = vec![
138            0.5 * w_min,                                          // a
139            (((w_max - w_min) / k_span.max(0.1)).max(1e-3)).ln(), // ln b
140            0.0,                                                  // atanh rho
141            k_at_min,                                             // m
142            0.2_f64.ln(),                                         // ln sigma
143        ];
144        let unpack = |u: &[f64]| SviParams {
145            a: u[0],
146            b: u[1].exp(),
147            rho: u[2].tanh(),
148            m: u[3],
149            sigma: u[4].exp(),
150        };
151        let residuals = |u: &[f64]| -> Vec<f64> {
152            let p = unpack(u);
153            w_target.iter().map(|&(k, w)| p.total_variance(k) - w).collect()
154        };
155        let fit = levenberg_marquardt(&OptimConfig::new(1e-14, 200), &residuals, None, &x0);
156        let params = unpack(&fit.x);
157        let rmse = (quotes
158            .iter()
159            .map(|&(k, v)| (params.vol(k, t) - v).powi(2))
160            .sum::<f64>()
161            / quotes.len() as f64)
162            .sqrt();
163        SviFit { params, rmse, iterations: fit.iterations, converged: fit.converged }
164    }
165}
166
167// ── SSVI: the whole surface ─────────────────────────────────────────────
168
169/// SSVI surface: ATM total-variance pillars plus global `(rho, eta,
170/// gamma)` with the power-law curvature.
171#[derive(Debug, Clone)]
172pub struct Ssvi {
173    pub rho: f64,
174    pub eta: f64,
175    /// Power-law exponent in `(0, 1]`.
176    pub gamma: f64,
177    /// `(t, theta_t)` pillars, `t` and `theta` strictly increasing.
178    pub theta_pillars: Vec<(f64, f64)>,
179}
180
181/// Result of an SSVI calibration.
182#[derive(Debug, Clone)]
183pub struct SsviFit {
184    pub surface: Ssvi,
185    /// Root-mean-square error in implied vol.
186    pub rmse: f64,
187    pub iterations: usize,
188    pub converged: bool,
189}
190
191impl Ssvi {
192    /// ATM total variance at `t`: proportional below the first pillar
193    /// (variance accrues from zero), linear between pillars, and
194    /// continued with the last segment's slope beyond.
195    pub fn theta(&self, t: f64) -> f64 {
196        let p = &self.theta_pillars;
197        let n = p.len();
198        if t <= 0.0 {
199            return 0.0;
200        }
201        if t <= p[0].0 {
202            return p[0].1 * t / p[0].0;
203        }
204        if t >= p[n - 1].0 {
205            if n == 1 {
206                return p[0].1 * t / p[0].0;
207            }
208            let slope = (p[n - 1].1 - p[n - 2].1) / (p[n - 1].0 - p[n - 2].0);
209            return p[n - 1].1 + slope * (t - p[n - 1].0);
210        }
211        let idx = p.partition_point(|&(ti, _)| ti < t);
212        let (t0, w0) = p[idx - 1];
213        let (t1, w1) = p[idx];
214        w0 + (w1 - w0) * (t - t0) / (t1 - t0)
215    }
216
217    /// Power-law curvature `phi(theta)`.
218    pub fn phi(&self, theta: f64) -> f64 {
219        self.eta / (theta.powf(self.gamma) * (1.0 + theta).powf(1.0 - self.gamma))
220    }
221
222    /// Total variance `w(k, t)`.
223    pub fn total_variance(&self, k: f64, t: f64) -> f64 {
224        let theta = self.theta(t);
225        if theta <= 0.0 {
226            return 0.0;
227        }
228        let phi = self.phi(theta);
229        let pk = phi * k;
230        0.5 * theta
231            * (1.0 + self.rho * pk + ((pk + self.rho).powi(2) + 1.0 - self.rho * self.rho).sqrt())
232    }
233
234    /// Implied vol for `strike` given the `forward` at expiry `t`.
235    pub fn vol(&self, strike: f64, forward: f64, t: f64) -> f64 {
236        (self.total_variance((strike / forward).ln(), t) / t).sqrt()
237    }
238
239    /// Static no-arbitrage checks (Gatheral-Jacquier): admissible
240    /// parameters, nondecreasing `theta` (calendar), the power-law
241    /// sufficient condition `eta (1 + |rho|) <= 2`, and the per-pillar
242    /// butterfly bounds `theta phi (1 + |rho|) <= 4` and
243    /// `theta phi^2 (1 + |rho|) <= 4`.
244    pub fn validate(&self) -> Result<(), RustyQLibError> {
245        if !(-1.0..1.0).contains(&self.rho) {
246            return Err(RustyQLibError::invalid_input("svi params", "rho must be in (-1, 1)"));
247        }
248        if self.eta <= 0.0 {
249            return Err(RustyQLibError::invalid_input("svi params", "eta must be positive"));
250        }
251        if !(0.0..=1.0).contains(&self.gamma) || self.gamma == 0.0 {
252            return Err(RustyQLibError::invalid_input("svi params", "gamma must be in (0, 1]"));
253        }
254        if self.theta_pillars.is_empty() {
255            return Err(RustyQLibError::invalid_input("svi params", "need at least one theta pillar"));
256        }
257        if self.theta_pillars.iter().any(|&(t, w)| t <= 0.0 || w <= 0.0) {
258            return Err(RustyQLibError::invalid_input("svi params", "theta pillars must have positive times and variances"));
259        }
260        if self.theta_pillars.windows(2).any(|p| p[1].0 <= p[0].0 || p[1].1 < p[0].1) {
261            return Err(RustyQLibError::invalid_input("svi params", "theta pillars must be increasing in time and nondecreasing in variance (calendar arbitrage)"));
262        }
263        if self.eta * (1.0 + self.rho.abs()) > 2.0 {
264            return Err(RustyQLibError::invalid_input("svi params", "eta (1 + |rho|) must not exceed 2 (static arbitrage)"));
265        }
266        for &(_, theta) in &self.theta_pillars {
267            let phi = self.phi(theta);
268            if theta * phi * (1.0 + self.rho.abs()) > 4.0
269                || theta * phi * phi * (1.0 + self.rho.abs()) > 4.0
270            {
271                return Err(RustyQLibError::invalid_input("svi params", "butterfly bound violated at a theta pillar"));
272            }
273        }
274        Ok(())
275    }
276
277    /// Calibrate `(rho, eta, gamma)` to surface quotes `(t, k, vol)`
278    /// given the ATM total-variance pillars, by Levenberg-Marquardt on
279    /// total-variance residuals (`tanh` / `exp` / logistic transforms
280    /// keep every trial admissible).
281    pub fn calibrate(
282        quotes: &[(f64, f64, f64)],
283        theta_pillars: &[(f64, f64)],
284        start: (f64, f64, f64),
285    ) -> SsviFit {
286        assert!(quotes.len() >= 3, "need at least three quotes for three parameters");
287        let make = |u: &[f64]| Ssvi {
288            rho: u[0].tanh(),
289            eta: u[1].exp(),
290            gamma: 1.0 / (1.0 + (-u[2]).exp()),
291            theta_pillars: theta_pillars.to_vec(),
292        };
293        let (rho0, eta0, gamma0) = start;
294        let x0 = vec![
295            rho0.clamp(-0.999, 0.999).atanh(),
296            eta0.ln(),
297            (gamma0.clamp(1e-3, 1.0 - 1e-9) / (1.0 - gamma0.clamp(1e-3, 1.0 - 1e-9))).ln(),
298        ];
299        let residuals = |u: &[f64]| -> Vec<f64> {
300            let s = make(u);
301            quotes
302                .iter()
303                .map(|&(t, k, v)| s.total_variance(k, t) - v * v * t)
304                .collect()
305        };
306        let fit = levenberg_marquardt(&OptimConfig::new(1e-14, 200), &residuals, None, &x0);
307        let surface = make(&fit.x);
308        let rmse = (quotes
309            .iter()
310            .map(|&(t, k, v)| ((surface.total_variance(k, t) / t).sqrt() - v).powi(2))
311            .sum::<f64>()
312            / quotes.len() as f64)
313            .sqrt();
314        SsviFit { surface, rmse, iterations: fit.iterations, converged: fit.converged }
315    }
316
317    /// Sample the SSVI surface into the canonical pricing
318    /// [`VolSurface`]: per expiry `(t, forward)`, strikes are placed at
319    /// `forward * exp(k)` over the log-moneyness grid.
320    pub fn to_vol_surface(
321        &self,
322        reference_date: NaiveDate,
323        day_count: DayCountConvention,
324        expiry_forwards: &[(f64, f64)],
325        log_moneyness_grid: &[f64],
326    ) -> Result<VolSurface, VolError> {
327        let expiries: Vec<Tenor> =
328            expiry_forwards.iter().map(|&(t, _)| Tenor::YearFraction(t)).collect();
329        let smiles: Vec<Vec<(f64, f64)>> = expiry_forwards
330            .iter()
331            .map(|&(t, forward)| {
332                log_moneyness_grid
333                    .iter()
334                    .map(|&k| {
335                        let strike = forward * k.exp();
336                        (strike, self.vol(strike, forward, t))
337                    })
338                    .collect()
339            })
340            .collect();
341        VolSurface::from_strike_smiles(&expiries, &smiles, reference_date, day_count)
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    fn sane() -> SviParams {
350        SviParams { a: 0.03, b: 0.12, rho: -0.4, m: -0.02, sigma: 0.3 }
351    }
352
353    #[test]
354    fn svi_shape_matches_the_closed_form_structure() {
355        let p = sane();
356        p.validate().unwrap();
357        // total variance at k = m is a + b sigma
358        assert!((p.total_variance(p.m) - (p.a + p.b * p.sigma)).abs() < 1e-14);
359        // asymptotic wing slopes b (1 +- rho), measured per unit of |k|
360        let far = 60.0;
361        let call_slope = p.total_variance(far + 1.0) - p.total_variance(far);
362        let put_slope = p.total_variance(-far - 1.0) - p.total_variance(-far);
363        assert!((call_slope - p.b * (1.0 + p.rho)).abs() < 1e-3, "{call_slope}");
364        assert!((put_slope - p.b * (1.0 - p.rho)).abs() < 1e-3, "{put_slope}");
365    }
366
367    #[test]
368    fn vogt_example_carries_butterfly_arbitrage_and_sane_params_do_not() {
369        // the classic arbitrageable SVI smile (Gatheral-Jacquier 2014 §3)
370        let vogt = SviParams { a: -0.0410, b: 0.1331, rho: 0.3060, m: 0.3586, sigma: 0.4153 };
371        assert!(vogt.has_butterfly_arbitrage(), "min g = {}", vogt.min_butterfly_g());
372        assert!((vogt.min_butterfly_g() - -0.0329).abs() < 2e-3);
373        assert!(!sane().has_butterfly_arbitrage(), "min g = {}", sane().min_butterfly_g());
374    }
375
376    #[test]
377    fn svi_calibration_round_trips() {
378        let truth = sane();
379        let t = 0.75;
380        let quotes: Vec<(f64, f64)> =
381            (0..15).map(|i| -0.42 + i as f64 * 0.06).map(|k| (k, truth.vol(k, t))).collect();
382        let fit = SviParams::calibrate(&quotes, t);
383        assert!(fit.rmse < 1e-6, "vol rmse {} params {:?}", fit.rmse, fit.params);
384        assert!(fit.params.validate().is_ok());
385        // the fitted smile matches off the quote grid too
386        for i in 0..=20 {
387            let k = -0.5 + i as f64 * 0.05;
388            assert!((fit.params.vol(k, t) - truth.vol(k, t)).abs() < 1e-4, "k = {k}");
389        }
390    }
391
392    fn ssvi() -> Ssvi {
393        Ssvi {
394            rho: -0.55,
395            eta: 0.9,
396            gamma: 0.45,
397            theta_pillars: vec![(0.25, 0.012), (0.5, 0.023), (1.0, 0.045), (2.0, 0.09)],
398        }
399    }
400
401    #[test]
402    fn ssvi_reproduces_the_atm_term_structure_and_skew_sign() {
403        let s = ssvi();
404        s.validate().unwrap();
405        for &(t, theta) in &s.theta_pillars {
406            assert!((s.total_variance(0.0, t) - theta).abs() < 1e-14, "w(0, {t})");
407        }
408        // negative rho: puts richer than calls
409        assert!(s.total_variance(-0.2, 1.0) > s.total_variance(0.2, 1.0));
410        // calendar: total variance nondecreasing in t at fixed k
411        for i in 1..40 {
412            let (t0, t1) = (i as f64 * 0.05, (i + 1) as f64 * 0.05);
413            assert!(s.total_variance(0.15, t1) >= s.total_variance(0.15, t0), "t = {t0}");
414        }
415    }
416
417    #[test]
418    fn ssvi_no_arbitrage_bounds_are_enforced() {
419        let mut bad = ssvi();
420        bad.eta = 1.5; // eta (1 + |rho|) = 2.325 > 2
421        assert!(bad.validate().is_err());
422        let mut decreasing = ssvi();
423        decreasing.theta_pillars[2].1 = 0.01; // calendar violation
424        assert!(decreasing.validate().is_err());
425    }
426
427    #[test]
428    fn ssvi_calibration_round_trips() {
429        let truth = ssvi();
430        let mut quotes = Vec::new();
431        for &(t, _) in &truth.theta_pillars {
432            for i in 0..7 {
433                let k = -0.3 + i as f64 * 0.1;
434                quotes.push((t, k, (truth.total_variance(k, t) / t).sqrt()));
435            }
436        }
437        let fit = Ssvi::calibrate(&quotes, &truth.theta_pillars, (-0.2, 0.5, 0.5));
438        assert!(fit.rmse < 1e-8, "vol rmse {}", fit.rmse);
439        assert!((fit.surface.rho - truth.rho).abs() < 1e-4, "rho {}", fit.surface.rho);
440        assert!((fit.surface.eta - truth.eta).abs() < 1e-3, "eta {}", fit.surface.eta);
441        assert!(fit.surface.validate().is_ok());
442    }
443
444    #[test]
445    fn sampled_vol_surface_agrees_with_the_parametric_form() {
446        use chrono::NaiveDate;
447        let s = ssvi();
448        let reference = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
449        let forwards = [(0.25, 101.0), (1.0, 104.0), (2.0, 108.0)];
450        let grid: Vec<f64> = (0..13).map(|i| -0.3 + i as f64 * 0.05).collect();
451        let surface = s
452            .to_vol_surface(reference, DayCountConvention::Act365, &forwards, &grid)
453            .unwrap();
454        // exact at the sampled nodes
455        for &(t, f) in &forwards {
456            for &k in &grid {
457                let strike = f * k.exp();
458                let sampled = surface.vol(strike, f, t);
459                let parametric = s.vol(strike, f, t);
460                assert!(
461                    (sampled - parametric).abs() < 1e-10,
462                    "t = {t}, k = {k}: {sampled} vs {parametric}"
463                );
464            }
465        }
466    }
467}