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}" ); } }}