use crate::errors::CalibrationError;
use crate::math::nelder_mead;
use crate::ssvi::{Phi, Ssvi};
use crate::types::Quote;
const OUTER_TOL: f64 = 1e-12;
const OUTER_MAX_ITER: usize = 3000;
const ARBITRAGE_PENALTY: f64 = 1e6;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PhiFamily {
Heston,
PowerLaw,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SsviMaturity {
pub t: f64,
pub theta: f64,
pub quotes: Vec<Quote>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SsviCalibration {
pub ssvi: Ssvi,
pub thetas: Vec<f64>,
pub rmse: f64,
pub arbitrage_free: bool,
}
pub fn calibrate(
maturities: &[SsviMaturity],
family: PhiFamily,
) -> Result<SsviCalibration, CalibrationError> {
if maturities.is_empty() || maturities.iter().all(|m| m.quotes.is_empty()) {
return Err(CalibrationError::EmptyQuotes);
}
let total_weight: f64 = maturities
.iter()
.flat_map(|m| m.quotes.iter())
.map(|q| q.weight)
.sum();
if total_weight <= 0.0 {
return Err(CalibrationError::AllWeightsZero);
}
let thetas: Vec<f64> = maturities.iter().map(|m| m.theta).collect();
let objective = |p: &[f64]| -> f64 {
let Some(ssvi) = surface_from_params(p, family) else {
return f64::INFINITY;
};
let mut cost = 0.0;
for mat in maturities {
for q in &mat.quotes {
if q.weight <= 0.0 {
continue;
}
let model = ssvi.total_variance(q.k, mat.theta);
let r = model - q.w;
cost += q.weight * r * r;
}
}
if !ssvi.is_butterfly_free(&thetas) || !ssvi.is_calendar_free(&thetas) {
cost += ARBITRAGE_PENALTY;
}
cost
};
let rho_seeds = [-0.6_f64, -0.2, 0.0, 0.2, 0.6];
let mut best_obj = f64::INFINITY;
let mut best_params: Vec<f64> = Vec::new();
match family {
PhiFamily::Heston => {
for &rho0 in &rho_seeds {
for &lambda0 in &[0.5_f64, 1.0, 2.0, 5.0] {
let start = [atanh_clamped(rho0), lambda0.ln()];
let res = nelder_mead(objective, &start, OUTER_TOL, OUTER_MAX_ITER);
if res.fx < best_obj {
best_obj = res.fx;
best_params = res.x;
}
}
}
}
PhiFamily::PowerLaw => {
for &rho0 in &rho_seeds {
for &eta0 in &[0.3_f64, 0.6, 1.0] {
for &gamma0 in &[0.3_f64, 0.5, 0.7] {
let start = [atanh_clamped(rho0), eta0.ln(), logit(gamma0)];
let res = nelder_mead(objective, &start, OUTER_TOL, OUTER_MAX_ITER);
if res.fx < best_obj {
best_obj = res.fx;
best_params = res.x;
}
}
}
}
}
}
let ssvi =
surface_from_params(&best_params, family).ok_or(CalibrationError::DidNotConverge {
iterations: OUTER_MAX_ITER,
residual: best_obj,
})?;
let arbitrage_free = ssvi.is_butterfly_free(&thetas) && ssvi.is_calendar_free(&thetas);
let mut residual = 0.0;
for mat in maturities {
for q in &mat.quotes {
if q.weight <= 0.0 {
continue;
}
let r = ssvi.total_variance(q.k, mat.theta) - q.w;
residual += q.weight * r * r;
}
}
let rmse = (residual / total_weight).sqrt();
Ok(SsviCalibration {
ssvi,
thetas,
rmse,
arbitrage_free,
})
}
fn surface_from_params(p: &[f64], family: PhiFamily) -> Option<Ssvi> {
let rho = p[0].tanh();
let phi = match family {
PhiFamily::Heston => {
let lambda = p[1].exp();
Phi::heston(lambda).ok()?
}
PhiFamily::PowerLaw => {
let eta = p[1].exp();
let gamma = logistic(p[2]);
Phi::power_law(eta, gamma).ok()?
}
};
Ssvi::new(rho, phi).ok()
}
#[inline]
fn atanh_clamped(x: f64) -> f64 {
let x = x.clamp(-0.999_999, 0.999_999);
0.5 * ((1.0 + x) / (1.0 - x)).ln()
}
#[inline]
fn logistic(x: f64) -> f64 {
1.0 / (1.0 + (-x).exp())
}
#[inline]
fn logit(p: f64) -> f64 {
let p = p.clamp(1e-6, 1.0 - 1e-6);
(p / (1.0 - p)).ln()
}
#[cfg(test)]
mod tests {
use super::*;
fn synthetic(truth: &Ssvi, ts_thetas: &[(f64, f64)], ks: &[f64]) -> Vec<SsviMaturity> {
ts_thetas
.iter()
.map(|&(t, theta)| SsviMaturity {
t,
theta,
quotes: ks
.iter()
.map(|&k| Quote::new(k, truth.total_variance(k, theta), 1.0).unwrap())
.collect(),
})
.collect()
}
#[test]
fn logistic_logit_invert() {
for &x in &[0.05, 0.3, 0.5, 0.8, 0.95] {
assert!((logistic(logit(x)) - x).abs() < 1e-10);
}
}
#[test]
fn rejects_empty() {
assert!(matches!(
calibrate(&[], PhiFamily::PowerLaw),
Err(CalibrationError::EmptyQuotes)
));
}
#[test]
fn recovers_power_law_surface() {
let truth = Ssvi::new(-0.3, Phi::power_law(0.5, 0.5).unwrap()).unwrap();
let ks = [-0.3, -0.15, 0.0, 0.15, 0.3];
let mats = synthetic(&truth, &[(0.5, 0.02), (1.0, 0.04), (2.0, 0.07)], &ks);
let fit = calibrate(&mats, PhiFamily::PowerLaw).unwrap();
assert!(fit.rmse < 1e-3, "rmse = {}", fit.rmse);
assert!(fit.arbitrage_free);
assert!(
(fit.ssvi.rho - (-0.3)).abs() < 0.1,
"rho = {}",
fit.ssvi.rho
);
}
#[test]
fn recovers_heston_surface() {
let truth = Ssvi::new(-0.2, Phi::heston(1.5).unwrap()).unwrap();
let ks = [-0.3, -0.15, 0.0, 0.15, 0.3];
let mats = synthetic(&truth, &[(0.5, 0.02), (1.0, 0.04), (2.0, 0.07)], &ks);
let fit = calibrate(&mats, PhiFamily::Heston).unwrap();
assert!(fit.rmse < 5e-3, "rmse = {}", fit.rmse);
assert!(fit.arbitrage_free);
}
#[test]
fn fitted_surface_is_arbitrage_free() {
let truth = Ssvi::new(-0.4, Phi::power_law(0.6, 0.4).unwrap()).unwrap();
let ks = [-0.4, -0.2, 0.0, 0.2, 0.4];
let mats = synthetic(&truth, &[(0.25, 0.015), (1.0, 0.05), (3.0, 0.11)], &ks);
let fit = calibrate(&mats, PhiFamily::PowerLaw).unwrap();
assert!(fit.arbitrage_free);
assert!(fit.ssvi.is_butterfly_free(&fit.thetas));
assert!(fit.ssvi.is_calendar_free(&fit.thetas));
}
}