use crate::error::Result;
use crate::math::optimize::{Minimum, NelderMeadOptions, nelder_mead};
use crate::models::common::black_scholes::bs_implied_vol;
use crate::models::common::calibration::{Calibration, CalibrationReport};
use crate::models::common::cos_pricer::CosPricer;
use crate::models::forex::fx_hhw::FxHhwParams;
use crate::models::forex::fx_hhw1_chf::FxHhw1ForwardChf;
use crate::models::forex::market_data::MarketSmileStrip;
#[derive(Copy, Clone, Debug)]
pub struct CalibrationTarget {
pub strike: f64,
pub market_vol: f64,
}
#[derive(Clone, Debug)]
pub struct CalibrationResult {
pub params: FxHhwParams,
pub rmse: f64,
pub optimiser: Minimum,
}
pub fn calibrate(
initial: FxHhwParams,
targets: &[CalibrationTarget],
expiry: f64,
kappa_floor: f64,
options: NelderMeadOptions,
) -> CalibrationResult {
assert!(expiry > 0.0);
assert!(!targets.is_empty());
assert!(kappa_floor >= 0.0);
let fwd = initial.fx_0 * (-initial.rf_0 * expiry).exp() / (-initial.rd_0 * expiry).exp();
let discount = (-initial.rd_0 * expiry).exp();
let kappa_shift = initial.heston.kappa - kappa_floor;
let x0 = vec![
inv_softplus(kappa_shift.max(1e-8)),
inv_softplus(initial.heston.gamma.max(1e-8)),
inv_softplus(initial.heston.theta.max(1e-8)),
inv_softplus(initial.heston.sigma_0.max(1e-8)),
initial
.correlations
.rho_xi_sigma
.clamp(-0.999, 0.999)
.atanh(),
];
let targets_cloned: Vec<CalibrationTarget> = targets.to_vec();
let initial_cloned = initial;
let objective = move |x: &[f64]| -> f64 {
let trial = reify_params(&initial_cloned, kappa_floor, x);
let chf = FxHhw1ForwardChf::new(&trial, expiry);
let pricer = CosPricer::new(&chf);
let mut ssr = 0.0_f64;
for t in &targets_cloned {
let price = pricer.call(t.strike, discount);
let model_vol = match bs_implied_vol(price, fwd, t.strike, expiry, discount, true) {
Some(v) => v,
None => return 1.0e6,
};
ssr += (model_vol - t.market_vol).powi(2);
}
ssr
};
let minimum = nelder_mead(objective, &x0, options);
let params = reify_params(&initial, kappa_floor, &minimum.x);
let rmse = (minimum.f / targets.len() as f64).sqrt();
CalibrationResult {
params,
rmse,
optimiser: minimum,
}
}
pub fn calibrate_bounded(
initial: FxHhwParams,
targets: &[CalibrationTarget],
expiry: f64,
kappa_floor: f64,
gamma_max: f64,
options: NelderMeadOptions,
) -> CalibrationResult {
assert!(expiry > 0.0);
assert!(!targets.is_empty());
assert!(kappa_floor >= 0.0);
assert!(gamma_max > 0.0);
let fwd = initial.fx_0 * (-initial.rf_0 * expiry).exp() / (-initial.rd_0 * expiry).exp();
let discount = (-initial.rd_0 * expiry).exp();
let gamma_seed = (initial.heston.gamma / gamma_max).clamp(1.0e-4, 1.0 - 1.0e-4);
let x0 = vec![
inv_softplus((initial.heston.kappa - kappa_floor).max(1e-8)),
(gamma_seed / (1.0 - gamma_seed)).ln(), inv_softplus(initial.heston.theta.max(1e-8)),
inv_softplus(initial.heston.sigma_0.max(1e-8)),
initial
.correlations
.rho_xi_sigma
.clamp(-0.999, 0.999)
.atanh(),
];
let targets_cloned: Vec<CalibrationTarget> = targets.to_vec();
let initial_cloned = initial;
let objective = move |x: &[f64]| -> f64 {
let trial = reify_params_bounded(&initial_cloned, kappa_floor, gamma_max, x);
let chf = FxHhw1ForwardChf::new(&trial, expiry);
let pricer = CosPricer::new(&chf);
let mut ssr = 0.0_f64;
for t in &targets_cloned {
let price = pricer.call(t.strike, discount);
let model_vol = match bs_implied_vol(price, fwd, t.strike, expiry, discount, true) {
Some(v) => v,
None => return 1.0e6,
};
ssr += (model_vol - t.market_vol).powi(2);
}
ssr
};
let minimum = nelder_mead(objective, &x0, options);
let params = reify_params_bounded(&initial, kappa_floor, gamma_max, &minimum.x);
let rmse = (minimum.f / targets.len() as f64).sqrt();
CalibrationResult {
params,
rmse,
optimiser: minimum,
}
}
fn reify_params_bounded(
base: &FxHhwParams,
kappa_floor: f64,
gamma_max: f64,
x: &[f64],
) -> FxHhwParams {
let mut out = *base;
out.heston.kappa = kappa_floor + softplus(x[0]);
out.heston.gamma = gamma_max / (1.0 + (-x[1]).exp());
out.heston.theta = softplus(x[2]);
out.heston.sigma_0 = softplus(x[3]);
out.correlations.rho_xi_sigma = x[4].tanh();
out
}
fn softplus(x: f64) -> f64 {
if x > 35.0 { x } else { (1.0 + x.exp()).ln() }
}
fn inv_softplus(y: f64) -> f64 {
assert!(y > 0.0);
if y > 35.0 { y } else { (y.exp() - 1.0).ln() }
}
fn reify_params(base: &FxHhwParams, kappa_floor: f64, x: &[f64]) -> FxHhwParams {
let mut out = *base;
out.heston.kappa = kappa_floor + softplus(x[0]);
out.heston.gamma = softplus(x[1]);
out.heston.theta = softplus(x[2]);
out.heston.sigma_0 = softplus(x[3]);
out.correlations.rho_xi_sigma = x[4].tanh();
out
}
pub fn targets_from_grid(strikes: &[f64], vols: &[f64]) -> Vec<CalibrationTarget> {
assert_eq!(strikes.len(), vols.len());
strikes
.iter()
.zip(vols.iter())
.map(|(&k, &v)| CalibrationTarget {
strike: k,
market_vol: v,
})
.collect()
}
pub fn model_implied_vols(params: &FxHhwParams, expiry: f64, strikes: &[f64]) -> Vec<Option<f64>> {
let chf = FxHhw1ForwardChf::new(params, expiry);
let pricer = CosPricer::new(&chf);
let fwd = params.fx_0 * (-params.rf_0 * expiry).exp() / (-params.rd_0 * expiry).exp();
let discount = (-params.rd_0 * expiry).exp();
strikes
.iter()
.map(|&k| {
let price = pricer.call(k, discount);
bs_implied_vol(price, fwd, k, expiry, discount, true)
})
.collect()
}
pub fn price_call(params: &FxHhwParams, expiry: f64, strike: f64) -> f64 {
let chf = FxHhw1ForwardChf::new(params, expiry);
let pricer = CosPricer::new(&chf);
let discount = (-params.rd_0 * expiry).exp();
pricer.call(strike, discount)
}
pub struct FxHhwSmileCalibrator {
pub initial: FxHhwParams,
pub kappa_floor: f64,
pub gamma_max: Option<f64>,
}
impl Calibration for FxHhwSmileCalibrator {
type Market = MarketSmileStrip;
type Params = FxHhwParams;
fn calibrate(
&self,
market: &Self::Market,
options: NelderMeadOptions,
) -> Result<CalibrationReport<Self::Params>> {
let targets = market.hhw_targets();
let res = match self.gamma_max {
Some(gmax) => calibrate_bounded(
self.initial,
&targets,
market.expiry_yf,
self.kappa_floor,
gmax,
options,
),
None => calibrate(
self.initial,
&targets,
market.expiry_yf,
self.kappa_floor,
options,
),
};
Ok(CalibrationReport {
params: res.params,
rmse: res.rmse,
optimiser: Some(res.optimiser),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::math::optimize::NelderMeadOptions;
use crate::models::common::cir::CirProcess;
use crate::models::forex::fx_hhw::{Correlation4x4, FxHhwParams};
use crate::models::interestrate::hull_white::HullWhite1F;
fn paper_params() -> FxHhwParams {
FxHhwParams {
fx_0: 1.35,
heston: CirProcess {
kappa: 0.5,
theta: 0.1,
gamma: 0.3,
sigma_0: 0.1,
},
domestic: HullWhite1F {
mean_reversion: 0.01,
sigma: 0.007,
},
foreign: HullWhite1F {
mean_reversion: 0.05,
sigma: 0.012,
},
rd_0: 0.02,
rf_0: 0.05,
theta_d: 0.02,
theta_f: 0.05,
correlations: Correlation4x4 {
rho_xi_sigma: -0.40,
rho_xi_d: -0.15,
rho_xi_f: -0.15,
rho_sigma_d: 0.30,
rho_sigma_f: 0.30,
rho_d_f: 0.25,
},
}
}
#[test]
fn softplus_inverse_roundtrip() {
for &y in &[0.001_f64, 0.01, 0.1, 1.0, 10.0, 100.0] {
let x = inv_softplus(y);
let y_back = softplus(x);
assert!((y_back - y).abs() < 1e-10, "y={}: back {}", y, y_back);
}
}
#[test]
fn calibration_recovers_synthetic_smile() {
let truth = paper_params();
let expiry = 1.0_f64;
let forward = truth.fx_0 * (-truth.rf_0 * expiry).exp() / (-truth.rd_0 * expiry).exp();
let strikes: Vec<f64> = (-4..=4)
.map(|n| forward * (0.05 * n as f64).exp())
.collect();
let market_vols: Vec<f64> = model_implied_vols(&truth, expiry, &strikes)
.iter()
.map(|o| o.expect("model vols exist for synthetic"))
.collect();
let targets = targets_from_grid(&strikes, &market_vols);
let mut start = truth;
start.heston.kappa = 0.3;
start.heston.gamma = 0.2;
start.heston.theta = 0.08;
start.heston.sigma_0 = 0.08;
start.correlations.rho_xi_sigma = -0.2;
let opts = NelderMeadOptions {
max_iter: 800,
ftol: 1e-10,
xtol: 1e-8,
step_frac: 0.1,
};
let result = calibrate(start, &targets, expiry, 1e-3, opts);
assert!(
result.rmse < 5.0e-3,
"RMSE {} too large ({} iters, converged={})",
result.rmse,
result.optimiser.iterations,
result.optimiser.converged
);
}
#[test]
fn calibration_reprices_to_near_zero_residual() {
let truth = paper_params();
let expiry = 1.0_f64;
let forward = truth.fx_0 * (-truth.rf_0 * expiry).exp() / (-truth.rd_0 * expiry).exp();
let strikes: Vec<f64> = (-3..=3)
.map(|n| forward * (0.04 * n as f64).exp())
.collect();
let market_vols: Vec<f64> = model_implied_vols(&truth, expiry, &strikes)
.iter()
.map(|o| o.unwrap())
.collect();
let targets = targets_from_grid(&strikes, &market_vols);
let result = calibrate(truth, &targets, expiry, 1e-3, NelderMeadOptions::default());
let refit_vols = model_implied_vols(&result.params, expiry, &strikes);
for (i, rv) in refit_vols.iter().enumerate() {
let v = rv.unwrap();
assert!(
(v - market_vols[i]).abs() < 1.0e-3,
"strike {}: market {} vs refit {}",
strikes[i],
market_vols[i],
v
);
}
}
}