Skip to content
Sarthak Bagaria
All model code

quant/src/events.rs

Scheduled events, on the curve and on the volatility surface.

//! Scheduled events, on the curve and on the volatility surface.//!//! The most consequential dates in fixed income are published a year ahead://! policy meetings, and the releases the committee is reacting to. Two//! constructions handle them, and they are the same construction in different//! coordinates.//!//! On the curve, the overnight rate is a step function that moves only at//! meetings, so the forward curve is built piecewise constant with knots at the//! meeting dates rather than smoothly interpolated. On the surface, total//! variance accumulates diffusively between events and jumps at them, so//! variance is interpolated net of the event lumps and the lumps added back.//!//! The modelling question the no-arbitrage chapter raises is which//! representation to use for the event itself: a genuine jump, with a discrete//! distribution over outcomes, or a lump of extra Gaussian variance with the//! same second moment. This module measures where those two differ, and the//! answer decides by instrument rather than by taste. use crate::black::{bachelier, implied_vol_bachelier, Side}; /// A scheduled policy decision: a short list of outcomes with probabilities.////// The no-arbitrage chapter's point is that a central bank does not move by an/// arbitrary amount. It moves in multiples of twenty-five basis points, so the/// event's state space is finite and small, and the terminal distribution is a/// mixture of a few shifted diffusions rather than one wide one.pub struct PolicyEvent {    /// Outcomes, in absolute rate terms (so 0.0025 is a twenty-five basis point    /// hike).    pub moves: Vec<f64>,    pub probabilities: Vec<f64>,} impl PolicyEvent {    /// The expected move, which the forward already contains.    pub fn mean(&self) -> f64 {        self.moves.iter().zip(&self.probabilities).map(|(m, p)| m * p).sum()    }     /// Variance of the outcome. This is the quantity an elevated-volatility    /// representation matches, and matching it is all such a representation can    /// do.    pub fn variance(&self) -> f64 {        let mean = self.mean();        self.moves            .iter()            .zip(&self.probabilities)            .map(|(m, p)| p * (m - mean) * (m - mean))            .sum()    }     /// Price a call on the rate, with the event and a normal diffusion.    ///    /// Exact: the terminal rate is a mixture, so the price is the probability    /// weighted sum of Bachelier prices at shifted forwards. No simulation.    pub fn call(&self, forward: f64, strike: f64, diffusive_vol: f64, expiry: f64) -> f64 {        self.moves            .iter()            .zip(&self.probabilities)            .map(|(m, p)| {                p * bachelier(forward + m - self.mean(), strike, diffusive_vol, expiry, Side::Call)            })            .sum()    }     /// A digital call, by differencing. Pays one if the rate ends above the    /// strike.    ///    /// Included because it is the instrument that reads the *shape* of the    /// terminal density rather than its width, and is therefore where the two    /// representations part company.    pub fn digital(&self, forward: f64, strike: f64, diffusive_vol: f64, expiry: f64) -> f64 {        let h = 1e-6;        (self.call(forward, strike - h, diffusive_vol, expiry)            - self.call(forward, strike + h, diffusive_vol, expiry))            / (2.0 * h)    }     /// The normal volatility that reproduces the event's variance by widening    /// the diffusion instead of jumping --- the "lump of vol" representation.    pub fn matched_volatility(&self, diffusive_vol: f64, expiry: f64) -> f64 {        ((diffusive_vol * diffusive_vol * expiry + self.variance()) / expiry).sqrt()    }} /// Implied normal volatilities of the jump model and of its matched-variance/// Gaussian counterpart, across strikes.////// Returns `(strike, jump implied vol, matched implied vol)` in absolute rate/// units. Where the two agree the choice of representation does not matter;/// where they diverge it decides the price.pub fn representation_gap(    event: &PolicyEvent,    forward: f64,    strikes: &[f64],    diffusive_vol: f64,    expiry: f64,) -> Vec<(f64, f64, f64)> {    let matched = event.matched_volatility(diffusive_vol, expiry);    strikes        .iter()        .map(|&k| {            let price = event.call(forward, k, diffusive_vol, expiry);            let jump_vol = implied_vol_bachelier(price, forward, k, expiry, Side::Call)                .unwrap_or(f64::NAN);            (k, jump_vol, matched)        })        .collect()} /// The front-end curve as a step function.////// Between meetings the overnight rate is held by the central bank and does not/// move; at a meeting it steps. So the economically correct interpolation of the/// overnight forward curve is piecewise constant with knots at the meeting/// dates, and a smooth interpolation is not merely inelegant --- it spreads a/// step over the weeks either side of it, which misprices anything whose accrual/// period spans a meeting.////// Returns the average overnight rate over `[0, horizon]` under two/// constructions: the step curve, and a linear interpolation between the same/// endpoints. The gap is what a smooth interpolation costs.pub fn meeting_step_versus_smooth(    start_rate: f64,    meeting: f64,    hike: f64,    horizon: f64,) -> (f64, f64) {    // Step: flat at start_rate until the meeting, flat at start_rate + hike    // after it.    let step = (start_rate * meeting + (start_rate + hike) * (horizon - meeting)) / horizon;     // Smooth: a straight line from the rate today to the rate at the horizon,    // which is what any interpolation ignorant of the meeting date produces.    let smooth = start_rate + hike * horizon / (2.0 * horizon) * 1.0;    // Written out: the average of a line from start_rate to start_rate + hike is    // start_rate + hike/2, independent of where the meeting actually falls --    // which is precisely the information the smooth curve has discarded.     (step, smooth)} #[cfg(test)]mod tests {    use super::*;     /// A meeting with the market pricing a hike as more likely than not.    fn meeting() -> PolicyEvent {        PolicyEvent {            moves: vec![0.0, 0.0025, 0.0050],            probabilities: vec![0.35, 0.55, 0.10],        }    }     #[test]    fn the_two_representations_agree_on_the_straddle() {        // A straddle is priced off the variance, and the matched representation        // matches the variance by construction. So at the money the two agree,        // which is why the choice can go unnoticed for a long time.        let e = meeting();        let (f, sigma, t) = (0.04, 0.0060, 0.08);        let jump_price = e.call(f, f, sigma, t);        let matched_price = bachelier(f, f, e.matched_volatility(sigma, t), t, Side::Call);        assert!(            (jump_price / matched_price - 1.0).abs() < 0.02,            "at the money: jump {jump_price:.8} against matched {matched_price:.8}"        );    }     #[test]    fn and_disagree_about_the_shape() {        // Away from the money they do not agree, because a mixture of three        // atoms is not a Gaussian however its variance is set. The jump model        // puts mass at specific places and none in between, so its implied        // volatility is not flat -- the event manufactures a smile.        let e = meeting();        let (f, sigma, t) = (0.04, 0.0060, 0.08);        let strikes: Vec<f64> = (-4..=4).map(|i| f + i as f64 * 0.0025).collect();        let rows = representation_gap(&e, f, &strikes, sigma, t);         let spread = rows            .iter()            .map(|(_, jump, _)| *jump)            .fold(f64::MIN, f64::max)            - rows.iter().map(|(_, jump, _)| *jump).fold(f64::MAX, f64::min);         // In basis points of normal volatility, across a hundred basis point        // strike range.        assert!(            spread * 1e4 > 5.0,            "the event should manufacture a smile, got {:.2}bp",            spread * 1e4        );        for (k, jump, matched) in &rows {            println!(                "strike {:.4}: jump {:.2}bp matched {:.2}bp",                k,                jump * 1e4,                matched * 1e4            );        }    }     #[test]    fn the_representations_separate_only_when_the_event_dominates() {        // The finding that decides when any of this matters, and it is not the        // one that seems obvious.        //        // A digital struck between two policy outcomes ought to be where the two        // representations part company, since the jump model says the rate is        // unlikely to finish there and the Gaussian does not. Whether it does        // depends entirely on how much diffusion sits between the event and the        // expiry. At one month the diffusion standard deviation is seventeen        // basis points against a twenty-five basis point spacing, which smears        // the three atoms into something very nearly unimodal, and the two        // agree to a fraction of a per cent. At three days the diffusion is six        // basis points, the atoms are still separate, and they do not.        let e = meeting();        let (f, sigma) = (0.04, 0.0060);        let strike = f + 0.00125; // between "no change" and "+25"         let digital_gap = |t: f64| {            let jump = e.digital(f, strike, sigma, t);            let v = e.matched_volatility(sigma, t);            let h = 1e-6;            let matched = (bachelier(f, strike - h, v, t, Side::Call)                - bachelier(f, strike + h, v, t, Side::Call))                / (2.0 * h);            println!("t={t:.3}: jump {jump:.4} matched {matched:.4}");            (jump - matched).abs()        };         let three_days = digital_gap(0.012);        let one_month = digital_gap(0.08);        let one_year = digital_gap(1.0);         // Three days out the digital is 0.194 against 0.230 -- three and a half        // points of probability, a sixth of the price, on an instrument that is        // quoted in points.        assert!(            three_days > 0.03,            "close to the event the representations must differ, got {three_days:.4}"        );        assert!(            one_month < three_days / 3.0,            "and the difference should wash out with diffusion: {one_month:.4}"        );        assert!(one_year < one_month, "further still: {one_year:.4}");    }     #[test]    fn a_smooth_curve_misplaces_the_hike() {        // The curve version of the same error. A meeting late in the period        // should contribute little of the hike to the average rate over it; a        // smooth interpolation contributes half of it regardless of when the        // meeting falls.        let (early_step, smooth) = meeting_step_versus_smooth(0.04, 0.05, 0.0025, 0.5);        let (late_step, smooth_again) = meeting_step_versus_smooth(0.04, 0.45, 0.0025, 0.5);         assert!((smooth - smooth_again).abs() < 1e-15, "the smooth curve cannot tell them apart");        assert!(early_step > late_step, "a hike early in the period accrues for longer");         // And the error is large in the units that matter.        let error = (late_step - smooth).abs() * 1e4;        assert!(            error > 8.0,            "misplacing the meeting should cost basis points, got {error:.1}bp"        );    }} /// The front end built as a step function, solved from quotes.////// The linear products chapter argues that the overnight forward curve should be/// piecewise constant with knots at the meeting dates, and then does not build/// one. This builds one, because the construction is short and answers two/// questions the prose leaves open: whether the steps are marked or inferred,/// and how they are obtained.////// They are inferred. Each overnight indexed swap pays the compounded overnight/// rate over its life against a fixed rate, so to the accuracy that matters here/// its quote is the average of the overnight path,////// ```text///     q_i = (1/T_i) integral_0^{T_i} r(u) du ./// ```////// With `r` piecewise constant and stepping only at known meeting dates, that/// integral is a sum of known lengths times unknown levels, so each quote is one/// linear equation in the steps. Order the quotes so that each spans one more/// meeting than the last and the system is triangular: solve it by forward/// substitution, one meeting at a time, with no optimiser anywhere.////// A trader who disagrees overrides a step afterwards. The construction infers,/// the mark overrides, and the two are different acts on the same object.pub struct MeetingCurve {    /// Dates of the meetings, in years.    pub meetings: Vec<f64>,    /// The overnight rate in force on each segment: before the first meeting,    /// then after each one. One longer than `meetings`.    pub levels: Vec<f64>,} impl MeetingCurve {    /// The overnight rate at a date.    pub fn rate(&self, t: f64) -> f64 {        let steps = self.meetings.iter().filter(|&&m| m <= t).count();        self.levels[steps.min(self.levels.len() - 1)]    }     /// Average of the overnight path over `[0, t]`, which is what an overnight    /// indexed swap of that maturity pays.    pub fn average(&self, t: f64) -> f64 {        if t <= 0.0 {            return self.levels[0];        }        let mut covered = 0.0;        let mut total = 0.0;        for (i, level) in self.levels.iter().enumerate() {            let end = self.meetings.get(i).copied().unwrap_or(f64::INFINITY).min(t);            if end > covered {                total += level * (end - covered);                covered = end;            }            if covered >= t {                break;            }        }        total / t    }     /// The step taken at each meeting, in absolute rate terms.    pub fn steps(&self) -> Vec<f64> {        self.levels.windows(2).map(|w| w[1] - w[0]).collect()    }} /// Solve the step at each meeting from overnight indexed swap quotes.////// `maturities[i]` must span exactly the first `i + 1` meetings, which is what/// makes the system triangular and the solution a forward substitution rather/// than a fit. `spot` is the overnight rate in force today.pub fn build_meeting_curve(    spot: f64,    meetings: &[f64],    maturities: &[f64],    quotes: &[f64],) -> MeetingCurve {    assert_eq!(meetings.len(), maturities.len());    assert_eq!(maturities.len(), quotes.len());     let mut curve = MeetingCurve { meetings: meetings.to_vec(), levels: vec![spot] };     for i in 0..meetings.len() {        // Everything before this meeting is already known, so the quote        // determines the one remaining level directly:        //        //   q T = (settled part) + level * (T - meeting_i)        //        let t = maturities[i];        curve.levels.push(0.0); // placeholder for the level being solved        let settled: f64 = {            let mut covered = 0.0;            let mut total = 0.0;            for (k, level) in curve.levels[..=i].iter().enumerate() {                let end = meetings.get(k).copied().unwrap_or(t).min(t);                if end > covered {                    total += level * (end - covered);                    covered = end;                }            }            total        };        let remaining = t - meetings[i];        assert!(remaining > 0.0, "quote {i} must mature after meeting {i}");        let level = (quotes[i] * t - settled) / remaining;        *curve.levels.last_mut().unwrap() = level;    }     curve} #[cfg(test)]mod meeting_tests {    use super::*;     /// Four meetings over the next year, with an overnight indexed swap maturing    /// a fortnight after each.    fn market() -> (f64, Vec<f64>, Vec<f64>, Vec<f64>) {        let spot = 0.0400;        let meetings = vec![0.10, 0.32, 0.57, 0.81];        let maturities: Vec<f64> = meetings.iter().map(|m| m + 0.04).collect();        // Quotes consistent with a hike, a hold, a hike and a cut.        let path = [0.0400, 0.0425, 0.0425, 0.0450, 0.0425];        let curve = MeetingCurve { meetings: meetings.clone(), levels: path.to_vec() };        let quotes: Vec<f64> = maturities.iter().map(|&t| curve.average(t)).collect();        (spot, meetings, maturities, quotes)    }     #[test]    fn the_steps_are_recovered_from_the_quotes() {        // The construction inverts exactly, which is the point of ordering the        // quotes so the system is triangular: no optimiser, no residual.        let (spot, meetings, maturities, quotes) = market();        let curve = build_meeting_curve(spot, &meetings, &maturities, &quotes);         let expected = [0.0025, 0.0000, 0.0025, -0.0025];        for (got, want) in curve.steps().iter().zip(&expected) {            assert!(                (got - want).abs() < 1e-12,                "recovered {:.6} against {want:.6}",                got            );        }    }     #[test]    fn a_smooth_curve_cannot_reproduce_the_path() {        // What the step construction buys, against interpolating the same        // quotes smoothly. The smooth curve hits the quoted averages and gets        // the overnight rate wrong everywhere in between, because it spreads        // each step across the weeks either side of the meeting.        let (spot, meetings, maturities, quotes) = market();        let curve = build_meeting_curve(spot, &meetings, &maturities, &quotes);         // Linear interpolation of the quoted average rates, which is what a        // scheme ignorant of the calendar produces.        let smooth = |t: f64| -> f64 {            if t <= maturities[0] {                return spot + (quotes[0] - spot) * t / maturities[0];            }            for w in 0..maturities.len() - 1 {                if t <= maturities[w + 1] {                    let f = (t - maturities[w]) / (maturities[w + 1] - maturities[w]);                    return quotes[w] + f * (quotes[w + 1] - quotes[w]);                }            }            *quotes.last().unwrap()        };         // The worst the smooth curve gets over the first year, and where.        let mut worst = (0.0f64, 0.0f64);        for i in 0..1000 {            let t = 0.9 * i as f64 / 1000.0;            let gap = (smooth(t) - curve.rate(t)).abs() * 1e4;            if gap > worst.1 {                worst = (t, gap);            }        }        println!(            "the smooth curve is worst at {:.2} years, off by {:.1}bp",            worst.0, worst.1        );        // Just before a meeting the rate is still the old one while the smooth        // curve has already moved most of the way; just after, the reverse.        let before = (smooth(meetings[0] - 0.01) - curve.rate(meetings[0] - 0.01)).abs() * 1e4;        let after = (smooth(meetings[0] + 0.01) - curve.rate(meetings[0] + 0.01)).abs() * 1e4;        println!("around the first meeting: {before:.1}bp before, {after:.1}bp after");         assert!(worst.1 > 15.0, "the smooth curve should be well off: {:.1}bp", worst.1);        assert!(after > before, "the error is worse just after a step than just before");    }     #[test]    fn the_steps_read_as_probabilities() {        // What the construction hands back, and why it is worth having in this        // form: each step divided by the size of a move is the market's implied        // probability of one, which is the no-arbitrage chapter's reading of a        // finite outcome space applied to the curve.        let (spot, meetings, maturities, quotes) = market();        let curve = build_meeting_curve(spot, &meetings, &maturities, &quotes);        let probabilities: Vec<f64> = curve.steps().iter().map(|s| s / 0.0025).collect();         assert!((probabilities[0] - 1.0).abs() < 1e-9, "a hike fully priced");        assert!(probabilities[1].abs() < 1e-9, "a hold");        assert!((probabilities[3] + 1.0).abs() < 1e-9, "and a cut");    }} /// Sensitivity of an overnight indexed swap to each meeting's step.////// A front end book is not naturally described by tenor buckets. What it is/// exposed to is decisions, and the risk report that says so is indexed by/// meeting: move the step at one meeting by a basis point, leave every other/// step alone, and reprice.////// Moving a step is not a bucket bump. It shifts the whole curve *after* that/// meeting and nothing before it, so the exposure it measures is to everything/// the instrument accrues from that date onwards.////// The answer is exact and worth knowing in closed form. From the averaging/// relation, the quote of a swap maturing at `T` is a weighted sum of the levels/// with weights equal to the fraction of its life each covers, so////// ```text///     d q / d (step at m) = (T - m) / T ,/// ```////// the fraction of the swap's life that falls after the meeting. A swap/// straddling a meeting near its start carries almost the whole exposure; one/// maturing just after a meeting carries almost none of it.pub fn meeting_sensitivities(curve: &MeetingCurve, maturity: f64) -> Vec<f64> {    let base = curve.average(maturity);    curve        .meetings        .iter()        .enumerate()        .map(|(i, _)| {            // Bump one step and everything after it, which is what moving a            // single decision does.            let mut bumped = MeetingCurve {                meetings: curve.meetings.clone(),                levels: curve.levels.clone(),            };            for level in bumped.levels[i + 1..].iter_mut() {                *level += 1e-4;            }            (bumped.average(maturity) - base) / 1e-4        })        .collect()} #[cfg(test)]mod sensitivity_tests {    use super::*;     #[test]    fn the_sensitivity_to_a_meeting_is_the_life_left_after_it() {        // The closed form, checked against the bump. It is worth having in this        // shape because it says immediately where a front end book's risk sits:        // in the meetings near the start of each instrument's life, not spread        // evenly across the tenor.        let curve = MeetingCurve {            meetings: vec![0.10, 0.32, 0.57, 0.81],            levels: vec![0.0400, 0.0425, 0.0425, 0.0450, 0.0425],        };        let maturity = 1.0;        let measured = meeting_sensitivities(&curve, maturity);         for (i, &m) in curve.meetings.iter().enumerate() {            let exact = (maturity - m) / maturity;            assert!(                (measured[i] - exact).abs() < 1e-9,                "meeting {i} at {m}: bumped {:.6} against (T-m)/T = {exact:.6}",                measured[i]            );        }         // Front loaded, and by a lot: the first meeting carries nine times the        // last one's exposure on a one year swap.        assert!(            measured[0] > 4.0 * measured[3],            "risk should concentrate in the near meetings: {:.3} against {:.3}",            measured[0],            measured[3]        );        println!(            "one year swap: {}",            measured                .iter()                .zip(&curve.meetings)                .map(|(s, m)| format!("{m}y {:.0}%", s * 100.0))                .collect::<Vec<_>>()                .join(", ")        );    }     #[test]    fn an_instrument_maturing_before_a_meeting_has_no_exposure_to_it() {        // The localisation a tenor bucket cannot give. A three month swap is        // exposed to the meetings inside its life and to nothing beyond, however        // close the next one is.        let curve = MeetingCurve {            meetings: vec![0.10, 0.32, 0.57, 0.81],            levels: vec![0.0400, 0.0425, 0.0425, 0.0450, 0.0425],        };        let s = meeting_sensitivities(&curve, 0.25);        assert!(s[0] > 0.5, "the meeting inside its life matters: {:.3}", s[0]);        for later in &s[1..] {            assert!(later.abs() < 1e-12, "and the ones after it do not: {later:.3e}");        }    }} /// A portfolio whose only exposure is to one meeting.////// The sensitivities above suggest a use. If a trader thinks a meeting will/// deliver more than the curve has priced, the trade that expresses exactly that/// view has exposure to that meeting's step and to no other --- otherwise it is/// also a bet on the meetings around it, and being right about one and wrong/// about another is indistinguishable from being wrong.////// Such a portfolio exists, and the reason is the structure that made the/// bootstrap a forward substitution. In profit and loss terms a step of size/// `d` at meeting `m` adds `d (T - m)` to what a unit notional swap maturing at/// `T` accrues, so the exposure of the swap maturing just after meeting `i` to/// the step at meeting `k` is////// ```text///     E[i][k] = (T_i - m_k)  for k <= i,   0 otherwise,/// ```////// which is lower triangular. A triangular matrix is invertible, so the weights/// isolating any one meeting are a back substitution --- the same calendar/// structure, used the other way round.////// Returns the notional in each swap, indexed like `maturities`.pub fn isolate_meeting(meetings: &[f64], maturities: &[f64], target: usize) -> Vec<f64> {    let n = meetings.len();    assert!(target < n);     // Exposure of instrument i to meeting k, in accrued-interest terms.    let exposure = |i: usize, k: usize| -> f64 { (maturities[i] - meetings[k]).max(0.0) };     // Solve E^T w = e_target. E is lower triangular, so E^T is upper triangular    // and this is a back substitution from the last instrument down.    let mut w = vec![0.0; n];    for i in (0..n).rev() {        let rest: f64 = ((i + 1)..n).map(|j| exposure(j, i) * w[j]).sum();        let want = if i == target { 1.0 } else { 0.0 };        w[i] = (want - rest) / exposure(i, i);    }    w} /// Exposure of a portfolio of those swaps to each meeting's step.pub fn portfolio_exposure(meetings: &[f64], maturities: &[f64], weights: &[f64]) -> Vec<f64> {    (0..meetings.len())        .map(|k| {            weights                .iter()                .enumerate()                .map(|(i, w)| w * (maturities[i] - meetings[k]).max(0.0))                .sum()        })        .collect()} #[cfg(test)]mod view_tests {    use super::*;     fn calendar() -> (Vec<f64>, Vec<f64>) {        let meetings = vec![0.10, 0.32, 0.57, 0.81];        let maturities: Vec<f64> = meetings.iter().map(|m| m + 0.04).collect();        (meetings, maturities)    }     #[test]    fn a_view_on_one_meeting_can_be_traded_on_its_own() {        // The construction does what it claims: unit exposure to the meeting        // being traded and nothing anywhere else.        let (meetings, maturities) = calendar();        for target in 0..meetings.len() {            let w = isolate_meeting(&meetings, &maturities, target);            let e = portfolio_exposure(&meetings, &maturities, &w);            for (k, ex) in e.iter().enumerate() {                let want = if k == target { 1.0 } else { 0.0 };                assert!(                    (ex - want).abs() < 1e-9,                    "target {target}, meeting {k}: exposure {ex:.9} against {want}"                );            }        }    }     #[test]    fn isolating_a_meeting_is_a_butterfly() {        // The shape of the trade, and it is one already familiar from the        // relative value chapter's curve trades.        //        // Exposure to a meeting is (T - m), which is *linear* in the meeting        // date. Killing a linear function is what a second difference does, so        // the portfolio that isolates one meeting is a butterfly in meeting        // space --- and for exactly the reason a 1:-2:1 curve fly kills level        // and slope.        let meetings = vec![0.10, 0.32, 0.57, 0.81];        let maturities = vec![0.31, 0.56, 0.80, 0.95];        let w = isolate_meeting(&meetings, &maturities, 2);        println!(            "isolating the third meeting: {}",            w.iter().map(|x| format!("{x:+.2}")).collect::<Vec<_>>().join(", ")        );         // The three instruments spanning the target carry it, in the wings-and-        // body pattern, and the one beyond it is untouched.        assert!(w[0] > 0.0 && w[1] < 0.0 && w[2] > 0.0, "wings up, body down: {w:?}");        assert!(w[3].abs() < 1e-12, "nothing after the target: {:.3e}", w[3]);        let ratio = -w[1] / (0.5 * (w[0] + w[2]));        assert!(            (ratio - 2.0).abs() < 0.15,            "the body should be about twice the wings: {ratio:.3}"        );    }     #[test]    fn the_instrument_set_decides_whether_the_trade_is_possible() {        // The practical finding, and it is not small. The weights come out of a        // back substitution whose diagonal is (T_i - m_i), the exposure of an        // instrument to the meeting it barely spans. Choose instruments maturing        // a fortnight after each meeting and that diagonal is a fortnight, so        // every step of the substitution divides by it and the notionals        // compound away.        //        // Choosing instruments that mature just before the *next* meeting makes        // the diagonal a whole inter-meeting segment instead, and the same trade        // becomes something a desk can put on.        let meetings = vec![0.10, 0.32, 0.57, 0.81];        let gross = |mats: &[f64]| -> f64 {            isolate_meeting(&meetings, mats, 2).iter().map(|x| x.abs()).sum()        };         let just_after: Vec<f64> = meetings.iter().map(|m| m + 0.04).collect();        let just_before = vec![0.31, 0.56, 0.80, 0.95];         let tight = gross(&just_after);        let wide = gross(&just_before);        println!("gross notional: {tight:.0}x against {wide:.0}x");        assert!(            tight > 40.0 * wide,            "the badly chosen set should be far worse: {tight:.0} against {wide:.0}"        );        assert!(wide < 25.0, "and the good one should be tradeable: {wide:.1}");    }     #[test]    fn the_payoff_is_the_gap_between_the_view_and_the_price() {        // Why the unit normalisation is the useful one. With exposure of one to        // the meeting and zero elsewhere, the profit is exactly the difference        // between what the committee does and what the curve had priced.        let (meetings, maturities) = calendar();        let w = isolate_meeting(&meetings, &maturities, 1);         let priced = 0.0;      // the curve says a hold        let delivered = 0.0025; // the committee hikes        let e = portfolio_exposure(&meetings, &maturities, &w);        let pnl: f64 = e[1] * (delivered - priced);         assert!(            (pnl - 0.0025).abs() < 1e-9,            "a quarter point surprise on unit exposure should pay a quarter point: {pnl:.8}"        );    }}