use crate::errors::QlResult;
use crate::handle::Handle;
use crate::interestrate::Compounding;
use crate::models::model::{
CalibratedModel, CalibratedModelHolder, TermStructureConsistentModel,
register_with_term_structure,
};
use crate::models::parameter::NullParameter;
use crate::models::shortrate::onefactormodel::OneFactorAffineModel;
use crate::models::shortrate::vasicek::Vasicek;
use crate::option::OptionType;
use crate::patterns::observable::Observer;
use crate::pricingengines::blackformula::black_formula;
use crate::require;
use crate::shared::{SharedMut, shared_mut};
use crate::termstructures::yieldtermstructure::YieldTermStructure;
use crate::time::frequency::Frequency;
use crate::types::{Rate, Real, Time};
#[allow(clippy::neg_cmp_op_on_partial_ord)]
pub fn convexity_bias(
futures_price: Real,
t: Time,
maturity: Time,
sigma: Real,
a: Real,
) -> QlResult<Rate> {
require!(
futures_price >= 0.0,
"negative futures price ({futures_price}) not allowed"
);
require!(t >= 0.0, "negative t ({t}) not allowed");
require!(
maturity >= t,
"T ({maturity}) must not be less than t ({t})"
);
require!(sigma >= 0.0, "negative sigma ({sigma}) not allowed");
require!(a >= 0.0, "negative a ({a}) not allowed");
let temp = |x: Real| {
if a < Real::EPSILON {
x
} else {
(1.0 - (-a * x).exp()) / a
}
};
let delta_t = maturity - t;
let temp_delta_t = temp(delta_t);
let half_sigma_square = sigma * sigma / 2.0;
let lambda = temp(2.0 * t) * temp_delta_t;
let temp_t = temp(t);
let phi = temp_t * temp_t;
let z = half_sigma_square * (lambda + phi);
let future_rate = (100.0 - futures_price) / 100.0;
if delta_t < Real::EPSILON {
Ok(z)
} else {
Ok((1.0 - (-z * temp_delta_t).exp()) * (future_rate + 1.0 / delta_t))
}
}
pub struct HullWhite {
base: Vasicek,
ts_model: TermStructureConsistentModel,
#[allow(dead_code)]
ts_observer: Option<SharedMut<dyn Observer>>,
}
impl HullWhite {
pub fn new(
term_structure: Handle<dyn YieldTermStructure>,
a: Real,
sigma: Real,
) -> QlResult<SharedMut<HullWhite>> {
let forward = term_structure
.current_link()?
.forward_rate(
0.0,
0.0,
Compounding::Continuous,
Frequency::NoFrequency,
false,
)?
.rate();
let mut base = Vasicek::new(forward, a, 0.0, sigma, 0.0)?;
base.calibrated_model_mut().arguments_mut()[1] = NullParameter::new();
base.calibrated_model_mut().arguments_mut()[3] = NullParameter::new();
let ts_model = TermStructureConsistentModel::new(term_structure.clone());
let mut model = HullWhite {
base,
ts_model,
ts_observer: None,
};
model.generate_arguments();
let shared = shared_mut(model);
let observer = register_with_term_structure(&shared, &term_structure);
shared.borrow_mut().ts_observer = Some(observer);
Ok(shared)
}
pub fn r0(&self) -> Rate {
self.base.r0()
}
pub fn term_structure(&self) -> &Handle<dyn YieldTermStructure> {
self.ts_model.term_structure()
}
fn discount(&self, t: Time) -> QlResult<Real> {
self.ts_model
.term_structure()
.current_link()?
.discount(t, false)
}
pub fn discount_bond_option(
&self,
option_type: OptionType,
strike: Real,
maturity: Time,
bond_maturity: Time,
) -> QlResult<Real> {
let a = self.base.a();
let sigma = self.base.sigma();
let b = OneFactorAffineModel::b(self, maturity, bond_maturity);
let v = if a < Real::EPSILON.sqrt() {
sigma * b * maturity.sqrt()
} else {
sigma * b * (0.5 * (1.0 - (-2.0 * a * maturity).exp()) / a).sqrt()
};
let f = self.discount(bond_maturity)?;
let k = self.discount(maturity)? * strike;
black_formula(option_type, k, f, v, 1.0, 0.0)
}
pub fn discount_bond_option_with_start(
&self,
option_type: OptionType,
strike: Real,
maturity: Time,
bond_start: Time,
bond_maturity: Time,
) -> QlResult<Real> {
let a = self.base.a();
let sigma = self.base.sigma();
let v = if a < Real::EPSILON.sqrt() {
sigma * OneFactorAffineModel::b(self, bond_start, bond_maturity) * maturity.sqrt()
} else {
let c = (-2.0 * a * (bond_start - maturity)).exp()
- (-2.0 * a * bond_start).exp()
- 2.0
* ((-a * (bond_start + bond_maturity - 2.0 * maturity)).exp()
- (-a * (bond_start + bond_maturity)).exp())
+ (-2.0 * a * (bond_maturity - maturity)).exp()
- (-2.0 * a * bond_maturity).exp();
sigma / (a * (2.0 * a).sqrt()) * c.max(0.0).sqrt()
};
let f = self.discount(bond_maturity)?;
let k = self.discount(bond_start)? * strike;
black_formula(option_type, k, f, v, 1.0, 0.0)
}
}
impl CalibratedModelHolder for HullWhite {
fn calibrated_model(&self) -> &CalibratedModel {
self.base.calibrated_model()
}
fn calibrated_model_mut(&mut self) -> &mut CalibratedModel {
self.base.calibrated_model_mut()
}
fn generate_arguments(&mut self) {
let zero = self
.ts_model
.term_structure()
.current_link()
.expect("the Hull-White model requires a non-empty term-structure handle")
.zero_rate(0.0, Compounding::Continuous, Frequency::NoFrequency, false)
.expect("the Hull-White zero rate at t=0 is well-defined on its curve")
.rate();
self.base.set_r0(zero);
}
}
impl OneFactorAffineModel for HullWhite {
fn a(&self, t: Time, maturity: Time) -> Real {
let curve = self
.ts_model
.term_structure()
.current_link()
.expect("the Hull-White model requires a non-empty term-structure handle");
let discount1 = curve
.discount(t, false)
.expect("the Hull-White model's discount is well-defined on its curve");
let discount2 = curve
.discount(maturity, false)
.expect("the Hull-White model's discount is well-defined on its curve");
let forward = curve
.forward_rate(t, t, Compounding::Continuous, Frequency::NoFrequency, false)
.expect("the Hull-White model's forward rate is well-defined on its curve")
.rate();
let b = OneFactorAffineModel::b(self, t, maturity);
let temp = self.base.sigma() * b;
let value = b * forward - 0.25 * temp * temp * OneFactorAffineModel::b(self, 0.0, 2.0 * t);
value.exp() * discount2 / discount1
}
fn b(&self, t: Time, maturity: Time) -> Real {
OneFactorAffineModel::b(&self.base, t, maturity)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cashflows::RateAveraging;
use crate::handle::RelinkableHandle;
use crate::indexes::ibor::Euribor;
use crate::math::array::Array;
use crate::math::interpolations::linear::Linear;
use crate::math::optimization::endcriteria::{EndCriteria, EndCriteriaType};
use crate::math::optimization::levenbergmarquardt::LevenbergMarquardt;
use crate::models::calibrationhelper::{
BlackCalibrationHelper, CalibrationErrorType, CalibrationHelper,
};
use crate::models::model::{calibrate, calibration_value};
use crate::models::shortrate::SwaptionHelper;
use crate::pricingengine::PricingEngine;
use crate::pricingengines::JamshidianSwaptionEngine;
use crate::quotes::{Quote, SimpleQuote};
use crate::settings::Settings;
use crate::shared::{Shared, shared};
use crate::termstructures::volatility::VolatilityType;
use crate::termstructures::yields::{FlatForward, ZeroCurve};
use crate::time::date::{Date, Month};
use crate::time::daycounters::actual360::Actual360;
use crate::time::daycounters::actual365fixed::Actual365Fixed;
use crate::time::daycounters::thirty360::{Convention, Thirty360};
use crate::time::period::Period;
use crate::time::timeunit::TimeUnit;
fn sloped_curve() -> Handle<dyn YieldTermStructure> {
let dates = vec![
Date::new(15, Month::January, 2026),
Date::new(15, Month::January, 2027),
Date::new(15, Month::January, 2028),
Date::new(15, Month::January, 2029),
Date::new(15, Month::January, 2031),
];
let zeros = vec![0.02, 0.025, 0.03, 0.033, 0.04];
let curve = ZeroCurve::new(dates, zeros, Actual365Fixed::new(), Linear).unwrap();
Handle::new(shared(curve) as Shared<dyn YieldTermStructure>)
}
#[test]
fn discount_bond_matches_cpp_hull_white() {
let handle = sloped_curve();
let curve = handle.current_link().unwrap();
assert!((curve.discount(2.0, false).unwrap() - 0.941_764_533_584_248_7).abs() < 1e-14);
assert!((curve.discount(3.0, false).unwrap() - 0.905_764_980_659_064).abs() < 1e-14);
assert!((curve.discount(5.0, false).unwrap() - 0.818_770_008_233_211_2).abs() < 1e-14);
let model = HullWhite::new(handle, 0.05, 0.01).unwrap();
let m = model.borrow();
assert!((m.discount_bond(0.0, 2.0, 0.03) - 0.924_010_757_799_029_2).abs() < 1e-10);
assert!((m.discount_bond(1.0, 3.0, 0.03) - 0.928_534_477_203_476).abs() < 1e-10);
assert!((m.discount_bond(2.0, 5.0, 0.025) - 0.900_808_567_926_092_5).abs() < 1e-10);
}
#[test]
fn discount_bond_option_matches_cpp_hull_white() {
let handle = sloped_curve();
let curve = handle.current_link().unwrap();
assert!((curve.discount(1.0, false).unwrap() - 0.975_309_912_028_332_6).abs() < 1e-14);
assert!((curve.discount(2.0, false).unwrap() - 0.941_764_533_584_248_7).abs() < 1e-14);
assert!((curve.discount(3.0, false).unwrap() - 0.905_764_980_659_064).abs() < 1e-14);
assert!((curve.discount(5.0, false).unwrap() - 0.818_770_008_233_211_2).abs() < 1e-14);
let model = HullWhite::new(handle, 0.05, 0.01).unwrap();
let m = model.borrow();
assert!(
(m.discount_bond_option(OptionType::Call, 0.9, 1.0, 3.0)
.unwrap()
- 0.028_295_945_329_515_248)
.abs()
< 1e-10
);
assert!(
(m.discount_bond_option(OptionType::Call, 0.95, 1.0, 3.0)
.unwrap()
- 0.000_912_556_023_061_549_3)
.abs()
< 1e-10
);
assert!(
(m.discount_bond_option(OptionType::Put, 0.9, 1.0, 3.0)
.unwrap()
- 0.000_309_885_495_950_584_07)
.abs()
< 1e-10
);
assert!(
(m.discount_bond_option(OptionType::Put, 0.95, 1.0, 3.0)
.unwrap()
- 0.021_691_991_790_913_523)
.abs()
< 1e-10
);
assert!(
(m.discount_bond_option_with_start(OptionType::Call, 0.85, 1.0, 2.0, 5.0)
.unwrap()
- 0.020_478_099_685_837_1)
.abs()
< 1e-10
);
assert!(
(m.discount_bond_option_with_start(OptionType::Call, 0.9, 1.0, 2.0, 5.0)
.unwrap()
- 0.000_903_569_724_417_981_9)
.abs()
< 1e-10
);
assert!(
(m.discount_bond_option_with_start(OptionType::Put, 0.85, 1.0, 2.0, 5.0)
.unwrap()
- 0.002_207_944_999_237_256_6)
.abs()
< 1e-10
);
assert!(
(m.discount_bond_option_with_start(OptionType::Put, 0.9, 1.0, 2.0, 5.0)
.unwrap()
- 0.029_721_641_717_030_61)
.abs()
< 1e-10
);
}
#[test]
fn discount_bond_option_small_mean_reversion_matches_cpp() {
let model = HullWhite::new(sloped_curve(), 1e-13, 0.01).unwrap();
let m = model.borrow();
assert!(
(m.discount_bond_option(OptionType::Call, 0.9, 1.0, 3.0)
.unwrap()
- 0.028_431_518_729_836_437)
.abs()
< 1e-10
);
assert!(
(m.discount_bond_option_with_start(OptionType::Call, 0.85, 1.0, 2.0, 5.0)
.unwrap()
- 0.021_443_413_387_680_313)
.abs()
< 1e-10
);
}
#[test]
fn r0_is_the_zero_rate_at_the_short_end() {
let handle = sloped_curve();
let model = HullWhite::new(handle, 0.05, 0.01).unwrap();
assert!((model.borrow().r0() - 0.020_000_499_999_160_704).abs() < 1e-12);
}
fn flat(rate: Rate) -> Shared<dyn YieldTermStructure> {
shared(FlatForward::with_rate(
Date::new(19, Month::May, 2026),
rate,
Actual365Fixed::new(),
Compounding::Continuous,
Frequency::Annual,
)) as Shared<dyn YieldTermStructure>
}
#[test]
fn r0_updates_when_the_term_structure_relinks() {
let rh: RelinkableHandle<dyn YieldTermStructure> = RelinkableHandle::new(flat(0.02));
let model = HullWhite::new(rh.handle(), 0.1, 0.01).unwrap();
let new_curve = flat(0.05);
rh.link_to(new_curve.clone());
let expected = new_curve
.forward_rate(
0.0,
0.0,
Compounding::Continuous,
Frequency::NoFrequency,
false,
)
.unwrap()
.rate();
assert!((model.borrow().r0() - expected).abs() < 1e-12);
}
#[test]
fn futures_convexity_bias_reproduces_the_kirikos_novak_table() {
let future_quote = 94.0;
let sigma = 0.015;
let t = 5.0;
let tolerance = 1e-7;
let future_implied_rate = (100.0 - future_quote) / 100.0;
for (maturity, a, expected_forward) in [
(5.25, 0.03, 0.0573037),
(5.25, 1e-4, 0.0568627),
(5.25, 0.0, 0.0568611),
(5.001, 0.03, 0.0575736),
(5.0, 0.03, 0.0575747),
] {
let bias = convexity_bias(future_quote, t, maturity, sigma, a).unwrap();
let calculated_forward = future_implied_rate - bias;
assert!(
(calculated_forward - expected_forward).abs() < tolerance,
"T={maturity}, a={a}: got {calculated_forward}, expected {expected_forward}"
);
}
}
#[test]
fn convexity_bias_rejects_out_of_range_inputs_with_the_cpp_messages() {
assert_eq!(
convexity_bias(-1.0, 5.0, 5.25, 0.015, 0.03)
.unwrap_err()
.message(),
"negative futures price (-1) not allowed"
);
assert_eq!(
convexity_bias(94.0, -5.0, 5.25, 0.015, 0.03)
.unwrap_err()
.message(),
"negative t (-5) not allowed"
);
assert_eq!(
convexity_bias(94.0, 5.0, 4.0, 0.015, 0.03)
.unwrap_err()
.message(),
"T (4) must not be less than t (5)"
);
assert_eq!(
convexity_bias(94.0, 5.0, 5.25, -0.015, 0.03)
.unwrap_err()
.message(),
"negative sigma (-0.015) not allowed"
);
assert_eq!(
convexity_bias(94.0, 5.0, 5.25, 0.015, -0.03)
.unwrap_err()
.message(),
"negative a (-0.03) not allowed"
);
}
fn calibrate_cached_hull_white(using_at_par: bool) -> (Array, EndCriteriaType, Real) {
let today = Date::new(15, Month::February, 2002);
let settlement = Date::new(19, Month::February, 2002);
let settings = shared(Settings::new());
settings.set_evaluation_date(today);
settings.set_using_at_par_coupons(using_at_par);
let term_structure: Handle<dyn YieldTermStructure> =
Handle::new(shared(FlatForward::with_rate(
settlement,
0.04875825,
Actual365Fixed::new(),
Compounding::Continuous,
Frequency::Annual,
)) as Shared<dyn YieldTermStructure>);
let model = HullWhite::new(term_structure.clone(), 0.1, 0.01).unwrap();
let index = shared(Euribor::six_months(
term_structure.clone(),
Shared::clone(&settings),
));
let engine = shared_mut(JamshidianSwaptionEngine::new(SharedMut::clone(&model)))
as SharedMut<dyn PricingEngine>;
let data = [
(1, 5, 0.1148),
(2, 4, 0.1108),
(3, 3, 0.1070),
(4, 2, 0.1021),
(5, 1, 0.1000),
];
let helpers: Vec<SharedMut<dyn CalibrationHelper>> = data
.into_iter()
.map(|(start, length, volatility)| {
let vol: Handle<dyn Quote> =
Handle::new(shared(SimpleQuote::new(volatility)) as Shared<dyn Quote>);
let mut helper = SwaptionHelper::new(
Period::new(start, TimeUnit::Years),
Period::new(length, TimeUnit::Years),
vol,
Shared::clone(&index),
Period::new(1, TimeUnit::Years),
Thirty360::with_convention(Convention::BondBasis),
Actual360::new(),
term_structure.clone(),
CalibrationErrorType::RelativePriceError,
None,
1.0,
VolatilityType::ShiftedLognormal,
0.0,
None,
RateAveraging::Compound,
);
helper
.base_mut()
.set_pricing_engine(SharedMut::clone(&engine));
shared_mut(helper) as SharedMut<dyn CalibrationHelper>
})
.collect();
let mut method = LevenbergMarquardt::new(1e-8, 1e-8, 1e-8, false);
let end_criteria = EndCriteria::new(10000, Some(100), 1e-6, 1e-8, Some(1e-8)).unwrap();
calibrate(
&model,
&helpers,
&mut method,
&end_criteria,
None,
Vec::new(),
Vec::new(),
)
.unwrap();
let params = model.borrow().calibrated_model().params();
let ec_type = model.borrow().calibrated_model().end_criteria();
let residual = calibration_value(&model, ¶ms, &helpers).unwrap();
(params, ec_type, residual)
}
#[test]
fn hull_white_calibrates_to_the_cached_swaption_values() {
let tolerance = 1.3e-5;
for (using_at_par, cached_a, cached_sigma) in [
(true, 0.0464041, 0.00579912),
(false, 0.0463679, 0.00579831),
] {
let (params, ec_type, residual) = calibrate_cached_hull_white(using_at_par);
assert!(
(params[0] - cached_a).abs() < tolerance,
"par={using_at_par}: a = {} vs cached {cached_a} (error {})",
params[0],
(params[0] - cached_a).abs()
);
assert!(
(params[1] - cached_sigma).abs() < tolerance,
"par={using_at_par}: sigma = {} vs cached {cached_sigma} (error {})",
params[1],
(params[1] - cached_sigma).abs()
);
assert!(
ec_type.succeeded(),
"par={using_at_par}: end criteria {ec_type} did not converge"
);
assert!(
residual.is_finite() && residual < 0.2,
"par={using_at_par}: residual f(a) = {residual} not finite/bounded"
);
}
}
}