use core::cell::Cell;
use crate::calibration::CalibrationResult;
use crate::calibration::config::{
ConstraintMode, SliceCalibrationConfig, validate_effective_quotes,
};
use crate::calibration::report::{ParameterizationEvidence, TerminationReason};
use crate::calibration::slice::residual_diagnostics;
use crate::error::CalibrationError;
use crate::market::quote::Quote;
use crate::numerics::levenberg_marquardt;
use crate::smile::raw::{RawSvi, stable_shape, stable_unit_slope};
const MIN_QUOTES: usize = 5;
#[allow(clippy::similar_names, clippy::many_single_char_names)]
pub fn refine(quotes: &[Quote], seed: &RawSvi) -> Result<CalibrationResult, CalibrationError> {
refine_with_config(quotes, seed, SliceCalibrationConfig::default())
}
#[allow(clippy::similar_names, clippy::many_single_char_names)]
pub fn refine_with_config(
quotes: &[Quote],
seed: &RawSvi,
config: SliceCalibrationConfig,
) -> Result<CalibrationResult, CalibrationError> {
validate_effective_quotes(quotes, MIN_QUOTES, config.distinct_tolerance())?;
let start = [
seed.w_min().max(1e-12).ln(),
seed.b.max(1e-12).ln(),
atanh(seed.rho.clamp(-0.999_999, 0.999_999)),
seed.m,
seed.sigma.max(1e-12).ln(),
];
let evaluations = Cell::new(0_usize);
let residual = |p: &[f64]| -> Vec<(f64, f64, Vec<f64>)> {
evaluations.set(evaluations.get().saturating_add(1));
let [v_hat, b_hat, rho_hat, m, sigma_hat] = p else {
return Vec::new();
};
let b = b_hat.exp();
let rho = rho_hat.tanh();
let sigma = sigma_hat.exp();
let w_min = v_hat.exp();
let sqrt_one_minus_rho2 = (1.0 - rho * rho).sqrt();
let a = w_min - b * sigma * sqrt_one_minus_rho2;
let db_dbhat = b; let drho_drhohat = 1.0 - rho * rho; let dsigma_dshat = sigma;
quotes
.iter()
.filter(|q| q.weight > 0.0)
.map(|q| {
let u = q.k - *m;
let r = u.hypot(sigma);
let shape = stable_shape(u, sigma, rho, r);
let model = b.mul_add(shape, a);
let resid = model - q.w;
let dw_da = 1.0;
let dw_db = shape;
let dw_drho = b * u;
let dw_dm = -b * stable_unit_slope(u, sigma, rho, r);
let dw_dsigma = b * sigma / r;
let da_dvhat = w_min;
let da_dbhat = -b * sigma * sqrt_one_minus_rho2;
let da_drhohat = if sqrt_one_minus_rho2 > 0.0 {
b * sigma * rho / sqrt_one_minus_rho2 * drho_drhohat
} else {
0.0
};
let da_dsigmahat = -b * sigma * sqrt_one_minus_rho2;
let jac = vec![
dw_da * da_dvhat,
dw_da * da_dbhat + dw_db * db_dbhat,
dw_da * da_drhohat + dw_drho * drho_drhohat,
dw_dm,
dw_da * da_dsigmahat + dw_dsigma * dsigma_dshat,
];
(resid, q.weight, jac)
})
.collect()
};
let res = levenberg_marquardt(
residual,
&start,
config.tolerance(),
config.polish_iterations(),
);
if !res.converged && config.constraint_mode() == ConstraintMode::Constrained {
return Err(CalibrationError::DidNotConverge {
iterations: res.iterations,
residual: res.cost,
});
}
let [v_hat, b_hat, rho_hat, m, sigma_hat] = res.params.as_slice() else {
return Err(CalibrationError::DidNotConverge {
iterations: res.iterations,
residual: res.cost,
});
};
let b = b_hat.exp();
let rho = rho_hat.tanh();
let sigma = sigma_hat.exp();
let w_min = v_hat.exp();
let a = w_min - b * sigma * (1.0 - rho * rho).sqrt();
let slice = RawSvi::new(a, b, rho, *m, sigma).map_err(CalibrationError::Param)?;
let residuals = residual_diagnostics(&slice, quotes, config.distinct_tolerance());
let termination = TerminationReason::from(res.termination);
CalibrationResult::from_parts(
slice,
residuals,
res.iterations,
evaluations.get(),
termination,
config,
"Levenberg–Marquardt",
1,
Some(0),
None,
ParameterizationEvidence::new(
"w_min,b,sigma=exp; rho=tanh",
"no projection or post-fit repair",
),
)
}
pub fn calibrate(quotes: &[Quote]) -> Result<CalibrationResult, CalibrationError> {
validate_effective_quotes(
quotes,
MIN_QUOTES,
SliceCalibrationConfig::default().distinct_tolerance(),
)?;
let w_atm = quotes
.iter()
.min_by(|x, y| {
x.k.abs()
.partial_cmp(&y.k.abs())
.unwrap_or(core::cmp::Ordering::Equal)
})
.map_or(0.04, |q| q.w);
let k_span = {
let lo = quotes.iter().map(|q| q.k).fold(f64::INFINITY, f64::min);
let hi = quotes.iter().map(|q| q.k).fold(f64::NEG_INFINITY, f64::max);
(hi - lo).max(1e-3)
};
let seed = RawSvi::new(0.5 * w_atm, 0.1, -0.1, 0.0, 0.5 * k_span)
.unwrap_or(RawSvi::new_unchecked(0.5 * w_atm, 0.1, -0.1, 0.0, 0.1));
refine(quotes, &seed)
}
#[inline]
fn atanh(x: f64) -> f64 {
0.5 * ((1.0 + x) / (1.0 - x)).ln()
}
#[cfg(test)]
#[allow(clippy::expect_used)] mod tests {
use super::*;
fn synthetic(svi: &RawSvi, ks: &[f64]) -> Vec<Quote> {
ks.iter()
.map(|&k| {
Quote::new(k, svi.total_variance(k), 1.0)
.expect("valid test or documentation fixture")
})
.collect()
}
#[test]
fn atanh_inverts_tanh() {
for &x in &[-0.9, -0.3, 0.0, 0.5, 0.95] {
assert!((atanh(x).tanh() - x).abs() < 1e-12);
}
}
#[test]
fn rejects_empty() {
assert!(matches!(calibrate(&[]), Err(CalibrationError::EmptyQuotes)));
}
#[test]
fn rejects_too_few() {
let q = Quote::new(0.0, 0.04, 1.0).expect("valid test or documentation fixture");
assert!(matches!(
calibrate(&[q, q]),
Err(CalibrationError::InsufficientEffectiveQuotes { .. })
));
}
#[test]
fn refine_recovers_from_perturbed_seed() {
let truth =
RawSvi::new(0.04, 0.4, -0.3, 0.05, 0.15).expect("valid test or documentation fixture");
let ks = [-0.4, -0.25, -0.1, 0.0, 0.1, 0.25, 0.4];
let quotes = synthetic(&truth, &ks);
let seed =
RawSvi::new(0.05, 0.3, -0.2, 0.0, 0.18).expect("valid test or documentation fixture");
let config =
SliceCalibrationConfig::default().with_constraint_mode(ConstraintMode::BestEffort);
let fit = refine_with_config("es, &seed, config)
.expect("valid test or documentation fixture");
assert!(fit.rmse() < 1e-6, "rmse = {}", fit.rmse());
for &k in &[-0.5, 0.0, 0.5] {
let err = (fit.slice().total_variance(k) - truth.total_variance(k)).abs();
assert!(err < 1e-4, "k = {k}, err = {err}");
}
}
#[test]
fn calibrate_standalone_fits() {
let truth = RawSvi::new(0.04, 0.35, -0.25, 0.02, 0.16)
.expect("valid test or documentation fixture");
let ks = [-0.4, -0.2, -0.05, 0.05, 0.2, 0.4];
let quotes = synthetic(&truth, &ks);
let fit = calibrate("es).expect("valid test or documentation fixture");
for &k in &[-0.3, 0.0, 0.3] {
let err = (fit.slice().total_variance(k) - truth.total_variance(k)).abs();
assert!(err < 1e-2, "k = {k}, err = {err}");
}
}
#[test]
fn refine_preserves_domain() {
let truth =
RawSvi::new(0.03, 0.5, 0.6, -0.05, 0.12).expect("valid test or documentation fixture");
let ks = [-0.3, -0.15, 0.0, 0.15, 0.3, 0.45];
let quotes = synthetic(&truth, &ks);
let seed =
RawSvi::new(0.04, 0.3, 0.3, 0.0, 0.2).expect("valid test or documentation fixture");
let config =
SliceCalibrationConfig::default().with_constraint_mode(ConstraintMode::BestEffort);
let fit = refine_with_config("es, &seed, config)
.expect("valid test or documentation fixture");
assert!(fit.slice().validate().is_ok());
assert!(fit.slice().b >= 0.0);
assert!(fit.slice().rho.abs() < 1.0);
assert!(fit.slice().sigma > 0.0);
}
}