use std::rc::Rc;
use crate::errors::QlResult;
use crate::handle::Handle;
use crate::interestrate::Compounding;
use crate::math::timegrid::TimeGrid;
use crate::methods::lattices::{Tree, TreeLattice1D, TrinomialTree};
use crate::models::model::{
CalibratedModel, CalibratedModelHolder, TermStructureConsistentModel,
register_with_term_structure,
};
use crate::models::parameter::{
NullParameter, NumericalImpl, Parameter, ParameterValue, TermStructureFittingParameter,
};
use crate::models::shortrate::onefactormodel::{
OneFactorAffineModel, ShortRateDynamics, ShortRateTree,
};
use crate::models::shortrate::vasicek::Vasicek;
use crate::option::OptionType;
use crate::patterns::observable::Observer;
use crate::pricingengines::blackformula::black_formula;
use crate::processes::OrnsteinUhlenbeckProcess;
use crate::require;
use crate::shared::{Shared, SharedMut, shared, shared_mut};
use crate::stochasticprocess::StochasticProcess1D;
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)
}
pub fn tree(&self, grid: TimeGrid) -> QlResult<TreeLattice1D<ShortRateTree>> {
let a = self.base.a();
let sigma = self.base.sigma();
let phi_impl = NumericalImpl::new(self.term_structure().clone());
let phi = TermStructureFittingParameter::new(phi_impl.clone() as Rc<dyn ParameterValue>);
let dynamics: Shared<dyn ShortRateDynamics> =
shared(HullWhiteDynamics::new(phi, a, sigma)?);
let trinomial = shared(TrinomialTree::new(dynamics.process(), grid.clone(), false)?);
let short_rate_tree = ShortRateTree::new(
Shared::clone(&trinomial),
Shared::clone(&dynamics),
grid.clone(),
);
let lattice = TreeLattice1D::new(short_rate_tree, grid.clone())?;
phi_impl.reset();
for i in 0..(grid.size() - 1) {
let discount_bond = self
.term_structure()
.current_link()?
.discount(grid[i + 1], false)?;
let state_prices = lattice.state_prices(i);
let size = trinomial.size(i);
let dt = grid.dt(i);
let dx = trinomial.dx(i);
let mut x = trinomial.underlying(i, 0);
let mut value = 0.0;
for j in 0..size {
value += state_prices[j] * (-x * dt).exp();
x += dx;
}
value = (value / discount_bond).ln() / dt;
phi_impl.set(grid[i], value);
}
Ok(lattice)
}
}
struct HullWhiteDynamics {
process: Shared<dyn StochasticProcess1D>,
fitting: Parameter,
}
impl HullWhiteDynamics {
fn new(fitting: Parameter, a: Real, sigma: Real) -> QlResult<Self> {
Ok(HullWhiteDynamics {
process: shared(OrnsteinUhlenbeckProcess::new(a, sigma, 0.0, 0.0)?)
as Shared<dyn StochasticProcess1D>,
fitting,
})
}
}
impl ShortRateDynamics for HullWhiteDynamics {
fn variable(&self, t: Time, r: Rate) -> Real {
r - self.fitting.value(t)
}
fn short_rate(&self, t: Time, x: Real) -> Rate {
x + self.fitting.value(t)
}
fn process(&self) -> Shared<dyn StochasticProcess1D> {
Shared::clone(&self.process)
}
}
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::IborIndex;
use crate::indexes::ibor::Euribor;
use crate::indexes::index::Index;
use crate::indexes::interestrateindex::InterestRateIndex;
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"
);
}
}
fn calibrate_cached_hull_white_variant(
using_at_par: bool,
a0: Real,
zero_fixing_days: bool,
end_criteria: EndCriteria,
fix_parameters: Vec<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(), a0, 0.01).unwrap();
let euribor = Euribor::six_months(term_structure.clone(), Shared::clone(&settings));
let index = if zero_fixing_days {
shared(IborIndex::new(
euribor.family_name().to_string(),
euribor.tenor(),
0,
euribor.currency().clone(),
euribor.fixing_calendar(),
euribor.business_day_convention(),
euribor.end_of_month(),
euribor.day_counter().clone(),
term_structure.clone(),
Shared::clone(&settings),
))
} else {
shared(euribor)
};
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);
calibrate(
&model,
&helpers,
&mut method,
&end_criteria,
None,
Vec::new(),
fix_parameters,
)
.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_with_fixed_reversion() {
let tolerance = 1.0e-5;
for (using_at_par, cached_sigma) in [(true, 0.00585858), (false, 0.00585835)] {
let end_criteria = EndCriteria::new(1000, Some(500), 1e-8, 1e-8, Some(1e-8)).unwrap();
let (params, ec_type, residual) = calibrate_cached_hull_white_variant(
using_at_par,
0.05,
false,
end_criteria,
vec![true, false],
);
assert!(
(params[0] - 0.05).abs() < 1e-15,
"par={using_at_par}: reversion must stay fixed at 0.05, got {}",
params[0]
);
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"
);
}
}
#[test]
fn hull_white_calibrates_without_start_delay() {
let tolerance = 1.0e-5;
for (using_at_par, cached_a, cached_sigma) in [
(true, 0.0482063, 0.00582687),
(false, 0.0481608, 0.00582493),
] {
let end_criteria = EndCriteria::new(10000, Some(100), 1e-6, 1e-8, Some(1e-8)).unwrap();
let (params, ec_type, residual) = calibrate_cached_hull_white_variant(
using_at_par,
0.1,
true,
end_criteria,
Vec::new(),
);
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"
);
}
}
use crate::math::timegrid::TimeGrid;
use crate::methods::lattices::TreeLatticeImpl;
fn pin_at(
lattice: &TreeLattice1D<ShortRateTree>,
curve: &Shared<dyn YieldTermStructure>,
grid: &TimeGrid,
i: usize,
) -> (Real, Real) {
let sp = lattice.state_prices(i);
let sum: Real = (0..sp.size())
.map(|j| sp[j] * lattice.implementation().discount(i, j))
.sum();
let bond = curve.discount(grid[i + 1], false).unwrap();
(sum, bond)
}
fn build_unfitted(
curve: Handle<dyn YieldTermStructure>,
a: Real,
sigma: Real,
grid: &TimeGrid,
) -> (
Rc<NumericalImpl>,
Shared<TrinomialTree>,
TreeLattice1D<ShortRateTree>,
) {
let phi_impl = NumericalImpl::new(curve);
let phi = TermStructureFittingParameter::new(phi_impl.clone() as Rc<dyn ParameterValue>);
let dynamics: Shared<dyn ShortRateDynamics> =
shared(HullWhiteDynamics::new(phi, a, sigma).unwrap());
let trinomial =
shared(TrinomialTree::new(dynamics.process(), grid.clone(), false).unwrap());
let short_rate_tree = ShortRateTree::new(
Shared::clone(&trinomial),
Shared::clone(&dynamics),
grid.clone(),
);
let lattice = TreeLattice1D::new(short_rate_tree, grid.clone()).unwrap();
(phi_impl, trinomial, lattice)
}
#[test]
fn dynamics_variable_and_short_rate_are_inverse_through_phi() {
let curve = flat(0.05);
let phi_impl = NumericalImpl::new(Handle::new(curve.clone()));
phi_impl.set(1.0, 0.02);
let phi = TermStructureFittingParameter::new(phi_impl.clone() as Rc<dyn ParameterValue>);
let dynamics = HullWhiteDynamics::new(phi, 0.1, 0.01).unwrap();
let r = 0.05;
let x = dynamics.variable(1.0, r);
assert!((x - (r - 0.02)).abs() < 1e-15);
assert!((dynamics.short_rate(1.0, x) - r).abs() < 1e-15);
assert_eq!(dynamics.process().x0().unwrap(), 0.0);
}
#[test]
fn tree_reprices_the_flat_curve_wiring_pin() {
let curve = flat(0.05);
let model = HullWhite::new(Handle::new(curve.clone()), 0.1, 0.01).unwrap();
let grid = TimeGrid::new(3.0, 12).unwrap();
let lattice = model.borrow().tree(grid.clone()).unwrap();
for i in [1usize, 4, 7, 11] {
let (sum, bond) = pin_at(&lattice, &curve, &grid, i);
assert!(
(sum - bond).abs() < 1e-10,
"flat pin slice {i}: {sum} vs {bond}"
);
}
}
#[test]
fn tree_reprices_the_sloped_curve_wiring_pin() {
let handle = sloped_curve();
let curve = handle.current_link().unwrap();
let model = HullWhite::new(handle, 0.1, 0.01).unwrap();
let grid = TimeGrid::new(3.0, 12).unwrap();
let lattice = model.borrow().tree(grid.clone()).unwrap();
for i in [1usize, 4, 7, 11] {
let (sum, bond) = pin_at(&lattice, &curve, &grid, i);
assert!(
(sum - bond).abs() < 1e-10,
"sloped pin slice {i}: {sum} vs {bond}"
);
}
}
#[test]
fn unfitted_tree_misprices_the_curve_confirm_by_stub() {
let curve = flat(0.05);
let grid = TimeGrid::new(3.0, 12).unwrap();
let (phi_impl, _trinomial, lattice) =
build_unfitted(Handle::new(curve.clone()), 0.1, 0.01, &grid);
for i in 0..(grid.size() - 1) {
phi_impl.set(grid[i], 0.0);
}
let (sum, bond) = pin_at(&lattice, &curve, &grid, 6);
assert!(
(sum - bond).abs() > 1e-6,
"phi=0 must misprice the curve: {sum} vs {bond}"
);
}
#[test]
#[should_panic(expected = "fitting parameter not set!")]
fn breaking_the_same_impl_wiring_panics_confirm_by_stub() {
let curve = flat(0.05);
let handle = Handle::new(curve.clone());
let grid = TimeGrid::new(3.0, 12).unwrap();
let (_dynamics_phi, trinomial, lattice) = build_unfitted(handle.clone(), 0.1, 0.01, &grid);
let fit_phi = NumericalImpl::new(handle);
fit_phi.reset();
for i in 0..(grid.size() - 1) {
let bond = curve.discount(grid[i + 1], false).unwrap();
let sp = lattice.state_prices(i);
let size = trinomial.size(i);
let dt = grid.dt(i);
let dx = trinomial.dx(i);
let mut x = trinomial.underlying(i, 0);
let mut value = 0.0;
for j in 0..size {
value += sp[j] * (-x * dt).exp();
x += dx;
}
value = (value / bond).ln() / dt;
fit_phi.set(grid[i], value);
}
}
}