Skip to content
Sarthak Bagaria
All model code

quant/src/svi.rs

The stochastic volatility inspired parameterisation of one smile.

//! The stochastic volatility inspired parameterisation of one smile.//!//! Despite the name there is no stochastic volatility in it, and no volatility//! dynamics of any kind. SVI is a formula for the shape of one expiry's total//! implied variance as a function of log-moneyness, with five parameters and//! nothing behind them. It cannot price a barrier, and asked how the smile will//! look tomorrow it has no answer, because it never made a statement about the//! underlying at all.//!//! What it is for is the job the local volatility chapter needs done before//! Dupire can be applied: turn a handful of quotes into a surface smooth enough//! to differentiate twice and convex enough that the second derivative stays//! positive. A model used for that is doing more than was asked, and — since//! SABR's expansion can imply a negative density in the wings — sometimes less//! than is needed.//!//! The shape is not arbitrary. Lee's moment formula bounds the growth of total//! variance in the wings to at most linear, so the curve must approach two//! straight lines, and it must be smooth and convex in between. The simplest//! curve that is exactly that is a hyperbola, and [`Svi::total_variance`] is a//! hyperbola written in the coordinates that make its five degrees of freedom//! into things a trader can name. use crate::black::{black76, Side}; /// One expiry's smile, in Gatheral's raw parameterisation.#[derive(Clone, Copy, Debug)]pub struct Svi {    /// Vertical shift: the overall level of variance.    pub a: f64,    /// The angle between the two asymptotes, so the overall size of the wings.    pub b: f64,    /// Asymmetry, in `(-1, 1)`. This is the skew: it tilts the hyperbola by    /// making one wing steeper than the other.    pub rho: f64,    /// Horizontal shift: where the vertex sits in log-moneyness.    pub m: f64,    /// How rounded the vertex is. As `s` goes to zero the curve becomes the two    /// asymptotes meeting at a corner.    pub s: f64,} impl Svi {    /// Total implied variance `w(k) = sigma^2 T` at log-moneyness `k = ln(K/F)`.    ///    /// Written in total variance rather than in volatility because that is the    /// quantity with the linear structure: the wings are asymptotically straight    /// in `w`, and calendar arbitrage is the statement that `w` increases with    /// maturity at every `k`. Volatility is that divided by a maturity, and the    /// division hides both facts.    pub fn total_variance(&self, k: f64) -> f64 {        let x = k - self.m;        self.a + self.b * (self.rho * x + (x * x + self.s * self.s).sqrt())    }     /// Implied volatility, which is what a screen shows.    pub fn implied_vol(&self, k: f64, t: f64) -> f64 {        (self.total_variance(k) / t).max(0.0).sqrt()    }     /// The slopes the two wings approach, `(left, right)`.    ///    /// As `k` runs to plus infinity the square root behaves like `x`, so the    /// curve approaches `a + b(rho + 1)(k - m)`; to minus infinity it behaves    /// like `-x` and the slope is `b(rho - 1)`. Taking the left slope as a    /// magnitude, the two are `b(1 - rho)` and `b(1 + rho)`.    pub fn wing_slopes(&self) -> (f64, f64) {        (self.b * (1.0 - self.rho), self.b * (1.0 + self.rho))    }     /// Whether the wings obey Lee's moment formula.    ///    /// Lee showed that total implied variance cannot grow faster than `2|k|` in    /// either wing without the underlying failing to have the moments the price    /// of a deep option implies. For a hyperbola the growth rate is the slope of    /// the asymptote, so the whole of the condition is that both wing slopes are    /// at most two — which for this parameterisation is `b(1 + |rho|) <= 2`.    pub fn satisfies_lee(&self) -> bool {        self.b * (1.0 + self.rho.abs()) <= 2.0    }     /// Whether the parameters keep the variance positive everywhere.    ///    /// The minimum of the hyperbola sits at `a + b s sqrt(1 - rho^2)`.    pub fn is_positive(&self) -> bool {        self.b >= 0.0            && self.s > 0.0            && self.rho.abs() < 1.0            && self.a + self.b * self.s * (1.0 - self.rho * self.rho).sqrt() >= 0.0    }     /// The risk-neutral density this smile implies, by the Breeden-Litzenberger    /// theorem of the local volatility chapter.    ///    /// Deliberately computed the long way — price three options and take a    /// second difference — rather than through the closed form for the density    /// in terms of the parameters. The long way is what a desk actually does to    /// the fitted surface, and it shares no algebra with the formula above, so    /// a negative value here is evidence rather than a rearrangement.    pub fn density(&self, forward: f64, k: f64, t: f64) -> f64 {        let strike = forward * k.exp();        let h = strike * 1e-4;        let price = |strike: f64| {            let k = (strike / forward).ln();            black76(forward, strike, self.implied_vol(k, t), t, Side::Call)        };        (price(strike + h) - 2.0 * price(strike) + price(strike - h)) / (h * h)    }     /// The most negative density over a grid of log-moneyness, or zero if the    /// smile is free of butterfly arbitrage across it.    ///    /// This is the check that matters before differentiating: Dupire divides by    /// this quantity, so a negative value is not a small error but a change of    /// sign in a denominator.    pub fn worst_density(&self, forward: f64, t: f64, reach: f64, points: usize) -> f64 {        let mut worst = 0.0f64;        for i in 0..=points {            let k = -reach + 2.0 * reach * i as f64 / points as f64;            worst = worst.min(self.density(forward, k, t));        }        worst    }} #[cfg(test)]mod tests {    use super::*;     /// A smile of roughly equity shape: skewed, with a rounded vertex.    const FITTED: Svi = Svi { a: 0.012, b: 0.10, rho: -0.6, m: 0.02, s: 0.12 };    const FORWARD: f64 = 100.0;    const T: f64 = 1.0;     /// The structural claim of the module docstring: it is a hyperbola, so far    /// out in the wings it is indistinguishable from its own asymptotes.    #[test]    fn the_wings_are_asymptotically_straight() {        let (left, right) = FITTED.wing_slopes();        for &k in &[40.0, 80.0, 160.0] {            let asymptote = FITTED.a + right * (k - FITTED.m);            assert!(                (FITTED.total_variance(k) - asymptote).abs() < 1e-3,                "right wing at k={k} was {} against asymptote {asymptote}",                FITTED.total_variance(k)            );            let asymptote = FITTED.a + left * (-k - FITTED.m).abs();            assert!(                (FITTED.total_variance(-k) - asymptote).abs() < 1e-3,                "left wing at k={} was {} against asymptote {asymptote}",                -k,                FITTED.total_variance(-k)            );        }    }     /// A negative rho makes the left wing the steep one, which is the skew every    /// equity index shows and the reason the parameter is the skew control.    #[test]    fn negative_rho_steepens_the_left_wing() {        let (left, right) = FITTED.wing_slopes();        assert!(left > right, "left {left} should exceed right {right} at rho < 0");        let flat = Svi { rho: 0.0, ..FITTED };        let (l, r) = flat.wing_slopes();        assert!((l - r).abs() < 1e-12, "rho = 0 should be symmetric, got {l} and {r}");    }     /// Zero `b` collapses the hyperbola to a horizontal line, which by the    /// implied volatility chapter is the lognormal case.    #[test]    fn no_wings_is_a_flat_smile() {        let flat = Svi { b: 0.0, a: 0.04, ..FITTED };        for &k in &[-1.0, 0.0, 0.5] {            assert!((flat.implied_vol(k, T) - 0.2).abs() < 1e-12);        }    }     /// The point of the parameterisation. A fit obeying Lee's bound has a    /// positive density everywhere, and one violating it does not — so the    /// constraint is not decoration, it is what makes the surface safe to    /// differentiate.    #[test]    fn lees_bound_separates_the_safe_fits_from_the_arbitrageable() {        assert!(FITTED.satisfies_lee() && FITTED.is_positive());        assert!(            FITTED.worst_density(FORWARD, T, 1.5, 400) >= 0.0,            "a compliant fit produced a negative density"        );         // Same shape, wings steepened past the bound.        let steep = Svi { b: 1.6, ..FITTED };        assert!(!steep.satisfies_lee(), "b = 1.6 at rho = -0.6 should violate Lee");        assert!(            steep.worst_density(FORWARD, T, 1.5, 400) < 0.0,            "a fit violating Lee's bound should imply a negative density somewhere"        );    }     /// Total variance rising with maturity at every strike is the calendar    /// condition, and for two slices sharing a shape it is a condition on `a`.    #[test]    fn slices_that_do_not_cross_are_free_of_calendar_arbitrage() {        let near = FITTED;        let far = Svi { a: FITTED.a + 0.03, ..FITTED };        for i in 0..=200 {            let k = -1.5 + 3.0 * i as f64 / 200.0;            assert!(                far.total_variance(k) > near.total_variance(k),                "slices crossed at k={k}"            );        }    }}