use crate::core::interpolation::interp_pairs;
use crate::core::montecarlo::path_rng;
use crate::equity::heston::HestonParams;
use crate::equity::local_vol::LocalVol;
use crate::equity::processes::qe_variance_step;
use crate::core::trade::PutOrCall;
use rand::Rng;
use rand_distr::StandardNormal;
struct SlvStepper {
hp: HestonParams,
rho_bar: f64,
drift: f64,
dt: f64,
}
impl SlvStepper {
fn new(hp: &HestonParams, r: f64, q: f64, dt: f64) -> Self {
SlvStepper {
hp: *hp,
rho_bar: (1.0 - hp.rho * hp.rho).sqrt(),
drift: r - q,
dt,
}
}
fn step(&self, lev: f64, s: f64, v: f64, z_perp: f64, z_v: f64) -> (f64, f64) {
let hp = &self.hp;
let vp = v.max(0.0);
let v_next = qe_variance_step(hp, vp, self.dt, z_v);
let vbar = 0.5 * (vp + v_next);
let integrated = (hp.rho / hp.vol_of_vol)
* (v_next - vp - hp.kappa * hp.theta * self.dt + hp.kappa * vbar * self.dt)
+ self.rho_bar * (vbar * self.dt).sqrt() * z_perp;
let s_next =
s * ((self.drift - 0.5 * lev * lev * vbar) * self.dt + lev * integrated).exp();
(s_next, v_next)
}
}
#[derive(Debug, Clone, Copy)]
pub struct SlvConfig {
pub paths: usize,
pub steps: usize,
pub bins: usize,
pub seed: u64,
}
impl Default for SlvConfig {
fn default() -> Self {
Self { paths: 20_000, steps: 50, bins: 20, seed: 42 }
}
}
#[derive(Debug, Clone)]
pub struct ConditionalVariance {
times: Vec<f64>,
slices: Vec<Vec<(f64, f64)>>,
}
impl ConditionalVariance {
pub fn value(&self, s: f64, t: f64) -> f64 {
let idx = match self.times.iter().rposition(|&ti| ti <= t + 1e-12) {
Some(i) => i,
None => 0,
};
let slice = &self.slices[idx];
if slice.len() == 1 {
return slice[0].1;
}
interp_pairs(slice, s)
}
}
pub struct Slv<'a> {
pub heston: HestonParams,
pub cond_var: ConditionalVariance,
local_vol: &'a LocalVol<'a>,
s0: f64,
r: f64,
q: f64,
dt: f64,
}
impl<'a> Slv<'a> {
pub fn leverage(&self, s: f64, t: f64) -> f64 {
self.local_vol.vol(s, t) / self.cond_var.value(s, t).max(1e-8).sqrt()
}
pub fn price_vanilla(
&self,
strike: f64,
t: f64,
put_or_call: PutOrCall,
paths: usize,
seed: u64,
) -> f64 {
let steps = (t / self.dt).round().max(1.0) as usize;
let dt = t / steps as f64;
let stepper = SlvStepper::new(&self.heston, self.r, self.q, dt);
let mut sum = 0.0;
for i in 0..paths {
let mut rng = path_rng(seed, i as u64);
let mut s = self.s0;
let mut v: f64 = self.heston.v0;
for k in 0..steps {
let tk = k as f64 * dt;
let z1: f64 = rng.sample(StandardNormal);
let z2: f64 = rng.sample(StandardNormal);
let lev = self.leverage(s, tk);
(s, v) = stepper.step(lev, s, v, z1, z2);
}
sum += match put_or_call {
PutOrCall::Call => (s - strike).max(0.0),
PutOrCall::Put => (strike - s).max(0.0),
};
}
(-self.r * t).exp() * sum / paths as f64
}
}
pub fn calibrate<'a>(
local_vol: &'a LocalVol<'a>,
heston: &HestonParams,
s0: f64,
r: f64,
q: f64,
horizon: f64,
cfg: &SlvConfig,
) -> Slv<'a> {
heston.validate().expect("invalid Heston parameters");
assert!(horizon > 0.0 && cfg.steps > 0 && cfg.bins >= 2 && cfg.paths >= cfg.bins * 10);
let dt = horizon / cfg.steps as f64;
let n = cfg.paths;
let stepper = SlvStepper::new(heston, r, q, dt);
let mut spots = vec![s0; n];
let mut vars = vec![heston.v0; n];
let mut times = Vec::with_capacity(cfg.steps);
let mut slices = Vec::with_capacity(cfg.steps);
for k in 0..cfg.steps {
let t = k as f64 * dt;
let slice: Vec<(f64, f64)> = if k == 0 {
vec![(s0, heston.v0)]
} else {
let mut order: Vec<usize> = (0..n).collect();
order.sort_by(|&a, &b| spots[a].total_cmp(&spots[b]));
let per_bin = n / cfg.bins;
(0..cfg.bins)
.map(|b| {
let lo = b * per_bin;
let hi = if b == cfg.bins - 1 { n } else { lo + per_bin };
let members = &order[lo..hi];
let ms = members.iter().map(|&i| spots[i]).sum::<f64>()
/ members.len() as f64;
let mv = members.iter().map(|&i| vars[i]).sum::<f64>()
/ members.len() as f64;
(ms, mv)
})
.collect()
};
times.push(t);
slices.push(slice.clone());
let cond = |s: f64| -> f64 {
if slice.len() == 1 { slice[0].1 } else { interp_pairs(&slice, s) }
};
for i in 0..n {
let mut rng = path_rng(cfg.seed.wrapping_add(0x51_1e * k as u64 + 1), i as u64);
let z1: f64 = rng.sample(StandardNormal);
let z2: f64 = rng.sample(StandardNormal);
let lev = local_vol.vol(spots[i], t) / cond(spots[i]).max(1e-8).sqrt();
(spots[i], vars[i]) = stepper.step(lev, spots[i], vars[i], z1, z2);
}
}
Slv {
heston: *heston,
cond_var: ConditionalVariance { times, slices },
local_vol,
s0,
r,
q,
dt,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::curves::YieldCurve;
use crate::core::curves::Compounding;
use crate::core::daycount::DayCountConvention;
use crate::core::vols::VolSurface;
use crate::equity::blackscholes::{bs_price, implied_vol_from_price};
use chrono::NaiveDate;
const S0: f64 = 100.0;
const R: f64 = 0.02;
fn reference() -> NaiveDate {
NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()
}
fn curve() -> YieldCurve {
YieldCurve::flat(R, reference(), DayCountConvention::Act365, Compounding::Continuous)
.unwrap()
}
fn mixing_heston() -> HestonParams {
HestonParams { v0: 1.0, kappa: 2.0, theta: 1.0, vol_of_vol: 0.8, rho: -0.6 }
}
#[test]
fn conditional_variance_lookup_interpolates_and_clamps() {
let cv = ConditionalVariance {
times: vec![0.0, 0.5],
slices: vec![vec![(100.0, 1.0)], vec![(90.0, 1.2), (110.0, 0.8)]],
};
assert_eq!(cv.value(50.0, 0.1), 1.0); assert!((cv.value(100.0, 0.7) - 1.0).abs() < 1e-12); assert_eq!(cv.value(50.0, 0.7), 1.2); assert_eq!(cv.value(150.0, 0.7), 0.8);
}
#[test]
fn flat_surface_slv_reprices_black_scholes() {
let surface =
VolSurface::flat(0.2, reference(), DayCountConvention::Act365).unwrap();
let yc = curve();
let lv = LocalVol::new(&surface, &yc, S0, 0.0, 0.0);
let cfg = SlvConfig { paths: 16_000, steps: 40, bins: 20, seed: 7 };
let slv = calibrate(&lv, &mixing_heston(), S0, R, 0.0, 1.0, &cfg);
let mc = slv.price_vanilla(100.0, 1.0, PutOrCall::Call, 32_000, 11);
let bs = bs_price(S0, 100.0, R, 0.0, 0.2, 1.0, PutOrCall::Call);
assert!((mc - bs).abs() < 0.02 * bs, "slv {mc} vs bs {bs}");
}
#[test]
fn skewed_surface_slv_reprices_the_smile() {
let strikes = [80.0, 90.0, 100.0, 110.0, 120.0];
let smile = |k: f64| 0.2 - 0.1 * (k / S0 - 1.0) + 0.15 * (k / S0 - 1.0).powi(2);
let smiles: Vec<Vec<(f64, f64)>> = (0..3)
.map(|_| strikes.iter().map(|&k| (k, smile(k))).collect())
.collect();
let surface = VolSurface::from_strike_smiles(
&[
crate::core::curves::Tenor::YearFraction(0.25),
crate::core::curves::Tenor::YearFraction(0.75),
crate::core::curves::Tenor::YearFraction(1.5),
],
&smiles,
reference(),
DayCountConvention::Act365,
)
.unwrap();
let yc = curve();
let lv = LocalVol::new(&surface, &yc, S0, 0.0, 0.0);
let cfg = SlvConfig { paths: 16_000, steps: 40, bins: 20, seed: 3 };
let slv = calibrate(&lv, &mixing_heston(), S0, R, 0.0, 1.0, &cfg);
for k in [90.0, 100.0, 110.0] {
let t = 0.75;
let mc = slv.price_vanilla(k, t, PutOrCall::Call, 32_000, 5);
let iv = implied_vol_from_price(S0, k, R, 0.0, t, mc, PutOrCall::Call)
.expect("inverting the SLV price");
let market = smile(k);
assert!(
(iv - market).abs() < 0.01,
"K = {k}: slv implied {iv:.4} vs market {market:.4}"
);
}
}
#[test]
fn zero_vol_of_vol_degenerates_to_pure_local_vol() {
let surface =
VolSurface::flat(0.25, reference(), DayCountConvention::Act365).unwrap();
let yc = curve();
let lv = LocalVol::new(&surface, &yc, S0, 0.0, 0.0);
let degenerate = HestonParams { v0: 1.0, kappa: 1.0, theta: 1.0, vol_of_vol: 1e-6, rho: 0.0 };
let cfg = SlvConfig { paths: 4_000, steps: 20, bins: 10, seed: 9 };
let slv = calibrate(&lv, °enerate, S0, R, 0.0, 1.0, &cfg);
for s in [80.0, 100.0, 125.0] {
let lev = slv.leverage(s, 0.5);
let sigma = lv.vol(s, 0.5);
assert!((lev - sigma).abs() < 1e-3, "S = {s}: {lev} vs {sigma}");
}
}
}