use crate::models::common::simulation::SimulationModel;
use rand::{Rng, SeedableRng};
use rand_chacha::ChaCha20Rng;
use rand_distr::StandardNormal;
#[derive(Clone, Debug, PartialEq)]
pub struct FmmTenor {
pub dates: Vec<f64>,
pub initial_rates: Vec<f64>,
}
impl FmmTenor {
pub fn new(dates: Vec<f64>, initial_rates: Vec<f64>) -> Self {
assert!(dates.len() >= 2, "need at least T_0 and T_1");
assert_eq!(
dates.len(),
initial_rates.len() + 1,
"dates has length M+1 when initial_rates has length M"
);
assert!(dates[0] >= 0.0, "T_0 must be non-negative");
for i in 1..dates.len() {
assert!(dates[i] > dates[i - 1], "tenor dates strictly increasing");
}
Self {
dates,
initial_rates,
}
}
pub fn m(&self) -> usize {
self.initial_rates.len()
}
pub fn tau(&self, j: usize) -> f64 {
assert!(j >= 1 && j <= self.m());
self.dates[j] - self.dates[j - 1]
}
pub fn eta(&self, t: f64) -> usize {
for j in 1..=self.m() {
if self.dates[j] >= t {
return j;
}
}
self.m() + 1
}
}
#[derive(Copy, Clone, Debug, Default, PartialEq)]
pub struct LinearDecay;
impl LinearDecay {
pub fn gamma(&self, j: usize, t: f64, tenor: &FmmTenor) -> f64 {
let lo = tenor.dates[j - 1];
let hi = tenor.dates[j];
if t <= lo {
1.0
} else if t >= hi {
0.0
} else {
(hi - t) / (hi - lo)
}
}
pub fn big_g(&self, j: usize, s: f64, big_t: f64, tenor: &FmmTenor) -> f64 {
self.gamma(j, s, tenor) - self.gamma(j, big_t, tenor)
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum VolSchedule {
PiecewiseConstant(Vec<Vec<(f64, f64)>>),
HoLeeEquivalent { vol: f64, mean_reversion: f64 },
}
impl VolSchedule {
fn sigma_at(&self, j: usize, t: f64, tenor: &FmmTenor) -> f64 {
match self {
VolSchedule::PiecewiseConstant(schedules) => {
let per_rate = &schedules[j - 1];
assert!(!per_rate.is_empty(), "empty per-rate schedule for j={j}");
let mut last = per_rate[0].1;
for &(t_start, sigma) in per_rate.iter() {
if t_start <= t {
last = sigma;
} else {
break;
}
}
last
}
VolSchedule::HoLeeEquivalent {
vol,
mean_reversion,
} => {
let a = *mean_reversion;
let t_km1 = tenor.dates[j - 1];
let t_k = tenor.dates[j];
if t >= t_k {
return 0.0;
}
if a.abs() < 1e-12 {
return vol * (t_k - t_km1);
}
vol * ((-a * (t_km1 - t)).exp() - (-a * (t_k - t)).exp()) / a
}
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Fmm {
pub tenor: FmmTenor,
pub sigmas: Vec<f64>,
pub correlation: Vec<Vec<f64>>,
pub decay: LinearDecay,
pub betas: Option<Vec<f64>>,
pub vol_schedule: Option<VolSchedule>,
}
impl Fmm {
pub fn new(
tenor: FmmTenor,
sigmas: Vec<f64>,
correlation: Vec<Vec<f64>>,
decay: LinearDecay,
) -> Self {
let m = tenor.m();
assert_eq!(sigmas.len(), m, "sigmas length must match tenor M");
assert_eq!(correlation.len(), m, "correlation must be M×M");
for (i, row) in correlation.iter().enumerate() {
assert_eq!(row.len(), m, "correlation row {i} length ≠ M");
assert!(
(row[i] - 1.0).abs() < 1e-12,
"correlation diagonal [{i}] ≠ 1"
);
for (j, &c) in row.iter().enumerate() {
assert!(
(c - correlation[j][i]).abs() < 1e-12,
"correlation not symmetric at ({i},{j})"
);
assert!(c.abs() <= 1.0 + 1e-12, "|ρ| > 1 at ({i},{j})");
}
}
for &s in &sigmas {
assert!(s.is_finite() && s >= 0.0, "σ_j must be non-negative");
}
Self {
tenor,
sigmas,
correlation,
decay,
betas: None,
vol_schedule: None,
}
}
pub fn with_betas(mut self, betas: Vec<f64>) -> Self {
let m = self.tenor.m();
assert_eq!(betas.len(), m, "betas length must match tenor M");
for &b in &betas {
assert!((0.0..=1.0).contains(&b), "β_j must be in [0, 1]");
}
self.betas = Some(betas);
self
}
pub fn with_vol_schedule(mut self, schedule: VolSchedule) -> Self {
if let VolSchedule::PiecewiseConstant(sched) = &schedule {
assert_eq!(
sched.len(),
self.tenor.m(),
"piecewise-constant schedule length must match tenor M"
);
for (j, per_rate) in sched.iter().enumerate() {
assert!(!per_rate.is_empty(), "schedule[{j}] must have ≥ 1 knot");
for w in per_rate.windows(2) {
assert!(
w[1].0 > w[0].0,
"schedule knots must have strictly increasing t_start"
);
}
}
}
self.vol_schedule = Some(schedule);
self
}
pub fn sigma_at(&self, j: usize, t: f64) -> f64 {
match &self.vol_schedule {
Some(s) => s.sigma_at(j, t, &self.tenor),
None => self.sigmas[j - 1],
}
}
pub fn displacement_level(&self, j: usize, r_j: f64) -> f64 {
match &self.betas {
None => 1.0,
Some(betas) => {
let b = betas[j - 1];
let r_j0 = self.tenor.initial_rates[j - 1];
b * r_j + (1.0 - b) * r_j0
}
}
}
pub fn adapted_vol(&self, j: usize, t: f64, r_j: f64) -> f64 {
self.sigma_at(j, t) * self.displacement_level(j, r_j)
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct FmmPath {
pub rates: Vec<f64>,
pub y_diag: Vec<f64>,
pub rates_at_start: Vec<f64>,
pub y_at_start: Vec<f64>,
pub x_active: f64,
pub t: f64,
}
pub struct FmmSimulator {
pub model: Fmm,
chol: Vec<Vec<f64>>,
rng: ChaCha20Rng,
}
impl FmmSimulator {
pub fn new(model: Fmm, seed: u64) -> Result<Self, &'static str> {
let chol = cholesky(&model.correlation).ok_or("correlation is not positive-definite")?;
Ok(Self {
model,
chol,
rng: ChaCha20Rng::seed_from_u64(seed),
})
}
pub fn initial_path(&self) -> FmmPath {
let m = self.model.tenor.m();
let mut rates_at_start = vec![0.0; m];
rates_at_start[0] = self.model.tenor.initial_rates[0];
FmmPath {
rates: self.model.tenor.initial_rates.clone(),
y_diag: vec![0.0; m],
rates_at_start,
y_at_start: vec![0.0; m],
x_active: 0.0,
t: 0.0,
}
}
pub fn step(&mut self, path: &mut FmmPath, dt: f64) {
assert!(dt > 0.0, "dt must be positive");
let m = self.model.tenor.m();
let t = path.t;
let t_mid = t + 0.5 * dt;
let sqrt_dt = dt.sqrt();
let z: Vec<f64> = (0..m).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 mut gamma_mid = vec![0.0_f64; m];
let mut drift_weight = vec![0.0_f64; m];
let mut adapted = vec![0.0_f64; m];
for i in 1..=m {
let g = self.model.decay.gamma(i, t_mid, &self.model.tenor);
gamma_mid[i - 1] = g;
let r_i = path.rates[i - 1];
let sig_adapt = self.model.adapted_vol(i, t_mid, r_i);
adapted[i - 1] = sig_adapt;
let tau_i = self.model.tenor.tau(i);
drift_weight[i - 1] = sig_adapt * g * tau_i / (1.0 + tau_i * r_i);
}
let eta_old = self.model.tenor.eta(t);
let mut new_rates = path.rates.clone();
for j in eta_old..=m {
let gamma_j = gamma_mid[j - 1];
if gamma_j == 0.0 {
continue;
}
let sig_adapt_j = adapted[j - 1];
let mut sum = 0.0;
for i in eta_old..=j {
sum += self.model.correlation[i - 1][j - 1] * drift_weight[i - 1];
}
let drift = sig_adapt_j * gamma_j * sum;
let diffusion = sig_adapt_j * gamma_j * dw[j - 1];
new_rates[j - 1] = path.rates[j - 1] + drift * dt + diffusion;
}
for j in 1..=m {
let tj = self.model.tenor.dates[j];
if t >= tj {
continue;
}
let effective_dt = (tj - t).min(dt);
let tau_j = self.model.tenor.tau(j);
let r_j = path.rates[j - 1];
let sig_adapt = self.model.adapted_vol(j, t_mid, r_j);
let integrand = (sig_adapt / (r_j + 1.0 / tau_j)).powi(2);
path.y_diag[j - 1] += integrand * effective_dt;
}
if eta_old >= 1 && eta_old <= m {
let k = eta_old;
let tk_minus_1 = self.model.tenor.dates[k - 1];
let tk = self.model.tenor.dates[k];
if t > tk_minus_1 && t < tk {
let tau_k = self.model.tenor.tau(k);
let r_k_mid = 0.5 * (path.rates[k - 1] + new_rates[k - 1]);
let sig_adapt_k = self.model.adapted_vol(k, t_mid, r_k_mid);
let g_k = 1.0 / tau_k;
let y_k = path.y_diag[k - 1] - path.y_at_start[k - 1];
let drift_x = g_k * y_k;
let diffusion_x = sig_adapt_k / (r_k_mid + 1.0 / tau_k) * dw[k - 1];
path.x_active += drift_x * dt + diffusion_x;
}
}
path.rates = new_rates;
path.t += dt;
let eta_new = self.model.tenor.eta(path.t);
if eta_new > eta_old {
for k in (eta_old + 1)..=eta_new.min(m) {
path.rates_at_start[k - 1] = path.rates[k - 1];
path.y_at_start[k - 1] = path.y_diag[k - 1];
path.x_active = 0.0;
}
}
}
pub fn simulate_terminal(
&mut self,
t_end: f64,
n_steps: usize,
n_paths: usize,
) -> Vec<FmmPath> {
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 path = self.initial_path();
for _ in 0..n_steps {
self.step(&mut path, dt);
}
out.push(path);
}
out
}
}
impl SimulationModel for FmmSimulator {
type State = FmmPath;
fn initial_state(&self) -> Self::State {
self.initial_path()
}
fn step(&mut self, state: &Self::State, _t: f64, dt: f64) -> Self::State {
let mut next = state.clone();
FmmSimulator::step(self, &mut next, dt);
next
}
}
pub fn back_stub_forward_bond(
model: &Fmm,
path: &FmmPath,
k: usize,
big_t: f64,
p0_tk_minus_1_big_t: f64,
p0_tk_minus_1_tk: f64,
) -> f64 {
let tenor = &model.tenor;
assert!(k >= 1 && k <= tenor.m());
let tk_minus_1 = tenor.dates[k - 1];
let tk = tenor.dates[k];
assert!(
path.t <= tk_minus_1 + 1e-15,
"back-stub requires t ≤ T_{{k-1}}; got t={} T_{{k-1}}={}",
path.t,
tk_minus_1
);
assert!(
big_t > tk_minus_1 - 1e-15 && big_t <= tk + 1e-15,
"back-stub requires T ∈ (T_{{k-1}}, T_k]"
);
let r_k = path.rates[k - 1];
let tau_k = tenor.tau(k);
let g_km1_t = model.decay.big_g(k, tk_minus_1, big_t, tenor);
let g_t_tk = model.decay.big_g(k, big_t, tk, tenor);
let y_kk = path.y_diag[k - 1];
let a = p0_tk_minus_1_big_t;
let b = (1.0 + tau_k * r_k).powf(-g_km1_t);
let c = p0_tk_minus_1_tk.powf(-g_km1_t);
let d = (0.5 * g_km1_t * g_t_tk * y_kk).exp();
a * b * c * d
}
pub fn front_stub_bond(
model: &Fmm,
path: &FmmPath,
big_t: f64,
p0_t_big_t: f64,
p0_tk_minus_1_tk: f64,
) -> f64 {
let tenor = &model.tenor;
let k = tenor.eta(path.t);
assert!(
k >= 1 && k <= tenor.m(),
"t must be inside some [T_{{k-1}}, T_k]"
);
let tk_minus_1 = tenor.dates[k - 1];
let tk = tenor.dates[k];
assert!(
path.t > tk_minus_1 - 1e-15 && path.t < tk + 1e-15,
"front-stub requires t ∈ [T_{{k-1}}, T_k]; got t={} in period k={}",
path.t,
k
);
assert!(
big_t > path.t - 1e-15 && big_t <= tk + 1e-15,
"front-stub requires t < T ≤ T_k"
);
let tau_k = tenor.tau(k);
let r_k_start = path.rates_at_start[k - 1];
let y_at_start = path.y_at_start[k - 1];
let y_k = path.y_diag[k - 1] - y_at_start;
let x_k = path.x_active;
let g_t_big_t = model.decay.big_g(k, path.t, big_t, tenor);
let g_big_t_tk = model.decay.big_g(k, big_t, tk, tenor);
let g_km1_big_t = model.decay.big_g(k, tk_minus_1, big_t, tenor);
let g_t_tk = model.decay.big_g(k, path.t, tk, tenor);
let g_km1_t = model.decay.big_g(k, tk_minus_1, path.t, tenor);
let a = p0_t_big_t;
let b = p0_tk_minus_1_tk.powf(-g_t_big_t);
let c = (1.0 + tau_k * r_k_start).powf(-g_t_big_t);
let exponent = -g_t_big_t * x_k - 0.5 * g_t_big_t * g_t_big_t * y_k
+ 0.5 * y_at_start * (g_big_t_tk * g_km1_big_t - g_t_tk * g_km1_t);
a * b * c * exponent.exp()
}
pub trait InitialDiscountCurve {
fn p0(&self, big_t: f64) -> f64;
fn p0_fwd(&self, s: f64, big_t: f64) -> f64 {
self.p0(big_t) / self.p0(s)
}
}
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct FlatCurve {
pub rate: f64,
}
impl InitialDiscountCurve for FlatCurve {
fn p0(&self, big_t: f64) -> f64 {
(-self.rate * big_t).exp()
}
}
pub fn bond_price<C: InitialDiscountCurve>(
model: &Fmm,
path: &FmmPath,
big_t: f64,
curve: &C,
) -> f64 {
let tenor = &model.tenor;
let m = tenor.m();
let t = path.t;
assert!(big_t >= t - 1e-15, "P(t, T) requires T ≥ t");
if (big_t - t).abs() < 1e-15 {
return 1.0;
}
let eta_t = tenor.eta(t);
let eta_big_t = tenor.eta(big_t);
if eta_big_t == eta_t && eta_t >= 1 && eta_t <= m {
let k = eta_t;
let tk_minus_1 = tenor.dates[k - 1];
if t <= tk_minus_1 + 1e-15 {
let p0_km1_big_t = curve.p0_fwd(tk_minus_1, big_t);
let p0_km1_tk = curve.p0_fwd(tk_minus_1, tenor.dates[k]);
return back_stub_forward_bond(model, path, k, big_t, p0_km1_big_t, p0_km1_tk);
}
let p0_t_big_t = curve.p0_fwd(t, big_t);
let p0_km1_tk = curve.p0_fwd(tk_minus_1, tenor.dates[k]);
return front_stub_bond(model, path, big_t, p0_t_big_t, p0_km1_tk);
}
assert!(
eta_big_t <= m,
"bond_price: T beyond tenor end (T_M={}) not supported",
tenor.dates[m]
);
let t_eta = tenor.dates[eta_t];
let front_piece = {
let k = eta_t;
let tk_minus_1 = tenor.dates[k - 1];
if t <= tk_minus_1 + 1e-15 {
let p0_km1_ek = curve.p0_fwd(tk_minus_1, t_eta);
let p0_km1_tk = curve.p0_fwd(tk_minus_1, tenor.dates[k]);
back_stub_forward_bond(model, path, k, t_eta, p0_km1_ek, p0_km1_tk)
} else {
let p0_t_big = curve.p0_fwd(t, t_eta);
let p0_km1_tk = curve.p0_fwd(tk_minus_1, tenor.dates[k]);
front_stub_bond(model, path, t_eta, p0_t_big, p0_km1_tk)
}
};
let mut middle = 1.0;
for j in (eta_t + 1)..eta_big_t {
middle /= 1.0 + tenor.tau(j) * path.rates[j - 1];
}
let back_piece = {
let k = eta_big_t;
let tk_minus_1 = tenor.dates[k - 1];
let tk = tenor.dates[k];
let p0_km1_big_t = curve.p0_fwd(tk_minus_1, big_t);
let p0_km1_tk = curve.p0_fwd(tk_minus_1, tk);
back_stub_forward_bond(model, path, k, big_t, p0_km1_big_t, p0_km1_tk)
};
front_piece * middle * back_piece
}
pub fn bank_account<C: InitialDiscountCurve>(model: &Fmm, path: &FmmPath, curve: &C) -> f64 {
let tenor = &model.tenor;
let m = tenor.m();
let t = path.t;
let eta_t = tenor.eta(t);
let upper = eta_t.min(m);
let mut product = 1.0;
for j in 1..=upper {
product *= 1.0 + tenor.tau(j) * path.rates[j - 1];
}
if eta_t > m {
return product;
}
let bond = bond_price(model, path, tenor.dates[eta_t], curve);
product * bond
}
#[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::simulation::simulate_at_dates;
use crate::time::daycounters::actual365fixed::Actual365Fixed;
use chrono::NaiveDate;
fn flat_tenor(m: usize, dt: f64, r0: f64) -> FmmTenor {
let dates: Vec<f64> = (0..=m).map(|k| k as f64 * dt).collect();
let rates = vec![r0; m];
FmmTenor::new(dates, rates)
}
fn identity_corr(m: usize) -> Vec<Vec<f64>> {
(0..m)
.map(|i| (0..m).map(|j| if i == j { 1.0 } else { 0.0 }).collect())
.collect()
}
#[test]
fn eta_tracks_tenor_crossings() {
let tenor = flat_tenor(3, 0.5, 0.02);
assert_eq!(tenor.eta(-1.0), 1);
assert_eq!(tenor.eta(0.0), 1);
assert_eq!(tenor.eta(0.25), 1);
assert_eq!(tenor.eta(0.5), 1); assert_eq!(tenor.eta(0.5 + 1e-12), 2);
assert_eq!(tenor.eta(1.5), 3);
assert_eq!(tenor.eta(10.0), 4); }
#[test]
fn linear_decay_hits_endpoints() {
let tenor = flat_tenor(2, 1.0, 0.02);
let d = LinearDecay;
assert!((d.gamma(1, 0.0, &tenor) - 1.0).abs() < 1e-15);
assert!((d.gamma(1, 0.5, &tenor) - 0.5).abs() < 1e-15);
assert!(d.gamma(1, 1.0, &tenor).abs() < 1e-15);
assert!((d.gamma(2, 0.9, &tenor) - 1.0).abs() < 1e-15);
assert!((d.gamma(2, 1.5, &tenor) - 0.5).abs() < 1e-15);
assert!(d.gamma(2, 2.0, &tenor).abs() < 1e-15);
assert!((d.big_g(1, 0.0, 1.0, &tenor) - 1.0).abs() < 1e-15);
assert!((d.big_g(2, 1.0, 2.0, &tenor) - 1.0).abs() < 1e-15);
}
#[test]
fn initial_path_matches_tenor() {
let tenor = flat_tenor(3, 0.5, 0.03);
let model = Fmm::new(tenor, vec![0.01; 3], identity_corr(3), LinearDecay);
let sim = FmmSimulator::new(model, 7).unwrap();
let path = sim.initial_path();
assert_eq!(path.rates, vec![0.03; 3]);
assert_eq!(path.y_diag, vec![0.0; 3]);
assert_eq!(path.rates_at_start[0], 0.03);
assert_eq!(path.y_at_start, vec![0.0; 3]);
assert_eq!(path.x_active, 0.0);
assert_eq!(path.t, 0.0);
}
#[test]
fn rate_freezes_after_its_maturity() {
let tenor = flat_tenor(2, 0.5, 0.02);
let model = Fmm::new(tenor, vec![0.05; 2], identity_corr(2), LinearDecay);
let mut sim = FmmSimulator::new(model, 42).unwrap();
let mut path = sim.initial_path();
let dt = 0.01;
let n_steps = 80; for _ in 0..n_steps {
sim.step(&mut path, dt);
}
let r1_past = path.rates[0];
for _ in 0..10 {
sim.step(&mut path, dt);
}
assert!((path.rates[0] - r1_past).abs() < 1e-14);
}
#[test]
fn mc_mean_near_initial_for_small_vol() {
let tenor = flat_tenor(3, 0.5, 0.02);
let model = Fmm::new(tenor, vec![0.005; 3], identity_corr(3), LinearDecay);
let mut sim = FmmSimulator::new(model, 1234).unwrap();
let t_end = 0.25; let n_steps = 50;
let n_paths = 5_000;
let paths = sim.simulate_terminal(t_end, n_steps, n_paths);
for j in 0..3 {
let mean: f64 = paths.iter().map(|p| p.rates[j]).sum::<f64>() / n_paths as f64;
assert!(
(mean - 0.02).abs() < 1e-3,
"rate {} mean {:.6} drifted too far from 0.02",
j + 1,
mean
);
}
}
#[test]
fn back_stub_reduces_at_period_end() {
let tenor = flat_tenor(2, 0.5, 0.03);
let model = Fmm::new(tenor, vec![0.01; 2], identity_corr(2), LinearDecay);
let sim = FmmSimulator::new(model.clone(), 7).unwrap();
let mut path = sim.initial_path();
path.rates[0] = 0.035;
path.y_diag[0] = 1e-6;
let p0_t0_t1 = (-0.03_f64 * 0.5).exp();
let p0_t0_big_t = p0_t0_t1;
let got = back_stub_forward_bond(&model, &path, 1, 0.5, p0_t0_big_t, p0_t0_t1);
let want = 1.0 / (1.0 + model.tenor.tau(1) * path.rates[0]);
assert!(
(got - want).abs() < 1e-12,
"back-stub at T_k: got {} vs single-period {} ",
got,
want
);
}
#[test]
fn back_stub_is_monotone_across_period() {
let tenor = flat_tenor(1, 1.0, 0.02);
let model = Fmm::new(tenor, vec![0.01], vec![vec![1.0]], LinearDecay);
let sim = FmmSimulator::new(model.clone(), 1).unwrap();
let path = sim.initial_path();
let flat = |t: f64| (-0.02_f64 * t).exp();
let p1 = back_stub_forward_bond(&model, &path, 1, 0.25, flat(0.25), flat(1.0));
let p2 = back_stub_forward_bond(&model, &path, 1, 0.50, flat(0.50), flat(1.0));
let p3 = back_stub_forward_bond(&model, &path, 1, 0.75, flat(0.75), flat(1.0));
let p4 = back_stub_forward_bond(&model, &path, 1, 1.00, flat(1.00), flat(1.0));
assert!(p1 > p2 && p2 > p3 && p3 > p4, "P(·,T) not decreasing in T");
}
#[test]
fn y_diag_stops_at_period_end() {
let tenor = flat_tenor(2, 0.5, 0.03);
let model = Fmm::new(tenor, vec![0.01; 2], identity_corr(2), LinearDecay);
let mut sim = FmmSimulator::new(model, 999).unwrap();
let mut path = sim.initial_path();
let dt = 0.05;
for _ in 0..12 {
sim.step(&mut path, dt);
}
assert!(path.t > 0.59 && path.t < 0.61);
let y1_frozen = path.y_diag[0];
assert!(y1_frozen > 0.0, "Y_1,1 should have grown across [0, T_1]");
for _ in 0..5 {
sim.step(&mut path, dt);
}
assert!(
(path.y_diag[0] - y1_frozen).abs() < 1e-15,
"Y_1,1 moved past T_1: {} vs {}",
path.y_diag[0],
y1_frozen
);
assert!(path.y_diag[1] > 0.0);
}
#[test]
fn period_crossing_snapshots_fire() {
let tenor = flat_tenor(2, 0.5, 0.02);
let model = Fmm::new(tenor, vec![0.01; 2], identity_corr(2), LinearDecay);
let mut sim = FmmSimulator::new(model, 7).unwrap();
let mut path = sim.initial_path();
assert_eq!(path.rates_at_start[0], 0.02);
assert_eq!(path.y_at_start[0], 0.0);
assert_eq!(path.rates_at_start[1], 0.0);
let dt = 0.05;
for _ in 0..11 {
sim.step(&mut path, dt);
}
assert!(path.t > 0.5 - 1e-12, "should be past T_1 = 0.5");
assert!(path.rates_at_start[1] != 0.0, "R_2(T_1) snapshot missing");
assert!(path.y_at_start[1] > 0.0, "Y_2,2(T_1) snapshot missing");
assert_eq!(path.x_active, 0.0);
}
#[test]
fn front_stub_bond_is_future_discount() {
let tenor = flat_tenor(2, 0.5, 0.03);
let model = Fmm::new(tenor.clone(), vec![0.005; 2], identity_corr(2), LinearDecay);
let mut sim = FmmSimulator::new(model.clone(), 42).unwrap();
let mut path = sim.initial_path();
let dt = 0.02;
for _ in 0..15 {
sim.step(&mut path, dt);
}
assert!(path.t > 0.29 && path.t < 0.31);
let curve = FlatCurve { rate: 0.03 };
let p0_t_big = curve.p0_fwd(path.t, 0.5);
let p0_km1_tk = curve.p0_fwd(0.0, 0.5);
let p_t_tk = front_stub_bond(&model, &path, 0.5, p0_t_big, p0_km1_tk);
assert!(p_t_tk > 0.0 && p_t_tk < 1.0);
let p_dispatch = bond_price(&model, &path, 0.5, &curve);
assert!(
(p_t_tk - p_dispatch).abs() < 1e-12,
"front-stub vs dispatcher: {} vs {}",
p_t_tk,
p_dispatch
);
}
#[test]
fn bond_price_identity_at_own_time() {
let tenor = flat_tenor(3, 0.5, 0.025);
let model = Fmm::new(tenor, vec![0.01; 3], identity_corr(3), LinearDecay);
let sim = FmmSimulator::new(model.clone(), 3).unwrap();
let path = sim.initial_path();
let curve = FlatCurve { rate: 0.025 };
assert!((bond_price(&model, &path, 0.0, &curve) - 1.0).abs() < 1e-15);
}
#[test]
fn bond_price_reproduces_market_curve_at_time_zero() {
let tenor = flat_tenor(3, 0.5, 0.02);
let r = 0.02_f64;
let tau = 0.5_f64;
let r_j = (r * tau).exp_m1() / tau;
let tenor = FmmTenor::new(tenor.dates.clone(), vec![r_j; 3]);
let model = Fmm::new(tenor, vec![0.01; 3], identity_corr(3), LinearDecay);
let sim = FmmSimulator::new(model.clone(), 0).unwrap();
let path = sim.initial_path();
let curve = FlatCurve { rate: r };
for &big_t in &[0.25_f64, 0.5, 0.8, 1.0, 1.25, 1.5] {
let got = bond_price(&model, &path, big_t, &curve);
let want = curve.p0(big_t);
assert!(
(got - want).abs() < 1e-12,
"P(0, {}) got {} vs market {}",
big_t,
got,
want
);
}
}
#[test]
fn bank_account_is_one_at_time_zero() {
let tenor = flat_tenor(2, 0.5, 0.02);
let r = 0.02_f64;
let tau = 0.5_f64;
let r_j = (r * tau).exp_m1() / tau;
let tenor = FmmTenor::new(tenor.dates.clone(), vec![r_j; 2]);
let model = Fmm::new(tenor, vec![0.01; 2], identity_corr(2), LinearDecay);
let sim = FmmSimulator::new(model.clone(), 0).unwrap();
let path = sim.initial_path();
let curve = FlatCurve { rate: r };
let b = bank_account(&model, &path, &curve);
assert!((b - 1.0).abs() < 1e-12, "B(0) = {} ≠ 1", b);
}
#[test]
fn bank_account_grows_along_path() {
let tenor = flat_tenor(3, 0.5, 0.03);
let model = Fmm::new(tenor, vec![0.005; 3], identity_corr(3), LinearDecay);
let mut sim = FmmSimulator::new(model.clone(), 2024).unwrap();
let mut path = sim.initial_path();
let curve = FlatCurve { rate: 0.03 };
let dt = 0.05;
let mut last = bank_account(&model, &path, &curve);
for _ in 0..30 {
sim.step(&mut path, dt);
let next = bank_account(&model, &path, &curve);
assert!(
next >= last - 1e-8,
"B({}) = {} dropped from previous {}",
path.t,
next,
last
);
last = next;
}
assert!(last > 1.0, "final B({}) = {} should exceed 1", path.t, last);
}
#[test]
fn cholesky_reproduces_correlation() {
let rho = vec![
vec![1.0, 0.3, 0.1],
vec![0.3, 1.0, 0.4],
vec![0.1, 0.4, 1.0],
];
let l = cholesky(&rho).expect("positive-definite");
for (i, li) in l.iter().enumerate() {
for (j, lj) in l.iter().enumerate() {
let s: f64 = li.iter().zip(lj.iter()).map(|(a, b)| a * b).sum();
assert!((s - rho[i][j]).abs() < 1e-12);
}
}
}
#[test]
fn simulator_rejects_bad_correlation() {
let tenor = flat_tenor(2, 0.5, 0.02);
let bad = vec![vec![1.0, 0.99], vec![0.99, 1.0]];
let model = Fmm::new(tenor, vec![0.01; 2], bad, LinearDecay);
assert!(FmmSimulator::new(model, 0).is_ok());
let tenor3 = flat_tenor(3, 0.5, 0.02);
let singular = vec![
vec![1.0, 1.0, 1.0],
vec![1.0, 1.0, 1.0],
vec![1.0, 1.0, 1.0],
];
let bad_model = Fmm::new(tenor3, vec![0.01; 3], singular, LinearDecay);
assert!(FmmSimulator::new(bad_model, 0).is_err());
}
#[test]
fn simulation_model_impl_works_with_generic_runner() {
let tenor = flat_tenor(3, 0.5, 0.03);
let model = Fmm::new(tenor, vec![0.005; 3], identity_corr(3), LinearDecay);
let mut sim = FmmSimulator::new(model, 99).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], 50, 7, &dc);
assert_eq!(paths.n_paths(), 50);
let at_d1 = paths.states_at(d1).unwrap();
let at_d2 = paths.states_at(d2).unwrap();
for (s1, s2) in at_d1.iter().zip(at_d2.iter()) {
assert_eq!(s1.rates.len(), 3);
assert!(s1.t < s2.t);
}
let r1_at_d2 = paths.sample(d2, |p| p.rates[0]).unwrap();
assert_eq!(r1_at_d2.len(), 50);
}
#[test]
fn displacement_level_matches_convention() {
let tenor = flat_tenor(2, 0.5, 0.04);
let model = Fmm::new(tenor.clone(), vec![0.01; 2], identity_corr(2), LinearDecay);
assert_eq!(model.displacement_level(1, 0.06), 1.0);
assert_eq!(model.displacement_level(2, 0.00), 1.0);
let dd = Fmm::new(tenor, vec![0.01; 2], identity_corr(2), LinearDecay)
.with_betas(vec![1.0, 0.5]);
assert!((dd.displacement_level(1, 0.06) - 0.06).abs() < 1e-15);
assert!((dd.displacement_level(2, 0.03) - 0.035).abs() < 1e-15);
}
#[test]
fn ho_lee_equivalent_vol_matches_paper_formula() {
let tenor = FmmTenor::new(vec![0.0, 0.5, 1.0, 1.5], vec![0.02; 3]);
let schedule = VolSchedule::HoLeeEquivalent {
vol: 0.01,
mean_reversion: 0.05,
};
let a = 0.05_f64;
let vol = 0.01_f64;
let want_k1 = vol * (1.0 - (-a * 0.5_f64).exp()) / a;
assert!((schedule.sigma_at(1, 0.0, &tenor) - want_k1).abs() < 1e-15);
let t = 0.25_f64;
let want_k2 = vol * ((-a * (0.5 - t)).exp() - (-a * (1.0 - t)).exp()) / a;
assert!((schedule.sigma_at(2, t, &tenor) - want_k2).abs() < 1e-15);
assert_eq!(schedule.sigma_at(1, 0.6, &tenor), 0.0);
let ho_lee = VolSchedule::HoLeeEquivalent {
vol: 0.01,
mean_reversion: 0.0,
};
assert!((ho_lee.sigma_at(1, 0.0, &tenor) - vol * 0.5).abs() < 1e-15);
assert!((ho_lee.sigma_at(2, 0.2, &tenor) - vol * 0.5).abs() < 1e-15);
}
#[test]
fn piecewise_constant_schedule_steps_at_knots() {
let tenor = flat_tenor(2, 0.5, 0.03);
let schedule = VolSchedule::PiecewiseConstant(vec![
vec![(0.0, 0.01), (0.25, 0.02)],
vec![(0.0, 0.015)],
]);
assert!((schedule.sigma_at(1, 0.0, &tenor) - 0.01).abs() < 1e-15);
assert!((schedule.sigma_at(1, 0.24, &tenor) - 0.01).abs() < 1e-15);
assert!((schedule.sigma_at(1, 0.25, &tenor) - 0.02).abs() < 1e-15);
assert!((schedule.sigma_at(1, 0.4, &tenor) - 0.02).abs() < 1e-15);
assert!((schedule.sigma_at(2, 0.1, &tenor) - 0.015).abs() < 1e-15);
}
#[test]
fn default_fmm_matches_normal_step_bitwise() {
let tenor = flat_tenor(2, 0.5, 0.03);
let base = Fmm::new(tenor.clone(), vec![0.02; 2], identity_corr(2), LinearDecay);
let mut sim1 = FmmSimulator::new(base.clone(), 7).unwrap();
let mut sim2 = FmmSimulator::new(base, 7).unwrap();
let mut p1 = sim1.initial_path();
let mut p2 = sim2.initial_path();
for _ in 0..20 {
sim1.step(&mut p1, 0.02);
sim2.step(&mut p2, 0.02);
}
assert_eq!(p1.rates, p2.rates);
assert_eq!(p1.y_diag, p2.y_diag);
}
#[test]
fn constant_schedule_matches_no_schedule() {
let tenor = flat_tenor(2, 0.5, 0.03);
let plain = Fmm::new(tenor.clone(), vec![0.02; 2], identity_corr(2), LinearDecay);
let scheduled =
Fmm::new(tenor, vec![0.02; 2], identity_corr(2), LinearDecay).with_vol_schedule(
VolSchedule::PiecewiseConstant(vec![vec![(0.0, 0.02)], vec![(0.0, 0.02)]]),
);
let mut sim1 = FmmSimulator::new(plain, 123).unwrap();
let mut sim2 = FmmSimulator::new(scheduled, 123).unwrap();
let mut p1 = sim1.initial_path();
let mut p2 = sim2.initial_path();
for _ in 0..20 {
sim1.step(&mut p1, 0.02);
sim2.step(&mut p2, 0.02);
}
for j in 0..2 {
assert!((p1.rates[j] - p2.rates[j]).abs() < 1e-14);
assert!((p1.y_diag[j] - p2.y_diag[j]).abs() < 1e-14);
}
}
#[test]
fn beta_one_lognormal_keeps_rates_mostly_positive() {
let tenor = flat_tenor(3, 0.5, 0.02);
let model =
Fmm::new(tenor, vec![0.30; 3], identity_corr(3), LinearDecay).with_betas(vec![1.0; 3]);
let mut sim = FmmSimulator::new(model, 2026).unwrap();
let paths = sim.simulate_terminal(1.0, 200, 500);
let negatives = paths
.iter()
.flat_map(|p| p.rates.iter())
.filter(|&&r| r < -1e-4)
.count();
let budget = paths.len() * 3 / 100;
assert!(
negatives <= budget,
"β=1 lognormal produced {} noticeable-negative rates (budget {})",
negatives,
budget
);
}
#[test]
fn beta_zero_gives_gaussian_with_r0_scaled_variance() {
let tenor = flat_tenor(3, 0.5, 0.04);
let sigma = 0.20_f64;
let r0 = 0.04_f64;
let t1 = 0.5_f64;
let t_end = 0.1_f64;
let model =
Fmm::new(tenor, vec![sigma; 3], identity_corr(3), LinearDecay).with_betas(vec![0.0; 3]);
let mut sim = FmmSimulator::new(model, 42).unwrap();
let paths = sim.simulate_terminal(t_end, 50, 10_000);
let r1: Vec<f64> = paths.iter().map(|p| p.rates[0]).collect();
let mean: f64 = r1.iter().sum::<f64>() / r1.len() as f64;
let var: f64 = r1.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / r1.len() as f64;
let gamma_sq_integral = (t1.powi(3) - (t1 - t_end).powi(3)) / (3.0 * t1 * t1);
let expected = (sigma * r0).powi(2) * gamma_sq_integral;
assert!(
(var - expected).abs() < 0.05 * expected,
"β=0 MC var {:.3e} vs expected {:.3e}",
var,
expected
);
}
#[test]
#[should_panic(expected = "β_j must be in [0, 1]")]
fn with_betas_rejects_out_of_range() {
let tenor = flat_tenor(2, 0.5, 0.03);
let _ = Fmm::new(tenor, vec![0.01; 2], identity_corr(2), LinearDecay)
.with_betas(vec![0.5, 1.5]);
}
}