rustyqlib/equity/
local_vol.rs1use 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
29pub struct LocalVol<'a> {
33 surface: &'a VolSurface,
34 curve: &'a YieldCurve,
35 spot: f64,
36 dividend_yield: f64,
37 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 fn total_variance(&self, strike: f64, t: f64) -> f64 {
64 let v = self.implied(strike, t);
65 v * v * t
66 }
67
68 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 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 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 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 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}