Skip to content
Sarthak Bagaria
All model code

quant/src/dependence.rs

Dependence between defaults, and what it does to a tranche.

//! Dependence between defaults, and what it does to a tranche.//!//! The credit chapter ended on the observation that single-name curves pin every//! marginal default distribution and say nothing whatever about the joint one.//! This module is the arithmetic of that gap: what the attainable correlations//! actually are, what tail dependence is and which models have any, and what the//! difference is worth on a tranche of a portfolio.//!//! The portfolio results are all in the large-portfolio limit, where the loss//! fraction converges to the conditional default probability given the common//! factors. That is not a simplification made for convenience — it is the regime//! the instruments live in, an index has a hundred and twenty five names, and it//! removes idiosyncratic noise so that what is left is the dependence, which is//! the only thing under discussion. use crate::black::norm_cdf;use crate::special::{norm_inv, t_cdf, t_inv}; // ---------------------------------------------------------------------------// What linear correlation can and cannot say.// --------------------------------------------------------------------------- /// The attainable range of linear correlation between two lognormals.////// Given `exp(X)` and `exp(Y)` with `X, Y` normal of volatilities `s1, s2`, the/// correlation of the *exponentials* cannot reach one even when `X` and `Y` are/// the same random variable. The Hoeffding-Frechet bounds put it at////// ```text///     rho_max = (exp( s1*s2) - 1) / sqrt((exp(s1^2)-1)(exp(s2^2)-1))///     rho_min = (exp(-s1*s2) - 1) / sqrt((exp(s1^2)-1)(exp(s2^2)-1))/// ```////// The dependence chapter makes this the first argument against thinking in correlations: a/// risk system that accepts a correlation of 0.9 between two volatile lognormals/// has accepted a number that no joint distribution can produce, and will/// produce an answer anyway.pub fn lognormal_correlation_bounds(s1: f64, s2: f64) -> (f64, f64) {    let scale = (((s1 * s1).exp() - 1.0) * ((s2 * s2).exp() - 1.0)).sqrt();    if scale <= 0.0 {        return (-1.0, 1.0);    }    (        ((-s1 * s2).exp() - 1.0) / scale,        ((s1 * s2).exp() - 1.0) / scale,    )} /// Kendall's tau of a Gaussian copula with correlation `rho`.////// `tau = (2/pi) arcsin(rho)`. A rank statistic, so unlike linear correlation it/// is unchanged by any increasing transformation of either margin — which is/// what makes it a property of the copula rather than of the marginals that/// happen to be attached to it.pub fn gaussian_kendall_tau(rho: f64) -> f64 {    2.0 / std::f64::consts::PI * rho.asin()} /// The coefficient of upper tail dependence.////// ```text///     lambda = lim as u -> 1 of P(U > u | V > u)/// ```////// The probability that one variable is extreme *given* the other is, in the/// limit of extreme. This is the number a senior tranche is a bet on, and the/// central fact of the dependence chapter is that the Gaussian copula sets it to zero for/// every correlation below one, while the Student-t sets it to////// ```text///     lambda = 2 * t_{nu+1}( -sqrt( (nu+1)(1-rho) / (1+rho) ) )./// ```////// Returns the Gaussian value, which is zero. Kept as a function rather than a/// constant because being able to write the two side by side is the point.pub fn gaussian_tail_dependence(_rho: f64) -> f64 {    0.0} /// The coefficient of upper tail dependence of a Student-t copula.////// Positive for every `rho > -1` and every finite `nu`, and it approaches the/// Gaussian zero only as `nu` grows. Two names with the same correlation and the/// same marginals can therefore have wildly different probabilities of failing/// together, which is precisely the freedom the credit chapter said was left open.pub fn t_tail_dependence(rho: f64, nu: f64) -> f64 {    if rho >= 1.0 {        return 1.0;    }    let argument = -((nu + 1.0) * (1.0 - rho) / (1.0 + rho)).sqrt();    2.0 * t_cdf(argument, nu + 1.0)} // ---------------------------------------------------------------------------// Portfolio loss.// --------------------------------------------------------------------------- /// Which copula links the defaults.#[derive(Clone, Copy, Debug, PartialEq)]pub enum Copula {    /// The one-factor Gaussian copula. Zero tail dependence by construction.    Gaussian,    /// A one-factor Student-t, whose common shock is scaled by an independent    /// chi-square. Same correlation, same marginals, tails that do not vanish.    StudentT { nu: f64 },} /// The distribution of the loss on a large homogeneous portfolio.////// Every name defaults by the horizon with probability `p`, loses `1 - recovery`/// when it does, and is linked to the others by `copula` with correlation `rho`.#[derive(Clone, Copy, Debug)]pub struct Portfolio {    pub default_probability: f64,    pub recovery: f64,    pub correlation: f64,    pub copula: Copula,} impl Portfolio {    /// The default rate the portfolio settles at, given the common factor `m`    /// and, for the Student-t, the mixing variable `w` drawn from a chi-square    /// with `nu` degrees of freedom.    ///    /// In the large-portfolio limit the idiosyncratic risk averages away, so this    /// *is* the realised default fraction rather than its expectation. The    /// Gaussian case is Vasicek's formula.    pub fn conditional_default_rate(&self, m: f64, w: f64) -> f64 {        let rho = self.correlation.clamp(0.0, 0.999_999);        let sqrt_rho = rho.sqrt();        let sqrt_one_minus = (1.0 - rho).sqrt();         match self.copula {            Copula::Gaussian => {                let threshold = norm_inv(self.default_probability);                norm_cdf((threshold - sqrt_rho * m) / sqrt_one_minus)            }            Copula::StudentT { nu } => {                // The t variate is sqrt(nu/w) * (sqrt(rho) m + sqrt(1-rho) z),                // so conditioning on w turns the threshold into a scaled one and                // the rest is Gaussian again.                let threshold = t_inv(self.default_probability, nu);                norm_cdf((threshold * (w / nu).sqrt() - sqrt_rho * m) / sqrt_one_minus)            }        }    }     /// The probability that the loss fraction exceeds `level`.    ///    /// For the Gaussian copula this is Vasicek's closed form, inverted. For the    /// Student-t the mixing variable has to be integrated out, which is done on a    /// grid over the chi-square density; the integrand is smooth and one    /// dimensional, so a few hundred points is far more accuracy than the model    /// deserves.    pub fn exceedance(&self, level: f64) -> f64 {        let loss_given_default = 1.0 - self.recovery;        if loss_given_default <= 0.0 {            return 0.0;        }        // Convert a loss level into the default rate that produces it.        let rate = level / loss_given_default;        if rate <= 0.0 {            return 1.0;        }        if rate >= 1.0 {            return 0.0;        }         let rho = self.correlation.clamp(1e-9, 0.999_999);        let sqrt_rho = rho.sqrt();        let sqrt_one_minus = (1.0 - rho).sqrt();         match self.copula {            Copula::Gaussian => {                // Loss exceeds the level exactly when the factor is low enough.                let threshold = norm_inv(self.default_probability);                let critical = (threshold - sqrt_one_minus * norm_inv(rate)) / sqrt_rho;                norm_cdf(critical)            }            Copula::StudentT { nu } => {                let threshold = t_inv(self.default_probability, nu);                chi_square_average(nu, |w| {                    // Given w, the same inversion as the Gaussian case.                    let critical = (threshold * (w / nu).sqrt()                        - sqrt_one_minus * norm_inv(rate))                        / sqrt_rho;                    norm_cdf(critical)                })            }        }    }     /// The expected loss on the tranche covering `[attach, detach]`, as a    /// fraction of the tranche's own notional.    ///    /// A tranche is a call spread on the portfolio loss, so its expected loss is    ///    /// ```text    ///     ( E[(L - a)+] - E[(L - d)+] ) / (d - a)    /// ```    ///    /// and each call is the integral of the exceedance probability above its    /// strike. This is Breeden-Litzenberger from the local volatility chapter    /// read backwards, and the dependence chapter makes something of that: the    /// capital structure of a portfolio is a strip of call spreads on one    /// variable, so a complete set of tranche quotes implies a loss    /// distribution the same way a complete set of option quotes implies a    /// density.    pub fn tranche_expected_loss(&self, attach: f64, detach: f64) -> f64 {        if detach <= attach {            return 0.0;        }        (self.call_on_loss(attach) - self.call_on_loss(detach)) / (detach - attach)    }     /// The common factor above which a tranche attaching at `strike` takes no    /// loss at all, given the mixing variable `w`.    ///    /// The payoff `(loss(m) - strike)+` has a kink exactly here, and it is worth    /// knowing where: Simpson's rule integrated straight through a kink converges    /// at first order instead of fourth, which showed up as the equity tranche    /// disagreeing with itself in the third significant figure. Splitting the    /// integration at this point removes the problem rather than out-resolving    /// it.    ///    /// `None` when the tranche is never touched.    fn critical_factor(&self, strike: f64, w: f64) -> Option<f64> {        let loss_given_default = 1.0 - self.recovery;        let rate = strike / loss_given_default;        if rate >= 1.0 {            return None;        }        if rate <= 0.0 {            // A tranche attaching at zero always takes some loss, so there is no            // factor above which the payoff switches off.            return Some(f64::INFINITY);        }        let rho = self.correlation.clamp(1e-9, 0.999_999);        let threshold = match self.copula {            Copula::Gaussian => norm_inv(self.default_probability),            Copula::StudentT { nu } => t_inv(self.default_probability, nu) * (w / nu).sqrt(),        };        // The payoff is positive for m below this.        Some((threshold - (1.0 - rho).sqrt() * norm_inv(rate)) / rho.sqrt())    }     /// `E[(L - strike)+]`.    ///    /// Integrated over the common factors rather than over the loss level. Both    /// routes are correct and the factor one is far better behaved: as a function    /// of the loss level the integrand has a steep shoulder near zero that a    /// fixed grid resolves badly, while as a function of the factor it is smooth    /// and monotone apart from the single kink located by [`Portfolio::critical_factor`].    fn call_on_loss(&self, strike: f64) -> f64 {        let loss_given_default = 1.0 - self.recovery;        if strike >= loss_given_default {            return 0.0;        }        let integrate = |w: f64| match self.critical_factor(strike, w) {            None => 0.0,            Some(upper) => normal_average_below(upper, |m| {                self.conditional_default_rate(m, w) * loss_given_default - strike            }),        };        match self.copula {            Copula::Gaussian => integrate(1.0),            Copula::StudentT { nu } => chi_square_average(nu, integrate),        }    }} /// `E[ f(M) 1{M < upper} ]` for a standard normal `M`, by Simpson.////// The caller passes the point where its integrand stops contributing, so the/// grid ends exactly on the kink rather than straddling it. Eight standard/// deviations is the lower limit, past which the weight is smaller than anything/// it multiplies.fn normal_average_below(upper: f64, f: impl Fn(f64) -> f64) -> f64 {    const STEPS: usize = 100;    const LIMIT: f64 = 8.0;     let upper = upper.min(LIMIT);    if upper <= -LIMIT {        return 0.0;    }    let h = (upper + LIMIT) / STEPS as f64;    let normalisation = (2.0 * std::f64::consts::PI).sqrt();     let mut total = 0.0;    for i in 0..=STEPS {        let m = -LIMIT + i as f64 * h;        let weight = if i == 0 || i == STEPS {            1.0        } else if i % 2 == 1 {            4.0        } else {            2.0        };        total += weight * (-0.5 * m * m).exp() * f(m);    }    total * h / 3.0 / normalisation} /// The average of `f(w)` over a chi-square with `nu` degrees of freedom.////// Simpson on the log of the variable, which keeps the grid tight where the/// density is and still reaches far enough into the right tail to matter: it is/// a small `w` that produces the fat tail of a Student-t, so the left end is the/// end that has to be resolved.fn chi_square_average(nu: f64, f: impl Fn(f64) -> f64) -> f64 {    const STEPS: usize = 150;    // Six e-folds either side of the mode covers the density to far below the    // precision of anything it is multiplied by.    let (lo, hi) = ((nu * 1e-4).ln(), (nu * 40.0).ln());    let h = (hi - lo) / STEPS as f64;     let mut total = 0.0;    let mut mass = 0.0;    for i in 0..=STEPS {        let log_w = lo + i as f64 * h;        let w = log_w.exp();        let weight = if i == 0 || i == STEPS {            1.0        } else if i % 2 == 1 {            4.0        } else {            2.0        };        // The chi-square density times w, since the variable of integration is        // log w. Normalising constants cancel in the ratio below.        let density = w.powf(0.5 * nu) * (-0.5 * w).exp();        total += weight * density * f(w);        mass += weight * density;    }    total / mass} #[cfg(test)]mod tests {    use super::*;    use crate::pathwise::Rng;     #[test]    fn equal_volatilities_can_be_perfectly_correlated_and_barely_anticorrelated() {        // Worth getting right rather than assuming. Two lognormals built from        // the *same* normal are the same variable up to a monotone map, so the        // upper bound is genuinely one. It is the lower bound that collapses:        // exp(X) and exp(-X) are the closest to opposed that two lognormals can        // be, and that is not very close at all.        let (lo, hi) = lognormal_correlation_bounds(1.0, 1.0);        assert!((hi - 1.0).abs() < 1e-12, "upper bound was {hi}");        assert!((lo + 0.3679).abs() < 1e-3, "lower bound was {lo}");    }     #[test]    fn the_lower_bound_collapses_towards_zero_as_volatility_rises() {        // At a 200% volatility two lognormals cannot be more than two percent        // negatively correlated, and at 300% they cannot be negatively        // correlated in any meaningful sense at all. A risk system that accepts        // -0.5 here has accepted a number no joint distribution can produce.        let mut previous = -1.0;        for sigma in [0.25, 0.5, 1.0, 2.0, 3.0] {            let (lo, _) = lognormal_correlation_bounds(sigma, sigma);            assert!(lo > previous, "lower bound not rising at sigma={sigma}");            assert!(lo < 0.0);            previous = lo;        }        let (lo, _) = lognormal_correlation_bounds(3.0, 3.0);        assert!(lo > -0.001, "lower bound at sigma=3 was {lo}");    }     #[test]    fn unequal_volatilities_cannot_reach_one_either() {        // The upper bound only survives because the two volatilities were equal.        // Make them differ and the ceiling drops fast: a 50% name and a 200%        // name cannot be more than 44% correlated however they are coupled.        let (lo, hi) = lognormal_correlation_bounds(0.5, 2.0);        assert!((hi - 0.4404).abs() < 1e-3, "upper bound {hi}");        assert!((lo + 0.1620).abs() < 1e-3, "lower bound {lo}");        // And the ceiling is monotone in how far apart the two volatilities are.        let mut previous = 1.0;        for spread in [1.0, 2.0, 4.0, 8.0] {            let (_, hi) = lognormal_correlation_bounds(0.5, 0.5 * spread);            assert!(hi <= previous + 1e-12, "ceiling rose at spread={spread}");            previous = hi;        }    }     #[test]    fn the_bounds_are_reached_by_the_comonotone_pair() {        // The upper bound is the correlation of exp(X) with exp(X) rescaled, so        // simulating the comonotone pair has to reproduce it.        let (s1, s2) = (0.8f64, 1.2f64);        let (_, analytic) = lognormal_correlation_bounds(s1, s2);         let mut rng = Rng::new(4242);        let n = 150_000;        let (mut sx, mut sy, mut sxx, mut syy, mut sxy) = (0.0, 0.0, 0.0, 0.0, 0.0);        for _ in 0..n {            let z = rng.next_normal();            let (x, y) = ((s1 * z).exp(), (s2 * z).exp());            sx += x;            sy += y;            sxx += x * x;            syy += y * y;            sxy += x * y;        }        let n = n as f64;        let (mx, my) = (sx / n, sy / n);        let cov = sxy / n - mx * my;        let simulated = cov / ((sxx / n - mx * mx).sqrt() * (syy / n - my * my).sqrt());        assert!(            (simulated - analytic).abs() < 0.02,            "simulated {simulated} against analytic {analytic}"        );    }     #[test]    fn the_gaussian_copula_has_no_tail_dependence_and_the_t_does() {        // The single most consequential fact in the chapter.        for rho in [0.1, 0.3, 0.5, 0.9, 0.99] {            assert_eq!(gaussian_tail_dependence(rho), 0.0);            let t = t_tail_dependence(rho, 4.0);            assert!(t > 0.0, "t copula had no tail dependence at rho={rho}");        }        // At a correlation of 0.3, two names under a t(4) copula fail together        // in the limit about a sixth of the time, against never.        let lambda = t_tail_dependence(0.3, 4.0);        assert!(lambda > 0.10 && lambda < 0.25, "lambda was {lambda}");    }     #[test]    fn tail_dependence_vanishes_as_the_t_becomes_normal() {        // The continuity check: a t copula with many degrees of freedom is a        // Gaussian copula, so its tail dependence must go to the Gaussian zero.        let mut previous = 1.0;        for nu in [2.0, 4.0, 10.0, 40.0, 200.0] {            let lambda = t_tail_dependence(0.5, nu);            assert!(lambda < previous, "not decreasing at nu={nu}");            previous = lambda;        }        assert!(t_tail_dependence(0.5, 5000.0) < 1e-3);    }     #[test]    fn the_gaussian_loss_distribution_is_vasicek() {        // Checked against a simulation of the factor, which shares only the        // conditional default rate with the closed form.        let p = Portfolio {            default_probability: 0.05,            recovery: 0.4,            correlation: 0.3,            copula: Copula::Gaussian,        };        let mut rng = Rng::new(918_273);        let n = 150_000;        for level in [0.005, 0.02, 0.05, 0.10] {            let mut count = 0.0;            for _ in 0..n {                let m = rng.next_normal();                if p.conditional_default_rate(m, 1.0) * 0.6 > level {                    count += 1.0;                }            }            let simulated = count / n as f64;            let analytic = p.exceedance(level);            assert!(                (simulated - analytic).abs() < 0.006,                "at {level}: simulated {simulated} against analytic {analytic}"            );        }    }     #[test]    fn the_t_loss_distribution_matches_a_simulation() {        // Same check for the branch that integrates out the mixing variable,        // which is the one with room to be wrong. The chi-square is simulated as        // a sum of squared normals, so the quadrature and the simulation share        // no code at all.        let nu = 6.0;        let p = Portfolio {            default_probability: 0.05,            recovery: 0.4,            correlation: 0.3,            copula: Copula::StudentT { nu },        };        let mut rng = Rng::new(555_111);        let n = 150_000;        for level in [0.005, 0.02, 0.05, 0.10] {            let mut count = 0.0;            for _ in 0..n {                let m = rng.next_normal();                let w: f64 = (0..nu as usize).map(|_| rng.next_normal().powi(2)).sum();                if p.conditional_default_rate(m, w) * 0.6 > level {                    count += 1.0;                }            }            let simulated = count / n as f64;            let analytic = p.exceedance(level);            assert!(                (simulated - analytic).abs() < 0.007,                "at {level}: simulated {simulated} against analytic {analytic}"            );        }    }     #[test]    fn the_whole_capital_structure_sums_to_the_portfolio_loss() {        // The identity that makes tranches a strip of call spreads: the        // notional-weighted expected losses of a partition of [0, 1-R] have to        // add back up to the expected loss of the portfolio itself. It is also        // the strongest available check on the integration, since it has to hold        // for either copula and any correlation.        for copula in [Copula::Gaussian, Copula::StudentT { nu: 5.0 }] {            for correlation in [0.05, 0.3, 0.7] {                let p = Portfolio {                    default_probability: 0.05,                    recovery: 0.4,                    correlation,                    copula,                };                let edges = [0.0, 0.03, 0.07, 0.15, 0.30, 0.60];                let total: f64 = edges                    .windows(2)                    .map(|w| p.tranche_expected_loss(w[0], w[1]) * (w[1] - w[0]))                    .sum();                // The portfolio's own expected loss, which needs no model.                let expected = p.default_probability * (1.0 - p.recovery);                assert!(                    (total - expected).abs() < 1e-6,                    "{copula:?} at rho={correlation}: tranches gave {total}, portfolio {expected}"                );            }        }    }     #[test]    fn the_senior_tranche_is_where_the_copula_choice_shows_up() {        // The dependence chapter's headline number. Same marginals, same correlation, same        // everything a risk report records -- and a senior tranche that is worth        // a different order of magnitude.        let gaussian = Portfolio {            default_probability: 0.05,            recovery: 0.4,            correlation: 0.3,            copula: Copula::Gaussian,        };        let student = Portfolio { copula: Copula::StudentT { nu: 4.0 }, ..gaussian };         // The effect is not a uniform shift: it is a transfer up the capital        // structure. Equity gets *safer* under the fat-tailed copula, because        // more of the probability sits at low losses; everything senior gets        // worse, and the further up the worse it gets.        let ratio = |a: f64, d: f64| {            student.tranche_expected_loss(a, d) / gaussian.tranche_expected_loss(a, d)        };        let equity = ratio(0.0, 0.03);        assert!(equity < 0.85, "equity ratio was {equity}, expected a fall");         let mut previous = equity;        for &(a, d) in &[(0.03, 0.07), (0.07, 0.15), (0.15, 0.30), (0.30, 0.60)] {            let r = ratio(a, d);            assert!(r > previous, "ratio not rising at {a}-{d}: {r} after {previous}");            previous = r;        }         // And the super senior, the tranche the whole argument is about, is out        // by an order of magnitude rather than a few percent.        assert!(previous > 5.0, "super senior ratio was only {previous}");    }} /// A spread option on two rates, priced from their marginals and a copula.////// This is the rates counterpart of the tranche above and it is set up the same/// way: both rates keep exactly the same marginal distribution whatever the/// copula, and the correlation is held fixed too, so anything that moves between/// two runs is the dependence structure and nothing else.////// The marginals are normal, which is the rates convention and also convenient/// here — with a Gaussian copula the spread is then normal and the option has a/// closed form, so one of the two columns can be checked against Bachelier.#[derive(Clone, Copy, Debug)]pub struct SpreadOption {    pub forward1: f64,    pub forward2: f64,    /// Absolute volatilities, in rate units per root year.    pub vol1: f64,    pub vol2: f64,    pub expiry: f64,    pub correlation: f64,    pub copula: Copula,} impl SpreadOption {    /// `E[(S1 - S2 - K)^+]`, undiscounted, by simulation.    pub fn price(&self, strike: f64, paths: usize, seed: u64) -> f64 {        let mut rng = crate::pathwise::Rng::new(seed);        let rho = self.correlation.clamp(-0.999_999, 0.999_999);        let (s1, s2) = (self.vol1 * self.expiry.sqrt(), self.vol2 * self.expiry.sqrt());        let mut total = 0.0;        for _ in 0..paths {            let z1 = rng.next_normal();            let z2 = rho * z1 + (1.0 - rho * rho).sqrt() * rng.next_normal();             // The copula enters only here, as the map from the correlated pair            // to uniforms. The marginals are applied afterwards and are the same            // in both branches.            let (u1, u2) = match self.copula {                Copula::Gaussian => (norm_cdf(z1), norm_cdf(z2)),                Copula::StudentT { nu } => {                    // Chi-square with nu degrees of freedom, nu a whole number.                    let k = nu.round().max(1.0) as usize;                    let w: f64 = (0..k).map(|_| rng.next_normal().powi(2)).sum::<f64>() / k as f64;                    let scale = w.max(1e-12).sqrt();                    (t_cdf(z1 / scale, nu), t_cdf(z2 / scale, nu))                }            };             let r1 = self.forward1 + s1 * norm_inv(u1);            let r2 = self.forward2 + s2 * norm_inv(u2);            total += (r1 - r2 - strike).max(0.0);        }        total / paths as f64    }     /// The Bachelier price the Gaussian case must reproduce, since normal    /// marginals joined by a Gaussian copula make the spread normal.    pub fn gaussian_closed_form(&self, strike: f64) -> f64 {        let (s1, s2) = (self.vol1 * self.expiry.sqrt(), self.vol2 * self.expiry.sqrt());        let sd = (s1 * s1 + s2 * s2 - 2.0 * self.correlation * s1 * s2).sqrt();        let forward = self.forward1 - self.forward2;        if sd <= 0.0 {            return (forward - strike).max(0.0);        }        let d = (forward - strike) / sd;        (forward - strike) * norm_cdf(d) + sd * crate::black::norm_pdf(d)    }} #[cfg(test)]mod spread_option_tests {    use super::*;     fn base(copula: Copula) -> SpreadOption {        SpreadOption {            forward1: 0.040,            forward2: 0.025,            vol1: 0.010,            vol2: 0.010,            expiry: 5.0,            correlation: 0.8,            copula,        }    }     /// Normal marginals joined by a Gaussian copula give a normal spread, so the    /// simulation has a closed form to answer to.    #[test]    fn the_gaussian_case_reproduces_bachelier() {        let m = base(Copula::Gaussian);        for &k in &[0.0, 0.015, 0.03] {            let simulated = m.price(k, 400_000, 11);            let exact = m.gaussian_closed_form(k);            assert!(                (simulated - exact).abs() < 0.02 * exact,                "strike {k}: simulated {simulated:.6} against {exact:.6}"            );        }    }     /// The dependence chapter's claim for rates. Both rates keep the same    /// marginal and the same correlation; only the copula changes, and the far    /// strike spread option moves by a multiple of its value.    ///    /// The direction is the same as the credit case, though the mechanism reads    /// differently at first. A Student-t copula is a bivariate normal divided by    /// a common random scale, and a small draw of that scale makes both rates    /// extreme without making them equal — so the *spread* inherits the heavy    /// tail too. The Gaussian copula therefore understates a far strike spread    /// option for the same reason it understated a senior tranche.    ///    /// Near the money it goes the other way and by much less: the extra mass in    /// the tails has come from somewhere, and it is taken out of the middle.    #[test]    fn the_copula_moves_the_far_strike_spread_option() {        let gaussian = base(Copula::Gaussian);        let student = base(Copula::StudentT { nu: 4.0 });        let ratio = |k: f64| student.price(k, 600_000, 7) / gaussian.price(k, 600_000, 7);         // Slightly cheaper in the middle, where the mass was taken from.        let middle = ratio(0.015);        assert!(            (middle - 0.949).abs() < 0.02,            "at the money the ratio was {middle:.3}, expected about 0.95"        );         // And a large multiple far out, which is where the instrument is a bet        // on the two rates coming apart.        let far = ratio(0.045);        assert!((far - 2.60).abs() < 0.25, "far strike ratio was {far:.3}, expected about 2.6");        assert!(ratio(0.035) > 1.1, "the crossover should be well inside the far strike");    }}