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] // 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"); }}