1use chrono::NaiveDate;
35
36use crate::core::curves::Tenor;
37use crate::core::daycount::DayCountConvention;
38use crate::core::optimization::{levenberg_marquardt, OptimConfig};
39use crate::core::vols::{VolError, VolSurface};
40use crate::core::errors::RustyQLibError;
41
42#[derive(Debug, Clone, Copy, PartialEq)]
46pub struct SviParams {
47 pub a: f64,
48 pub b: f64,
49 pub rho: f64,
50 pub m: f64,
51 pub sigma: f64,
52}
53
54#[derive(Debug, Clone)]
56pub struct SviFit {
57 pub params: SviParams,
58 pub rmse: f64,
60 pub iterations: usize,
61 pub converged: bool,
62}
63
64impl SviParams {
65 pub fn total_variance(&self, k: f64) -> f64 {
67 let d = k - self.m;
68 self.a + self.b * (self.rho * d + (d * d + self.sigma * self.sigma).sqrt())
69 }
70
71 pub fn vol(&self, k: f64, t: f64) -> f64 {
73 (self.total_variance(k).max(0.0) / t).sqrt()
74 }
75
76 pub fn validate(&self) -> Result<(), RustyQLibError> {
79 if self.b < 0.0 {
80 return Err(RustyQLibError::invalid_input("svi params", "b must be non-negative"));
81 }
82 if !(-1.0..1.0).contains(&self.rho) && self.rho != -1.0 {
83 return Err(RustyQLibError::invalid_input("svi params", "rho must be in (-1, 1)"));
84 }
85 if self.sigma <= 0.0 {
86 return Err(RustyQLibError::invalid_input("svi params", "sigma must be positive"));
87 }
88 if self.a + self.b * self.sigma * (1.0 - self.rho * self.rho).sqrt() < 0.0 {
89 return Err(RustyQLibError::invalid_input("svi params", "minimum total variance is negative"));
90 }
91 Ok(())
92 }
93
94 pub fn butterfly_g(&self, k: f64) -> f64 {
98 let d = k - self.m;
99 let root = (d * d + self.sigma * self.sigma).sqrt();
100 let w = self.a + self.b * (self.rho * d + root);
101 let w1 = self.b * (self.rho + d / root);
102 let w2 = self.b * self.sigma * self.sigma / (root * root * root);
103 (1.0 - k * w1 / (2.0 * w)).powi(2) - (w1 * w1 / 4.0) * (1.0 / w + 0.25) + w2 / 2.0
104 }
105
106 pub fn min_butterfly_g(&self) -> f64 {
109 (0..=800)
110 .map(|i| self.butterfly_g(-2.0 + i as f64 * 0.005))
111 .fold(f64::INFINITY, f64::min)
112 }
113
114 pub fn has_butterfly_arbitrage(&self) -> bool {
115 self.min_butterfly_g() < 0.0
116 }
117
118 pub fn calibrate(quotes: &[(f64, f64)], t: f64) -> SviFit {
123 assert!(quotes.len() >= 5, "SVI has five parameters; need at least five quotes");
124 assert!(t > 0.0);
125 let w_target: Vec<(f64, f64)> =
126 quotes.iter().map(|&(k, v)| (k, v * v * t)).collect();
127 let (w_min, w_max) = w_target
128 .iter()
129 .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &(_, w)| (lo.min(w), hi.max(w)));
130 let k_at_min = w_target
131 .iter()
132 .fold((0.0, f64::INFINITY), |acc, &(k, w)| if w < acc.1 { (k, w) } else { acc })
133 .0;
134 let k_span = quotes.iter().map(|q| q.0).fold(f64::NEG_INFINITY, f64::max)
135 - quotes.iter().map(|q| q.0).fold(f64::INFINITY, f64::min);
136 let x0 = vec![
138 0.5 * w_min, (((w_max - w_min) / k_span.max(0.1)).max(1e-3)).ln(), 0.0, k_at_min, 0.2_f64.ln(), ];
144 let unpack = |u: &[f64]| SviParams {
145 a: u[0],
146 b: u[1].exp(),
147 rho: u[2].tanh(),
148 m: u[3],
149 sigma: u[4].exp(),
150 };
151 let residuals = |u: &[f64]| -> Vec<f64> {
152 let p = unpack(u);
153 w_target.iter().map(|&(k, w)| p.total_variance(k) - w).collect()
154 };
155 let fit = levenberg_marquardt(&OptimConfig::new(1e-14, 200), &residuals, None, &x0);
156 let params = unpack(&fit.x);
157 let rmse = (quotes
158 .iter()
159 .map(|&(k, v)| (params.vol(k, t) - v).powi(2))
160 .sum::<f64>()
161 / quotes.len() as f64)
162 .sqrt();
163 SviFit { params, rmse, iterations: fit.iterations, converged: fit.converged }
164 }
165}
166
167#[derive(Debug, Clone)]
172pub struct Ssvi {
173 pub rho: f64,
174 pub eta: f64,
175 pub gamma: f64,
177 pub theta_pillars: Vec<(f64, f64)>,
179}
180
181#[derive(Debug, Clone)]
183pub struct SsviFit {
184 pub surface: Ssvi,
185 pub rmse: f64,
187 pub iterations: usize,
188 pub converged: bool,
189}
190
191impl Ssvi {
192 pub fn theta(&self, t: f64) -> f64 {
196 let p = &self.theta_pillars;
197 let n = p.len();
198 if t <= 0.0 {
199 return 0.0;
200 }
201 if t <= p[0].0 {
202 return p[0].1 * t / p[0].0;
203 }
204 if t >= p[n - 1].0 {
205 if n == 1 {
206 return p[0].1 * t / p[0].0;
207 }
208 let slope = (p[n - 1].1 - p[n - 2].1) / (p[n - 1].0 - p[n - 2].0);
209 return p[n - 1].1 + slope * (t - p[n - 1].0);
210 }
211 let idx = p.partition_point(|&(ti, _)| ti < t);
212 let (t0, w0) = p[idx - 1];
213 let (t1, w1) = p[idx];
214 w0 + (w1 - w0) * (t - t0) / (t1 - t0)
215 }
216
217 pub fn phi(&self, theta: f64) -> f64 {
219 self.eta / (theta.powf(self.gamma) * (1.0 + theta).powf(1.0 - self.gamma))
220 }
221
222 pub fn total_variance(&self, k: f64, t: f64) -> f64 {
224 let theta = self.theta(t);
225 if theta <= 0.0 {
226 return 0.0;
227 }
228 let phi = self.phi(theta);
229 let pk = phi * k;
230 0.5 * theta
231 * (1.0 + self.rho * pk + ((pk + self.rho).powi(2) + 1.0 - self.rho * self.rho).sqrt())
232 }
233
234 pub fn vol(&self, strike: f64, forward: f64, t: f64) -> f64 {
236 (self.total_variance((strike / forward).ln(), t) / t).sqrt()
237 }
238
239 pub fn validate(&self) -> Result<(), RustyQLibError> {
245 if !(-1.0..1.0).contains(&self.rho) {
246 return Err(RustyQLibError::invalid_input("svi params", "rho must be in (-1, 1)"));
247 }
248 if self.eta <= 0.0 {
249 return Err(RustyQLibError::invalid_input("svi params", "eta must be positive"));
250 }
251 if !(0.0..=1.0).contains(&self.gamma) || self.gamma == 0.0 {
252 return Err(RustyQLibError::invalid_input("svi params", "gamma must be in (0, 1]"));
253 }
254 if self.theta_pillars.is_empty() {
255 return Err(RustyQLibError::invalid_input("svi params", "need at least one theta pillar"));
256 }
257 if self.theta_pillars.iter().any(|&(t, w)| t <= 0.0 || w <= 0.0) {
258 return Err(RustyQLibError::invalid_input("svi params", "theta pillars must have positive times and variances"));
259 }
260 if self.theta_pillars.windows(2).any(|p| p[1].0 <= p[0].0 || p[1].1 < p[0].1) {
261 return Err(RustyQLibError::invalid_input("svi params", "theta pillars must be increasing in time and nondecreasing in variance (calendar arbitrage)"));
262 }
263 if self.eta * (1.0 + self.rho.abs()) > 2.0 {
264 return Err(RustyQLibError::invalid_input("svi params", "eta (1 + |rho|) must not exceed 2 (static arbitrage)"));
265 }
266 for &(_, theta) in &self.theta_pillars {
267 let phi = self.phi(theta);
268 if theta * phi * (1.0 + self.rho.abs()) > 4.0
269 || theta * phi * phi * (1.0 + self.rho.abs()) > 4.0
270 {
271 return Err(RustyQLibError::invalid_input("svi params", "butterfly bound violated at a theta pillar"));
272 }
273 }
274 Ok(())
275 }
276
277 pub fn calibrate(
282 quotes: &[(f64, f64, f64)],
283 theta_pillars: &[(f64, f64)],
284 start: (f64, f64, f64),
285 ) -> SsviFit {
286 assert!(quotes.len() >= 3, "need at least three quotes for three parameters");
287 let make = |u: &[f64]| Ssvi {
288 rho: u[0].tanh(),
289 eta: u[1].exp(),
290 gamma: 1.0 / (1.0 + (-u[2]).exp()),
291 theta_pillars: theta_pillars.to_vec(),
292 };
293 let (rho0, eta0, gamma0) = start;
294 let x0 = vec![
295 rho0.clamp(-0.999, 0.999).atanh(),
296 eta0.ln(),
297 (gamma0.clamp(1e-3, 1.0 - 1e-9) / (1.0 - gamma0.clamp(1e-3, 1.0 - 1e-9))).ln(),
298 ];
299 let residuals = |u: &[f64]| -> Vec<f64> {
300 let s = make(u);
301 quotes
302 .iter()
303 .map(|&(t, k, v)| s.total_variance(k, t) - v * v * t)
304 .collect()
305 };
306 let fit = levenberg_marquardt(&OptimConfig::new(1e-14, 200), &residuals, None, &x0);
307 let surface = make(&fit.x);
308 let rmse = (quotes
309 .iter()
310 .map(|&(t, k, v)| ((surface.total_variance(k, t) / t).sqrt() - v).powi(2))
311 .sum::<f64>()
312 / quotes.len() as f64)
313 .sqrt();
314 SsviFit { surface, rmse, iterations: fit.iterations, converged: fit.converged }
315 }
316
317 pub fn to_vol_surface(
321 &self,
322 reference_date: NaiveDate,
323 day_count: DayCountConvention,
324 expiry_forwards: &[(f64, f64)],
325 log_moneyness_grid: &[f64],
326 ) -> Result<VolSurface, VolError> {
327 let expiries: Vec<Tenor> =
328 expiry_forwards.iter().map(|&(t, _)| Tenor::YearFraction(t)).collect();
329 let smiles: Vec<Vec<(f64, f64)>> = expiry_forwards
330 .iter()
331 .map(|&(t, forward)| {
332 log_moneyness_grid
333 .iter()
334 .map(|&k| {
335 let strike = forward * k.exp();
336 (strike, self.vol(strike, forward, t))
337 })
338 .collect()
339 })
340 .collect();
341 VolSurface::from_strike_smiles(&expiries, &smiles, reference_date, day_count)
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348
349 fn sane() -> SviParams {
350 SviParams { a: 0.03, b: 0.12, rho: -0.4, m: -0.02, sigma: 0.3 }
351 }
352
353 #[test]
354 fn svi_shape_matches_the_closed_form_structure() {
355 let p = sane();
356 p.validate().unwrap();
357 assert!((p.total_variance(p.m) - (p.a + p.b * p.sigma)).abs() < 1e-14);
359 let far = 60.0;
361 let call_slope = p.total_variance(far + 1.0) - p.total_variance(far);
362 let put_slope = p.total_variance(-far - 1.0) - p.total_variance(-far);
363 assert!((call_slope - p.b * (1.0 + p.rho)).abs() < 1e-3, "{call_slope}");
364 assert!((put_slope - p.b * (1.0 - p.rho)).abs() < 1e-3, "{put_slope}");
365 }
366
367 #[test]
368 fn vogt_example_carries_butterfly_arbitrage_and_sane_params_do_not() {
369 let vogt = SviParams { a: -0.0410, b: 0.1331, rho: 0.3060, m: 0.3586, sigma: 0.4153 };
371 assert!(vogt.has_butterfly_arbitrage(), "min g = {}", vogt.min_butterfly_g());
372 assert!((vogt.min_butterfly_g() - -0.0329).abs() < 2e-3);
373 assert!(!sane().has_butterfly_arbitrage(), "min g = {}", sane().min_butterfly_g());
374 }
375
376 #[test]
377 fn svi_calibration_round_trips() {
378 let truth = sane();
379 let t = 0.75;
380 let quotes: Vec<(f64, f64)> =
381 (0..15).map(|i| -0.42 + i as f64 * 0.06).map(|k| (k, truth.vol(k, t))).collect();
382 let fit = SviParams::calibrate("es, t);
383 assert!(fit.rmse < 1e-6, "vol rmse {} params {:?}", fit.rmse, fit.params);
384 assert!(fit.params.validate().is_ok());
385 for i in 0..=20 {
387 let k = -0.5 + i as f64 * 0.05;
388 assert!((fit.params.vol(k, t) - truth.vol(k, t)).abs() < 1e-4, "k = {k}");
389 }
390 }
391
392 fn ssvi() -> Ssvi {
393 Ssvi {
394 rho: -0.55,
395 eta: 0.9,
396 gamma: 0.45,
397 theta_pillars: vec![(0.25, 0.012), (0.5, 0.023), (1.0, 0.045), (2.0, 0.09)],
398 }
399 }
400
401 #[test]
402 fn ssvi_reproduces_the_atm_term_structure_and_skew_sign() {
403 let s = ssvi();
404 s.validate().unwrap();
405 for &(t, theta) in &s.theta_pillars {
406 assert!((s.total_variance(0.0, t) - theta).abs() < 1e-14, "w(0, {t})");
407 }
408 assert!(s.total_variance(-0.2, 1.0) > s.total_variance(0.2, 1.0));
410 for i in 1..40 {
412 let (t0, t1) = (i as f64 * 0.05, (i + 1) as f64 * 0.05);
413 assert!(s.total_variance(0.15, t1) >= s.total_variance(0.15, t0), "t = {t0}");
414 }
415 }
416
417 #[test]
418 fn ssvi_no_arbitrage_bounds_are_enforced() {
419 let mut bad = ssvi();
420 bad.eta = 1.5; assert!(bad.validate().is_err());
422 let mut decreasing = ssvi();
423 decreasing.theta_pillars[2].1 = 0.01; assert!(decreasing.validate().is_err());
425 }
426
427 #[test]
428 fn ssvi_calibration_round_trips() {
429 let truth = ssvi();
430 let mut quotes = Vec::new();
431 for &(t, _) in &truth.theta_pillars {
432 for i in 0..7 {
433 let k = -0.3 + i as f64 * 0.1;
434 quotes.push((t, k, (truth.total_variance(k, t) / t).sqrt()));
435 }
436 }
437 let fit = Ssvi::calibrate("es, &truth.theta_pillars, (-0.2, 0.5, 0.5));
438 assert!(fit.rmse < 1e-8, "vol rmse {}", fit.rmse);
439 assert!((fit.surface.rho - truth.rho).abs() < 1e-4, "rho {}", fit.surface.rho);
440 assert!((fit.surface.eta - truth.eta).abs() < 1e-3, "eta {}", fit.surface.eta);
441 assert!(fit.surface.validate().is_ok());
442 }
443
444 #[test]
445 fn sampled_vol_surface_agrees_with_the_parametric_form() {
446 use chrono::NaiveDate;
447 let s = ssvi();
448 let reference = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
449 let forwards = [(0.25, 101.0), (1.0, 104.0), (2.0, 108.0)];
450 let grid: Vec<f64> = (0..13).map(|i| -0.3 + i as f64 * 0.05).collect();
451 let surface = s
452 .to_vol_surface(reference, DayCountConvention::Act365, &forwards, &grid)
453 .unwrap();
454 for &(t, f) in &forwards {
456 for &k in &grid {
457 let strike = f * k.exp();
458 let sampled = surface.vol(strike, f, t);
459 let parametric = s.vol(strike, f, t);
460 assert!(
461 (sampled - parametric).abs() < 1e-10,
462 "t = {t}, k = {k}: {sampled} vs {parametric}"
463 );
464 }
465 }
466 }
467}