Skip to content
Sarthak Bagaria
All model code

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]    fn a_quanto_is_flat_when_nothing_is_correlated() {        // 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}]"            );        }    }}