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