use crate::models::common::cir::CirProcess;
use crate::models::interestrate::fmm::{FmmTenor, LinearDecay};
#[derive(Clone, Debug, PartialEq)]
pub struct FmmSide {
pub sigmas: Vec<f64>,
pub lambda: f64,
pub eta: f64,
pub v_0: f64,
pub rate_corr: Vec<Vec<f64>>,
pub decay: LinearDecay,
}
impl FmmSide {
pub fn validate(&self, tenor: &FmmTenor) -> Result<(), String> {
let m = tenor.m();
if self.sigmas.len() != m {
return Err(format!(
"sigmas length {} vs tenor M = {}",
self.sigmas.len(),
m
));
}
if self.rate_corr.len() != m {
return Err("rate_corr must be M×M".to_string());
}
for row in &self.rate_corr {
if row.len() != m {
return Err("rate_corr row length ≠ M".to_string());
}
}
for i in 0..m {
if (self.rate_corr[i][i] - 1.0).abs() > 1e-12 {
return Err(format!("rate_corr diagonal [{i}] ≠ 1"));
}
for j in 0..m {
if (self.rate_corr[i][j] - self.rate_corr[j][i]).abs() > 1e-12 {
return Err(format!("rate_corr not symmetric at ({i},{j})"));
}
if self.rate_corr[i][j].abs() > 1.0 + 1e-12 {
return Err(format!("|rate_corr[{i},{j}]| > 1"));
}
}
}
if self.lambda <= 0.0 || self.eta <= 0.0 || self.v_0 <= 0.0 {
return Err("λ, η, v_0 must be strictly positive".to_string());
}
for &s in &self.sigmas {
if !s.is_finite() || s < 0.0 {
return Err("σ_j must be non-negative".to_string());
}
}
Ok(())
}
pub fn psi_base(&self, tenor: &FmmTenor) -> Vec<f64> {
(0..tenor.m())
.map(|idx| {
let j = idx + 1;
let tau_j = tenor.tau(j);
let r_j0 = tenor.initial_rates[idx];
tau_j * self.sigmas[idx] / (1.0 + tau_j * r_j0)
})
.collect()
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct FxFmmCorrelations {
pub rho_xi_sigma: f64,
pub rho_xi_d: Vec<f64>,
pub rho_xi_f: Vec<f64>,
pub rho_sigma_d: Vec<f64>,
pub rho_sigma_f: Vec<f64>,
pub cross_rate_corr: Vec<Vec<f64>>,
}
impl FxFmmCorrelations {
pub fn validate(&self, tenor: &FmmTenor) -> Result<(), String> {
let m = tenor.m();
if self.rho_xi_sigma.abs() > 1.0 + 1e-12 {
return Err(format!("|rho_xi_sigma| = {} > 1", self.rho_xi_sigma));
}
if self.rho_xi_d.len() != m {
return Err(format!(
"rho_xi_d length {} vs M = {}",
self.rho_xi_d.len(),
m
));
}
if self.rho_xi_f.len() != m {
return Err(format!(
"rho_xi_f length {} vs M = {}",
self.rho_xi_f.len(),
m
));
}
if self.rho_sigma_d.len() != m {
return Err(format!(
"rho_sigma_d length {} vs M = {}",
self.rho_sigma_d.len(),
m
));
}
if self.rho_sigma_f.len() != m {
return Err(format!(
"rho_sigma_f length {} vs M = {}",
self.rho_sigma_f.len(),
m
));
}
if self.cross_rate_corr.len() != m {
return Err("cross_rate_corr rows ≠ M".to_string());
}
for row in &self.cross_rate_corr {
if row.len() != m {
return Err("cross_rate_corr cols ≠ M".to_string());
}
for &c in row {
if c.abs() > 1.0 + 1e-12 {
return Err("|cross_rate_corr| > 1".to_string());
}
}
}
for &c in self
.rho_xi_d
.iter()
.chain(self.rho_xi_f.iter())
.chain(self.rho_sigma_d.iter())
.chain(self.rho_sigma_f.iter())
{
if c.abs() > 1.0 + 1e-12 {
return Err("|rho_xi_·| or |rho_sigma_·| > 1".to_string());
}
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct FxFmmParams {
pub fx_0: f64,
pub heston: CirProcess,
pub tenor: FmmTenor,
pub domestic: FmmSide,
pub foreign: FmmSide,
pub correlations: FxFmmCorrelations,
}
impl FxFmmParams {
pub fn validate(&self) -> Result<(), String> {
self.domestic.validate(&self.tenor)?;
self.foreign.validate(&self.tenor)?;
self.correlations.validate(&self.tenor)?;
Ok(())
}
}
pub fn psi_at(side: &FmmSide, tenor: &FmmTenor, t: f64) -> Vec<f64> {
let base = side.psi_base(tenor);
(0..tenor.m())
.map(|idx| {
let j = idx + 1;
base[idx] * side.decay.gamma(j, t, tenor)
})
.collect()
}
fn compute_a_side(side: &FmmSide, tenor: &FmmTenor, t: f64, start_idx: usize) -> f64 {
let psi = psi_at(side, tenor, t);
let m = tenor.m();
if start_idx > m {
return 0.0;
}
let mut total = 0.0_f64;
for j in start_idx..=m {
total += psi[j - 1] * psi[j - 1];
}
for i in start_idx..=m {
for j in (i + 1)..=m {
total += 2.0 * psi[i - 1] * psi[j - 1] * side.rate_corr[i - 1][j - 1];
}
}
total
}
pub fn compute_a_d(params: &FxFmmParams, t: f64, start_idx: usize) -> f64 {
compute_a_side(¶ms.domestic, ¶ms.tenor, t, start_idx)
}
pub fn compute_a_f(params: &FxFmmParams, t: f64, start_idx: usize) -> f64 {
compute_a_side(¶ms.foreign, ¶ms.tenor, t, start_idx)
}
pub fn compute_f_linearised(params: &FxFmmParams, t: f64, start_idx: usize) -> f64 {
let psi_d = psi_at(¶ms.domestic, ¶ms.tenor, t);
let psi_f = psi_at(¶ms.foreign, ¶ms.tenor, t);
let phi_xi = params.heston.sqrt_mean(t);
let phi_d = CirProcess {
kappa: params.domestic.lambda,
theta: params.domestic.v_0,
gamma: params.domestic.eta,
sigma_0: params.domestic.v_0,
}
.sqrt_mean(t);
let phi_f = CirProcess {
kappa: params.foreign.lambda,
theta: params.foreign.v_0,
gamma: params.foreign.eta,
sigma_0: params.foreign.v_0,
}
.sqrt_mean(t);
let m = params.tenor.m();
if start_idx > m {
return 0.0;
}
let mut two_ab = 0.0_f64;
for j in start_idx..=m {
two_ab += psi_d[j - 1] * params.correlations.rho_xi_d[j - 1];
}
two_ab *= 2.0 * phi_xi * phi_d;
let mut two_ac = 0.0_f64;
for j in start_idx..=m {
two_ac += psi_f[j - 1] * params.correlations.rho_xi_f[j - 1];
}
two_ac *= 2.0 * phi_xi * phi_f;
let mut two_bc = 0.0_f64;
for j in start_idx..=m {
for k in start_idx..=m {
two_bc +=
psi_d[j - 1] * psi_f[k - 1] * params.correlations.cross_rate_corr[j - 1][k - 1];
}
}
two_bc *= 2.0 * phi_d * phi_f;
two_ab - two_ac - two_bc
}
#[cfg(test)]
mod tests {
use super::*;
fn toy_tenor() -> FmmTenor {
FmmTenor::new(vec![0.0, 0.5, 1.0, 1.5], vec![0.03, 0.03, 0.03])
}
fn toy_side() -> FmmSide {
FmmSide {
sigmas: vec![0.15, 0.15, 0.15],
lambda: 1.0,
eta: 0.1,
v_0: 1.0,
rate_corr: vec![
vec![1.0, 0.9, 0.8],
vec![0.9, 1.0, 0.9],
vec![0.8, 0.9, 1.0],
],
decay: LinearDecay,
}
}
fn toy_params() -> FxFmmParams {
FxFmmParams {
fx_0: 1.35,
heston: CirProcess {
kappa: 0.5,
theta: 0.1,
gamma: 0.3,
sigma_0: 0.1,
},
tenor: toy_tenor(),
domestic: toy_side(),
foreign: toy_side(),
correlations: FxFmmCorrelations {
rho_xi_sigma: -0.4,
rho_xi_d: vec![-0.15, -0.15, -0.15],
rho_xi_f: vec![-0.15, -0.15, -0.15],
rho_sigma_d: vec![0.30, 0.30, 0.30],
rho_sigma_f: vec![0.30, 0.30, 0.30],
cross_rate_corr: vec![
vec![0.25, 0.25, 0.25],
vec![0.25, 0.25, 0.25],
vec![0.25, 0.25, 0.25],
],
},
}
}
#[test]
fn validate_passes_on_toy() {
toy_params().validate().expect("toy should validate");
}
#[test]
fn side_rejects_mismatched_sigmas() {
let tenor = toy_tenor();
let mut side = toy_side();
side.sigmas.push(0.1);
assert!(side.validate(&tenor).is_err());
}
#[test]
fn side_rejects_asymmetric_correlation() {
let tenor = toy_tenor();
let mut side = toy_side();
side.rate_corr[0][1] = 0.5;
side.rate_corr[1][0] = 0.6;
assert!(side.validate(&tenor).is_err());
}
#[test]
fn psi_base_closed_form() {
let tenor = toy_tenor();
let side = toy_side();
let base = side.psi_base(&tenor);
let expected = 0.5 * 0.15 / (1.0 + 0.5 * 0.03);
for v in &base {
assert!((v - expected).abs() < 1e-15);
}
}
#[test]
fn psi_at_scales_with_gamma() {
let tenor = toy_tenor();
let side = toy_side();
let base = side.psi_base(&tenor);
let at0 = psi_at(&side, &tenor, 0.0);
for (a, b) in at0.iter().zip(base.iter()) {
assert!((a - b).abs() < 1e-15);
}
let mid1 = psi_at(&side, &tenor, 0.25);
assert!((mid1[0] - 0.5 * base[0]).abs() < 1e-15);
assert!((mid1[1] - base[1]).abs() < 1e-15);
assert!((mid1[2] - base[2]).abs() < 1e-15);
let past = psi_at(&side, &tenor, 1.0);
assert!(past[0].abs() < 1e-15);
assert!(past[1].abs() < 1e-15);
assert!((past[2] - base[2]).abs() < 1e-15);
}
#[test]
fn a_d_reduces_to_squared_sum_at_full_corr() {
let mut side = toy_side();
side.rate_corr = vec![vec![1.0; 3]; 3];
let params = FxFmmParams {
domestic: side.clone(),
foreign: side,
..toy_params()
};
let psi0 = psi_at(¶ms.domestic, ¶ms.tenor, 0.0);
let sum: f64 = psi0.iter().sum();
let a_d = compute_a_d(¶ms, 0.0, 1);
assert!(
(a_d - sum * sum).abs() < 1e-14,
"A_d = {} vs (Σψ)² = {}",
a_d,
sum * sum
);
}
#[test]
fn a_d_reduces_to_diag_sum_at_zero_corr() {
let mut side = toy_side();
side.rate_corr = vec![
vec![1.0, 0.0, 0.0],
vec![0.0, 1.0, 0.0],
vec![0.0, 0.0, 1.0],
];
let params = FxFmmParams {
domestic: side.clone(),
foreign: side,
..toy_params()
};
let psi0 = psi_at(¶ms.domestic, ¶ms.tenor, 0.0);
let diag: f64 = psi0.iter().map(|v| v * v).sum();
let a_d = compute_a_d(¶ms, 0.0, 1);
assert!((a_d - diag).abs() < 1e-15);
}
#[test]
fn a_d_decreases_continuously_inside_period() {
let params = toy_params();
let a_00 = compute_a_d(¶ms, 0.00, 1);
let a_10 = compute_a_d(¶ms, 0.10, 1);
let a_25 = compute_a_d(¶ms, 0.25, 1);
let a_40 = compute_a_d(¶ms, 0.40, 1);
assert!(
a_00 > a_10 && a_10 > a_25 && a_25 > a_40,
"A_d not monotone in t"
);
let a_start2 = compute_a_d(¶ms, 0.5 + 1e-12, 2);
assert!(a_start2 < a_40);
}
#[test]
fn a_d_vanishes_past_tenor_end() {
let params = toy_params();
assert_eq!(compute_a_d(¶ms, 2.0, 4), 0.0);
}
#[test]
fn f_vanishes_with_zero_cross_correlations() {
let mut params = toy_params();
let m = params.tenor.m();
params.correlations.rho_xi_d = vec![0.0; m];
params.correlations.rho_xi_f = vec![0.0; m];
params.correlations.cross_rate_corr = vec![vec![0.0; m]; m];
for &t in &[0.1_f64, 0.5, 1.0, 1.4] {
let start = params.tenor.eta(t);
let f = compute_f_linearised(¶ms, t, start);
assert!(f.abs() < 1e-14, "f(t={}) = {} ≠ 0", t, f);
}
}
#[test]
fn f_flips_when_xi_rate_corrs_flip() {
let mut params = toy_params();
let m = params.tenor.m();
params.correlations.cross_rate_corr = vec![vec![0.0; m]; m];
let f_plus = compute_f_linearised(¶ms, 0.25, 1);
for r in &mut params.correlations.rho_xi_d {
*r = -*r;
}
for r in &mut params.correlations.rho_xi_f {
*r = -*r;
}
let f_minus = compute_f_linearised(¶ms, 0.25, 1);
assert!(
(f_plus + f_minus).abs() < 1e-12,
"f+ + f- = {} ≠ 0",
f_plus + f_minus
);
}
}