Skip to main content

rustyqlib/equity/
local_vol.rs

1//! Dupire local volatility calibrated from an implied vol surface.
2//!
3//! Uses Gatheral's formulation of the Dupire equation in total variance
4//! `w(y, t) = sigma_imp(K, t)^2 * t`, `y = ln(K / F(t))`:
5//!
6//! ```text
7//! sigma_loc^2(K, t) = (dw/dt) /
8//!   [ 1 - (y/w) w_y + 1/4 (-1/4 - 1/w + y^2/w^2) w_y^2 + 1/2 w_yy ]
9//! ```
10//!
11//! Derivatives are taken numerically on the implied surface: the time
12//! derivative at fixed moneyness `y`, the strike derivatives at fixed `t`.
13//! The "calibration" is therefore non-parametric — the local vol function
14//! is the exact transformation of whatever implied surface it is given.
15//!
16//! Guards: at very short times the implied vol is returned directly; where
17//! interpolation noise makes the denominator or numerator non-positive
18//! (butterfly / calendar violations in the inputs) the implied vol is used
19//! as a fallback; the result is clamped to `[1%, 300%]`.
20
21use crate::core::curves::{Compounding, YieldCurve};
22use crate::core::vols::VolSurface;
23
24const TIME_BUMP: f64 = 1.0 / 365.0;
25const LOG_STRIKE_BUMP: f64 = 0.01;
26const MIN_LOCAL_VOL: f64 = 0.01;
27const MAX_LOCAL_VOL: f64 = 3.0;
28
29/// Local volatility function `sigma_loc(level, t)`, frozen at construction
30/// from an implied surface, a discount curve (for forwards) and a dividend
31/// yield.
32pub struct LocalVol<'a> {
33    surface: &'a VolSurface,
34    curve: &'a YieldCurve,
35    spot: f64,
36    dividend_yield: f64,
37    /// Parallel shift added to every implied vol before the Dupire
38    /// transformation — used by vega bump-and-reprice.
39    vol_shift: f64,
40}
41
42impl<'a> LocalVol<'a> {
43    pub fn new(
44        surface: &'a VolSurface,
45        curve: &'a YieldCurve,
46        spot: f64,
47        dividend_yield: f64,
48        vol_shift: f64,
49    ) -> Self {
50        LocalVol { surface, curve, spot, dividend_yield, vol_shift }
51    }
52
53    fn forward(&self, t: f64) -> f64 {
54        let r = self.curve.zero_rate_with(t, Compounding::Continuous);
55        self.spot * ((r - self.dividend_yield) * t).exp()
56    }
57
58    fn implied(&self, strike: f64, t: f64) -> f64 {
59        self.surface.vol(strike, self.forward(t), t) + self.vol_shift
60    }
61
62    /// Total variance at absolute strike `k` and expiry `t`.
63    fn total_variance(&self, strike: f64, t: f64) -> f64 {
64        let v = self.implied(strike, t);
65        v * v * t
66    }
67
68    /// Local volatility at underlying level `level` and time `t`.
69    pub fn vol(&self, level: f64, t: f64) -> f64 {
70        let implied = self.implied(level, t.max(1e-4));
71        if t < 1e-3 {
72            return implied.clamp(MIN_LOCAL_VOL, MAX_LOCAL_VOL);
73        }
74
75        let f = self.forward(t);
76        let y = (level / f).ln();
77        let w = self.total_variance(level, t);
78        if w < 1e-8 {
79            return implied.clamp(MIN_LOCAL_VOL, MAX_LOCAL_VOL);
80        }
81
82        // dw/dt at fixed moneyness y: strike moves with the forward
83        let ht = TIME_BUMP.min(0.5 * t);
84        let w_up = self.total_variance(self.forward(t + ht) * y.exp(), t + ht);
85        let w_dn = self.total_variance(self.forward(t - ht) * y.exp(), t - ht);
86        let dw_dt = (w_up - w_dn) / (2.0 * ht);
87
88        // strike derivatives at fixed t (central, multiplicative bump)
89        let hy = LOG_STRIKE_BUMP;
90        let w_plus = self.total_variance(level * hy.exp(), t);
91        let w_minus = self.total_variance(level * (-hy).exp(), t);
92        let dw_dy = (w_plus - w_minus) / (2.0 * hy);
93        let d2w_dy2 = (w_plus - 2.0 * w + w_minus) / (hy * hy);
94
95        let denom = 1.0 - (y / w) * dw_dy
96            + 0.25 * (-0.25 - 1.0 / w + (y * y) / (w * w)) * dw_dy * dw_dy
97            + 0.5 * d2w_dy2;
98
99        if dw_dt <= 0.0 || denom <= 1e-4 {
100            // calendar / butterfly violation in the interpolated inputs:
101            // fall back to the implied vol at this point
102            return implied.clamp(MIN_LOCAL_VOL, MAX_LOCAL_VOL);
103        }
104        (dw_dt / denom).sqrt().clamp(MIN_LOCAL_VOL, MAX_LOCAL_VOL)
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use crate::core::curves::Tenor;
112    use crate::core::daycount::DayCountConvention;
113    use chrono::NaiveDate;
114
115    fn asof() -> NaiveDate {
116        NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()
117    }
118
119    fn flat_curve() -> YieldCurve {
120        YieldCurve::flat(0.05, asof(), DayCountConvention::Act365, Compounding::Continuous)
121            .unwrap()
122    }
123
124    #[test]
125    fn flat_surface_gives_flat_local_vol() {
126        let surface = VolSurface::flat(0.25, asof(), DayCountConvention::Act365).unwrap();
127        let curve = flat_curve();
128        let lv = LocalVol::new(&surface, &curve, 100.0, 0.0, 0.0);
129        for level in [60.0, 90.0, 100.0, 130.0] {
130            for t in [0.05, 0.5, 1.0, 2.0] {
131                let v = lv.vol(level, t);
132                assert!((v - 0.25).abs() < 1e-6, "level={level} t={t}: {v}");
133            }
134        }
135    }
136
137    #[test]
138    fn term_structure_gives_forward_variance() {
139        // sigma(0.5) = 20%, sigma(1.0) = 25% (flat in strike): between the
140        // pillars the local variance is the forward variance
141        // (w2 - w1)/(t2 - t1) = (0.0625 - 0.02)/0.5 = 0.085
142        let surface = VolSurface::from_strike_smiles(
143            &[Tenor::YearFraction(0.5), Tenor::YearFraction(1.0)],
144            &[vec![(100.0, 0.20)], vec![(100.0, 0.25)]],
145            asof(),
146            DayCountConvention::Act365,
147        )
148        .unwrap();
149        let curve = flat_curve();
150        let lv = LocalVol::new(&surface, &curve, 100.0, 0.0, 0.0);
151        let expected = (0.085_f64).sqrt();
152        let v = lv.vol(100.0, 0.75);
153        assert!((v - expected).abs() < 1e-3, "{v} vs {expected}");
154    }
155
156    #[test]
157    fn vol_shift_moves_local_vol() {
158        let surface = VolSurface::flat(0.25, asof(), DayCountConvention::Act365).unwrap();
159        let curve = flat_curve();
160        let lv = LocalVol::new(&surface, &curve, 100.0, 0.0, 0.01);
161        assert!((lv.vol(100.0, 1.0) - 0.26).abs() < 1e-6);
162    }
163}