use crate::models::common::cir::CirProcess;
use crate::models::common::simulation::SimulationModel;
use crate::models::forex::fx_hhw::{FxHhwParams, FxHhwState};
use rand::{Rng, SeedableRng};
use rand_chacha::ChaCha20Rng;
use rand_distr::StandardNormal;
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct ForeignStock {
pub s_0: f64,
pub variance: CirProcess,
pub rho_xi_sf: f64,
pub rho_sf_omega: f64,
}
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct FxHhwStockState {
pub fx: FxHhwState,
pub stock: f64,
pub stock_variance: f64,
}
impl FxHhwStockState {
pub fn initial(fx: &FxHhwParams, stock: &ForeignStock) -> Self {
Self {
fx: FxHhwState::initial(fx),
stock: stock.s_0,
stock_variance: stock.variance.sigma_0,
}
}
}
pub struct FxHhwStockSimulator {
pub fx: FxHhwParams,
pub stock: ForeignStock,
base_chol: [[f64; 4]; 4],
rng: ChaCha20Rng,
}
impl FxHhwStockSimulator {
pub fn new(fx: FxHhwParams, stock: ForeignStock, seed: u64) -> Result<Self, &'static str> {
let base_chol = fx
.correlations
.cholesky()
.ok_or("FX-HHW correlation not positive-definite")?;
if stock.rho_xi_sf.abs() > 1.0 {
return Err("|rho_xi_sf| > 1");
}
if stock.rho_sf_omega.abs() > 1.0 {
return Err("|rho_sf_omega| > 1");
}
Ok(Self {
fx,
stock,
base_chol,
rng: ChaCha20Rng::seed_from_u64(seed),
})
}
#[allow(clippy::needless_range_loop)] pub fn step(&mut self, state: &FxHhwStockState, dt: f64) -> FxHhwStockState {
assert!(dt > 0.0);
let z: [f64; 6] = [
self.rng.sample(StandardNormal),
self.rng.sample(StandardNormal),
self.rng.sample(StandardNormal),
self.rng.sample(StandardNormal),
self.rng.sample(StandardNormal),
self.rng.sample(StandardNormal),
];
let sqrt_dt = dt.sqrt();
let mut dw = [0.0_f64; 4];
for i in 0..4 {
let mut s = 0.0;
for j in 0..=i {
s += self.base_chol[i][j] * z[j];
}
dw[i] = s * sqrt_dt;
}
let z_sf_component = z[4];
let rho_xsf = self.stock.rho_xi_sf;
let z_sf = rho_xsf * z[0] + (1.0 - rho_xsf * rho_xsf).sqrt() * z_sf_component;
let dw_sf = z_sf * sqrt_dt;
let rho_swo = self.stock.rho_sf_omega;
let z_omega = rho_swo * z_sf + (1.0 - rho_swo * rho_swo).sqrt() * z[5];
let dw_omega = z_omega * sqrt_dt;
let p = &self.fx;
let sigma = state.fx.variance.max(0.0);
let sqrt_sigma = sigma.sqrt();
let new_log_fx =
state.fx.fx.ln() + (state.fx.rd - state.fx.rf - 0.5 * sigma) * dt + sqrt_sigma * dw[0];
let new_fx = new_log_fx.exp();
let new_variance = (sigma
+ p.heston.kappa * (p.heston.theta - sigma) * dt
+ p.heston.gamma * sqrt_sigma * dw[1])
.max(0.0);
let new_rd = state.fx.rd
+ p.domestic.mean_reversion * (p.theta_d - state.fx.rd) * dt
+ p.domestic.sigma * dw[2];
let rf_drift = p.foreign.mean_reversion * (p.theta_f - state.fx.rf)
- p.foreign.sigma * p.correlations.rho_xi_f * sqrt_sigma;
let new_rf = state.fx.rf + rf_drift * dt + p.foreign.sigma * dw[3];
let omega = state.stock_variance.max(0.0);
let sqrt_omega = omega.sqrt();
let stock_drift = state.fx.rf - rho_xsf * sqrt_sigma * sqrt_omega - 0.5 * omega;
let new_stock = (state.stock.ln() + stock_drift * dt + sqrt_omega * dw_sf).exp();
let s = &self.stock;
let stock_var_drift = s.variance.kappa * (s.variance.theta - omega)
- s.rho_sf_omega * rho_xsf * s.variance.gamma * sqrt_omega * sqrt_sigma;
let new_stock_variance =
(omega + stock_var_drift * dt + s.variance.gamma * sqrt_omega * dw_omega).max(0.0);
FxHhwStockState {
fx: FxHhwState {
fx: new_fx,
variance: new_variance,
rd: new_rd,
rf: new_rf,
},
stock: new_stock,
stock_variance: new_stock_variance,
}
}
pub fn simulate(&mut self, t_end: f64, n_steps: usize, n_paths: usize) -> Vec<FxHhwStockState> {
assert!(n_steps > 0 && n_paths > 0 && t_end > 0.0);
let dt = t_end / n_steps as f64;
let mut terminals = Vec::with_capacity(n_paths);
for _ in 0..n_paths {
let mut state = FxHhwStockState::initial(&self.fx, &self.stock);
for _ in 0..n_steps {
state = self.step(&state, dt);
}
terminals.push(state);
}
terminals
}
}
impl SimulationModel for FxHhwStockSimulator {
type State = FxHhwStockState;
fn initial_state(&self) -> Self::State {
FxHhwStockState::initial(&self.fx, &self.stock)
}
fn step(&mut self, state: &Self::State, _t: f64, dt: f64) -> Self::State {
self.step(state, dt)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::forex::fx_hhw::Correlation4x4;
use crate::models::interestrate::hull_white::HullWhite1F;
fn base_fx() -> FxHhwParams {
FxHhwParams {
fx_0: 1.35,
heston: CirProcess {
kappa: 0.5,
theta: 0.04,
gamma: 0.3,
sigma_0: 0.04,
},
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.4,
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,
},
}
}
fn equity_stock() -> ForeignStock {
ForeignStock {
s_0: 100.0,
variance: CirProcess {
kappa: 1.0,
theta: 0.04,
gamma: 0.3,
sigma_0: 0.04,
},
rho_xi_sf: -0.2,
rho_sf_omega: -0.5,
}
}
#[test]
fn initial_state_matches_params() {
let fx = base_fx();
let stock = equity_stock();
let s0 = FxHhwStockState::initial(&fx, &stock);
assert_eq!(s0.fx.fx, 1.35);
assert_eq!(s0.stock, 100.0);
assert_eq!(s0.stock_variance, 0.04);
}
#[test]
fn same_seed_reproducible() {
let fx = base_fx();
let stock = equity_stock();
let mut s1 = FxHhwStockSimulator::new(fx, stock, 7).unwrap();
let mut s2 = FxHhwStockSimulator::new(fx, stock, 7).unwrap();
let t1 = s1.simulate(0.5, 50, 8);
let t2 = s2.simulate(0.5, 50, 8);
for (a, b) in t1.iter().zip(t2.iter()) {
assert_eq!(a, b);
}
}
#[test]
fn stock_mean_matches_deterministic_limit() {
let mut fx = base_fx();
fx.heston.gamma = 0.0;
fx.domestic.sigma = 0.0;
fx.foreign.sigma = 0.0;
fx.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 stock = ForeignStock {
s_0: 100.0,
variance: CirProcess {
kappa: 1.0,
theta: 0.04,
gamma: 0.0,
sigma_0: 0.04,
},
rho_xi_sf: 0.0,
rho_sf_omega: 0.0,
};
let mut sim = FxHhwStockSimulator::new(fx, stock, 99).unwrap();
let t = 0.5_f64;
let n_paths = 30_000_usize;
let terminals = sim.simulate(t, 200, n_paths);
let mean: f64 = terminals.iter().map(|s| s.stock).sum::<f64>() / n_paths as f64;
let expected = stock.s_0 * (fx.rf_0 * t).exp();
let se = (terminals
.iter()
.map(|s| (s.stock - mean).powi(2))
.sum::<f64>()
/ (n_paths as f64).powi(2))
.sqrt();
let err = (mean - expected).abs();
assert!(
err < 4.0 * se + 0.25,
"E[S_f(T)] = {} vs expected {}, err {} > 4SE+0.25 = {}",
mean,
expected,
err,
4.0 * se + 0.25
);
}
#[test]
fn fx_times_stock_discounted_is_martingale() {
let mut fx = base_fx();
fx.domestic.sigma = 0.0;
fx.foreign.sigma = 0.0;
let stock = equity_stock();
let mut sim = FxHhwStockSimulator::new(fx, stock, 123).unwrap();
let t = 0.5_f64;
let n_paths = 30_000_usize;
let terminals = sim.simulate(t, 200, n_paths);
let discount = (-fx.rd_0 * t).exp();
let mean: f64 = terminals
.iter()
.map(|s| s.fx.fx * s.stock * discount)
.sum::<f64>()
/ n_paths as f64;
let expected = fx.fx_0 * stock.s_0;
let se = (terminals
.iter()
.map(|s| (s.fx.fx * s.stock * discount - mean).powi(2))
.sum::<f64>()
/ (n_paths as f64).powi(2))
.sqrt();
let err = (mean - expected).abs();
assert!(
err < 4.0 * se + 0.5,
"E[ξ·S_f·discount] = {} vs ξ₀·S_0 = {}, err {} > 4SE+0.5 = {}",
mean,
expected,
err,
4.0 * se + 0.5
);
}
#[test]
fn date_driven_matches_year_fraction_simulate() {
use crate::models::common::simulation::simulate_at_dates;
use crate::time::daycounters::DayCounters;
use crate::time::daycounters::actual365fixed::Actual365Fixed;
use chrono::NaiveDate;
let fx = base_fx();
let stock = equity_stock();
let valuation = NaiveDate::from_ymd_opt(2026, 4, 22).unwrap();
let observation = NaiveDate::from_ymd_opt(2027, 4, 22).unwrap(); let dc = Actual365Fixed::default();
let mut sim_d = FxHhwStockSimulator::new(fx, stock, 42).unwrap();
let paths = simulate_at_dates(&mut sim_d, valuation, &[observation], 100, 1, &dc);
assert_eq!(paths.n_paths(), 100);
let t = dc.year_fraction(valuation, observation).unwrap();
let mut sim_y = FxHhwStockSimulator::new(fx, stock, 42).unwrap();
let terminals = sim_y.simulate(t, 365, 100);
for (i, s) in terminals.iter().enumerate() {
let dated = &paths.paths[i][0];
assert!((dated.fx.fx - s.fx.fx).abs() < 1e-10);
assert!((dated.stock - s.stock).abs() < 1e-10);
assert!((dated.stock_variance - s.stock_variance).abs() < 1e-10);
}
}
#[test]
fn rejects_out_of_range_correlations() {
let fx = base_fx();
let bad = ForeignStock {
rho_xi_sf: 1.5,
..equity_stock()
};
assert!(FxHhwStockSimulator::new(fx, bad, 1).is_err());
}
}