1use chrono::NaiveDate;
25use serde::{Deserialize, Serialize};
26
27use crate::core::traits::Instrument;
28use crate::core::utils::norm_cdf;
29use crate::core::vols::{VolInput, VolSurface};
30use crate::core::errors::RustyQLibError;
31
32pub fn realized_variance(log_returns: &[f64], periods_per_year: f64) -> f64 {
35 assert!(!log_returns.is_empty());
36 log_returns.iter().map(|r| r * r).sum::<f64>() / log_returns.len() as f64
37 * periods_per_year
38}
39
40pub fn fair_variance_strike(forward: f64, t: f64, smile: impl Fn(f64) -> f64) -> f64 {
44 assert!(forward > 0.0 && t > 0.0);
45 let atm_vol = smile(forward).max(1e-4);
46 let width = (10.0 * atm_vol * t.sqrt()).max(1.0);
47 let steps = 4000usize;
48 let du = 2.0 * width / steps as f64;
49 let otm = |u: f64| -> f64 {
51 let k = forward * u.exp();
52 let sigma = smile(k).max(1e-6);
53 let st = sigma * t.sqrt();
54 let d1 = ((forward / k).ln() + 0.5 * st * st) / st;
55 let d2 = d1 - st;
56 if k >= forward {
57 forward * norm_cdf(d1) - k * norm_cdf(d2) } else {
59 k * norm_cdf(-d2) - forward * norm_cdf(-d1) }
61 };
62 let mut sum = 0.0;
64 for i in 0..=steps {
65 let u = -width + i as f64 * du;
66 let w = if i == 0 || i == steps {
67 1.0
68 } else if i % 2 == 1 {
69 4.0
70 } else {
71 2.0
72 };
73 sum += w * otm(u) * (-u).exp();
74 }
75 let integral = sum * du / 3.0 / forward;
76 2.0 / t * integral
77}
78
79pub fn fair_gamma_swap_strike(
95 spot: f64,
96 forward: f64,
97 t: f64,
98 smile: impl Fn(f64) -> f64,
99) -> f64 {
100 assert!(spot > 0.0 && forward > 0.0 && t > 0.0);
101 let atm_vol = smile(forward).max(1e-4);
102 let width = (10.0 * atm_vol * t.sqrt()).max(1.0);
103 let steps = 4000usize;
104 let du = 2.0 * width / steps as f64;
105 let otm = |u: f64| -> f64 {
106 let k = forward * u.exp();
107 let sigma = smile(k).max(1e-6);
108 let st = sigma * t.sqrt();
109 let d1 = ((forward / k).ln() + 0.5 * st * st) / st;
110 let d2 = d1 - st;
111 if k >= forward { forward * norm_cdf(d1) - k * norm_cdf(d2) } else { k * norm_cdf(-d2) - forward * norm_cdf(-d1) }
112 };
113 let mut sum = 0.0;
115 for i in 0..=steps {
116 let u = -width + i as f64 * du;
117 let w = if i == 0 || i == steps { 1.0 } else if i % 2 == 1 { 4.0 } else { 2.0 };
118 sum += w * otm(u);
119 }
120 let integral = sum * du / 3.0;
121 let b_t = (forward / spot).ln();
122 let phi = if b_t.abs() < 1e-12 { 1.0 } else { (1.0 - (-b_t).exp()) / b_t };
123 2.0 / (t * spot) * integral * phi
124}
125
126pub fn fair_corridor_variance_strike(
134 forward: f64,
135 t: f64,
136 low: f64,
137 high: f64,
138 smile: impl Fn(f64) -> f64,
139) -> f64 {
140 assert!(forward > 0.0 && t > 0.0 && low >= 0.0 && high > low);
141 let atm_vol = smile(forward).max(1e-4);
142 let width = (10.0 * atm_vol * t.sqrt()).max(1.0);
143 let u_lo = if low <= 0.0 { -width } else { (low / forward).ln().max(-width) };
145 let u_hi = if high.is_infinite() { width } else { (high / forward).ln().min(width) };
146 if u_hi <= u_lo {
147 return 0.0;
148 }
149 let steps = 4000usize;
150 let du = (u_hi - u_lo) / steps as f64;
151 let otm = |u: f64| -> f64 {
152 let k = forward * u.exp();
153 let sigma = smile(k).max(1e-6);
154 let st = sigma * t.sqrt();
155 let d1 = ((forward / k).ln() + 0.5 * st * st) / st;
156 let d2 = d1 - st;
157 if k >= forward { forward * norm_cdf(d1) - k * norm_cdf(d2) } else { k * norm_cdf(-d2) - forward * norm_cdf(-d1) }
158 };
159 let mut sum = 0.0;
160 for i in 0..=steps {
161 let u = u_lo + i as f64 * du;
162 let w = if i == 0 || i == steps { 1.0 } else if i % 2 == 1 { 4.0 } else { 2.0 };
163 sum += w * otm(u) * (-u).exp();
164 }
165 let integral = sum * du / 3.0 / forward;
166 2.0 / t * integral
167}
168
169pub fn realized_gamma_variance(spots: &[f64], s0: f64, periods_per_year: f64) -> f64 {
172 assert!(spots.len() >= 2 && s0 > 0.0);
173 let n = spots.len() - 1;
174 let sum: f64 = spots
175 .windows(2)
176 .map(|w| {
177 let r = (w[1] / w[0]).ln();
178 w[1] / s0 * r * r
179 })
180 .sum();
181 sum / n as f64 * periods_per_year
182}
183
184pub fn realized_corridor_variance(
188 spots: &[f64],
189 low: f64,
190 high: f64,
191 periods_per_year: f64,
192) -> f64 {
193 assert!(spots.len() >= 2 && high > low);
194 let n = spots.len() - 1;
195 let sum: f64 = spots
196 .windows(2)
197 .map(|w| {
198 if w[0] >= low && w[0] <= high {
199 let r = (w[1] / w[0]).ln();
200 r * r
201 } else {
202 0.0
203 }
204 })
205 .sum();
206 sum / n as f64 * periods_per_year
207}
208
209pub fn volatility_swap_strike_gbm(sigma: f64, observations: usize) -> f64 {
214 assert!(sigma > 0.0 && observations >= 1);
215 let n = observations as f64;
216 let log_ratio = libm::lgamma((n + 1.0) / 2.0) - libm::lgamma(n / 2.0);
217 sigma * (2.0 / n).sqrt() * log_ratio.exp()
218}
219
220#[derive(Clone, Debug, Deserialize, Serialize)]
222pub struct VarianceSwapData {
223 pub symbol: String,
224 pub underlying_price: f64,
225 pub strike_vol: f64,
228 pub notional: f64,
230 pub maturity: String,
232 pub risk_free_rate: f64,
233 pub dividend: Option<f64>,
234 pub volatility: f64,
236 pub vol_surface: Option<VolInput>,
238 pub accrued_variance: Option<f64>,
240 pub elapsed: Option<f64>,
242 pub swap_type: Option<String>,
244 pub corridor_low: Option<f64>,
247 pub corridor_high: Option<f64>,
248 pub valuation_date: Option<String>,
250}
251
252#[derive(Debug, Clone)]
254pub struct VarianceSwap {
255 pub notional: f64,
257 pub strike_variance: f64,
259 pub t_remaining: f64,
261 pub r: f64,
262 pub fair_remaining_variance: f64,
264 pub accrued: Option<(f64, f64)>,
266}
267
268impl VarianceSwap {
269 pub fn expected_total_variance(&self) -> f64 {
272 match self.accrued {
273 None => self.fair_remaining_variance,
274 Some((elapsed, accrued)) => {
275 let total = elapsed + self.t_remaining;
276 (elapsed * accrued + self.t_remaining * self.fair_remaining_variance) / total
277 }
278 }
279 }
280
281 pub fn mtm(&self) -> f64 {
283 self.notional
284 * (-self.r * self.t_remaining).exp()
285 * (self.expected_total_variance() - self.strike_variance)
286 }
287
288 pub fn from_json(data: &VarianceSwapData) -> Box<VarianceSwap> {
291 Self::try_from_json(data).unwrap_or_else(|e| panic!("{e}"))
292 }
293
294 pub fn try_from_json(data: &VarianceSwapData) -> Result<Box<VarianceSwap>, RustyQLibError> {
295 let today =
296 crate::core::data_models::parse_valuation_date(data.valuation_date.as_deref())?;
297 let maturity = NaiveDate::parse_from_str(&data.maturity, "%Y-%m-%d")
298 .map_err(|_| RustyQLibError::invalid_input(
299 "maturity",
300 format!("invalid date '{}' (expected YYYY-MM-DD)", data.maturity),
301 ))?;
302 let t = (maturity - today).num_days() as f64 / 365.0;
303 if t <= 0.0 {
304 return Err(RustyQLibError::invalid_input("maturity", "variance swap is expired"));
305 }
306 let q = data.dividend.unwrap_or(0.0);
307 let forward = data.underlying_price * ((data.risk_free_rate - q) * t).exp();
308 let surface = data
309 .vol_surface
310 .as_ref()
311 .map(|input| VolSurface::from_input(input, today))
312 .transpose()?;
313 let flat = data.volatility;
314 let smile = |k: f64| match &surface {
315 Some(s) => s.vol(k, forward, t),
316 None => flat,
317 };
318 let fair = match data.swap_type.as_deref().map(str::trim) {
319 None | Some("variance") => fair_variance_strike(forward, t, smile),
320 Some("gamma") => {
321 fair_gamma_swap_strike(data.underlying_price, forward, t, smile)
322 }
323 Some("corridor") => fair_corridor_variance_strike(
324 forward,
325 t,
326 data.corridor_low.unwrap_or(0.0),
327 data.corridor_high.unwrap_or(f64::INFINITY),
328 smile,
329 ),
330 Some(other) => return Err(RustyQLibError::invalid_input(
331 "swap_type",
332 format!("invalid swap_type '{other}' (use variance, gamma or corridor)"),
333 )),
334 };
335 let accrued = match (data.elapsed, data.accrued_variance) {
336 (Some(e), Some(v)) => {
337 if e < 0.0 || v < 0.0 {
338 return Err(RustyQLibError::invalid_input(
339 "accrued_variance",
340 "elapsed and accrued_variance must be non-negative",
341 ));
342 }
343 Some((e, v))
344 }
345 (None, None) => None,
346 _ => return Err(RustyQLibError::invalid_input(
347 "accrued_variance",
348 "seasoned swaps need both elapsed and accrued_variance",
349 )),
350 };
351 Ok(Box::new(VarianceSwap {
352 notional: data.notional,
353 strike_variance: data.strike_vol * data.strike_vol,
354 t_remaining: t,
355 r: data.risk_free_rate,
356 fair_remaining_variance: fair,
357 accrued,
358 }))
359 }
360}
361
362impl Instrument for VarianceSwap {
363 fn try_npv(&self) -> Result<f64, RustyQLibError> {
364 Ok(self.mtm())
365 }
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371
372 #[test]
373 fn flat_surface_replication_recovers_sigma_squared_exactly() {
374 for sigma in [0.1, 0.25, 0.6] {
375 for t in [0.25, 1.0, 3.0] {
376 let k_var = fair_variance_strike(100.0, t, |_| sigma);
377 assert!(
378 (k_var - sigma * sigma).abs() < 1e-6,
379 "sigma {sigma} t {t}: {k_var} vs {}",
380 sigma * sigma
381 );
382 }
383 }
384 }
385
386 #[test]
387 fn skew_lifts_the_variance_strike_above_atm_squared() {
388 let atm = 0.2;
391 let smile =
392 |k: f64| atm - 0.15 * (k / 100.0 - 1.0) + 0.1 * (k / 100.0 - 1.0).powi(2);
393 let k_var = fair_variance_strike(100.0, 1.0, smile);
394 assert!(k_var > atm * atm * 1.02, "{k_var} vs {}", atm * atm);
395 let svi = crate::equity::svi::SviParams {
397 a: 0.03,
398 b: 0.12,
399 rho: -0.4,
400 m: -0.02,
401 sigma: 0.3,
402 };
403 let k_svi = fair_variance_strike(100.0, 0.75, |k| svi.vol((k / 100.0_f64).ln(), 0.75));
404 let atm_svi = svi.vol(0.0, 0.75);
405 assert!(k_svi > atm_svi * atm_svi, "{k_svi} vs {}", atm_svi * atm_svi);
406 }
407
408 #[test]
409 fn realized_leg_matches_convention_and_the_gbm_expectation() {
410 let rv = realized_variance(&[0.01, -0.01], 252.0);
412 assert!((rv - 252.0 * 0.0001).abs() < 1e-12);
413 use crate::core::montecarlo::path_rng;
416 use rand::Rng;
417 let (sigma, n_days, n_paths) = (0.3, 252, 3000);
418 let dt: f64 = 1.0 / 252.0;
419 let mut sum = 0.0;
420 for p in 0..n_paths {
421 let mut rng = path_rng(11, p);
422 let returns: Vec<f64> = (0..n_days)
423 .map(|_| {
424 let z: f64 = rng.sample(rand_distr::StandardNormal);
425 (0.03 - 0.5 * sigma * sigma) * dt + sigma * dt.sqrt() * z
426 })
427 .collect();
428 sum += realized_variance(&returns, 252.0);
429 }
430 let mean_rv = sum / n_paths as f64;
431 assert!((mean_rv - sigma * sigma).abs() < 0.002, "{mean_rv}");
433 }
434
435 #[test]
436 fn seasoned_mtm_blends_accrued_and_remaining_variance() {
437 let swap = VarianceSwap {
438 notional: 1_000_000.0,
439 strike_variance: 0.04,
440 t_remaining: 0.5,
441 r: 0.03,
442 fair_remaining_variance: 0.05,
443 accrued: Some((0.5, 0.09)), };
445 assert!((swap.expected_total_variance() - 0.07).abs() < 1e-12);
447 let expected = 1_000_000.0 * (-0.03_f64 * 0.5).exp() * (0.07 - 0.04);
448 assert!((swap.mtm() - expected).abs() < 1e-9);
449 let fresh = VarianceSwap {
451 notional: 1_000_000.0,
452 strike_variance: 0.05,
453 t_remaining: 1.0,
454 r: 0.03,
455 fair_remaining_variance: 0.05,
456 accrued: None,
457 };
458 assert!(fresh.mtm().abs() < 1e-9);
459 }
460
461 #[test]
462 fn volatility_swap_strike_shows_the_jensen_gap_and_converges() {
463 let sigma = 0.25;
464 let k21 = volatility_swap_strike_gbm(sigma, 21);
466 assert!(k21 < sigma, "{k21}");
467 use crate::core::montecarlo::path_rng;
469 use rand::Rng;
470 let mut sum = 0.0;
471 let paths = 200_000;
472 for p in 0..paths {
473 let mut rng = path_rng(5, p);
474 let mean_sq: f64 = (0..21)
475 .map(|_| {
476 let z: f64 = rng.sample(rand_distr::StandardNormal);
477 z * z
478 })
479 .sum::<f64>()
480 / 21.0;
481 sum += sigma * mean_sq.sqrt();
482 }
483 let mc = sum / paths as f64;
484 assert!((k21 - mc).abs() < 5e-4, "chi mean {k21} vs mc {mc}");
485 let k_dense = volatility_swap_strike_gbm(sigma, 100_000);
487 assert!(sigma - k_dense < 1e-5 && k_dense < sigma);
488 assert!(volatility_swap_strike_gbm(sigma, 252) > k21);
489 }
490
491 #[test]
492 fn gamma_swap_flat_vol_matches_the_carry_closed_form() {
493 for (sigma, b, t) in [(0.2, 0.04, 1.0), (0.3, -0.02, 0.5), (0.25, 0.0, 2.0)] {
495 let spot = 100.0_f64;
496 let forward = spot * (b * t as f64).exp();
497 let k_gamma = fair_gamma_swap_strike(spot, forward, t, |_| sigma);
498 let expect = if b == 0.0 {
499 sigma * sigma
500 } else {
501 sigma * sigma * ((b * t).exp() - 1.0) / (b * t)
502 };
503 assert!(
504 (k_gamma - expect).abs() < 1e-6,
505 "sigma {sigma} b {b} t {t}: {k_gamma} vs {expect}"
506 );
507 }
508 }
509
510 #[test]
511 fn gamma_swap_matches_monte_carlo_and_discounts_the_crash_leg() {
512 use crate::core::montecarlo::path_rng;
513 use rand::Rng;
514 let (sigma, b, t, s0) = (0.3, 0.04, 1.0, 100.0);
516 let n_days = 252;
517 let dt = t / n_days as f64;
518 let mut sum = 0.0;
519 let paths = 4000;
520 for p in 0..paths {
521 let mut rng = path_rng(23, p);
522 let mut spots = vec![s0];
523 for _ in 0..n_days {
524 let z: f64 = rng.sample(rand_distr::StandardNormal);
525 let prev = *spots.last().unwrap();
526 spots.push(prev * ((b - 0.5 * sigma * sigma) * dt + sigma * dt.sqrt() * z).exp());
527 }
528 sum += realized_gamma_variance(&spots, s0, 252.0);
529 }
530 let mc = sum / paths as f64;
531 let analytic = fair_gamma_swap_strike(s0, s0 * (b * t as f64).exp(), t, |_| sigma);
532 assert!((mc - analytic).abs() < 0.004, "mc {mc} vs analytic {analytic}");
533
534 let smile = |k: f64| 0.2 - 0.15 * (k / 100.0_f64 - 1.0);
537 let k_var = fair_variance_strike(100.0, 1.0, smile);
538 let k_gam = fair_gamma_swap_strike(100.0, 100.0, 1.0, smile);
539 assert!(k_gam < k_var, "gamma {k_gam} vs variance {k_var}");
540 }
541
542 #[test]
543 fn corridor_strikes_are_additive_and_recover_the_full_swap() {
544 let smile = |k: f64| 0.2 - 0.1 * (k / 100.0_f64 - 1.0) + 0.2 * (k / 100.0_f64 - 1.0).powi(2);
545 let full = fair_variance_strike(100.0, 1.0, smile);
546 let below = fair_corridor_variance_strike(100.0, 1.0, 0.0, 90.0, smile);
547 let middle = fair_corridor_variance_strike(100.0, 1.0, 90.0, 115.0, smile);
548 let above = fair_corridor_variance_strike(100.0, 1.0, 115.0, f64::INFINITY, smile);
549 assert!(
551 (below + middle + above - full).abs() < 1e-6,
552 "{below} + {middle} + {above} vs {full}"
553 );
554 for part in [below, middle, above] {
556 assert!(part > 0.0 && part < full);
557 }
558 let line = fair_corridor_variance_strike(100.0, 1.0, 0.0, f64::INFINITY, smile);
560 assert!((line - full).abs() < 1e-9);
561 let down = fair_corridor_variance_strike(100.0, 1.0, 70.0, 90.0, smile);
564 let up = fair_corridor_variance_strike(100.0, 1.0, 111.0, 143.0, smile);
565 assert!(down > up, "down {down} vs up {up}");
566 }
567
568 #[test]
569 fn corridor_realized_leg_matches_a_monte_carlo_of_the_strike() {
570 use crate::core::montecarlo::path_rng;
571 use rand::Rng;
572 let (sigma, t, s0) = (0.25_f64, 1.0, 100.0);
573 let (low, high) = (90.0, 115.0);
574 let n_days = 504; let dt = t / n_days as f64;
576 let mut sum = 0.0;
577 let paths = 4000;
578 for p in 0..paths {
579 let mut rng = path_rng(31, p);
580 let mut spots = vec![s0];
581 for _ in 0..n_days {
582 let z: f64 = rng.sample(rand_distr::StandardNormal);
583 let prev = *spots.last().unwrap();
584 spots.push(prev * ((-0.5 * sigma * sigma) * dt + sigma * dt.sqrt() * z).exp());
585 }
586 sum += realized_corridor_variance(&spots, low, high, n_days as f64);
587 }
588 let mc = sum / paths as f64;
589 let analytic = fair_corridor_variance_strike(s0, t, low, high, |_| sigma);
590 assert!((mc - analytic).abs() < 0.003, "mc {mc} vs analytic {analytic}");
591 let path = [100.0, 120.0, 110.0, 80.0, 85.0];
594 let expect = ((120.0_f64 / 100.0).ln().powi(2) + (80.0_f64 / 110.0).ln().powi(2))
595 / 4.0
596 * 252.0;
597 assert!((realized_corridor_variance(&path, 90.0, 115.0, 252.0) - expect).abs() < 1e-12);
598 }
599
600 #[test]
601 fn json_contract_round_trip() {
602 let json = r#"{
603 "symbol": "VSWAP", "underlying_price": 100.0,
604 "strike_vol": 0.22, "notional": 1000000.0,
605 "maturity": "2030-01-01", "risk_free_rate": 0.03,
606 "volatility": 0.25
607 }"#;
608 let data: VarianceSwapData = serde_json::from_str(json).unwrap();
609 let swap = VarianceSwap::from_json(&data);
610 assert!((swap.fair_remaining_variance - 0.0625).abs() < 1e-5);
612 assert!(swap.npv() > 0.0);
613 let atm = r#"{
615 "symbol": "VSWAP", "underlying_price": 100.0,
616 "strike_vol": 0.25, "notional": 1000000.0,
617 "maturity": "2030-01-01", "risk_free_rate": 0.03,
618 "volatility": 0.25
619 }"#;
620 let fair: VarianceSwapData = serde_json::from_str(atm).unwrap();
621 assert!(VarianceSwap::from_json(&fair).npv().abs() < 50.0);
622
623 let gamma = r#"{
626 "symbol": "GSWAP", "underlying_price": 100.0,
627 "strike_vol": 0.25, "notional": 1000000.0,
628 "maturity": "2030-01-01", "risk_free_rate": 0.03,
629 "volatility": 0.25, "swap_type": "gamma"
630 }"#;
631 let g: VarianceSwapData = serde_json::from_str(gamma).unwrap();
632 let g_swap = VarianceSwap::from_json(&g);
633 assert!(g_swap.fair_remaining_variance > 0.0625, "{}", g_swap.fair_remaining_variance);
634 let corridor = r#"{
635 "symbol": "CSWAP", "underlying_price": 100.0,
636 "strike_vol": 0.20, "notional": 1000000.0,
637 "maturity": "2030-01-01", "risk_free_rate": 0.03,
638 "volatility": 0.25, "swap_type": "corridor",
639 "corridor_low": 80.0, "corridor_high": 120.0
640 }"#;
641 let c: VarianceSwapData = serde_json::from_str(corridor).unwrap();
642 let c_swap = VarianceSwap::from_json(&c);
643 assert!(
644 c_swap.fair_remaining_variance < 0.0625,
645 "{}",
646 c_swap.fair_remaining_variance
647 );
648 }
649}