use crate::models::common::simulation::SimulationModel;
use rand::{Rng, SeedableRng};
use rand_chacha::ChaCha20Rng;
use rand_distr::StandardNormal;
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct HullWhite1F {
pub mean_reversion: f64,
pub sigma: f64,
}
impl HullWhite1F {
pub fn b(&self, t: f64, big_t: f64) -> f64 {
let tau = big_t - t;
if self.mean_reversion.abs() < 1.0e-12 {
return -tau;
}
((-self.mean_reversion * tau).exp() - 1.0) / self.mean_reversion
}
pub fn short_rate_variance(&self, t1: f64, t2: f64) -> f64 {
let tau = t2 - t1;
if tau <= 0.0 {
return 0.0;
}
if self.mean_reversion.abs() < 1.0e-12 {
return self.sigma * self.sigma * tau;
}
let a = 2.0 * self.mean_reversion;
self.sigma * self.sigma / a * (1.0 - (-a * tau).exp())
}
pub fn bond_log_variance(&self, s1: f64, s2: f64, big_t: f64) -> f64 {
assert!(s2 >= s1);
if s2 == s1 {
return 0.0;
}
let eta = self.sigma;
let lambda = self.mean_reversion;
let tau_fn = |s: f64| -> f64 {
if lambda.abs() < 1.0e-12 {
return -((big_t - s).powi(3)) / 3.0;
}
let e1 = (-lambda * (big_t - s)).exp();
let e2 = (-2.0 * lambda * (big_t - s)).exp();
(1.0 / (lambda * lambda)) * (-0.5 / lambda * e2 + 2.0 / lambda * e1 + s)
};
eta * eta * (tau_fn(s2) - tau_fn(s1))
}
pub fn discount_affine(
&self,
t: f64,
big_t: f64,
r_t: f64,
p0t: f64,
p0big_t: f64,
f0t: f64,
) -> f64 {
let b = self.b(t, big_t);
let lambda = self.mean_reversion;
let eta = self.sigma;
let convexity = if lambda.abs() < 1.0e-12 {
eta * eta * t * b * b * 0.5
} else {
eta * eta / (4.0 * lambda) * (1.0 - (-2.0 * lambda * t).exp()) * b * b
};
(p0big_t / p0t) * (-b * f0t - convexity + b * r_t).exp()
}
}
pub struct HullWhiteSimulator {
pub model: HullWhite1F,
pub r_0: f64,
rng: ChaCha20Rng,
theta_fn: Box<dyn FnMut(f64) -> f64 + 'static>,
}
impl HullWhiteSimulator {
pub fn new_constant_theta(
model: HullWhite1F,
r_0: f64,
theta_constant: f64,
seed: u64,
) -> Self {
Self {
model,
r_0,
rng: ChaCha20Rng::seed_from_u64(seed),
theta_fn: Box::new(move |_t| theta_constant),
}
}
pub fn with_theta_fn<F>(mut self, f: F) -> Self
where
F: FnMut(f64) -> f64 + 'static,
{
self.theta_fn = Box::new(f);
self
}
}
impl SimulationModel for HullWhiteSimulator {
type State = f64;
fn initial_state(&self) -> Self::State {
self.r_0
}
fn step(&mut self, state: &Self::State, t: f64, dt: f64) -> Self::State {
let z: f64 = self.rng.sample(StandardNormal);
let theta = (self.theta_fn)(t);
state + self.model.mean_reversion * (theta - state) * dt + self.model.sigma * dt.sqrt() * z
}
}
#[cfg(test)]
mod tests {
use super::{HullWhite1F, HullWhiteSimulator};
use crate::models::common::simulation::simulate_at_dates;
use crate::time::daycounters::actual365fixed::Actual365Fixed;
use chrono::NaiveDate;
fn grzelak_usd_like() -> HullWhite1F {
HullWhite1F {
mean_reversion: 0.01,
sigma: 0.007,
}
}
#[test]
fn b_vanishes_at_maturity() {
let hw = grzelak_usd_like();
assert!(hw.b(5.0, 5.0).abs() < 1e-15);
}
#[test]
fn b_is_negative_before_maturity() {
let hw = grzelak_usd_like();
assert!(hw.b(0.0, 1.0) < 0.0);
assert!(hw.b(0.0, 30.0) < 0.0);
}
#[test]
fn b_recovers_ho_lee_limit_at_small_lambda() {
let hw = HullWhite1F {
mean_reversion: 1.0e-14,
sigma: 0.01,
};
let tau = 5.0_f64;
assert!((hw.b(0.0, tau) - (-tau)).abs() < 1e-10);
}
#[test]
fn short_rate_variance_matches_closed_form() {
let hw = grzelak_usd_like();
let v = hw.short_rate_variance(0.0, 1.0);
let expected = hw.sigma * hw.sigma / (2.0 * hw.mean_reversion)
* (1.0 - (-2.0 * hw.mean_reversion).exp());
assert!((v - expected).abs() < 1e-18);
}
#[test]
fn short_rate_variance_is_monotone() {
let hw = grzelak_usd_like();
let v1 = hw.short_rate_variance(0.0, 1.0);
let v10 = hw.short_rate_variance(0.0, 10.0);
assert!(v10 > v1);
assert!(hw.short_rate_variance(0.0, 0.0).abs() < 1e-18);
}
#[test]
fn bond_log_variance_sanity() {
let hw = grzelak_usd_like();
assert!(hw.bond_log_variance(0.0, 0.0, 5.0).abs() < 1e-18);
let v = hw.bond_log_variance(0.0, 5.0, 5.0);
assert!(v > 0.0);
let ho_lee = HullWhite1F {
mean_reversion: 1.0e-14,
sigma: 0.01,
};
let t_end = 5.0_f64;
let got = ho_lee.bond_log_variance(0.0, t_end, t_end);
let want = ho_lee.sigma * ho_lee.sigma * t_end.powi(3) / 3.0;
assert!(
(got - want).abs() < 1e-10,
"Ho–Lee limit: got {} want {}",
got,
want
);
}
#[test]
fn discount_is_monotone_in_tenor_and_rate() {
let hw = grzelak_usd_like();
let p0 = |t: f64| (-0.02_f64 * t).exp();
let d5 = hw.discount_affine(1.0, 5.0, 0.02, p0(1.0), p0(5.0), 0.02);
let d10 = hw.discount_affine(1.0, 10.0, 0.02, p0(1.0), p0(10.0), 0.02);
assert!(
d10 < d5,
"P(t, 10) should be < P(t, 5), got {} vs {}",
d10,
d5
);
let d5_low = hw.discount_affine(1.0, 5.0, 0.01, p0(1.0), p0(5.0), 0.02);
let d5_high = hw.discount_affine(1.0, 5.0, 0.04, p0(1.0), p0(5.0), 0.02);
assert!(
d5_high < d5_low,
"P(t, T) with r=4% should be < with r=1%, got {} vs {}",
d5_high,
d5_low
);
}
#[test]
fn affine_discount_matches_market_at_t_equal_zero() {
let hw = grzelak_usd_like();
let p0_big_t = (-0.02_f64 * 5.0).exp();
let got = hw.discount_affine(0.0, 5.0, 0.02, 1.0, p0_big_t, 0.02);
assert!(
(got - p0_big_t).abs() < 1e-12,
"HW discount at t=0 should equal market, got {} want {}",
got,
p0_big_t
);
}
#[test]
fn hw_simulator_variance_matches_closed_form() {
let hw = grzelak_usd_like();
let r0 = 0.035;
let theta = 0.035; let mut sim = HullWhiteSimulator::new_constant_theta(hw, r0, theta, 42);
let val = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
let horizon = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
let dc = Actual365Fixed::default();
let paths = simulate_at_dates(&mut sim, val, &[horizon], 20_000, 1, &dc);
let rs = paths.states_at(horizon).unwrap();
let mean: f64 = rs.iter().sum::<f64>() / rs.len() as f64;
let var: f64 = rs.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / rs.len() as f64;
let expected = hw.short_rate_variance(0.0, 1.0);
assert!(
(var - expected).abs() < 0.05 * expected,
"MC var {:.3e} vs closed form {:.3e}",
var,
expected
);
let se = (var / rs.len() as f64).sqrt();
assert!(
(mean - r0).abs() < 4.0 * se,
"MC mean {} vs r_0 {}",
mean,
r0
);
}
#[test]
fn hw_simulator_time_dep_theta_drags_mean() {
let hw = HullWhite1F {
mean_reversion: 0.5, sigma: 0.01,
};
let r0 = 0.02;
let target = 0.06;
let mut sim =
HullWhiteSimulator::new_constant_theta(hw, r0, r0, 7).with_theta_fn(move |_t| target);
let val = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
let horizon = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
let dc = Actual365Fixed::default();
let paths = simulate_at_dates(&mut sim, val, &[horizon], 5_000, 1, &dc);
let rs = paths.states_at(horizon).unwrap();
let mean: f64 = rs.iter().sum::<f64>() / rs.len() as f64;
let expected =
r0 * (-hw.mean_reversion).exp() + target * (1.0 - (-hw.mean_reversion).exp());
assert!(
(mean - expected).abs() < 0.002,
"MC mean {:.5} vs expected {:.5}",
mean,
expected
);
}
}