Skip to main content

rustyqlib/equity/
slv.rs

1//! Stochastic Local Volatility (SLV): Heston-style stochastic variance
2//! multiplied by a **leverage function** calibrated so the model
3//! reprices the market's vanilla surface exactly (in the limit):
4//!
5//! ```text
6//! dS/S = (r - q) dt + L(S, t) sqrt(v) dW1
7//! dv   = kappa (theta - v) dt + xi sqrt(v) dW2,   d<W1, W2> = rho dt
8//! ```
9//!
10//! By Gyongy's theorem the model matches the market when
11//! `L^2(S, t) = sigma_LV^2(S, t) / E[v_t | S_t = S]`, with `sigma_LV`
12//! the Dupire local vol. The conditional expectation is estimated by
13//! the standard particle / binning method: simulate forward, bin paths
14//! by spot at each step, average the variance per bin, and use the
15//! resulting leverage for the next step. Both the calibration and the
16//! pricing simulations step the variance with the Andersen QE transition
17//! and the spot through the Broadie-Kaya decomposition (see
18//! [`SlvStepper`]), so the particle distribution carries no
19//! variance-truncation bias.
20//!
21//! SLV interpolates between the two pure models: `xi -> 0` recovers
22//! pure local vol (`E[v|S] -> v0`, `L -> sigma_LV / sqrt(v0)`), while a
23//! flat market surface makes `L` collapse the stochastic vol back to
24//! flat vanilla prices — but forward smiles and path-dependent payoffs
25//! keep genuine stochastic-vol dynamics. Vanilla repricing is the
26//! calibration test, forward-smile richness the reason to use it.
27
28use crate::core::interpolation::interp_pairs;
29use crate::core::montecarlo::path_rng;
30use crate::equity::heston::HestonParams;
31use crate::equity::local_vol::LocalVol;
32use crate::equity::processes::qe_variance_step;
33use crate::core::trade::PutOrCall;
34use rand::Rng;
35use rand_distr::StandardNormal;
36
37/// One SLV time step, shared by calibration and pricing: the variance
38/// leg is sampled with the Andersen QE transition (exact CIR conditional
39/// moments — no truncation bias), and the spot moves through the
40/// Broadie-Kaya decomposition of the integrated variance shock with the
41/// leverage frozen over the step,
42///
43/// ```text
44/// int sqrt(v) dW1 = (rho/xi)(v' - v - kappa theta dt + kappa vbar dt)
45///                 + sqrt(1 - rho^2) sqrt(vbar dt) Z_perp
46/// ln S += (r - q - 1/2 L^2 vbar) dt + L * int sqrt(v) dW1
47/// ```
48///
49/// with `vbar = (v + v')/2`. Spot-variance correlation enters through
50/// the sampled `v'`, so the two normals fed in are **independent**; at
51/// `L = 1` the scheme is exactly Andersen's K-coefficient QE spot step.
52struct SlvStepper {
53    hp: HestonParams,
54    rho_bar: f64,
55    drift: f64,
56    dt: f64,
57}
58
59impl SlvStepper {
60    fn new(hp: &HestonParams, r: f64, q: f64, dt: f64) -> Self {
61        SlvStepper {
62            hp: *hp,
63            rho_bar: (1.0 - hp.rho * hp.rho).sqrt(),
64            drift: r - q,
65            dt,
66        }
67    }
68
69    /// Advance `(s, v)` one step under leverage `lev` with independent
70    /// standard normals `z_perp` (spot) and `z_v` (variance).
71    fn step(&self, lev: f64, s: f64, v: f64, z_perp: f64, z_v: f64) -> (f64, f64) {
72        let hp = &self.hp;
73        let vp = v.max(0.0);
74        let v_next = qe_variance_step(hp, vp, self.dt, z_v);
75        let vbar = 0.5 * (vp + v_next);
76        let integrated = (hp.rho / hp.vol_of_vol)
77            * (v_next - vp - hp.kappa * hp.theta * self.dt + hp.kappa * vbar * self.dt)
78            + self.rho_bar * (vbar * self.dt).sqrt() * z_perp;
79        let s_next =
80            s * ((self.drift - 0.5 * lev * lev * vbar) * self.dt + lev * integrated).exp();
81        (s_next, v_next)
82    }
83}
84
85/// Simulation / calibration controls.
86#[derive(Debug, Clone, Copy)]
87pub struct SlvConfig {
88    /// Calibration paths (also the default pricing paths).
89    pub paths: usize,
90    /// Time steps to the calibration horizon.
91    pub steps: usize,
92    /// Equal-count spot bins for the conditional expectation.
93    pub bins: usize,
94    pub seed: u64,
95}
96
97impl Default for SlvConfig {
98    fn default() -> Self {
99        Self { paths: 20_000, steps: 50, bins: 20, seed: 42 }
100    }
101}
102
103/// The binned conditional-variance curves `E[v_t | S_t]`, one per time
104/// step — together with the Dupire surface they define the leverage.
105#[derive(Debug, Clone)]
106pub struct ConditionalVariance {
107    times: Vec<f64>,
108    /// Per time: `(mean spot, mean variance)` per bin, sorted by spot.
109    slices: Vec<Vec<(f64, f64)>>,
110}
111
112impl ConditionalVariance {
113    /// `E[v_t | S_t = s]`: linear in spot with flat wings, from the
114    /// slice nearest below `t` (piecewise-constant in time, matching
115    /// how the calibration used it).
116    pub fn value(&self, s: f64, t: f64) -> f64 {
117        let idx = match self.times.iter().rposition(|&ti| ti <= t + 1e-12) {
118            Some(i) => i,
119            None => 0,
120        };
121        let slice = &self.slices[idx];
122        if slice.len() == 1 {
123            return slice[0].1;
124        }
125        interp_pairs(slice, s)
126    }
127}
128
129/// A calibrated SLV model (borrows the Dupire local vol it was built on).
130pub struct Slv<'a> {
131    pub heston: HestonParams,
132    pub cond_var: ConditionalVariance,
133    local_vol: &'a LocalVol<'a>,
134    s0: f64,
135    r: f64,
136    q: f64,
137    dt: f64,
138}
139
140impl<'a> Slv<'a> {
141    /// Leverage `L(s, t) = sigma_LV(s, t) / sqrt(E[v_t | S_t = s])`.
142    pub fn leverage(&self, s: f64, t: f64) -> f64 {
143        self.local_vol.vol(s, t) / self.cond_var.value(s, t).max(1e-8).sqrt()
144    }
145
146    /// Price a European vanilla by simulating the calibrated dynamics
147    /// (same step size as the calibration, deterministic per seed).
148    pub fn price_vanilla(
149        &self,
150        strike: f64,
151        t: f64,
152        put_or_call: PutOrCall,
153        paths: usize,
154        seed: u64,
155    ) -> f64 {
156        let steps = (t / self.dt).round().max(1.0) as usize;
157        let dt = t / steps as f64;
158        let stepper = SlvStepper::new(&self.heston, self.r, self.q, dt);
159        let mut sum = 0.0;
160        for i in 0..paths {
161            let mut rng = path_rng(seed, i as u64);
162            let mut s = self.s0;
163            let mut v: f64 = self.heston.v0;
164            for k in 0..steps {
165                let tk = k as f64 * dt;
166                let z1: f64 = rng.sample(StandardNormal);
167                let z2: f64 = rng.sample(StandardNormal);
168                let lev = self.leverage(s, tk);
169                (s, v) = stepper.step(lev, s, v, z1, z2);
170            }
171            sum += match put_or_call {
172                PutOrCall::Call => (s - strike).max(0.0),
173                PutOrCall::Put => (strike - s).max(0.0),
174            };
175        }
176        (-self.r * t).exp() * sum / paths as f64
177    }
178}
179
180/// Calibrate the leverage function to `local_vol` out to `horizon`
181/// years: forward simulation with per-step binning of `E[v | S]`.
182/// Deterministic for a given config.
183pub fn calibrate<'a>(
184    local_vol: &'a LocalVol<'a>,
185    heston: &HestonParams,
186    s0: f64,
187    r: f64,
188    q: f64,
189    horizon: f64,
190    cfg: &SlvConfig,
191) -> Slv<'a> {
192    heston.validate().expect("invalid Heston parameters");
193    assert!(horizon > 0.0 && cfg.steps > 0 && cfg.bins >= 2 && cfg.paths >= cfg.bins * 10);
194    let dt = horizon / cfg.steps as f64;
195    let n = cfg.paths;
196    let stepper = SlvStepper::new(heston, r, q, dt);
197
198    let mut spots = vec![s0; n];
199    let mut vars = vec![heston.v0; n];
200    let mut times = Vec::with_capacity(cfg.steps);
201    let mut slices = Vec::with_capacity(cfg.steps);
202
203    for k in 0..cfg.steps {
204        let t = k as f64 * dt;
205        // conditional expectation E[v | S] by equal-count spot bins
206        let slice: Vec<(f64, f64)> = if k == 0 {
207            vec![(s0, heston.v0)]
208        } else {
209            let mut order: Vec<usize> = (0..n).collect();
210            order.sort_by(|&a, &b| spots[a].total_cmp(&spots[b]));
211            let per_bin = n / cfg.bins;
212            (0..cfg.bins)
213                .map(|b| {
214                    let lo = b * per_bin;
215                    let hi = if b == cfg.bins - 1 { n } else { lo + per_bin };
216                    let members = &order[lo..hi];
217                    let ms = members.iter().map(|&i| spots[i]).sum::<f64>()
218                        / members.len() as f64;
219                    let mv = members.iter().map(|&i| vars[i]).sum::<f64>()
220                        / members.len() as f64;
221                    (ms, mv)
222                })
223                .collect()
224        };
225        times.push(t);
226        slices.push(slice.clone());
227
228        // step every path with the freshly-fitted leverage
229        let cond = |s: f64| -> f64 {
230            if slice.len() == 1 { slice[0].1 } else { interp_pairs(&slice, s) }
231        };
232        for i in 0..n {
233            let mut rng = path_rng(cfg.seed.wrapping_add(0x51_1e * k as u64 + 1), i as u64);
234            let z1: f64 = rng.sample(StandardNormal);
235            let z2: f64 = rng.sample(StandardNormal);
236            let lev = local_vol.vol(spots[i], t) / cond(spots[i]).max(1e-8).sqrt();
237            (spots[i], vars[i]) = stepper.step(lev, spots[i], vars[i], z1, z2);
238        }
239    }
240
241    Slv {
242        heston: *heston,
243        cond_var: ConditionalVariance { times, slices },
244        local_vol,
245        s0,
246        r,
247        q,
248        dt,
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use crate::core::curves::YieldCurve;
256    use crate::core::curves::Compounding;
257    use crate::core::daycount::DayCountConvention;
258    use crate::core::vols::VolSurface;
259    use crate::equity::blackscholes::{bs_price, implied_vol_from_price};
260    use chrono::NaiveDate;
261
262    const S0: f64 = 100.0;
263    const R: f64 = 0.02;
264
265    fn reference() -> NaiveDate {
266        NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()
267    }
268
269    fn curve() -> YieldCurve {
270        YieldCurve::flat(R, reference(), DayCountConvention::Act365, Compounding::Continuous)
271            .unwrap()
272    }
273
274    fn mixing_heston() -> HestonParams {
275        // normalized variance process (v0 = theta = 1) supplying the
276        // stochasticity the leverage must neutralize for vanillas
277        HestonParams { v0: 1.0, kappa: 2.0, theta: 1.0, vol_of_vol: 0.8, rho: -0.6 }
278    }
279
280    #[test]
281    fn conditional_variance_lookup_interpolates_and_clamps() {
282        let cv = ConditionalVariance {
283            times: vec![0.0, 0.5],
284            slices: vec![vec![(100.0, 1.0)], vec![(90.0, 1.2), (110.0, 0.8)]],
285        };
286        assert_eq!(cv.value(50.0, 0.1), 1.0); // single-point slice: flat
287        assert!((cv.value(100.0, 0.7) - 1.0).abs() < 1e-12); // midpoint
288        assert_eq!(cv.value(50.0, 0.7), 1.2); // flat wings
289        assert_eq!(cv.value(150.0, 0.7), 0.8);
290    }
291
292    #[test]
293    fn flat_surface_slv_reprices_black_scholes() {
294        // a flat 20% market: after leverage calibration the stochastic
295        // vol must wash out of vanilla prices
296        let surface =
297            VolSurface::flat(0.2, reference(), DayCountConvention::Act365).unwrap();
298        let yc = curve();
299        let lv = LocalVol::new(&surface, &yc, S0, 0.0, 0.0);
300        let cfg = SlvConfig { paths: 16_000, steps: 40, bins: 20, seed: 7 };
301        let slv = calibrate(&lv, &mixing_heston(), S0, R, 0.0, 1.0, &cfg);
302
303        let mc = slv.price_vanilla(100.0, 1.0, PutOrCall::Call, 32_000, 11);
304        let bs = bs_price(S0, 100.0, R, 0.0, 0.2, 1.0, PutOrCall::Call);
305        assert!((mc - bs).abs() < 0.02 * bs, "slv {mc} vs bs {bs}");
306    }
307
308    #[test]
309    fn skewed_surface_slv_reprices_the_smile() {
310        // a skewed market smile (same vols at each expiry, so total
311        // variance grows in t): the calibrated SLV must give back the
312        // input implied vols across strikes
313        let strikes = [80.0, 90.0, 100.0, 110.0, 120.0];
314        let smile = |k: f64| 0.2 - 0.1 * (k / S0 - 1.0) + 0.15 * (k / S0 - 1.0).powi(2);
315        let smiles: Vec<Vec<(f64, f64)>> = (0..3)
316            .map(|_| strikes.iter().map(|&k| (k, smile(k))).collect())
317            .collect();
318        let surface = VolSurface::from_strike_smiles(
319            &[
320                crate::core::curves::Tenor::YearFraction(0.25),
321                crate::core::curves::Tenor::YearFraction(0.75),
322                crate::core::curves::Tenor::YearFraction(1.5),
323            ],
324            &smiles,
325            reference(),
326            DayCountConvention::Act365,
327        )
328        .unwrap();
329        let yc = curve();
330        let lv = LocalVol::new(&surface, &yc, S0, 0.0, 0.0);
331        let cfg = SlvConfig { paths: 16_000, steps: 40, bins: 20, seed: 3 };
332        let slv = calibrate(&lv, &mixing_heston(), S0, R, 0.0, 1.0, &cfg);
333
334        for k in [90.0, 100.0, 110.0] {
335            let t = 0.75;
336            let mc = slv.price_vanilla(k, t, PutOrCall::Call, 32_000, 5);
337            let iv = implied_vol_from_price(S0, k, R, 0.0, t, mc, PutOrCall::Call)
338                .expect("inverting the SLV price");
339            let market = smile(k);
340            assert!(
341                (iv - market).abs() < 0.01,
342                "K = {k}: slv implied {iv:.4} vs market {market:.4}"
343            );
344        }
345    }
346
347    #[test]
348    fn zero_vol_of_vol_degenerates_to_pure_local_vol() {
349        // xi -> 0 with v0 = theta = 1: leverage becomes sigma_LV itself
350        let surface =
351            VolSurface::flat(0.25, reference(), DayCountConvention::Act365).unwrap();
352        let yc = curve();
353        let lv = LocalVol::new(&surface, &yc, S0, 0.0, 0.0);
354        let degenerate = HestonParams { v0: 1.0, kappa: 1.0, theta: 1.0, vol_of_vol: 1e-6, rho: 0.0 };
355        let cfg = SlvConfig { paths: 4_000, steps: 20, bins: 10, seed: 9 };
356        let slv = calibrate(&lv, &degenerate, S0, R, 0.0, 1.0, &cfg);
357        // leverage equals the local vol (E[v|S] = 1)
358        for s in [80.0, 100.0, 125.0] {
359            let lev = slv.leverage(s, 0.5);
360            let sigma = lv.vol(s, 0.5);
361            assert!((lev - sigma).abs() < 1e-3, "S = {s}: {lev} vs {sigma}");
362        }
363    }
364}