use crate::models::forex::fx_hhw::FxHhwParams;
use num_complex::Complex64;
#[derive(Copy, Clone, Debug)]
pub struct ChfComponents {
pub a: Complex64,
pub b: Complex64,
pub c: Complex64,
}
impl ChfComponents {
pub fn assemble(&self, x: f64, sigma: f64) -> Complex64 {
(self.a + self.b * x + self.c * sigma).exp()
}
}
pub struct FxHhw1ForwardChf<'a> {
params: &'a FxHhwParams,
pub expiry: f64,
pub n_simpson: usize,
grid: SimpsonGrid,
}
#[derive(Clone, Debug)]
struct SimpsonGrid {
h: f64,
phi: Vec<f64>,
bd: Vec<f64>,
bf: Vec<f64>,
zeta_no_phi: Vec<f64>,
zeta_phi_coef: Vec<f64>,
}
impl SimpsonGrid {
fn build(params: &FxHhwParams, expiry: f64, n: usize) -> Self {
let h = expiry / n as f64;
let mut phi = Vec::with_capacity(n + 1);
let mut bd = Vec::with_capacity(n + 1);
let mut bf = Vec::with_capacity(n + 1);
let mut zeta_no_phi = Vec::with_capacity(n + 1);
let mut zeta_phi_coef = Vec::with_capacity(n + 1);
let eta_d = params.domestic.sigma;
let eta_f = params.foreign.sigma;
let rho = ¶ms.correlations;
for k in 0..=n {
let s = k as f64 * h;
let p = params.heston.sqrt_mean(s);
let bds = params.domestic.b(s, expiry);
let bfs = params.foreign.b(s, expiry);
phi.push(p);
bd.push(bds);
bf.push(bfs);
zeta_no_phi.push(
rho.rho_d_f * eta_d * eta_f * bds * bfs
- 0.5 * (eta_d * eta_d * bds * bds + eta_f * eta_f * bfs * bfs),
);
zeta_phi_coef.push(rho.rho_xi_d * eta_d * bds - rho.rho_xi_f * eta_f * bfs);
}
Self {
h,
phi,
bd,
bf,
zeta_no_phi,
zeta_phi_coef,
}
}
}
impl<'a> FxHhw1ForwardChf<'a> {
pub fn new(params: &'a FxHhwParams, expiry: f64) -> Self {
Self::with_simpson_steps(params, expiry, 128)
}
pub fn with_simpson_steps(params: &'a FxHhwParams, expiry: f64, n: usize) -> Self {
assert!(n >= 2 && n.is_multiple_of(2), "n_simpson must be even");
let grid = SimpsonGrid::build(params, expiry, n);
Self {
params,
expiry,
n_simpson: n,
grid,
}
}
pub fn params(&self) -> &'a FxHhwParams {
self.params
}
pub fn components(&self, u: Complex64, tau: f64) -> ChfComponents {
assert!(tau >= 0.0 && tau <= self.expiry + 1e-12);
let b = Complex64::new(0.0, 1.0) * u;
let c = c_of_tau(u, tau, self.params);
let a = if (tau - self.expiry).abs() < 1e-12 {
a_of_tau_cached(u, self.params, &self.grid)
} else {
a_of_tau(u, tau, self.expiry, self.params, self.n_simpson)
};
ChfComponents { a, b, c }
}
pub fn evaluate(&self, u: Complex64) -> Complex64 {
let t = self.expiry;
let comps = self.components(u, t);
let p = self.params;
let pd0t = (-p.rd_0 * t).exp();
let pf0t = (-p.rf_0 * t).exp();
let x0 = (p.fx_0 * pf0t / pd0t).ln();
comps.assemble(x0, p.heston.sigma_0)
}
}
fn c_of_tau(u: Complex64, tau: f64, params: &FxHhwParams) -> Complex64 {
if tau <= 0.0 {
return Complex64::new(0.0, 0.0);
}
let kappa = Complex64::new(params.heston.kappa, 0.0);
let gamma = Complex64::new(params.heston.gamma, 0.0);
let rho = Complex64::new(params.correlations.rho_xi_sigma, 0.0);
let iu = Complex64::new(0.0, 1.0) * u;
let b_sq_minus_b = iu * iu - iu; let x = kappa - gamma * rho * iu;
let d = (x * x - gamma * gamma * iu * (iu - Complex64::new(1.0, 0.0))).sqrt();
let exp_dtau = (-d * tau).exp();
b_sq_minus_b * (Complex64::new(1.0, 0.0) - exp_dtau) / (x + d - (x - d) * exp_dtau)
}
fn a_of_tau(u: Complex64, tau: f64, big_t: f64, params: &FxHhwParams, n: usize) -> Complex64 {
if tau <= 0.0 {
return Complex64::new(0.0, 0.0);
}
let h = tau / n as f64;
let mut acc = Complex64::new(0.0, 0.0);
for k in 0..=n {
let s = k as f64 * h;
let weight = if k == 0 || k == n {
1.0
} else if k % 2 == 1 {
4.0
} else {
2.0
};
acc += weight * integrand(u, s, big_t, params);
}
acc * (h / 3.0)
}
fn a_of_tau_cached(u: Complex64, params: &FxHhwParams, grid: &SimpsonGrid) -> Complex64 {
let n = grid.phi.len() - 1;
let iu = Complex64::new(0.0, 1.0) * u;
let one_minus_iu = Complex64::new(1.0, 0.0) - iu;
let u2_plus_iu = u * u + iu;
let kappa_theta = params.heston.kappa * params.heston.theta;
let gamma = params.heston.gamma;
let eta_d = params.domestic.sigma;
let eta_f = params.foreign.sigma;
let rho = ¶ms.correlations;
let mut acc = Complex64::new(0.0, 0.0);
for k in 0..=n {
let s = k as f64 * grid.h;
let c = c_of_tau(u, s, params);
let phi_k = grid.phi[k];
let bd_k = grid.bd[k];
let bf_k = grid.bf[k];
let zeta_k = grid.zeta_no_phi[k] + grid.zeta_phi_coef[k] * phi_k;
let term1 = Complex64::new(kappa_theta, 0.0) * c;
let term2 =
Complex64::new(rho.rho_sigma_d * gamma * eta_d * phi_k * bd_k, 0.0) * c * one_minus_iu;
let term3 = Complex64::new(rho.rho_sigma_f * gamma * eta_f * phi_k * bf_k, 0.0) * c * iu;
let term4 = u2_plus_iu * zeta_k;
let weight = if k == 0 || k == n {
1.0
} else if k % 2 == 1 {
4.0
} else {
2.0
};
acc += weight * (term1 + term2 + term3 + term4);
}
acc * (grid.h / 3.0)
}
fn integrand(u: Complex64, s: f64, big_t: f64, params: &FxHhwParams) -> Complex64 {
let c = c_of_tau(u, s, params);
let iu = Complex64::new(0.0, 1.0) * u;
let one_minus_iu = Complex64::new(1.0, 0.0) - iu;
let u2_plus_iu = u * u + iu;
let phi = params.heston.sqrt_mean(s);
let bd = params.domestic.b(s, big_t);
let bf = params.foreign.b(s, big_t);
let eta_d = params.domestic.sigma;
let eta_f = params.foreign.sigma;
let rho = ¶ms.correlations;
let kappa_theta = params.heston.kappa * params.heston.theta;
let term1 = Complex64::new(kappa_theta, 0.0) * c;
let term2 = Complex64::new(
rho.rho_sigma_d * params.heston.gamma * eta_d * phi * bd,
0.0,
) * c
* one_minus_iu;
let term3 = Complex64::new(
rho.rho_sigma_f * params.heston.gamma * eta_f * phi * bf,
0.0,
) * c
* iu;
let zeta = (rho.rho_xi_d * eta_d * bd - rho.rho_xi_f * eta_f * bf) * phi
+ rho.rho_d_f * eta_d * eta_f * bd * bf
- 0.5 * (eta_d * eta_d * bd * bd + eta_f * eta_f * bf * bf);
let term4 = u2_plus_iu * zeta;
term1 + term2 + term3 + term4
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::common::cir::CirProcess;
use crate::models::forex::fx_hhw::{Correlation4x4, FxHhwParams, FxHhwSimulator};
use crate::models::interestrate::hull_white::HullWhite1F;
fn paper_params() -> FxHhwParams {
FxHhwParams {
fx_0: 1.35,
heston: CirProcess {
kappa: 0.5,
theta: 0.1,
gamma: 0.3,
sigma_0: 0.1,
},
domestic: HullWhite1F {
mean_reversion: 0.01,
sigma: 0.007,
},
foreign: HullWhite1F {
mean_reversion: 0.05,
sigma: 0.012,
},
rd_0: 0.02,
rf_0: 0.05,
theta_d: 0.02,
theta_f: 0.05,
correlations: Correlation4x4 {
rho_xi_sigma: -0.40,
rho_xi_d: -0.15,
rho_xi_f: -0.15,
rho_sigma_d: 0.30,
rho_sigma_f: 0.30,
rho_d_f: 0.25,
},
}
}
#[test]
fn chf_at_zero_frequency_is_one() {
let p = paper_params();
for &t in &[0.25_f64, 1.0, 5.0, 10.0] {
let chf = FxHhw1ForwardChf::new(&p, t);
let v = chf.evaluate(Complex64::new(0.0, 0.0));
assert!(
(v.re - 1.0).abs() < 1e-10 && v.im.abs() < 1e-10,
"T={}: φ(0) = {} + {}i",
t,
v.re,
v.im
);
}
}
#[test]
fn chf_at_zero_tau_is_pure_phase() {
let p = paper_params();
let t = 2.0;
let chf = FxHhw1ForwardChf::new(&p, t);
let u = Complex64::new(0.5, 0.0);
let comps = chf.components(u, 0.0);
assert!(comps.a.norm() < 1e-15);
assert!(comps.c.norm() < 1e-15);
assert_eq!(comps.b, Complex64::new(0.0, 0.5));
}
#[test]
fn reduces_to_pure_heston_when_rates_are_deterministic() {
let mut p = paper_params();
p.domestic.sigma = 0.0;
p.foreign.sigma = 0.0;
p.correlations = Correlation4x4 {
rho_xi_sigma: -0.5,
rho_xi_d: 0.0,
rho_xi_f: 0.0,
rho_sigma_d: 0.0,
rho_sigma_f: 0.0,
rho_d_f: 0.0,
};
let u = Complex64::new(0.7, -0.3);
let s = 0.75;
let big_t = 2.0;
let integ = super::integrand(u, s, big_t, &p);
let c = super::c_of_tau(u, s, &p);
let expected = Complex64::new(p.heston.kappa * p.heston.theta, 0.0) * c;
let diff = (integ - expected).norm();
assert!(
diff < 1e-15,
"integrand {} vs κσ̄·C {}: diff {}",
integ,
expected,
diff
);
}
#[test]
fn reduces_to_black_scholes_chf_in_gbm_limit() {
let p = FxHhwParams {
fx_0: 1.0,
heston: CirProcess {
kappa: 1.0,
theta: 0.04,
gamma: 0.0,
sigma_0: 0.04,
},
domestic: HullWhite1F {
mean_reversion: 0.01,
sigma: 0.0,
},
foreign: HullWhite1F {
mean_reversion: 0.05,
sigma: 0.0,
},
rd_0: 0.02,
rf_0: 0.02,
theta_d: 0.02,
theta_f: 0.02,
correlations: Correlation4x4 {
rho_xi_sigma: 0.0,
rho_xi_d: 0.0,
rho_xi_f: 0.0,
rho_sigma_d: 0.0,
rho_sigma_f: 0.0,
rho_d_f: 0.0,
},
};
let t = 1.0;
let chf = FxHhw1ForwardChf::new(&p, t);
let u = Complex64::new(0.4, 0.0);
let v = chf.evaluate(u);
let sigma0 = p.heston.sigma_0;
let mean = -0.5 * sigma0 * t;
let var = sigma0 * t;
let iu = Complex64::new(0.0, 1.0) * u;
let expected = (iu * mean - 0.5 * var * u * u).exp();
let err = (v - expected).norm();
assert!(
err < 1e-8,
"BS ChF mismatch: got {} expected {} err {}",
v,
expected,
err
);
}
#[test]
fn chf_modulus_decreases_with_maturity() {
let p = paper_params();
let u = Complex64::new(1.5, 0.0);
let mut prev = f64::INFINITY;
for &t in &[0.25_f64, 0.5, 1.0, 2.0, 5.0] {
let v = FxHhw1ForwardChf::new(&p, t).evaluate(u).norm();
assert!(
v < prev,
"|φ| should decrease in τ: τ={} gave {} but prev was {}",
t,
v,
prev
);
prev = v;
}
}
#[test]
fn a_integral_converges_with_finer_grid() {
let p = paper_params();
let t = 1.0_f64;
let u = Complex64::new(0.6, 0.1);
let chf64 = FxHhw1ForwardChf::with_simpson_steps(&p, t, 64);
let chf512 = FxHhw1ForwardChf::with_simpson_steps(&p, t, 512);
let v64 = chf64.evaluate(u);
let v512 = chf512.evaluate(u);
let rel = (v64 - v512).norm() / v512.norm();
assert!(rel < 1e-8, "ChF Simpson convergence: rel err {}", rel);
}
#[test]
fn monte_carlo_agreement_in_deterministic_rate_limit() {
let mut p = paper_params();
p.domestic.sigma = 0.0;
p.foreign.sigma = 0.0;
p.correlations = Correlation4x4 {
rho_xi_sigma: -0.40,
rho_xi_d: 0.0,
rho_xi_f: 0.0,
rho_sigma_d: 0.0,
rho_sigma_f: 0.0,
rho_d_f: 0.0,
};
let t = 0.5_f64;
let u = Complex64::new(0.4, 0.0);
let mut sim = FxHhwSimulator::new(p, 1234).unwrap();
let n_paths = 30_000_usize;
let terminals = sim.simulate(t, 200, n_paths);
let mc_mean: Complex64 = terminals
.iter()
.map(|s| (Complex64::new(0.0, 1.0) * u * s.fx.ln()).exp())
.sum::<Complex64>()
/ (n_paths as f64);
let chf = FxHhw1ForwardChf::new(&p, t);
let v = chf.evaluate(u);
let shift = Complex64::new(0.0, 1.0) * u * (p.rd_0 - p.rf_0) * t;
let mc_forward = mc_mean * shift.exp();
let rel = (v - mc_forward).norm() / v.norm();
assert!(
rel < 0.02,
"MC vs ChF disagreement: |ChF − MC·shift|/|ChF| = {:.4}% (ChF={}, MC*shift={})",
rel * 100.0,
v,
mc_forward
);
}
}