Skip to content
Sarthak Bagaria
All model code

quant/src/cms.rs

Pricing a CMS payoff out of the swaption smile, without a term structure model.

//! Pricing a CMS payoff out of the swaption smile, without a term structure model.//!//! The CMS chapter's claim, and the reason the chapter sits after the smile//! rather than in the term structure chapter with Hull-White: a payment of a//! swap rate on the wrong date needs a convexity adjustment, and that//! adjustment can be read straight off one expiry's swaption smile by static//! replication. No short rate model, no calibration to a term structure of//! volatility, no dynamics at all.//!//! The machinery is the local volatility chapter's, transplanted. There, a//! European payoff was rebuilt from a strip of calls and the density fell out//! of the second derivative in strike. Here the same identity rebuilds the CMS//! payoff from a strip of swaptions, and what falls out is an expectation under//! the wrong measure, corrected. use crate::sabr::Sabr; /// The linear terminal swap rate map, `a(t) = u t + v`.////// The annuity mapping function is `M(s, t) = a(t)(s - S0) + P(0,t)/Ann(0)`, and/// the CMS chapter derives three conditions it must satisfy. The first fixes the/// constant term. The other two pin `u` and `v`:////// ```text///     sum_i delta_i a(tau_i) = 0          (the annuity is the sum of its bonds)///     a(T) - a(tau_N) = 1                 (the swap rate is what it is defined as)/// ```////// The sign of the second is worth being careful about, because getting it/// backwards flips the CMS adjustment from positive to negative and the error is/// invisible until someone asks which way convexity should go. The floating leg/// of a swap starting at `T` is worth `1 - P(T,tau_N)`, not the other way round.////// Two equations, two unknowns, and no model anywhere in them.#[derive(Clone, Copy, Debug)]pub struct LinearTsr {    pub u: f64,    pub v: f64,} impl LinearTsr {    /// Solve the two conditions for a swap starting at `start` with fixed leg    /// payment dates `times` and accruals `accruals`.    pub fn new(start: f64, times: &[f64], accruals: &[f64]) -> Self {        assert_eq!(times.len(), accruals.len());        let last = *times.last().expect("a swap has at least one payment");         // Condition 3: a(start) - a(tau_N) = 1, and a is affine, so the slope is        // fixed by the length of the swap alone. Negative, so that `a` is        // positive at the front of the swap where CMS actually pays.        let u = -1.0 / (last - start);         // Condition 2: the accrual-weighted sum of a over the payment dates is        // zero, which places v.        let weighted: f64 = times.iter().zip(accruals).map(|(t, d)| d * t).sum();        let total: f64 = accruals.iter().sum();        let v = -u * weighted / total;         LinearTsr { u, v }    }     pub fn a(&self, t: f64) -> f64 {        self.u * t + self.v    }} /// Undiscounted swaption prices per unit annuity, read off a smile.////// `call` and `put` are `E[(S-K)+]` and `E[(K-S)+]` in the annuity measure --/// exactly what the swaption market quotes, once the annuity is divided out.fn call(smile: &Sabr, forward: f64, strike: f64, expiry: f64) -> f64 {    match smile.implied_vol(forward, strike, expiry) {        Some(vol) => crate::black::black76(forward, strike, vol, expiry, crate::black::Side::Call),        None => 0.0,    }} fn put(smile: &Sabr, forward: f64, strike: f64, expiry: f64) -> f64 {    // Put-call parity in the annuity measure, where the swap rate is a    // martingale and the forward is its own expectation.    call(smile, forward, strike, expiry) + strike - forward} /// `E[g(S)]` for a payoff whose second derivative in strike is `weight`.////// The static replication identity of the CMS chapter, in the annuity measure:////// ```text///     E[g(S)] = g(F) + integral over K of g''(K) * (call or put at K)/// ```////// with puts used below the forward and calls above, since each is the cheaper/// and better quoted of the pair there. Nothing about the distribution of `S` is/// assumed --- only that the smile supplies a price at every strike.pub fn replicate(    smile: &Sabr,    forward: f64,    expiry: f64,    at_forward: f64,    weight: impl Fn(f64) -> f64,    upper: f64,    steps: usize,) -> f64 {    let n = steps.max(2) & !1;     // Below the forward, integrate puts down to zero strike.    let mut lower_leg = 0.0;    let h_low = forward / n as f64;    for i in 0..=n {        let k = i as f64 * h_low;        if k <= 0.0 {            continue;        }        let w = if i == 0 || i == n {            1.0        } else if i % 2 == 1 {            4.0        } else {            2.0        };        lower_leg += w * weight(k) * put(smile, forward, k, expiry);    }    lower_leg *= h_low / 3.0;     // Above the forward, integrate calls out to a strike far enough that the    // remaining mass is negligible.    let mut upper_leg = 0.0;    let h_high = (upper - forward) / n as f64;    for i in 0..=n {        let k = forward + i as f64 * h_high;        let w = if i == 0 || i == n {            1.0        } else if i % 2 == 1 {            4.0        } else {            2.0        };        upper_leg += w * weight(k) * call(smile, forward, k, expiry);    }    upper_leg *= h_high / 3.0;     at_forward + lower_leg + upper_leg} /// The variance of the swap rate under the annuity measure, from the smile alone.////// Replication of `g(s) = s^2`, whose second derivative is the constant `2`. The/// result is the same object a variance swap pays: the smile integrated, with no/// model in between.pub fn annuity_variance(smile: &Sabr, forward: f64, expiry: f64, upper: f64, steps: usize) -> f64 {    let second_moment = replicate(        smile,        forward,        expiry,        forward * forward,        |_| 2.0,        upper,        steps,    );    second_moment - forward * forward} /// The CMS convexity adjustment, in rate terms.////// A swap rate paid on its own annuity is a martingale and needs no correction./// Paid as a single cashflow on `pay`, it is being valued under a different/// numeraire, and the difference is////// ```text///     E^pay[S(T)] - S(0) = (Ann(0) / P(0,pay)) * a(pay) * Var^Ann(S(T))./// ```////// Everything on the right is observable: the annuity and the discount factor/// come from the curve construction chapter's curve, `a` from the three/// conditions, and the variance from the smile. The adjustment is positive/// whenever `a` is, which is the familiar statement that CMS is long convexity.#[allow(clippy::too_many_arguments)]pub fn convexity_adjustment(    smile: &Sabr,    forward: f64,    expiry: f64,    tsr: &LinearTsr,    pay: f64,    annuity: f64,    discount: f64,    upper: f64,    steps: usize,) -> f64 {    let variance = annuity_variance(smile, forward, expiry, upper, steps);    annuity / discount * tsr.a(pay) * variance} /// A CMS caplet, by replication.////// The same correction applied to an option rather than to the rate:////// ```text///     E^pay[(S-K)+] = C(K) + (Ann/P) a(pay) [ 2 * integral of C above K///                                             + (K - S0) C(K) ]./// ```////// The integral of calls above `K` is `E[((S-K)+)^2]`, by the same replication/// identity with a second derivative that switches on at `K`. Setting `K` to/// zero recovers the swaplet, which is the check worth doing.#[allow(clippy::too_many_arguments)]pub fn cms_caplet(    smile: &Sabr,    forward: f64,    expiry: f64,    strike: f64,    tsr: &LinearTsr,    pay: f64,    annuity: f64,    discount: f64,    upper: f64,    steps: usize,) -> f64 {    let intrinsic = call(smile, forward, strike, expiry);     // 2 * integral of calls from the strike upwards.    let n = steps.max(2) & !1;    let h = (upper - strike).max(0.0) / n as f64;    let mut tail = 0.0;    for i in 0..=n {        let k = strike + i as f64 * h;        let w = if i == 0 || i == n {            1.0        } else if i % 2 == 1 {            4.0        } else {            2.0        };        tail += w * call(smile, forward, k, expiry);    }    tail *= 2.0 * h / 3.0;     intrinsic + annuity / discount * tsr.a(pay) * (tail + (strike - forward) * intrinsic)} #[cfg(test)]mod tests {    use super::*;     const FORWARD: f64 = 0.03;    const EXPIRY: f64 = 5.0;    const UPPER: f64 = 0.40;    const STEPS: usize = 4000;     /// Roughly a 30% lognormal volatility at a 3% forward. `alpha` is not a    /// volatility when `beta < 1` -- it carries different units -- so it has to    /// be set through the at-the-money level rather than guessed.    fn smile(nu: f64) -> Sabr {        Sabr { alpha: 0.05, beta: 0.5, rho: -0.30, nu }    }     /// A ten year annual swap starting in five years.    fn schedule() -> (f64, Vec<f64>, Vec<f64>) {        let start = 5.0;        let times: Vec<f64> = (1..=10).map(|i| start + i as f64).collect();        let accruals = vec![1.0; 10];        (start, times, accruals)    }     #[test]    fn the_map_satisfies_the_conditions_it_was_derived_from() {        // Not a tautology: the coefficients are solved from the two conditions        // in one form and checked here in the other.        let (start, times, accruals) = schedule();        let tsr = LinearTsr::new(start, &times, &accruals);         let weighted: f64 = times.iter().zip(&accruals).map(|(t, d)| d * tsr.a(*t)).sum();        assert!(weighted.abs() < 1e-12, "accrual weighted sum was {weighted}");         let span = tsr.a(start) - tsr.a(*times.last().unwrap());        assert!((span - 1.0).abs() < 1e-12, "a(T) - a(tau_N) was {span}");         // And the consequence that matters: `a` is positive where CMS pays, so        // the convexity adjustment comes out positive. With the sign the other        // way it does not, and nothing else in the calculation notices.        assert!(tsr.a(start) > 0.0, "a at the swap start was {}", tsr.a(start));    }     #[test]    fn replication_recovers_a_lognormal_variance() {        // The check that the replication integral is right, against a case with        // an answer in closed form. A flat smile is a lognormal swap rate, whose        // variance is F^2 (exp(sigma^2 T) - 1); the replication never learns        // that and has to find it from prices alone.        let flat = Sabr { alpha: 0.20, beta: 1.0, rho: 0.0, nu: 1e-9 };        let vol = flat            .implied_vol(FORWARD, FORWARD, EXPIRY)            .expect("a volatility at the money");        let analytic = FORWARD * FORWARD * ((vol * vol * EXPIRY).exp() - 1.0);        let replicated = annuity_variance(&flat, FORWARD, EXPIRY, UPPER, 20_000);        assert!(            (replicated / analytic - 1.0).abs() < 2e-3,            "replicated {replicated} against analytic {analytic}"        );    }     #[test]    fn the_adjustment_is_positive_and_grows_with_volatility() {        // CMS is long convexity: the payment ignores that a higher swap rate        // discounts its own annuity more heavily, and the holder is paid for it.        let (start, times, accruals) = schedule();        let tsr = LinearTsr::new(start, &times, &accruals);        let (annuity, discount) = (8.0, 0.85);         let mut previous = 0.0;        for nu in [0.10, 0.30, 0.60] {            let adjustment = convexity_adjustment(                &smile(nu), FORWARD, EXPIRY, &tsr, 5.0, annuity, discount, UPPER, STEPS,            );            assert!(adjustment > 0.0, "adjustment was {adjustment} at nu={nu}");            assert!(adjustment > previous, "not increasing in nu at {nu}");            previous = adjustment;        }    }     #[test]    fn the_adjustment_vanishes_without_volatility() {        // With no uncertainty there is no convexity to be paid for, and the        // whole correction has to disappear -- a check that nothing constant has        // been left in the formula.        let still = Sabr { alpha: 1e-7, beta: 0.5, rho: 0.0, nu: 1e-9 };        let (start, times, accruals) = schedule();        let tsr = LinearTsr::new(start, &times, &accruals);        let adjustment = convexity_adjustment(            &still, FORWARD, EXPIRY, &tsr, 5.0, 8.0, 0.85, UPPER, STEPS,        );        assert!(adjustment.abs() < 1e-8, "adjustment was {adjustment}");    }     #[test]    fn a_zero_strike_caplet_is_the_swaplet() {        // The two formulas are derived separately and must meet: a caplet struck        // at zero pays the rate itself, so it has to equal the forward plus the        // convexity adjustment.        let (start, times, accruals) = schedule();        let tsr = LinearTsr::new(start, &times, &accruals);        let (annuity, discount, pay) = (8.0, 0.85, 5.0);        let s = smile(0.40);         let caplet = cms_caplet(            &s, FORWARD, EXPIRY, 1e-9, &tsr, pay, annuity, discount, UPPER, 20_000,        );        let swaplet = FORWARD            + convexity_adjustment(&s, FORWARD, EXPIRY, &tsr, pay, annuity, discount, UPPER, 20_000);        assert!(            (caplet - swaplet).abs() < 2e-5,            "caplet {caplet} against swaplet {swaplet}"        );    }     #[test]    fn delaying_the_payment_works_against_the_convexity() {        // Worth pinning because the naive guess is the other way round. `a(t)`        // decreases across the swap -- it is positive at the front and negative        // at the back -- so pushing the payment later shrinks the adjustment        // rather than growing it.        //        // The reason is that a delayed cashflow is discounted by a bond that        // itself falls when rates rise, which pulls against the convexity the        // annuity mismatch created. The market calls the two pieces the        // convexity and timing adjustments, and they have opposite signs.        let (start, times, accruals) = schedule();        let tsr = LinearTsr::new(start, &times, &accruals);        let s = smile(0.40);         assert!(tsr.u < 0.0, "a was not decreasing across the swap");         let at_start = convexity_adjustment(&s, FORWARD, EXPIRY, &tsr, 5.0, 8.0, 0.85, UPPER, STEPS);        let delayed = convexity_adjustment(&s, FORWARD, EXPIRY, &tsr, 6.0, 8.0, 0.82, UPPER, STEPS);        assert!(            delayed < at_start,            "delayed {delayed} was not below {at_start}"        );        assert!(delayed > 0.0, "the timing effect should not overwhelm it: {delayed}");    }     #[test]    fn the_adjustment_is_worth_tens_of_basis_points() {        // The number that decides whether any of this matters. A five year        // expiry into a ten year swap, at a thirty percent volatility.        let (start, times, accruals) = schedule();        let tsr = LinearTsr::new(start, &times, &accruals);        let bp = convexity_adjustment(            &smile(0.40), FORWARD, EXPIRY, &tsr, 5.0, 8.0, 0.85, UPPER, STEPS,        ) * 1e4;        assert!(            bp > 10.0 && bp < 100.0,            "adjustment was {bp} bp, which is not the order claimed"        );    }} /// The static replication identity, evaluated pointwise.////// The CMS chapter's theorem states that for twice differentiable `g` and any/// reference level `f`,////// ```text///     g(s) = g(f) + g'(f)(s-f)///          + integral_f^infinity g''(k) (s-k)^+ dk///          + integral_0^f      g''(k) (k-s)^+ dk ./// ```////// It is an identity in `s`, not an approximation, and it is the foundation of/// everything else in that chapter --- so it is worth checking directly rather/// than only through the prices it produces. Returns the right-hand side, by/// quadrature over the strikes.pub fn replication_identity(    payoff: impl Fn(f64) -> f64,    second_derivative: impl Fn(f64) -> f64,    first_derivative_at_f: f64,    f: f64,    s: f64,    upper: f64,    nodes: usize,) -> f64 {    let mut total = payoff(f) + first_derivative_at_f * (s - f);     // Calls, from f upwards. Midpoint rule.    let dk = (upper - f) / nodes as f64;    for i in 0..nodes {        let k = f + (i as f64 + 0.5) * dk;        total += second_derivative(k) * (s - k).max(0.0) * dk;    }     // Puts, from zero up to f.    let dk = f / nodes as f64;    for i in 0..nodes {        let k = (i as f64 + 0.5) * dk;        total += second_derivative(k) * (k - s).max(0.0) * dk;    }     total} #[cfg(test)]mod identity_tests {    use super::*;     #[test]    fn the_replication_identity_holds_pointwise() {        // Three payoffs with different curvature signs, each checked across a        // range of underlying levels either side of the reference. Nothing about        // the identity is approximate, so the only error should be the quadrature.        let f = 0.03;        let cases: [(&str, fn(f64) -> f64, fn(f64) -> f64, f64); 3] = [            // A quadratic: constant second derivative, the simplest non-trivial case.            ("s^2", |s| s * s, |_| 2.0, 2.0 * 0.03),            // A cube: second derivative grows, so the wings matter.            ("s^3", |s| s * s * s, |k| 6.0 * k, 3.0 * 0.03 * 0.03),            // Something not polynomial, with curvature of one sign throughout.            ("exp(10 s)", |s| (10.0 * s).exp(), |k| 100.0 * (10.0 * k).exp(), 10.0 * (10.0 * 0.03f64).exp()),        ];         for (name, payoff, second, slope) in cases {            for s in [0.005, 0.02, 0.03, 0.045, 0.08] {                let rebuilt = replication_identity(payoff, second, slope, f, s, 0.5, 400_000);                let exact = payoff(s);                assert!(                    (rebuilt - exact).abs() < 1e-6 * exact.abs().max(1e-6),                    "{name} at s={s}: rebuilt {rebuilt:.10} against {exact:.10}"                );            }        }    }     #[test]    fn the_reference_level_is_arbitrary() {        // A property worth confirming because the chapter uses it: the identity        // holds for any expansion point, and the choice of the forward is a        // convenience that makes the linear term vanish in expectation rather        // than a requirement of the algebra.        let payoff = |s: f64| s * s;        for f in [0.01, 0.03, 0.06] {            for s in [0.005, 0.03, 0.09] {                let rebuilt =                    replication_identity(payoff, |_| 2.0, 2.0 * f, f, s, 0.5, 400_000);                assert!(                    (rebuilt - payoff(s)).abs() < 1e-9,                    "f={f}, s={s}: {rebuilt:.12} against {:.12}",                    payoff(s)                );            }        }    }     #[test]    fn a_kinked_payoff_needs_only_the_call_leg() {        // The degenerate case the chapter relies on without stating: for        // g(s) = (s-k0)^+ the second derivative is a delta at k0, and the        // identity reduces to the tautology that a call is a call. Approximating        // the delta by a narrow bump recovers it, which is a check that the        // integral is doing what it claims rather than being fitted.        let (f, k0, width) = (0.03, 0.04, 1e-5);        let bump = |k: f64| {            if (k - k0).abs() < width {                0.5 / width            } else {                0.0            }        };        for s in [0.02, 0.035, 0.05, 0.07] {            let rebuilt = replication_identity(|_| 0.0, bump, 0.0, f, s, 0.5, 2_000_000);            let exact = (s - k0).max(0.0);            assert!(                (rebuilt - exact).abs() < 1e-4,                "s={s}: rebuilt {rebuilt:.8} against the call {exact:.8}"            );        }    }} /// The annuity mapping a one-factor model gives, without any linearity assumed.////// The CMS chapter defines the mapping as a conditional expectation and then/// assumes it affine. In a one-factor model no conditioning is needed at all:/// the whole curve at the expiry is a deterministic function of the single/// state, so the swap rate and the bond-to-annuity ratio are both explicit/// functions of it, and eliminating the state between them gives the mapping/// exactly.////// Hull-White supplies the curve in exponential-affine form,////// ```text///     P(T, tau) = (P(0,tau)/P(0,T)) exp(-B(tau-T) x - B(tau-T)^2 V / 2),/// ```////// with `B(u) = (1 - exp(-kappa u))/kappa` and `V` the variance of the state at/// `T`. A flat initial curve is used here because the mapping's shape is what is/// being examined and an initial curve of any shape only moves it rigidly.#[derive(Clone, Copy, Debug)]pub struct HullWhiteTsr {    pub kappa: f64,    pub sigma: f64,    pub expiry: f64,    /// Flat continuously compounded initial rate.    pub flat_rate: f64,    pub accrual: f64,    pub periods: usize,    /// Payment date, as a year fraction after the expiry.    pub delay: f64,} impl HullWhiteTsr {    fn b(&self, u: f64) -> f64 {        if self.kappa.abs() < 1e-9 {            u        } else {            (1.0 - (-self.kappa * u).exp()) / self.kappa        }    }     fn state_variance(&self) -> f64 {        let k = self.kappa;        if k.abs() < 1e-9 {            self.sigma * self.sigma * self.expiry        } else {            self.sigma * self.sigma * (1.0 - (-2.0 * k * self.expiry).exp()) / (2.0 * k)        }    }     /// `P(T, T + u)` in state `x`.    fn bond(&self, x: f64, u: f64) -> f64 {        let b = self.b(u);        let v = self.state_variance();        (-self.flat_rate * u - b * x - 0.5 * b * b * v).exp()    }     /// The swap rate and the mapping value at one state of the world.    pub fn at_state(&self, x: f64) -> (f64, f64) {        let annuity: f64 = (1..=self.periods)            .map(|i| self.accrual * self.bond(x, self.accrual * i as f64))            .sum();        let last = self.bond(x, self.accrual * self.periods as f64);        let swap = (1.0 - last) / annuity;        (swap, self.bond(x, self.delay) / annuity)    }     /// `M(s)`, by inverting the swap rate in the state.    ///    /// The swap rate is *increasing* in `x`: a higher state lowers every bond,    /// which both raises the numerator and shrinks the annuity.    pub fn mapping(&self, swap: f64) -> f64 {        let sd = self.state_variance().sqrt();        let (mut lo, mut hi) = (-8.0 * sd, 8.0 * sd);        for _ in 0..200 {            let mid = 0.5 * (lo + hi);            if self.at_state(mid).0 < swap {                lo = mid;            } else {                hi = mid;            }        }        self.at_state(0.5 * (lo + hi)).1    }     /// The slope of the mapping at the forward, which is what the linear model    /// calls `a`.    pub fn slope(&self) -> f64 {        let (s0, _) = self.at_state(0.0);        let h = 1e-4;        (self.mapping(s0 + h) - self.mapping(s0 - h)) / (2.0 * h)    }     /// How far the exact mapping departs from its own best straight line over a    /// band of swap rates either side of the forward, as a fraction of the    /// mapping's value at the forward.    pub fn departure_from_linear(&self, band: f64, points: usize) -> f64 {        let (s0, m0) = self.at_state(0.0);        let rates: Vec<f64> =            (0..=points).map(|i| s0 - band + 2.0 * band * i as f64 / points as f64).collect();        let values: Vec<f64> = rates.iter().map(|&s| self.mapping(s)).collect();         let n = rates.len() as f64;        let mx = rates.iter().sum::<f64>() / n;        let my = values.iter().sum::<f64>() / n;        let sxx: f64 = rates.iter().map(|s| (s - mx).powi(2)).sum();        let sxy: f64 = rates.iter().zip(&values).map(|(s, m)| (s - mx) * (m - my)).sum();        let slope = sxy / sxx;         rates            .iter()            .zip(&values)            .map(|(s, m)| (m - (my + slope * (s - mx))).abs())            .fold(0.0f64, f64::max)            / m0    }} #[cfg(test)]mod terminal_swap_rate_tests {    use super::*;     fn model(kappa: f64) -> HullWhiteTsr {        HullWhiteTsr {            kappa,            sigma: 0.01,            expiry: 5.0,            flat_rate: 0.04,            accrual: 1.0,            periods: 10,            delay: 0.0,        }    }     /// The mapping exists as a function at all, which is the one-factor property:    /// the swap rate is monotone in the state, so the state can be eliminated.    #[test]    fn the_swap_rate_is_monotone_in_the_state() {        let m = model(0.03);        let mut previous = f64::MIN;        for i in 0..=100 {            let x = -0.05 + 0.1 * i as f64 / 100.0;            let (s, _) = m.at_state(x);            assert!(s > previous, "the swap rate fell at x = {x}");            previous = s;        }    }     /// What the CMS chapter's assumption is actually worth. Over a band of two    /// hundred basis points either side of the forward, the exact mapping is    /// within a fifth of a per cent of a straight line — so linearity is a good    /// description of the shape, better than the chapter's caution implies.    #[test]    fn the_exact_mapping_is_very_nearly_linear() {        let departure = model(0.03).departure_from_linear(0.02, 200);        assert!(            departure < 0.003,            "the exact mapping departed from a line by {departure:.4} of its level"        );        assert!((departure - 0.0016).abs() < 0.0008, "departure was {departure:.5}");    }     /// And where the uncertainty actually sits. The shape is nearly a line, but    /// which line depends on the mean reversion, and that is the parameter the    /// quasi-Gaussian chapter calls the largest model risk in a callable book and    /// which no vanilla identifies.    #[test]    fn the_slope_is_a_question_about_mean_reversion() {        let flat = model(0.001).slope();        let usual = model(0.03).slope();        let strong = model(0.10).slope();         assert!(flat < usual && usual < strong, "{flat} {usual} {strong}");        assert!((flat - 0.627).abs() < 0.01, "slope at no mean reversion was {flat:.4}");        assert!((strong - 0.697).abs() < 0.01, "slope at kappa = 0.10 was {strong:.4}");        // Eleven per cent across a plausible range, against a shape error of a        // fifth of one per cent.        assert!((strong / flat - 1.112).abs() < 0.02, "ratio was {:.4}", strong / flat);    }} #[cfg(test)]mod mapping_shape_tests {    use super::*;     fn model(delay: f64) -> HullWhiteTsr {        HullWhiteTsr {            kappa: 0.03,            sigma: 0.01,            expiry: 5.0,            flat_rate: 0.04,            accrual: 1.0,            periods: 10,            delay,        }    }     /// Where the curvature does matter: far out, which is where a replication    /// puts its weight. Negligible near the money and growing roughly with the    /// square of the distance.    #[test]    fn the_curvature_only_appears_in_the_wings() {        let m = model(0.0);        let near = m.departure_from_linear(0.01, 400);        let far = m.departure_from_linear(0.06, 400);        assert!((near - 0.0004).abs() < 0.0002, "at 100bp the departure was {near:.5}");        assert!((far - 0.0148).abs() < 0.002, "at 600bp the departure was {far:.5}");        // Six times the distance, well over an order of magnitude the error.        assert!(far / near > 20.0, "ratio was {:.1}", far / near);    }     /// And where it does not. A payment delay makes the mapping flatter and    /// straighter, not steeper and more curved: the delayed bond falls with the    /// rate as well, which partly offsets the annuity that is falling under it.    #[test]    fn a_payment_delay_flattens_the_mapping() {        let (immediate, delayed) = (model(0.0), model(2.0));        assert!(            delayed.slope() < immediate.slope(),            "delay raised the slope, {:.4} against {:.4}",            delayed.slope(),            immediate.slope()        );        assert!((immediate.slope() - 0.641).abs() < 0.01);        assert!((delayed.slope() - 0.346).abs() < 0.01);        assert!(            delayed.departure_from_linear(0.02, 400) < immediate.departure_from_linear(0.02, 400),            "delay increased the curvature"        );    }}