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