quant/src/crosscurrency.rs
Quanto adjustments, and the inflation convexity that turns out to be one.
//! Quanto adjustments, and the inflation convexity that turns out to be one.//!//! The foreign exchange chapter's claim is that foreign exchange, quanto//! payoffs and inflation are one subject with three names. The exchange rate is//! the price of the foreign numeraire in domestic units; a quanto payoff is one//! settled in the wrong numeraire; and inflation is foreign exchange with the//! real economy as the foreign country, the CPI index as the exchange rate and//! index-linked bonds as the foreign bonds.//!//! The code follows the same line. There is one adjustment here,//!//! ```text//! drift shift = -rho * sigma_foreign * sigma_fx//! ```//!//! and it is applied twice: to an asset settled in a foreign currency, and to//! the real short rate seen from the nominal economy. The second is what makes a//! year-on-year inflation swap disagree with the zero-coupon curve. use crate::pathwise::Rng; // ---------------------------------------------------------------------------// Quanto.// --------------------------------------------------------------------------- /// The forward of an asset that pays in a currency it does not live in.////// A payoff on a foreign asset, settled in domestic currency at a fixed rate,/// has to be valued in the domestic measure, and Girsanov charges for the move:/// the asset's drift picks up `-rho * sigma_asset * sigma_fx`, so its forward is/// scaled by the exponential of that over the life of the trade.////// `rho` is the correlation between the asset and the exchange rate quoted as/// domestic per unit of foreign. A positive correlation means the asset tends to/// rise when the foreign currency does, and the quanto forward is *below* the/// ordinary one: the payoff has been stripped of a currency exposure that was/// worth having.pub fn quanto_forward(forward: f64, rho: f64, sigma_asset: f64, sigma_fx: f64, t: f64) -> f64 { forward * (-rho * sigma_asset * sigma_fx * t).exp()} /// The same adjustment expressed as a rate, which is how a desk quotes it.pub fn quanto_drift(rho: f64, sigma_asset: f64, sigma_fx: f64) -> f64 { -rho * sigma_asset * sigma_fx} // ---------------------------------------------------------------------------// Inflation.// --------------------------------------------------------------------------- /// The Hull-White `B` factor, `(1 - exp(-a*(T-t)))/a`.////// The maturity the model sees, as against the maturity on the term sheet: mean/// reversion at rate `a` caps it at `1/a` however far out the bond matures. The/// limit as `a -> 0` is `T - t`, handled here because the figures let a reader/// drag the mean reversion to zero.pub fn hw_b(a: f64, tenor: f64) -> f64 { if a.abs() < 1e-10 { tenor } else { (1.0 - (-a * tenor).exp()) / a }} /// The breakeven of a zero-coupon inflation swap, as an annually compounded rate.////// The foreign exchange chapter derives this in one line once inflation is/// recognised as foreign exchange: `I_t * P_real(t,T)` is a nominal tradable,/// so the forward index is `I_0 * P_real(0,T) / P_nominal(0,T)` and the/// breakeven is the rate that grows the index to it. There is no convexity here/// and no model — it is a forward, pinned by static replication out of nominal/// and index-linked bonds.pub fn zc_breakeven(p_real: f64, p_nominal: f64, t: f64) -> f64 { (p_real / p_nominal).powf(1.0 / t) - 1.0} /// The parameters of the two-economy Gaussian model the foreign exchange chapter uses.////// Jarrow-Yildirim, which is Hull-White twice over with a lognormal exchange/// rate between them: a nominal short rate, a real short rate, and the CPI index/// linking the two economies exactly as a spot rate links two currencies.#[derive(Clone, Copy, Debug)]pub struct Inflation { /// Nominal mean reversion and volatility. pub a_nominal: f64, pub sigma_nominal: f64, /// Real mean reversion and volatility. pub a_real: f64, pub sigma_real: f64, /// Volatility of the CPI index — the exchange rate's volatility. pub sigma_index: f64, /// Correlation of the nominal rate with the real rate. pub rho_nominal_real: f64, /// Correlation of the real rate with the index. This is the one that carries /// the quanto adjustment. pub rho_real_index: f64,} impl Inflation { /// A plausible calibration, used by the figures and the tests. pub fn typical() -> Self { Inflation { a_nominal: 0.05, sigma_nominal: 0.010, a_real: 0.05, sigma_real: 0.007, sigma_index: 0.012, rho_nominal_real: 0.6, rho_real_index: -0.3, } } /// The mean of the real rate's deviation from its initial forward curve, at /// `start`, under the nominal `end`-forward measure. /// /// Three drifts contribute and it is worth naming them separately, because /// the chapter's argument is about which is which: /// /// * the Hull-White term that keeps the model fitted to its own initial /// curve, present in any one-factor Gaussian model; /// * `-rho_real_index * sigma_real * sigma_index`, the quanto adjustment /// of [`quanto_drift`], here because the real economy is a foreign /// currency and the CPI index is the exchange rate; /// * `-rho_nominal_real * sigma_nominal * sigma_real * B_n(u,end)`, from /// changing to the nominal forward measure — the same correction that /// produces the future-forward basis of the term structure chapter. /// /// Integrated numerically against the mean-reversion kernel. The integrand is /// smooth and one-dimensional, so this is exact to many more digits than the /// model is worth, and it keeps the code readable where a closed form would /// be four lines of unverifiable exponentials. pub fn real_mean(&self, start: f64, end: f64, steps: usize) -> f64 { self.kernel_integral(start, steps, |u, this| { this.hw_fitting_drift(this.a_real, this.sigma_real, u) - this.rho_real_index * this.sigma_real * this.sigma_index - this.rho_nominal_real * this.sigma_nominal * this.sigma_real * hw_b(this.a_nominal, end - u) }, self.a_real) } /// The same for the nominal rate, which has no quanto term: it is the home /// economy, and there is nothing to convert it into. pub fn nominal_mean(&self, start: f64, end: f64, steps: usize) -> f64 { self.kernel_integral(start, steps, |u, this| { this.hw_fitting_drift(this.a_nominal, this.sigma_nominal, u) - this.sigma_nominal * this.sigma_nominal * hw_b(this.a_nominal, end - u) }, self.a_nominal) } /// The drift a Hull-White model carries to stay fitted to the curve it was /// built from, `sigma^2 (1 - exp(-2 a u)) / (2 a)`. fn hw_fitting_drift(&self, a: f64, sigma: f64, u: f64) -> f64 { if a.abs() < 1e-10 { sigma * sigma * u } else { sigma * sigma * (1.0 - (-2.0 * a * u).exp()) / (2.0 * a) } } /// `integral of exp(-a(start-u)) * f(u) du` over `[0, start]`, by Simpson. fn kernel_integral( &self, start: f64, steps: usize, f: impl Fn(f64, &Self) -> f64, a: f64, ) -> f64 { let n = steps.max(2) & !1; // Simpson needs an even number of intervals. let h = start / n as f64; let mut total = 0.0; for i in 0..=n { let u = i as f64 * h; let weight = if i == 0 || i == n { 1.0 } else if i % 2 == 1 { 4.0 } else { 2.0 }; total += weight * (-a * (start - u)).exp() * f(u, self); } total * h / 3.0 } /// Variance of a rate's deviation at `t`. Unaffected by every drift above, /// which is why the measure changes move the answer without changing the /// shape of the distribution. fn variance(&self, a: f64, sigma: f64, t: f64) -> f64 { if a.abs() < 1e-10 { sigma * sigma * t } else { sigma * sigma * (1.0 - (-2.0 * a * t).exp()) / (2.0 * a) } } /// Covariance of the two rates' deviations at `t`. fn covariance(&self, t: f64) -> f64 { let sum = self.a_nominal + self.a_real; let kernel = if sum.abs() < 1e-10 { t } else { (1.0 - (-sum * t).exp()) / sum }; self.rho_nominal_real * self.sigma_nominal * self.sigma_real * kernel } /// The convexity multiplier on a year-on-year inflation swap payment. /// /// A year-on-year swap pays `I(end)/I(start) - 1` at `end`. The naive /// answer is the ratio of the zero-coupon forwards, and it is wrong. The /// foreign exchange chapter derives why: conditioning on the start date /// turns the payment into /// /// ```text /// E^end[ I(end)/I(start) ] = E^end[ P_real(start,end) / P_nominal(start,end) ] /// ``` /// /// so what has to be valued is a *future* bond price ratio, and that needs a /// model. Under this one both rates are Gaussian at the start date, the ratio /// is lognormal in them, and the whole correction collapses to /// /// ```text /// C = exp( -B_r m_r + B_n m_n + B_n^2 V_n - B_r B_n Cov ) /// ``` /// /// where the deterministic halves of the two reconstitution formulas have /// already cancelled against half the variances. The quanto adjustment sits /// inside `m_r` and nowhere else, so setting `rho_real_index` to zero removes /// it and leaves the ordinary forward-measure convexity behind. /// /// Returns the factor the naive forward is multiplied by; 1.0 is no /// adjustment. pub fn yoy_convexity(&self, start: f64, end: f64) -> f64 { const STEPS: usize = 400; let b_r = hw_b(self.a_real, end - start); let b_n = hw_b(self.a_nominal, end - start); let m_r = self.real_mean(start, end, STEPS); let m_n = self.nominal_mean(start, end, STEPS); let v_n = self.variance(self.a_nominal, self.sigma_nominal, start); let cov = self.covariance(start); (-b_r * m_r + b_n * m_n + b_n * b_n * v_n - b_r * b_n * cov).exp() } /// The year-on-year breakeven for one period, in absolute rate terms. /// /// `p_real` and `p_nominal` are each `(start, end)` discount factors off the /// two zero-coupon curves. Pass a model with every volatility zero to get the /// naive answer the convexity is measured against. pub fn yoy_breakeven( &self, p_real: (f64, f64), p_nominal: (f64, f64), start: f64, end: f64, ) -> f64 { let naive = (p_real.1 / p_real.0) * (p_nominal.0 / p_nominal.1); (naive * self.yoy_convexity(start, end) - 1.0) / (end - start) } /// The same convexity, simulated rather than solved. /// /// Only used to check [`Inflation::yoy_convexity`]. The two share no algebra /// beyond the drifts themselves: this one steps both rates forward under the /// nominal `end`-forward measure and averages the bond price ratio it finds, /// so agreement is evidence and not a restatement. pub fn yoy_convexity_simulated( &self, start: f64, end: f64, paths: usize, steps: usize, seed: u64, ) -> f64 { let dt = start / steps as f64; let sqrt_dt = dt.sqrt(); let b_r = hw_b(self.a_real, end - start); let b_n = hw_b(self.a_nominal, end - start); // The deterministic halves of the two reconstitution formulas. let det = -0.5 * b_r * b_r * self.variance(self.a_real, self.sigma_real, start) + 0.5 * b_n * b_n * self.variance(self.a_nominal, self.sigma_nominal, start); // Cholesky of the two-by-two correlation, so one normal drives the // nominal rate and a correlated combination drives the real one. let rho = self.rho_nominal_real; let orthogonal = (1.0 - rho * rho).max(0.0).sqrt(); let mut rng = Rng::new(seed); let mut total = 0.0; for _ in 0..paths { let (mut y_n, mut y_r) = (0.0f64, 0.0f64); for step in 0..steps { let u = step as f64 * dt; let (z1, z2) = (rng.next_normal(), rng.next_normal()); let dw_n = sqrt_dt * z1; let dw_r = sqrt_dt * (rho * z1 + orthogonal * z2); let drift_n = self.hw_fitting_drift(self.a_nominal, self.sigma_nominal, u) - self.sigma_nominal * self.sigma_nominal * hw_b(self.a_nominal, end - u) - self.a_nominal * y_n; let drift_r = self.hw_fitting_drift(self.a_real, self.sigma_real, u) - self.rho_real_index * self.sigma_real * self.sigma_index - self.rho_nominal_real * self.sigma_nominal * self.sigma_real * hw_b(self.a_nominal, end - u) - self.a_real * y_r; y_n += drift_n * dt + self.sigma_nominal * dw_n; y_r += drift_r * dt + self.sigma_real * dw_r; } total += (det - b_r * y_r + b_n * y_n).exp(); } total / paths as f64 }} #[cfg(test)]mod tests { use super::*; #[test] // No correlation, no adjustment: the payoff being settled elsewhere // costs nothing if the elsewhere does not move with it. let f = quanto_forward(100.0, 0.0, 0.25, 0.10, 2.0); assert!((f - 100.0).abs() < 1e-12); } #[test] fn positive_correlation_lowers_the_quanto_forward() { // An asset that rises when the foreign currency rises is worth more to a // domestic holder who keeps the currency exposure. Stripping it out, as // a quanto does, has to lower the forward. let plain = 100.0; let up = quanto_forward(plain, 0.5, 0.25, 0.10, 1.0); let down = quanto_forward(plain, -0.5, 0.25, 0.10, 1.0); assert!(up < plain, "positive correlation gave {up}"); assert!(down > plain, "negative correlation gave {down}"); // And symmetric in log space, since the adjustment is a drift. assert!(((up / plain).ln() + (down / plain).ln()).abs() < 1e-12); } #[test] fn the_quanto_adjustment_is_the_size_a_desk_quotes() { // 25% asset vol, 10% currency vol, 50% correlated, one year: 1.25% of // the forward. Small enough to be ignored on a short trade and not on a // long one, which is why it is quoted as a drift rather than a price. let shift = quanto_drift(0.5, 0.25, 0.10); assert!((shift + 0.0125).abs() < 1e-12, "drift shift {shift}"); let f = quanto_forward(100.0, 0.5, 0.25, 0.10, 1.0); assert!((f - 100.0 * (-0.0125f64).exp()).abs() < 1e-12); } #[test] fn hw_b_degrades_to_the_tenor_without_mean_reversion() { assert!((hw_b(0.0, 7.0) - 7.0).abs() < 1e-12); assert!((hw_b(1e-12, 7.0) - 7.0).abs() < 1e-6); // And is capped by 1/a however long the bond. assert!(hw_b(0.05, 200.0) < 20.0); } #[test] fn the_zero_coupon_breakeven_is_just_the_ratio_of_curves() { // No model in it at all: nominal and index-linked bonds replicate it. let (t, real_rate, nominal_rate) = (10.0f64, 0.005f64, 0.030f64); let p_real = (-real_rate * t).exp(); let p_nominal = (-nominal_rate * t).exp(); let breakeven = zc_breakeven(p_real, p_nominal, t); // Continuously compounded 2.5% expressed annually. let expected = (0.025f64).exp() - 1.0; assert!((breakeven - expected).abs() < 1e-12, "breakeven {breakeven}"); } #[test] fn the_closed_form_convexity_matches_a_simulation() { // The test the rest of the chapter rests on. The closed form and the // Monte Carlo share the drifts and nothing else -- one solves the // Gaussian integral, the other steps both rates forward and averages -- // so agreeing is evidence rather than a restatement. let base = Inflation::typical(); let cases = [ base, Inflation { rho_real_index: 0.0, ..base }, Inflation { rho_real_index: 0.6, ..base }, Inflation { rho_nominal_real: -0.4, sigma_index: 0.02, ..base }, Inflation { sigma_nominal: 0.0, sigma_index: 0.015, rho_real_index: 0.5, ..base }, ]; for (i, model) in cases.iter().enumerate() { let (start, end) = (5.0, 6.0); let closed = model.yoy_convexity(start, end); let simulated = model.yoy_convexity_simulated(start, end, 120_000, 160, 20260804); assert!( (closed - simulated).abs() < 6e-5, "case {i}: closed {closed} against simulated {simulated}" ); } } #[test] fn the_quanto_term_is_the_only_thing_the_index_correlation_touches() { // The foreign exchange chapter's claim in one assertion: rho_real_index // enters the answer through the quanto drift alone, so the difference // between two models differing only in it is exactly that drift // integrated up. let base = Inflation { rho_real_index: 0.0, ..Inflation::typical() }; let (start, end) = (5.0, 6.0); let without = base.yoy_convexity(start, end); for rho in [-0.6, -0.3, 0.3, 0.6] { let with = Inflation { rho_real_index: rho, ..base }.yoy_convexity(start, end); // The extra drift is a constant -rho*sigma_r*sigma_i, so integrating // it against the kernel gives it times B(0,start), and it enters the // answer multiplied by -B_r(start,end). let predicted = (hw_b(base.a_real, end - start) * hw_b(base.a_real, start) * rho * base.sigma_real * base.sigma_index) .exp(); assert!( (with / without - predicted).abs() < 1e-9, "rho={rho}: ratio {} against predicted {predicted}", with / without ); } } #[test] fn a_model_with_no_volatility_has_no_convexity() { // The naive answer, recovered. Worth pinning because it is the baseline // every other number in the chapter is quoted against. let still = Inflation { a_nominal: 0.05, sigma_nominal: 0.0, a_real: 0.05, sigma_real: 0.0, sigma_index: 0.0, rho_nominal_real: 0.0, rho_real_index: 0.0, }; assert!((still.yoy_convexity(5.0, 6.0) - 1.0).abs() < 1e-12); } #[test] fn the_convexity_is_monotone_in_the_index_correlation() { let base = Inflation::typical(); let mut previous = f64::NEG_INFINITY; for rho in [-0.8, -0.4, 0.0, 0.4, 0.8] { // Positive correlation between the real rate and the index pushes // the real rate down under the nominal measure, which raises real // bond prices and so raises the year-on-year payment. let c = Inflation { rho_real_index: rho, ..base }.yoy_convexity(5.0, 6.0); assert!(c > previous, "not increasing in rho at {rho}"); previous = c; } } #[test] fn the_convexity_grows_with_how_far_out_the_period_starts() { // Because every term carries B(0,start): there is more time for the // measure changes to have moved the rates by the time the period opens. let base = Inflation::typical(); let front = (base.yoy_convexity(1.0, 2.0) - 1.0).abs(); let back = (base.yoy_convexity(20.0, 21.0) - 1.0).abs(); assert!(back > 5.0 * front, "front {front}, back {back}"); } #[test] fn the_convexity_is_worth_basis_points_not_rounding_error() { // The number that decides whether any of this is worth writing down. A // twenty year year-on-year payment at a plausible calibration, against // the zero-coupon curve that ignores all of it. let flat_real = |t: f64| (-0.005f64 * t).exp(); let flat_nominal = |t: f64| (-0.030f64 * t).exp(); let model = Inflation::typical(); let still = Inflation { sigma_nominal: 0.0, sigma_real: 0.0, sigma_index: 0.0, ..model }; for (start, end, lo, hi) in [(2.0, 3.0, 0.05, 2.0), (20.0, 21.0, 2.0, 40.0)] { let curves = ( (flat_real(start), flat_real(end)), (flat_nominal(start), flat_nominal(end)), ); let with = model.yoy_breakeven(curves.0, curves.1, start, end); let without = still.yoy_breakeven(curves.0, curves.1, start, end); let basis_points = (without - with) * 1e4; assert!( basis_points > lo && basis_points < hi, "at {start}y the convexity was {basis_points} bp, outside [{lo}, {hi}]" ); } }}