Skip to main content

rustyqlib/equity/
variance_swap.rs

1//! Volatility derivatives: variance swaps (and the volatility-swap
2//! strike under GBM).
3//!
4//! A variance swap pays `notional * (realized variance - strike)` at
5//! maturity, with realized variance the annualized mean of squared log
6//! returns (**no mean subtraction** — the market convention). Its fair
7//! strike is model-free by the log-contract replication
8//! (Demeterfi-Derman-Kamal-Zou 1999):
9//!
10//! ```text
11//! K_var = (2/T) * [ int_0^F P(K)/K^2 dK + int_F^inf C(K)/K^2 dK ]
12//! ```
13//!
14//! with undiscounted OTM option prices struck off the forward. On a
15//! flat surface the integral collapses to `sigma^2` **exactly** (the
16//! test checks to 1e-6); a skewed smile adds the convexity that makes
17//! variance strikes trade above ATM vol squared.
18//!
19//! The volatility swap (paying realized vol) needs the distribution,
20//! not just the expectation: under GBM the exact fair strike is the
21//! chi-distribution mean `sigma * sqrt(2/n) * Gamma((n+1)/2) /
22//! Gamma(n/2)` — below `sigma` for finite sampling by Jensen.
23
24use 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
32/// Annualized realized variance of a log-return series, market
33/// convention (mean **not** subtracted).
34pub 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
40/// Model-free fair variance strike by the log-contract replication.
41/// `smile(strike) -> implied vol`; integration over ten ATM standard
42/// deviations of log-strike with a fine Simpson rule.
43pub 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    // undiscounted Black OTM price at strike K = F e^u
50    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) // call
58        } else {
59            k * norm_cdf(-d2) - forward * norm_cdf(-d1) // put
60        }
61    };
62    // int Q(K)/K^2 dK = int Q(F e^u) e^{-u} du / F  (Simpson)
63    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
79/// Fair **gamma-swap** strike: the spot-weighted variance
80/// `(1/T) int (S_t/S_0) sigma_t^2 dt`, replicated by the `S ln S`
81/// contract with a `1/K` strike kernel:
82///
83/// ```text
84/// K_gamma = (2/(T S_0)) * [ int_0^F P(K)/K dK + int_F^inf C(K)/K dK ]
85///           * phi(bT),   phi(x) = (1 - e^-x)/x
86/// ```
87///
88/// The `phi` factor is the carry adjustment for the drift-weighting
89/// interaction: it makes the flat-vol strike exactly
90/// `sigma^2 (e^{bT} - 1)/(bT)` for any carry `b` (tested), and is exact
91/// for any smile when `b = 0`. Gamma swaps weight down-moves by a low
92/// `S/S_0`, so under a put skew the gamma strike sits **below** the
93/// variance strike — the crash-discount that motivates the product.
94pub 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    // int Q(K)/K dK = int Q(F e^u) du  (log-strike substitution)
114    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
126/// Fair **corridor variance** strike: variance accrues only while the
127/// spot is inside `[low, high]` (Carr-Lewis), which truncates the
128/// replication integral to the corridor's strikes:
129/// `K_corr = (2/T) int_low^high Q(K)/K^2 dK`. Corridors are exactly
130/// additive: adjacent corridors sum to the full variance strike
131/// (tested), and the full-line corridor reproduces
132/// [`fair_variance_strike`].
133pub 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    // integrate in log-strike over the corridor clipped to the window
144    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
169/// Realized leg of a gamma swap over a spot path: the annualized
170/// spot-weighted squared returns `(A/n) sum (S_i/S_0) ln(S_i/S_{i-1})^2`.
171pub 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
184/// Realized leg of a corridor variance swap: squared returns accrue
185/// when the **previous** observation was inside `[low, high]` (the
186/// standard convention).
187pub 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
209/// Exact fair **volatility**-swap strike under GBM with `observations`
210/// sampling dates: `sigma sqrt(2/n) Gamma((n+1)/2)/Gamma(n/2)`, the
211/// mean of the chi distribution — strictly below `sigma`, converging to
212/// it as sampling densifies.
213pub 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/// JSON contract data (`"product_type": "variance_swap"`).
221#[derive(Clone, Debug, Deserialize, Serialize)]
222pub struct VarianceSwapData {
223    pub symbol: String,
224    pub underlying_price: f64,
225    /// Strike quoted in **volatility** units (0.20 = 20 vol);
226    /// `K_var = strike_vol^2`.
227    pub strike_vol: f64,
228    /// Variance notional (payout per unit of annualized variance).
229    pub notional: f64,
230    /// Maturity date, `YYYY-MM-DD`.
231    pub maturity: String,
232    pub risk_free_rate: f64,
233    pub dividend: Option<f64>,
234    /// Flat implied vol, used when no surface is given.
235    pub volatility: f64,
236    /// Optional smile/surface: the fair strike integrates over it.
237    pub vol_surface: Option<VolInput>,
238    /// Seasoned swaps: annualized variance realized so far.
239    pub accrued_variance: Option<f64>,
240    /// Seasoned swaps: elapsed observation time in years.
241    pub elapsed: Option<f64>,
242    /// "variance" (default) | "gamma" | "corridor".
243    pub swap_type: Option<String>,
244    /// Corridor bounds (corridor swaps only; either may be omitted for
245    /// a one-sided corridor).
246    pub corridor_low: Option<f64>,
247    pub corridor_high: Option<f64>,
248    /// Pricing as-of date (`YYYY-MM-DD`); defaults to today.
249    pub valuation_date: Option<String>,
250}
251
252/// A (possibly seasoned) variance swap.
253#[derive(Debug, Clone)]
254pub struct VarianceSwap {
255    /// Variance notional.
256    pub notional: f64,
257    /// Strike in variance units.
258    pub strike_variance: f64,
259    /// Remaining time to maturity (years).
260    pub t_remaining: f64,
261    pub r: f64,
262    /// Fair strike of the **remaining** variance (annualized).
263    pub fair_remaining_variance: f64,
264    /// (elapsed years, annualized variance realized over them).
265    pub accrued: Option<(f64, f64)>,
266}
267
268impl VarianceSwap {
269    /// Expected total-period annualized variance: the time-weighted
270    /// blend of what has been realized and the fair value of the rest.
271    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    /// Mark-to-market: discounted expected payoff.
282    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    /// Build from contract data, panicking on any invalid field. Fallible
289    /// callers should use [`VarianceSwap::try_from_json`].
290    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        // a put-skewed smile: OTM puts are priced richer than flat, and
389        // the 1/K^2 weighting loads on them
390        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        // and the SVI smile from the vol-model module plugs straight in
396        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        // hand check: two returns of 1% at daily frequency
411        let rv = realized_variance(&[0.01, -0.01], 252.0);
412        assert!((rv - 252.0 * 0.0001).abs() < 1e-12);
413        // under GBM the expected realized variance is sigma^2 (exactly,
414        // including the drift-free convention); check by simulation
415        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        // small positive drift bias of the convention is O(mu^2 dt)
432        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)), // a realized-vol spike of 30%
444        };
445        // blend: (0.5*0.09 + 0.5*0.05) / 1.0 = 0.07
446        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        // a fresh swap struck at fair value has zero MtM
450        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        // finite sampling: strictly below sigma
465        let k21 = volatility_swap_strike_gbm(sigma, 21);
466        assert!(k21 < sigma, "{k21}");
467        // exact chi-mean vs simulation at n = 21
468        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        // dense sampling converges up to sigma
486        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        // flat vol: K_gamma = sigma^2 (e^{bT} - 1)/(bT), exactly
494        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        // MC oracle: E[(A/n) sum (S_i/S0) r_i^2] under GBM
515        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        // under a put skew the spot-weighting discounts crash variance:
535        // gamma strike < variance strike
536        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        // adjacent corridors tile the line: strikes add to the full swap
550        assert!(
551            (below + middle + above - full).abs() < 1e-6,
552            "{below} + {middle} + {above} vs {full}"
553        );
554        // every corridor is a strict subset of the full variance
555        for part in [below, middle, above] {
556            assert!(part > 0.0 && part < full);
557        }
558        // the full-line corridor IS the variance swap
559        let line = fair_corridor_variance_strike(100.0, 1.0, 0.0, f64::INFINITY, smile);
560        assert!((line - full).abs() < 1e-9);
561        // under put skew the downside corridor carries more variance than
562        // the mirrored upside one
563        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; // dense monitoring shrinks the indicator bias
575        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        // hand check of the accrual convention: only the step leaving the
592        // corridor from inside counts
593        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        // flat 25% vol: fair variance 0.0625 vs strike 0.0484 -> positive MtM
611        assert!((swap.fair_remaining_variance - 0.0625).abs() < 1e-5);
612        assert!(swap.npv() > 0.0);
613        // strike at fair vol prices to ~zero
614        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        // typed contracts: gamma (b > 0 lifts the strike above sigma^2 at
624        // fair) and corridor (a sub-range strikes below the full swap)
625        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}