use crate::errors::QlResult;
use crate::exercise::{EuropeanExercise, Exercise};
use crate::fail;
use crate::handle::Handle;
use crate::instrument::Instrument;
use crate::instruments::{PlainVanillaPayoff, StrikedTypePayoff, VanillaOption};
use crate::models::calibrationhelper::{
BlackCalibrationHelper, BlackCalibrationHelperBase, CalibrationErrorType,
};
use crate::option::OptionType;
use crate::pricingengines::blackformula::black_formula;
use crate::quotes::{Quote, SimpleQuote};
use crate::settings::Settings;
use crate::shared::{Shared, SharedMut, shared, shared_mut};
use crate::termstructures::volatility::VolatilityType;
use crate::termstructures::yieldtermstructure::YieldTermStructure;
use crate::time::businessdayconvention::BusinessDayConvention;
use crate::time::calendar::Calendar;
use crate::time::date::Date;
use crate::time::period::Period;
use crate::types::{Real, Time};
struct Derived {
exercise_date: Date,
tau: Time,
option_type: OptionType,
discounted_strike: Real,
discounted_spot: Real,
}
pub struct HestonModelHelper {
base: BlackCalibrationHelperBase,
maturity: Period,
calendar: Calendar,
s0: Handle<dyn Quote>,
strike_price: Real,
risk_free_rate: Handle<dyn YieldTermStructure>,
dividend_yield: Handle<dyn YieldTermStructure>,
settings: Shared<Settings<Date>>,
}
impl HestonModelHelper {
#[allow(clippy::too_many_arguments)]
pub fn new(
maturity: Period,
calendar: Calendar,
s0: Real,
strike_price: Real,
volatility: Handle<dyn Quote>,
risk_free_rate: Handle<dyn YieldTermStructure>,
dividend_yield: Handle<dyn YieldTermStructure>,
error_type: CalibrationErrorType,
settings: Shared<Settings<Date>>,
) -> HestonModelHelper {
let s0: Handle<dyn Quote> = Handle::new(shared(SimpleQuote::new(s0)) as Shared<dyn Quote>);
HestonModelHelper::with_spot_handle(
maturity,
calendar,
s0,
strike_price,
volatility,
risk_free_rate,
dividend_yield,
error_type,
settings,
)
}
#[allow(clippy::too_many_arguments)]
pub fn with_spot_handle(
maturity: Period,
calendar: Calendar,
s0: Handle<dyn Quote>,
strike_price: Real,
volatility: Handle<dyn Quote>,
risk_free_rate: Handle<dyn YieldTermStructure>,
dividend_yield: Handle<dyn YieldTermStructure>,
error_type: CalibrationErrorType,
settings: Shared<Settings<Date>>,
) -> HestonModelHelper {
let base = BlackCalibrationHelperBase::new(
volatility,
error_type,
VolatilityType::ShiftedLognormal,
0.0,
);
let observer = base.observer();
s0.register_observer(&observer);
risk_free_rate.register_observer(&observer);
dividend_yield.register_observer(&observer);
HestonModelHelper {
base,
maturity,
calendar,
s0,
strike_price,
risk_free_rate,
dividend_yield,
settings,
}
}
pub fn maturity(&self) -> QlResult<Time> {
Ok(self.derive()?.tau)
}
fn derive(&self) -> QlResult<Derived> {
let risk_free = self.risk_free_rate.current_link()?;
let reference_date = risk_free.reference_date()?;
let exercise_date = self.calendar.advance_by_period(
reference_date,
self.maturity,
BusinessDayConvention::Following,
false,
);
let tau = risk_free.time_from_reference(exercise_date)?;
let discounted_strike = self.strike_price * risk_free.discount(tau, false)?;
let dividend = self.dividend_yield.current_link()?;
let spot = self.s0.current_link()?.value()?;
let discounted_spot = spot * dividend.discount(tau, false)?;
let option_type = if discounted_strike >= discounted_spot {
OptionType::Call
} else {
OptionType::Put
};
Ok(Derived {
exercise_date,
tau,
option_type,
discounted_strike,
discounted_spot,
})
}
fn build_option(&self, derived: &Derived) -> SharedMut<VanillaOption> {
let payoff = shared(PlainVanillaPayoff::new(
derived.option_type,
self.strike_price,
)) as Shared<dyn StrikedTypePayoff>;
let exercise = shared(EuropeanExercise::new(derived.exercise_date)) as Shared<dyn Exercise>;
shared_mut(VanillaOption::new(
payoff,
exercise,
Shared::clone(&self.settings),
))
}
}
impl BlackCalibrationHelper for HestonModelHelper {
fn base(&self) -> &BlackCalibrationHelperBase {
&self.base
}
fn base_mut(&mut self) -> &mut BlackCalibrationHelperBase {
&mut self.base
}
fn model_value(&self) -> QlResult<Real> {
let derived = self.derive()?;
let option = self.build_option(&derived);
let Some(engine) = self.base.pricing_engine() else {
fail!("no model pricing engine set on the heston model helper");
};
option
.borrow_mut()
.base_mut()
.set_pricing_engine(SharedMut::clone(engine));
let value = option.borrow_mut().npv()?;
Ok(value)
}
fn black_price(&self, volatility: Real) -> QlResult<Real> {
let derived = self.derive()?;
let std_dev = volatility * derived.tau.sqrt();
black_formula(
derived.option_type,
derived.discounted_strike,
derived.discounted_spot,
std_dev,
1.0,
0.0,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::interestrate::Compounding;
use crate::math::interpolations::linear::Linear;
use crate::math::optimization::endcriteria::EndCriteria;
use crate::math::optimization::levenbergmarquardt::LevenbergMarquardt;
use crate::models::calibrationhelper::CalibrationHelper;
use crate::models::{HestonModel, calibrate};
use crate::pricingengine::PricingEngine;
use crate::pricingengines::vanilla::analytichestonengine::AnalyticHestonEngine;
use crate::processes::HestonProcess;
use crate::termstructures::yields::{FlatForward, ZeroCurve};
use crate::time::calendars::nullcalendar::NullCalendar;
use crate::time::calendars::target::Target;
use crate::time::date::Month;
use crate::time::daycounters::actual360::Actual360;
use crate::time::daycounters::actual365fixed::Actual365Fixed;
use crate::time::frequency::Frequency;
use crate::time::timeunit::TimeUnit;
use crate::types::Integer;
const VOL: Real = 0.1;
fn today() -> Date {
Date::new(15, Month::January, 2026)
}
struct Fixture {
settings: Shared<Settings<Date>>,
risk_free: Handle<dyn YieldTermStructure>,
dividend: Handle<dyn YieldTermStructure>,
s0: Handle<dyn Quote>,
vol: Handle<dyn Quote>,
calendar: Calendar,
}
impl Fixture {
fn new() -> Fixture {
let settings = shared(Settings::new());
settings.set_evaluation_date(today());
let flat = |rate: Real| -> Handle<dyn YieldTermStructure> {
Handle::new(shared(FlatForward::with_rate(
today(),
rate,
Actual360::new(),
Compounding::Continuous,
Frequency::Annual,
)) as Shared<dyn YieldTermStructure>)
};
Fixture {
settings,
risk_free: flat(0.04),
dividend: flat(0.50),
s0: Handle::new(shared(SimpleQuote::new(1.0)) as Shared<dyn Quote>),
vol: Handle::new(shared(SimpleQuote::new(VOL)) as Shared<dyn Quote>),
calendar: NullCalendar::new(),
}
}
fn tau(&self, maturity: Period) -> Time {
let risk_free = self.risk_free.current_link().unwrap();
let reference = risk_free.reference_date().unwrap();
let exercise = self.calendar.advance_by_period(
reference,
maturity,
BusinessDayConvention::Following,
false,
);
risk_free.time_from_reference(exercise).unwrap()
}
fn forward(&self, tau: Time) -> Real {
let risk_free = self.risk_free.current_link().unwrap();
let dividend = self.dividend.current_link().unwrap();
let s0 = self.s0.current_link().unwrap().value().unwrap();
s0 * dividend.discount(tau, false).unwrap() / risk_free.discount(tau, false).unwrap()
}
fn helper(&self, maturity: Period, strike: Real) -> HestonModelHelper {
HestonModelHelper::with_spot_handle(
maturity,
self.calendar.clone(),
self.s0.clone(),
strike,
self.vol.clone(),
self.risk_free.clone(),
self.dividend.clone(),
CalibrationErrorType::RelativePriceError,
Shared::clone(&self.settings),
)
}
fn direct_black(
&self,
maturity: Period,
strike: Real,
option_type: OptionType,
vol: Real,
) -> Real {
let tau = self.tau(maturity);
let risk_free = self.risk_free.current_link().unwrap();
let dividend = self.dividend.current_link().unwrap();
let s0 = self.s0.current_link().unwrap().value().unwrap();
black_formula(
option_type,
strike * risk_free.discount(tau, false).unwrap(),
s0 * dividend.discount(tau, false).unwrap(),
vol * tau.sqrt(),
1.0,
0.0,
)
.unwrap()
}
}
#[test]
fn black_price_matches_a_direct_black_formula_and_market_value() {
let fixture = Fixture::new();
let maturity = Period::new(6, TimeUnit::Months);
let strike = fixture.forward(fixture.tau(maturity));
let mut helper = fixture.helper(maturity, strike);
let expected = fixture.direct_black(maturity, strike, OptionType::Call, VOL);
let black = helper.black_price(VOL).unwrap();
assert!(
(black - expected).abs() <= 1.0e-12,
"black_price {black} vs direct black_formula {expected} (error {})",
(black - expected).abs()
);
let market = helper.market_value().unwrap();
assert!(
(market - black).abs() <= 1.0e-12,
"market_value {market} vs black_price {black} (error {})",
(market - black).abs()
);
}
#[test]
fn black_price_tracks_the_option_type_decision() {
let fixture = Fixture::new();
let maturity = Period::new(1, TimeUnit::Years);
let forward = fixture.forward(fixture.tau(maturity));
let put_strike = forward * 0.5;
let put_black = fixture
.helper(maturity, put_strike)
.black_price(VOL)
.unwrap();
let forced_put = fixture.direct_black(maturity, put_strike, OptionType::Put, VOL);
let forced_call = fixture.direct_black(maturity, put_strike, OptionType::Call, VOL);
assert!(
(put_black - forced_put).abs() <= 1.0e-12,
"a strike below the forward must price as a put: {put_black} vs {forced_put}"
);
assert!(
(put_black - forced_call).abs() > 1.0e-8,
"the type decision is dead: the put priced as a forced call ({put_black})"
);
let call_strike = forward * 2.0;
let call_black = fixture
.helper(maturity, call_strike)
.black_price(VOL)
.unwrap();
let forced_call = fixture.direct_black(maturity, call_strike, OptionType::Call, VOL);
let forced_put = fixture.direct_black(maturity, call_strike, OptionType::Put, VOL);
assert!(
(call_black - forced_call).abs() <= 1.0e-12,
"a strike above the forward must price as a call: {call_black} vs {forced_call}"
);
assert!(
(call_black - forced_put).abs() > 1.0e-8,
"the type decision is dead: the call priced as a forced put ({call_black})"
);
}
#[test]
fn heston_calibrates_to_a_flat_vol_surface() {
let fixture = Fixture::new();
let maturities = [
Period::new(1, TimeUnit::Months),
Period::new(2, TimeUnit::Months),
Period::new(3, TimeUnit::Months),
Period::new(6, TimeUnit::Months),
Period::new(9, TimeUnit::Months),
Period::new(1, TimeUnit::Years),
Period::new(2, TimeUnit::Years),
];
let moneynesses = [-1.0, 0.0, 1.0];
let mut helpers: Vec<SharedMut<HestonModelHelper>> = Vec::new();
let mut puts = 0usize;
let mut calls = 0usize;
for &maturity in &maturities {
for &moneyness in &moneynesses {
let tau = fixture.tau(maturity);
let strike = fixture.forward(tau) * (-moneyness * VOL * tau.sqrt()).exp();
let helper = fixture.helper(maturity, strike);
let black = helper.black_price(VOL).unwrap();
let call = fixture.direct_black(maturity, strike, OptionType::Call, VOL);
let put = fixture.direct_black(maturity, strike, OptionType::Put, VOL);
if (black - put).abs() < (black - call).abs() {
puts += 1;
} else {
calls += 1;
}
helpers.push(shared_mut(helper));
}
}
assert_eq!(helpers.len(), 21);
assert_eq!(
puts, 7,
"the 50% dividend fixture must exercise the put branch (moneyness +1)"
);
assert_eq!(calls, 14);
let tolerance = 3.0e-3;
let expected_variance = VOL * VOL;
for &sigma in &[0.1, 0.3, 0.5] {
let process = shared(HestonProcess::new(
fixture.risk_free.clone(),
fixture.dividend.clone(),
fixture.s0.clone(),
0.01,
0.2,
0.02,
sigma,
-0.75,
));
let model = HestonModel::new(process).unwrap();
let engine =
shared_mut(AnalyticHestonEngine::new(SharedMut::clone(&model), 96).unwrap())
as SharedMut<dyn PricingEngine>;
for helper in &helpers {
helper
.borrow_mut()
.base_mut()
.set_pricing_engine(SharedMut::clone(&engine));
}
let dyn_helpers: Vec<SharedMut<dyn CalibrationHelper>> = helpers
.iter()
.map(|helper| SharedMut::clone(helper) as SharedMut<dyn CalibrationHelper>)
.collect();
let mut method = LevenbergMarquardt::new(1e-8, 1e-8, 1e-8, false);
let end_criteria = EndCriteria::new(400, Some(40), 1e-8, 1e-8, Some(1e-8)).unwrap();
calibrate(
&model,
&dyn_helpers,
&mut method,
&end_criteria,
None,
Vec::new(),
Vec::new(),
)
.unwrap();
let model = model.borrow();
assert!(
model.sigma() < tolerance,
"sigma {} exceeds {tolerance} (start {sigma})",
model.sigma()
);
let theta_residual = model.kappa() * (model.theta() - expected_variance);
assert!(
theta_residual.abs() < tolerance,
"kappa*(theta - vol^2) = {theta_residual} exceeds {tolerance} (start {sigma})"
);
assert!(
(model.v0() - expected_variance).abs() < tolerance,
"v0 {} vs vol^2 {expected_variance} exceeds {tolerance} (start {sigma})",
model.v0()
);
}
}
struct DaxMarketData {
risk_free: Handle<dyn YieldTermStructure>,
dividend: Handle<dyn YieldTermStructure>,
s0: Handle<dyn Quote>,
options: Vec<SharedMut<HestonModelHelper>>,
}
fn dax_market_data() -> DaxMarketData {
let settlement = Date::new(5, Month::July, 2002);
let settings = shared(Settings::new());
settings.set_evaluation_date(settlement);
let day_counter = Actual365Fixed::new();
let calendar = Target::new();
let t: [Integer; 8] = [13, 41, 75, 165, 256, 345, 524, 703];
let r: [Real; 8] = [
0.0357, 0.0349, 0.0341, 0.0355, 0.0359, 0.0368, 0.0386, 0.0401,
];
let mut dates = vec![settlement];
let mut rates = vec![0.0357];
for i in 0..8 {
dates.push(settlement + t[i]);
rates.push(r[i]);
}
let risk_free: Handle<dyn YieldTermStructure> = Handle::new(shared(
ZeroCurve::new(dates, rates, day_counter.clone(), Linear).unwrap(),
)
as Shared<dyn YieldTermStructure>);
let dividend: Handle<dyn YieldTermStructure> = Handle::new(shared(FlatForward::with_rate(
settlement,
0.0,
day_counter.clone(),
Compounding::Continuous,
Frequency::Annual,
))
as Shared<dyn YieldTermStructure>);
let s0: Handle<dyn Quote> =
Handle::new(shared(SimpleQuote::new(4468.17)) as Shared<dyn Quote>);
let v: [[Real; 8]; 13] = [
[
0.6625, 0.4875, 0.4204, 0.3667, 0.3431, 0.3267, 0.3121, 0.3121,
],
[
0.6007, 0.4543, 0.3967, 0.3511, 0.3279, 0.3154, 0.2984, 0.2921,
],
[
0.5084, 0.4221, 0.3718, 0.3327, 0.3155, 0.3027, 0.2919, 0.2889,
],
[
0.4541, 0.3869, 0.3492, 0.3149, 0.2963, 0.2926, 0.2819, 0.2800,
],
[
0.4060, 0.3607, 0.3330, 0.2999, 0.2887, 0.2811, 0.2751, 0.2775,
],
[
0.3726, 0.3396, 0.3108, 0.2781, 0.2788, 0.2722, 0.2661, 0.2686,
],
[
0.3550, 0.3277, 0.3012, 0.2781, 0.2781, 0.2661, 0.2661, 0.2681,
],
[
0.3428, 0.3209, 0.2958, 0.2740, 0.2688, 0.2627, 0.2580, 0.2620,
],
[
0.3302, 0.3062, 0.2799, 0.2631, 0.2573, 0.2533, 0.2504, 0.2544,
],
[
0.3343, 0.2959, 0.2705, 0.2540, 0.2504, 0.2464, 0.2448, 0.2462,
],
[
0.3460, 0.2845, 0.2624, 0.2463, 0.2425, 0.2385, 0.2373, 0.2422,
],
[
0.3857, 0.2860, 0.2578, 0.2399, 0.2357, 0.2327, 0.2312, 0.2351,
],
[
0.3976, 0.2860, 0.2607, 0.2356, 0.2297, 0.2268, 0.2241, 0.2320,
],
];
let strike: [Real; 13] = [
3400.0, 3600.0, 3800.0, 4000.0, 4200.0, 4400.0, 4500.0, 4600.0, 4800.0, 5000.0, 5200.0,
5400.0, 5600.0,
];
let mut options = Vec::with_capacity(13 * 8);
for s in 0..13 {
for m in 0..8 {
let vol: Handle<dyn Quote> =
Handle::new(shared(SimpleQuote::new(v[s][m])) as Shared<dyn Quote>);
let maturity = Period::new((t[m] + 3) / 7, TimeUnit::Weeks);
options.push(shared_mut(HestonModelHelper::with_spot_handle(
maturity,
calendar.clone(),
s0.clone(),
strike[s],
vol,
risk_free.clone(),
dividend.clone(),
CalibrationErrorType::ImpliedVolError,
Shared::clone(&settings),
)));
}
}
DaxMarketData {
risk_free,
dividend,
s0,
options,
}
}
#[test]
fn heston_calibrates_to_dax_vol_data() {
let market = dax_market_data();
let process = shared(HestonProcess::new(
market.risk_free.clone(),
market.dividend.clone(),
market.s0.clone(),
0.1,
1.0,
0.1,
0.5,
-0.5,
));
let model = HestonModel::new(process).unwrap();
let engine = shared_mut(AnalyticHestonEngine::new(SharedMut::clone(&model), 64).unwrap())
as SharedMut<dyn PricingEngine>;
for helper in &market.options {
helper
.borrow_mut()
.base_mut()
.set_pricing_engine(SharedMut::clone(&engine));
}
let dyn_helpers: Vec<SharedMut<dyn CalibrationHelper>> = market
.options
.iter()
.map(|helper| SharedMut::clone(helper) as SharedMut<dyn CalibrationHelper>)
.collect();
let mut method = LevenbergMarquardt::new(1e-8, 1e-8, 1e-8, false);
let end_criteria = EndCriteria::new(400, Some(40), 1e-8, 1e-8, Some(1e-8)).unwrap();
calibrate(
&model,
&dyn_helpers,
&mut method,
&end_criteria,
None,
Vec::new(),
Vec::new(),
)
.unwrap();
let mut sse = 0.0;
for helper in &market.options {
let diff = helper.borrow_mut().calibration_error().unwrap() * 100.0;
sse += diff * diff;
}
assert!(
(sse - 177.2).abs() < 1.0,
"sse {sse} vs expected 177.2 (error {})",
(sse - 177.2).abs()
);
}
}