use crate::curves::DiscountCurve;
use crate::traits::PricerExt;
use crate::traits::TimeExt;
#[derive(Default, Debug, Clone)]
pub struct HullWhite {
pub r_t: f64,
pub alpha: f64,
pub sigma: f64,
pub tau: f64,
pub t: f64,
pub p0_at_t: f64,
pub p0_at_maturity: f64,
pub f0_at_t: f64,
pub eval: Option<chrono::NaiveDate>,
pub expiration: Option<chrono::NaiveDate>,
}
impl HullWhite {
pub fn from_curve(
curve: &DiscountCurve<f64>,
r_t: f64,
alpha: f64,
sigma: f64,
t: f64,
tau: f64,
eval: Option<chrono::NaiveDate>,
expiration: Option<chrono::NaiveDate>,
) -> Self {
let p0_at_t = curve.discount_factor(t);
let p0_at_maturity = curve.discount_factor(t + tau);
let f0_at_t = instantaneous_forward(curve, t);
Self {
r_t,
alpha,
sigma,
tau,
t,
p0_at_t,
p0_at_maturity,
f0_at_t,
eval,
expiration,
}
}
}
fn instantaneous_forward(curve: &DiscountCurve<f64>, t: f64) -> f64 {
let h = (1e-4_f64).max(1e-4 * t);
if t > h {
curve.forward_rate(t - h, t + h)
} else {
curve.forward_rate(0.0, h)
}
}
impl PricerExt for HullWhite {
fn calculate_call_put(&self) -> (f64, f64) {
let price = self.calculate_price();
(price, price)
}
fn calculate_price(&self) -> f64 {
let a = self.alpha;
let sigma = self.sigma;
let r = self.r_t;
let tau = self.tau;
let t = self.t;
let b = (1.0 - (-a * tau).exp()) / a;
let exponent =
b * self.f0_at_t - (sigma * sigma) / (4.0 * a) * (1.0 - (-2.0 * a * t).exp()) * b * b - b * r;
self.p0_at_maturity / self.p0_at_t * exponent.exp()
}
}
impl TimeExt for HullWhite {
fn tau(&self) -> Option<f64> {
Some(self.tau)
}
fn eval(&self) -> Option<chrono::NaiveDate> {
self.eval
}
fn expiration(&self) -> Option<chrono::NaiveDate> {
self.expiration
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::curves::types::CurvePoint;
use crate::curves::types::InterpolationMethod;
fn flat_curve(rate: f64) -> DiscountCurve<f64> {
let mut pts = vec![CurvePoint {
time: 0.0,
discount_factor: 1.0,
}];
for i in 1..=20 {
let t = i as f64 * 0.5;
pts.push(CurvePoint {
time: t,
discount_factor: (-rate * t).exp(),
});
}
DiscountCurve::new(pts, InterpolationMethod::LogLinearOnDiscountFactors)
}
#[test]
fn zcb_at_zero_tau_equals_one() {
let curve = flat_curve(0.05);
let h = HullWhite::from_curve(&curve, 0.05, 0.5, 0.01, 0.5, 0.0, None, None);
let p = h.calculate_price();
assert!((p - 1.0).abs() < 1e-12, "P(t,t) must be 1, got {p}");
}
#[test]
fn zcb_at_t_zero_matches_market_curve() {
let rate = 0.05;
let curve = flat_curve(rate);
let f0_at_zero = instantaneous_forward(&curve, 0.0);
let h = HullWhite::from_curve(&curve, f0_at_zero, 0.5, 0.01, 0.0, 2.0, None, None);
let p_hw = h.calculate_price();
let p_market = curve.discount_factor(2.0);
assert!(
(p_hw - p_market).abs() < 1e-6,
"HW@t=0 must match market: hw={p_hw} market={p_market}"
);
}
#[test]
fn zcb_finite_and_positive() {
let curve = flat_curve(0.05);
let h = HullWhite::from_curve(&curve, 0.05, 0.5, 0.01, 0.0, 2.0, None, None);
let p = h.calculate_price();
assert!(
p.is_finite() && p > 0.0,
"ZCB must be finite-positive, got {p}"
);
}
#[test]
fn zcb_decreases_with_short_rate() {
let curve = flat_curve(0.05);
let make = |r| HullWhite::from_curve(&curve, r, 0.5, 0.01, 0.0, 1.0, None, None);
let p_low = make(0.02).calculate_price();
let p_high = make(0.08).calculate_price();
assert!(
p_high < p_low,
"ZCB must decrease with short rate: p(0.02)={p_low} vs p(0.08)={p_high}"
);
}
#[test]
fn zcb_below_one_for_positive_rate_and_tau() {
let curve = flat_curve(0.05);
let h = HullWhite::from_curve(&curve, 0.05, 0.5, 0.01, 0.0, 5.0, None, None);
let p = h.calculate_price();
assert!(p < 1.0 && p > 0.0, "ZCB out of range: {p}");
}
#[test]
fn deterministic_no_clock_dependency() {
let curve = flat_curve(0.04);
let make = || HullWhite::from_curve(&curve, 0.04, 0.3, 0.015, 1.0, 2.0, None, None);
let p1 = make().calculate_price();
let p2 = make().calculate_price();
assert_eq!(
p1.to_bits(),
p2.to_bits(),
"HW must be deterministic, got {p1} != {p2}"
);
}
}