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, ×, &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, ×, &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, ×, &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, ×, &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, ×, &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, ×, &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" ); }}