use crate::models::common::simulation::SimulationModel;
use crate::models::forex::fx_fmm::{FmmSide, FxFmmParams};
use rand::{Rng, SeedableRng};
use rand_chacha::ChaCha20Rng;
use rand_distr::StandardNormal;
#[derive(Clone, Debug, PartialEq)]
pub struct FxFmmState {
pub fx: f64,
pub variance: f64,
pub rates_d: Vec<f64>,
pub rates_f: Vec<f64>,
pub y_diag_d: Vec<f64>,
pub y_diag_f: Vec<f64>,
pub rates_at_start_d: Vec<f64>,
pub rates_at_start_f: Vec<f64>,
pub y_at_start_d: Vec<f64>,
pub y_at_start_f: Vec<f64>,
pub x_active_d: f64,
pub x_active_f: f64,
pub t: f64,
}
impl FxFmmState {
pub fn initial(p: &FxFmmParams) -> Self {
let m = p.tenor.m();
let mut rates_at_start_d = vec![0.0; m];
let mut rates_at_start_f = vec![0.0; m];
rates_at_start_d[0] = p.tenor.initial_rates[0];
rates_at_start_f[0] = p.tenor.initial_rates[0];
Self {
fx: p.fx_0,
variance: p.heston.sigma_0,
rates_d: p.tenor.initial_rates.clone(),
rates_f: p.tenor.initial_rates.clone(),
y_diag_d: vec![0.0; m],
y_diag_f: vec![0.0; m],
rates_at_start_d,
rates_at_start_f,
y_at_start_d: vec![0.0; m],
y_at_start_f: vec![0.0; m],
x_active_d: 0.0,
x_active_f: 0.0,
t: 0.0,
}
}
}
pub struct FxFmmSimulator {
pub params: FxFmmParams,
chol: Vec<Vec<f64>>,
rng: ChaCha20Rng,
}
impl FxFmmSimulator {
pub fn new(params: FxFmmParams, seed: u64) -> Result<Self, &'static str> {
params.validate().map_err(|_| "invalid FX-FMM params")?;
let corr = build_joint_correlation(¶ms);
let chol = cholesky(&corr).ok_or("joint correlation is not positive-definite")?;
Ok(Self {
params,
chol,
rng: ChaCha20Rng::seed_from_u64(seed),
})
}
#[allow(clippy::too_many_lines)]
pub fn step(&mut self, state: &mut FxFmmState, dt: f64) {
assert!(dt > 0.0, "dt must be positive");
let p = &self.params;
let tenor = &p.tenor;
let m = tenor.m();
let n = 2 + 2 * m;
let t = state.t;
let t_mid = t + 0.5 * dt;
let sqrt_dt = dt.sqrt();
let z: Vec<f64> = (0..n).map(|_| self.rng.sample(StandardNormal)).collect();
let dw: Vec<f64> = self
.chol
.iter()
.enumerate()
.map(|(i, row)| {
let s: f64 = row
.iter()
.zip(z.iter())
.take(i + 1)
.map(|(a, b)| a * b)
.sum();
s * sqrt_dt
})
.collect();
let dw_fx = dw[0];
let dw_sigma = dw[1];
let dw_d = &dw[2..2 + m];
let dw_f = &dw[2 + m..2 + 2 * m];
let sigma = state.variance.max(0.0);
let sqrt_sigma = sigma.sqrt();
let new_variance = (sigma
+ p.heston.kappa * (p.heston.theta - sigma) * dt
+ p.heston.gamma * sqrt_sigma * dw_sigma)
.max(0.0);
let eta_old = tenor.eta(t);
let k = eta_old.min(m);
let rd_now = state.rates_d[k - 1];
let rf_now = state.rates_f[k - 1];
let log_fx = state.fx.ln() + (rd_now - rf_now - 0.5 * sigma) * dt + sqrt_sigma * dw_fx;
let new_fx = log_fx.exp();
let mut gamma_mid_d = vec![0.0_f64; m];
let mut drift_weight_d = vec![0.0_f64; m];
let mut adapted_d = vec![0.0_f64; m];
for i in 1..=m {
let g = p.domestic.decay.gamma(i, t_mid, tenor);
gamma_mid_d[i - 1] = g;
let r_i = state.rates_d[i - 1];
let sig = side_adapted_vol(&p.domestic, i, r_i);
adapted_d[i - 1] = sig;
let tau_i = tenor.tau(i);
drift_weight_d[i - 1] = sig * g * tau_i / (1.0 + tau_i * r_i);
}
let mut new_rates_d = state.rates_d.clone();
for j in eta_old..=m {
let gamma_j = gamma_mid_d[j - 1];
if gamma_j == 0.0 {
continue;
}
let sig_j = adapted_d[j - 1];
let mut sum = 0.0_f64;
for i in eta_old..=j {
sum += p.domestic.rate_corr[i - 1][j - 1] * drift_weight_d[i - 1];
}
let drift = sig_j * gamma_j * sum;
let diffusion = sig_j * gamma_j * dw_d[j - 1];
new_rates_d[j - 1] = state.rates_d[j - 1] + drift * dt + diffusion;
}
let mut gamma_mid_f = vec![0.0_f64; m];
let mut drift_weight_f = vec![0.0_f64; m];
let mut adapted_f = vec![0.0_f64; m];
for i in 1..=m {
let g = p.foreign.decay.gamma(i, t_mid, tenor);
gamma_mid_f[i - 1] = g;
let r_i = state.rates_f[i - 1];
let sig = side_adapted_vol(&p.foreign, i, r_i);
adapted_f[i - 1] = sig;
let tau_i = tenor.tau(i);
drift_weight_f[i - 1] = sig * g * tau_i / (1.0 + tau_i * r_i);
}
let mut new_rates_f = state.rates_f.clone();
for j in eta_old..=m {
let gamma_j = gamma_mid_f[j - 1];
if gamma_j == 0.0 {
continue;
}
let sig_j = adapted_f[j - 1];
let mut sum = 0.0_f64;
for i in eta_old..=j {
sum += p.foreign.rate_corr[i - 1][j - 1] * drift_weight_f[i - 1];
}
let quanto = sig_j * gamma_j * p.correlations.rho_xi_f[j - 1] * sqrt_sigma;
let drift = sig_j * gamma_j * sum - quanto;
let diffusion = sig_j * gamma_j * dw_f[j - 1];
new_rates_f[j - 1] = state.rates_f[j - 1] + drift * dt + diffusion;
}
for j in 1..=m {
let tj = tenor.dates[j];
if t >= tj {
continue;
}
let eff_dt = (tj - t).min(dt);
let tau_j = tenor.tau(j);
let sig_d = side_adapted_vol(&p.domestic, j, state.rates_d[j - 1]);
let integrand_d = (sig_d / (state.rates_d[j - 1] + 1.0 / tau_j)).powi(2);
state.y_diag_d[j - 1] += integrand_d * eff_dt;
let sig_f = side_adapted_vol(&p.foreign, j, state.rates_f[j - 1]);
let integrand_f = (sig_f / (state.rates_f[j - 1] + 1.0 / tau_j)).powi(2);
state.y_diag_f[j - 1] += integrand_f * eff_dt;
}
if eta_old >= 1 && eta_old <= m {
let k = eta_old;
let tk_minus_1 = tenor.dates[k - 1];
let tk = tenor.dates[k];
if t > tk_minus_1 && t < tk {
let tau_k = tenor.tau(k);
let g_k = 1.0 / tau_k; let r_d_mid = 0.5 * (state.rates_d[k - 1] + new_rates_d[k - 1]);
let sig_d = side_adapted_vol(&p.domestic, k, r_d_mid);
let y_k_d = state.y_diag_d[k - 1] - state.y_at_start_d[k - 1];
state.x_active_d +=
g_k * y_k_d * dt + sig_d / (r_d_mid + 1.0 / tau_k) * dw_d[k - 1];
let r_f_mid = 0.5 * (state.rates_f[k - 1] + new_rates_f[k - 1]);
let sig_f = side_adapted_vol(&p.foreign, k, r_f_mid);
let y_k_f = state.y_diag_f[k - 1] - state.y_at_start_f[k - 1];
state.x_active_f +=
g_k * y_k_f * dt + sig_f / (r_f_mid + 1.0 / tau_k) * dw_f[k - 1];
}
}
state.fx = new_fx;
state.variance = new_variance;
state.rates_d = new_rates_d;
state.rates_f = new_rates_f;
state.t += dt;
let eta_new = tenor.eta(state.t);
if eta_new > eta_old {
for k in (eta_old + 1)..=eta_new.min(m) {
state.rates_at_start_d[k - 1] = state.rates_d[k - 1];
state.rates_at_start_f[k - 1] = state.rates_f[k - 1];
state.y_at_start_d[k - 1] = state.y_diag_d[k - 1];
state.y_at_start_f[k - 1] = state.y_diag_f[k - 1];
state.x_active_d = 0.0;
state.x_active_f = 0.0;
}
}
}
pub fn simulate_terminal(
&mut self,
t_end: f64,
n_steps: usize,
n_paths: usize,
) -> Vec<FxFmmState> {
assert!(t_end > 0.0 && n_steps > 0 && n_paths > 0);
let dt = t_end / n_steps as f64;
let mut out = Vec::with_capacity(n_paths);
for _ in 0..n_paths {
let mut state = FxFmmState::initial(&self.params);
for _ in 0..n_steps {
self.step(&mut state, dt);
}
out.push(state);
}
out
}
}
impl SimulationModel for FxFmmSimulator {
type State = FxFmmState;
fn initial_state(&self) -> Self::State {
FxFmmState::initial(&self.params)
}
fn step(&mut self, state: &Self::State, _t: f64, dt: f64) -> Self::State {
let mut next = state.clone();
FxFmmSimulator::step(self, &mut next, dt);
next
}
}
fn side_adapted_vol(side: &FmmSide, j: usize, _r_j: f64) -> f64 {
side.sigmas[j - 1]
}
fn build_joint_correlation(p: &FxFmmParams) -> Vec<Vec<f64>> {
let m = p.tenor.m();
let n = 2 + 2 * m;
let mut c = vec![vec![0.0_f64; n]; n];
for (i, row) in c.iter_mut().enumerate().take(n) {
row[i] = 1.0;
}
c[0][1] = p.correlations.rho_xi_sigma;
c[1][0] = p.correlations.rho_xi_sigma;
for j in 0..m {
c[0][2 + j] = p.correlations.rho_xi_d[j];
c[2 + j][0] = p.correlations.rho_xi_d[j];
c[0][2 + m + j] = p.correlations.rho_xi_f[j];
c[2 + m + j][0] = p.correlations.rho_xi_f[j];
}
for j in 0..m {
c[1][2 + j] = p.correlations.rho_sigma_d[j];
c[2 + j][1] = p.correlations.rho_sigma_d[j];
c[1][2 + m + j] = p.correlations.rho_sigma_f[j];
c[2 + m + j][1] = p.correlations.rho_sigma_f[j];
}
for i in 0..m {
for j in 0..m {
c[2 + i][2 + j] = p.domestic.rate_corr[i][j];
}
}
for i in 0..m {
for j in 0..m {
c[2 + m + i][2 + m + j] = p.foreign.rate_corr[i][j];
}
}
for i in 0..m {
for j in 0..m {
let sym =
0.5 * (p.correlations.cross_rate_corr[i][j] + p.correlations.cross_rate_corr[j][i]);
c[2 + i][2 + m + j] = sym;
c[2 + m + j][2 + i] = sym;
}
}
c
}
#[allow(clippy::needless_range_loop)]
fn cholesky(m: &[Vec<f64>]) -> Option<Vec<Vec<f64>>> {
let n = m.len();
let mut l = vec![vec![0.0_f64; n]; n];
for i in 0..n {
for j in 0..=i {
let mut sum = m[i][j];
for k in 0..j {
sum -= l[i][k] * l[j][k];
}
if i == j {
if sum <= 0.0 {
return None;
}
l[i][i] = sum.sqrt();
} else {
l[i][j] = sum / l[j][j];
}
}
}
Some(l)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::common::cir::CirProcess;
use crate::models::forex::fx_fmm::{FmmSide, FxFmmCorrelations, FxFmmParams};
use crate::models::interestrate::fmm::{FmmTenor, LinearDecay};
fn toy_params(m: usize) -> FxFmmParams {
let dates: Vec<f64> = (0..=m).map(|k| k as f64 * 0.5).collect();
let rates = vec![0.03_f64; m];
let tenor = FmmTenor::new(dates, rates);
let side = FmmSide {
sigmas: vec![0.01; m],
lambda: 1.0,
eta: 0.1,
v_0: 1.0,
rate_corr: {
let mut rc = vec![vec![0.0_f64; m]; m];
for (i, row) in rc.iter_mut().enumerate() {
for (j, v) in row.iter_mut().enumerate() {
*v = if i == j { 1.0 } else { 0.5 };
}
}
rc
},
decay: LinearDecay,
};
FxFmmParams {
fx_0: 1.17,
heston: CirProcess {
kappa: 1.0,
theta: 0.01,
gamma: 0.2,
sigma_0: 0.01,
},
tenor,
domestic: side.clone(),
foreign: side,
correlations: FxFmmCorrelations {
rho_xi_sigma: -0.3,
rho_xi_d: vec![-0.1; m],
rho_xi_f: vec![-0.1; m],
rho_sigma_d: vec![0.2; m],
rho_sigma_f: vec![0.2; m],
cross_rate_corr: vec![vec![0.25; m]; m],
},
}
}
#[test]
fn initial_state_matches_params() {
let p = toy_params(3);
let sim = FxFmmSimulator::new(p.clone(), 0).unwrap();
let s = sim.initial_state();
assert_eq!(s.fx, p.fx_0);
assert_eq!(s.variance, p.heston.sigma_0);
assert_eq!(s.rates_d, p.tenor.initial_rates);
assert_eq!(s.rates_f, p.tenor.initial_rates);
assert_eq!(s.rates_at_start_d[0], p.tenor.initial_rates[0]);
assert_eq!(s.x_active_d, 0.0);
assert_eq!(s.x_active_f, 0.0);
assert_eq!(s.t, 0.0);
}
#[test]
fn simulator_rejects_non_pd_correlation() {
let mut p = toy_params(3);
p.correlations.cross_rate_corr = vec![vec![1.0; 3]; 3];
p.correlations.rho_xi_d = vec![0.99, 0.99, 0.99];
p.correlations.rho_xi_f = vec![-0.99, -0.99, -0.99];
assert!(FxFmmSimulator::new(p, 1).is_err());
}
#[test]
fn simulation_model_impl_captures_observations() {
use crate::models::common::simulation::simulate_at_dates;
use crate::time::daycounters::actual365fixed::Actual365Fixed;
use chrono::NaiveDate;
let p = toy_params(2);
let mut sim = FxFmmSimulator::new(p, 7).unwrap();
let val = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
let d1 = NaiveDate::from_ymd_opt(2025, 4, 1).unwrap();
let d2 = NaiveDate::from_ymd_opt(2025, 7, 1).unwrap();
let dc = Actual365Fixed::default();
let paths = simulate_at_dates(&mut sim, val, &[d1, d2], 40, 7, &dc);
assert_eq!(paths.n_paths(), 40);
let at_d1 = paths.states_at(d1).unwrap();
for s in &at_d1 {
assert!(s.fx.is_finite());
assert!(s.variance >= 0.0);
assert_eq!(s.rates_d.len(), 2);
}
}
#[test]
fn mc_forward_tracks_initial_under_small_vol() {
let mut p = toy_params(2);
p.heston.sigma_0 = 0.005;
p.heston.theta = 0.005;
p.correlations.rho_xi_d = vec![0.0; 2];
p.correlations.rho_xi_f = vec![0.0; 2];
let mut sim = FxFmmSimulator::new(p.clone(), 2026).unwrap();
let terminals = sim.simulate_terminal(0.5, 100, 2_000);
let mean: f64 = terminals.iter().map(|s| s.fx).sum::<f64>() / terminals.len() as f64;
let rel_err = (mean - p.fx_0).abs() / p.fx_0;
assert!(
rel_err < 0.01,
"E[ξ(T)] = {} vs fx_0 {} ({:.1} bp drift)",
mean,
p.fx_0,
rel_err * 10_000.0
);
}
#[test]
fn joint_correlation_cholesky_roundtrip() {
let p = toy_params(2);
let c = build_joint_correlation(&p);
let l = cholesky(&c).expect("positive-definite");
let n = c.len();
for i in 0..n {
for j in 0..n {
let s: f64 = l[i].iter().zip(l[j].iter()).map(|(a, b)| a * b).sum();
assert!(
(s - c[i][j]).abs() < 1e-12,
"[{i}][{j}]: {s} vs {}",
c[i][j]
);
}
}
}
#[test]
fn variance_stays_nonnegative() {
let mut p = toy_params(2);
p.heston.kappa = 0.5;
p.heston.theta = 0.02;
p.heston.gamma = 0.4;
p.heston.sigma_0 = 0.02;
let mut sim = FxFmmSimulator::new(p, 11).unwrap();
let terminals = sim.simulate_terminal(1.0, 100, 500);
for s in &terminals {
assert!(s.variance >= 0.0, "variance went negative: {}", s.variance);
}
}
}