Skip to content
Sarthak Bagaria
All model code

quant/src/smile.rs

Models that produce a smile, and the map from a model to the smile it implies.

//! Models that produce a smile, and the map from a model to the smile it implies.//!//! The point of this module in the notes is negative as much as positive. The//! implied volatility of an option is *defined* as the number you must put into//! Black-76 to recover its price, so a model that is Black-76 produces, by//! construction, the same number at every strike: a flat line. Every smile is//! therefore a statement that the market does not believe the forward is//! lognormal, and the shape of the smile is a picture of how it disagrees.//!//! [`Model::Lognormal`] draws that flat line. The other two are the two smallest//! departures from it that produce the two features every real surface has —//! a slope and a curvature — and they are worth separating, because they come//! from different places. A displaced diffusion tilts the smile because it//! changes how volatility scales with the level of the forward. A mixture bends//! the smile because it puts more mass in both tails than a single lognormal//! has. The local volatility and smile dynamics chapters spend their time on models//! that do both at once. use crate::black::{black76, implied_vol_black76, Side}; /// The models the smile figures can be driven by.#[derive(Clone, Copy, Debug)]pub enum Model {    /// `dF = sigma F dW`. The Black-76 model itself.    Lognormal { sigma: f64 },     /// `dF = sigma [beta F + (1 - beta) F_0] dW`.    ///    /// `beta = 1` is lognormal, `beta -> 0` is the Bachelier normal model, and    /// values in between interpolate. One dial, and it controls the skew: the    /// lower `beta`, the less the diffusion coefficient grows with the forward,    /// so the more volatility a *fall* in the forward implies relative to    /// lognormal, and the more the smile tilts down to the right.    DisplacedDiffusion { sigma: f64, beta: f64 },     /// A mixture of two lognormals sharing an expiry: with probability `weight`    /// the forward is lognormal around `f_lo` with volatility `sigma_lo`, and    /// otherwise around `f_hi` with `sigma_hi`.    ///    /// The two forwards are pinned by the martingale condition rather than given    /// — see [`Model::mixture_calibrated`] — because a mixture whose components    /// do not average back to today's forward is not a model of anything, it is    /// an arbitrage.    ///    /// # This is a smile, not a process    ///    /// At any single expiry this is a perfectly good risk neutral distribution:    /// the density is positive and its mean is today's forward. But the family    /// obtained by varying `t` with the components held fixed is *not* the set    /// of marginals of any process starting at the forward. As `t` shrinks the    /// distribution collapses onto two points rather than onto one, so the    /// at-the-money implied volatility diverges — about 30% at one year and over    /// 200% at a hundredth of a year, for the parameters the figures use.    ///    /// The consequence that matters: Dupire's formula must not be applied to it.    /// That formula differentiates in the expiry, and here there is no process    /// for that derivative to be about. Use [`Model::DisplacedDiffusion`] when a    /// genuine diffusion is wanted, which is why the local volatility tests use    /// that one.    Mixture {        weight: f64,        f_lo: f64,        sigma_lo: f64,        f_hi: f64,        sigma_hi: f64,    },} impl Model {    /// Build a two-state mixture that respects the martingale condition.    ///    /// `spread` is how far apart the two states are placed, as a fraction of the    /// forward; the weights then determine where each sits so that    /// `weight * f_lo + (1 - weight) * f_hi = f`. This is the "crash scenario"    /// parametrisation: a small probability of a much lower forward with a much    /// higher volatility is exactly the story an equity index skew tells.    pub fn mixture_calibrated(        f: f64,        weight: f64,        spread: f64,        sigma_lo: f64,        sigma_hi: f64,    ) -> Model {        // Place the two states symmetrically in the sense of the constraint:        // f_lo = f (1 - spread), and f_hi is whatever makes the mean come back        // to f. With weight -> 0 the high state converges on f, as it should.        let f_lo = f * (1.0 - spread);        let f_hi = if weight < 1.0 {            (f - weight * f_lo) / (1.0 - weight)        } else {            f        };        Model::Mixture { weight, f_lo, sigma_lo, f_hi, sigma_hi }    }     /// Undiscounted price of a European option on the forward.    pub fn price(&self, f: f64, k: f64, t: f64, side: Side) -> f64 {        match *self {            Model::Lognormal { sigma } => black76(f, k, sigma, t, side),             Model::DisplacedDiffusion { sigma, beta } => {                if beta <= 1e-8 {                    // The beta -> 0 limit is the normal model. Take it directly                    // rather than dividing by beta.                    return crate::black::bachelier(f, k, sigma * f, t, side);                }                // G = beta F + (1 - beta) f is lognormal with volatility                // beta * sigma, and (F - K)^+ = (1/beta) (G - G_K)^+.                let g0 = f;                let gk = beta * k + (1.0 - beta) * f;                if gk <= 0.0 {                    // Strike below the model's absolute floor on the forward: a                    // call is certain to pay, a put is certain not to.                    return match side {                        Side::Call => f - k,                        Side::Put => 0.0,                    };                }                black76(g0, gk, beta * sigma, t, side) / beta            }             Model::Mixture { weight, f_lo, sigma_lo, f_hi, sigma_hi } => {                weight * black76(f_lo, k, sigma_lo, t, side)                    + (1.0 - weight) * black76(f_hi, k, sigma_hi, t, side)            }        }    }     /// The Black-76 volatility that reproduces this model's price.    ///    /// `None` where the price is close enough to its no-arbitrage bound that no    /// finite volatility reproduces it — deep in a wing, or past the edge of a    /// mixture's support. The figures leave a gap there rather than drawing a    /// number that does not exist.    pub fn implied_vol(&self, f: f64, k: f64, t: f64) -> Option<f64> {        // Price the out-of-the-money option in each wing. In-the-money prices are        // dominated by intrinsic value, so inverting them loses precision exactly        // where the smile is most interesting.        let side = if k >= f { Side::Call } else { Side::Put };        let price = self.price(f, k, t, side);        implied_vol_black76(price, f, k, t, side)    }     /// The implied volatility smile over a strike grid.    pub fn smile(&self, f: f64, t: f64, strikes: &[f64]) -> Vec<Option<f64>> {        strikes.iter().map(|&k| self.implied_vol(f, k, t)).collect()    }     /// The risk neutral density of the forward at expiry, read off the option    /// prices by Breeden-Litzenberger.    ///    /// The local volatility chapter proves that the density is the second    /// derivative of the call price in the strike. This computes exactly that,    /// as the second difference over a spacing `h` --- which is to say it    /// prices a butterfly of width `h` centred at each strike and divides by    /// `h^2`.    ///    /// Doing it by differencing prices rather than by writing down the density    /// analytically is the point. The market quotes prices, not densities, and    /// this is the operation that turns the one into the other; that the answer    /// agrees with the density we could have written down is the check that the    /// theorem is true.    pub fn density(&self, f: f64, t: f64, k: f64, h: f64) -> f64 {        let up = self.price(f, k + h, t, Side::Call);        let mid = self.price(f, k, t, Side::Call);        let dn = self.price(f, k - h, t, Side::Call);        (up - 2.0 * mid + dn) / (h * h)    }} /// The lognormal density of the forward at expiry under Black-76.////// Written out so a figure can show what the market's density is being compared/// against: the one the model of the no-arbitrage chapter would have insisted on.pub fn lognormal_density(f: f64, t: f64, sigma: f64, k: f64) -> f64 {    if k <= 0.0 || t <= 0.0 || sigma <= 0.0 {        return 0.0;    }    let v = sigma * t.sqrt();    let d = ((k / f).ln() + 0.5 * v * v) / v;    crate::black::norm_pdf(d) / (k * v)} /// A strike grid spaced evenly in log-moneyness, which is the coordinate a smile/// is actually a function of — a grid even in strike wastes most of its points/// far out of the money where nothing happens.pub fn log_moneyness_grid(f: f64, lo: f64, hi: f64, n: usize) -> Vec<f64> {    (0..n)        .map(|i| {            let z = lo + (hi - lo) * (i as f64) / ((n - 1).max(1) as f64);            f * z.exp()        })        .collect()} #[cfg(test)]mod tests {    use super::*;     const F: f64 = 100.0;    const T: f64 = 1.0;     #[test]    fn lognormal_smile_is_flat() {        let m = Model::Lognormal { sigma: 0.25 };        for k in log_moneyness_grid(F, -0.6, 0.6, 25) {            let v = m.implied_vol(F, k, T).unwrap();            assert!((v - 0.25).abs() < 1e-7, "k={k} gave {v}, expected a flat 0.25");        }    }     #[test]    fn displaced_diffusion_is_lognormal_at_beta_one() {        let dd = Model::DisplacedDiffusion { sigma: 0.3, beta: 1.0 };        let ln = Model::Lognormal { sigma: 0.3 };        for k in log_moneyness_grid(F, -0.4, 0.4, 15) {            let a = dd.price(F, k, T, Side::Call);            let b = ln.price(F, k, T, Side::Call);            assert!((a - b).abs() < 1e-10, "k={k}");        }    }     #[test]    fn displaced_diffusion_skews_downward() {        // The whole point of the model: beta < 1 must tilt the smile down to the        // right, and more so the smaller beta is.        let strikes = [80.0, 100.0, 120.0];        let mut previous_slope = f64::INFINITY;        for beta in [1.0, 0.6, 0.3] {            let m = Model::DisplacedDiffusion { sigma: 0.25, beta };            let v: Vec<f64> = strikes.iter().map(|&k| m.implied_vol(F, k, T).unwrap()).collect();            let slope = v[2] - v[0];            assert!(slope < previous_slope, "beta={beta} did not steepen the skew");            previous_slope = slope;        }        assert!(previous_slope < 0.0, "expected a downward skew");    }     #[test]    fn the_mixture_is_not_a_process() {        // Pinning down the caveat in the docs above, so that nobody later feeds        // this model to Dupire and believes the answer. A genuine process has        // its at-the-money implied volatility settle down as the expiry shrinks;        // this one runs away, because the distribution is collapsing onto two        // points instead of one.        let m = Model::mixture_calibrated(F, 0.3, 0.3, 0.5, 0.15);        let long = m.implied_vol(F, F, 1.0).unwrap();        let short = m.implied_vol(F, F, 0.01).unwrap();        assert!(short > 5.0 * long, "{short} should dwarf {long}");         // Whereas a displaced diffusion, which is a process, does settle.        let d = Model::DisplacedDiffusion { sigma: 0.25, beta: 0.5 };        let long = d.implied_vol(F, F, 1.0).unwrap();        let short = d.implied_vol(F, F, 0.01).unwrap();        assert!((short - long).abs() < 0.02, "{short} vs {long}");    }     #[test]    fn mixture_respects_the_martingale_condition() {        // If the components did not average back to the forward, the model would        // misprice the forward itself, which is the one price it must get right.        let m = Model::mixture_calibrated(F, 0.2, 0.25, 0.45, 0.18);        let call = m.price(F, F, T, Side::Call);        let put = m.price(F, F, T, Side::Put);        assert!((call - put).abs() < 1e-10, "put-call parity broken: {call} vs {put}");    }     #[test]    fn mixture_curves_where_lognormal_is_straight() {        // Convexity is the property that separates a mixture from a displaced        // diffusion: both tilt, only the mixture bends. Measured as the second        // difference of the smile in log-moneyness.        let ks = log_moneyness_grid(F, -0.5, 0.5, 41);        let curvature = |m: &Model| -> f64 {            let v: Vec<f64> = ks.iter().map(|&k| m.implied_vol(F, k, T).unwrap()).collect();            v.windows(3).map(|w| w[0] - 2.0 * w[1] + w[2]).sum::<f64>()        };         let flat = curvature(&Model::Lognormal { sigma: 0.25 });        let mixed = curvature(&Model::mixture_calibrated(F, 0.2, 0.25, 0.45, 0.18));        assert!(flat.abs() < 1e-6, "lognormal should be straight, got {flat}");        assert!(mixed > 1e-3, "mixture should be convex, got {mixed}");    }     #[test]    fn mixture_skews_towards_the_crash_state() {        // A 20% chance of the forward being a quarter lower, at nearly triple the        // volatility, has to make downside strikes the expensive ones.        let m = Model::mixture_calibrated(F, 0.2, 0.25, 0.45, 0.18);        let low = m.implied_vol(F, 70.0, T).unwrap();        let atm = m.implied_vol(F, F, T).unwrap();        let high = m.implied_vol(F, 140.0, T).unwrap();        assert!(low > atm, "downside {low} should exceed the money {atm}");        assert!(high < atm, "upside {high} should sit below the money {atm}");    }     #[test]    fn breeden_litzenberger_recovers_the_lognormal_density() {        // Differencing Black-76 prices must give back the density Black-76 was        // built on. If this fails, the theorem is being misapplied rather than        // the arithmetic being slightly off.        let m = Model::Lognormal { sigma: 0.3 };        for k in [60.0, 90.0, 100.0, 130.0, 180.0] {            let from_prices = m.density(F, T, k, 0.01);            let analytic = lognormal_density(F, T, 0.3, k);            assert!(                (from_prices - analytic).abs() < 1e-6,                "k={k}: {from_prices} vs {analytic}"            );        }    }     #[test]    fn a_density_integrates_to_one_and_prices_the_forward() {        // The two conditions that make it a risk neutral density at all: total        // mass one, and a mean equal to today's forward.        let m = Model::mixture_calibrated(F, 0.2, 0.25, 0.45, 0.18);        let (lo, hi, n) = (1.0, 600.0, 120_000);        let h = (hi - lo) / n as f64;        let (mut mass, mut mean) = (0.0, 0.0);        for i in 0..n {            let k = lo + (i as f64 + 0.5) * h;            let p = m.density(F, T, k, 0.01);            mass += p * h;            mean += k * p * h;        }        assert!((mass - 1.0).abs() < 1e-3, "mass was {mass}");        assert!((mean - F).abs() < 1e-1, "mean was {mean}, forward is {F}");    }     #[test]    fn the_smile_puts_more_mass_in_the_tails_than_the_lognormal() {        // What a smile *means*, stated as a property of the density: the market        // thinks large moves are likelier than lognormal allows. This is the        // sentence the local volatility chapter has to earn, so it is worth testing.        let m = Model::mixture_calibrated(F, 0.2, 0.25, 0.45, 0.18);        let atm = m.implied_vol(F, F, T).unwrap();        for k in [45.0, 55.0] {            let market = m.density(F, T, k, 0.01);            let lognormal = lognormal_density(F, T, atm, k);            assert!(market > lognormal, "at k={k}: {market} vs {lognormal}");        }    }     #[test]    fn smile_leaves_gaps_rather_than_inventing_numbers() {        // Far enough out, the price underflows to its no-arbitrage bound and no        // implied volatility exists. The figure must show a gap.        let m = Model::Lognormal { sigma: 0.2 };        assert!(m.implied_vol(F, F * 40.0, 0.02).is_none());    }}