use serde::{Deserialize, Serialize};
use crate::calibration::{DataFilter, WeightingScheme, apply_filter};
use crate::error::{self, VolSurfError};
use crate::smile::arbitrage::ArbitrageReport;
use crate::smile::{ArbitrageScanConfig, SmileSection};
use crate::surface::CALENDAR_ARB_TOL;
use crate::surface::VolSurface;
use crate::surface::arbitrage::{CalendarViolation, SurfaceDiagnostics};
use crate::surface::ssvi::{CALENDAR_CHECK_GRID_SIZE, SsviSlice, strike_grid};
use crate::types::{Strike, Tenor, Variance, Vol};
use crate::validate::validate_positive;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StructuralViolation {
pub tenor: f64,
pub theta: f64,
pub condition_lhs: f64,
pub condition_rhs: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PerTenorFit {
pub tenor: f64,
pub forward: f64,
pub svi: crate::smile::SviSmile,
pub theta: f64,
pub rms_error: f64,
pub market_data: Vec<(f64, f64)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(try_from = "EssviSliceRaw", into = "EssviSliceRaw")]
pub struct EssviSlice(SsviSlice);
#[derive(Serialize, Deserialize)]
struct EssviSliceRaw {
forward: f64,
expiry: f64,
rho: f64,
eta: f64,
gamma: f64,
theta: f64,
}
impl TryFrom<EssviSliceRaw> for EssviSlice {
type Error = VolSurfError;
fn try_from(raw: EssviSliceRaw) -> Result<Self, Self::Error> {
Self::new(
raw.forward,
raw.expiry,
raw.rho,
raw.eta,
raw.gamma,
raw.theta,
)
}
}
impl From<EssviSlice> for EssviSliceRaw {
fn from(s: EssviSlice) -> Self {
Self {
forward: s.0.forward(),
expiry: s.0.expiry(),
rho: s.rho(),
eta: s.eta(),
gamma: s.gamma(),
theta: s.0.theta(),
}
}
}
impl EssviSlice {
pub fn new(
forward: f64,
expiry: f64,
rho: f64,
eta: f64,
gamma: f64,
theta: f64,
) -> error::Result<Self> {
SsviSlice::new(forward, expiry, rho, eta, gamma, theta).map(EssviSlice)
}
pub fn theta(&self) -> f64 {
self.0.theta()
}
pub fn rho(&self) -> f64 {
self.0.rho()
}
pub fn eta(&self) -> f64 {
self.0.eta()
}
pub fn gamma(&self) -> f64 {
self.0.gamma()
}
}
impl SmileSection for EssviSlice {
fn vol(&self, strike: Strike) -> error::Result<Vol> {
self.0.vol(strike)
}
fn variance(&self, strike: Strike) -> error::Result<Variance> {
self.0.variance(strike)
}
fn density(&self, strike: Strike) -> error::Result<f64> {
self.0.density(strike)
}
fn forward(&self) -> f64 {
self.0.forward()
}
fn expiry(&self) -> f64 {
self.0.expiry()
}
fn model_name(&self) -> &'static str {
"eSSVI"
}
fn is_arbitrage_free(&self) -> error::Result<ArbitrageReport> {
self.0.is_arbitrage_free()
}
fn is_arbitrage_free_with(
&self,
config: &ArbitrageScanConfig,
) -> error::Result<ArbitrageReport> {
self.0.is_arbitrage_free_with(config)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(try_from = "EssviSurfaceRaw", into = "EssviSurfaceRaw")]
pub struct EssviSurface {
rho_0: f64,
rho_m: f64,
a: f64,
eta: f64,
gamma: f64,
tenors: Vec<f64>,
forwards: Vec<f64>,
thetas: Vec<f64>,
theta_max: f64,
}
#[derive(Serialize, Deserialize)]
struct EssviSurfaceRaw {
rho_0: f64,
rho_m: f64,
a: f64,
eta: f64,
gamma: f64,
tenors: Vec<f64>,
forwards: Vec<f64>,
thetas: Vec<f64>,
}
impl TryFrom<EssviSurfaceRaw> for EssviSurface {
type Error = VolSurfError;
fn try_from(raw: EssviSurfaceRaw) -> Result<Self, Self::Error> {
Self::new(
raw.rho_0,
raw.rho_m,
raw.a,
raw.eta,
raw.gamma,
raw.tenors,
raw.forwards,
raw.thetas,
)
}
}
impl From<EssviSurface> for EssviSurfaceRaw {
fn from(s: EssviSurface) -> Self {
Self {
rho_0: s.rho_0,
rho_m: s.rho_m,
a: s.a,
eta: s.eta,
gamma: s.gamma,
tenors: s.tenors,
forwards: s.forwards,
thetas: s.thetas,
}
}
}
fn a_max_eq57(gamma: f64, rho_diff: f64, rho_m: f64) -> f64 {
let gamma_thm = 1.0 - gamma;
if rho_diff > 0.0 {
gamma_thm * (1.0 - rho_m) / rho_diff
} else {
gamma_thm * (1.0 + rho_m) / (-rho_diff)
}
}
impl EssviSurface {
#[expect(clippy::too_many_arguments)]
pub fn new(
rho_0: f64,
rho_m: f64,
a: f64,
eta: f64,
gamma: f64,
tenors: Vec<f64>,
forwards: Vec<f64>,
thetas: Vec<f64>,
) -> error::Result<Self> {
if rho_0.abs() >= 1.0 || rho_0.is_nan() {
return Err(VolSurfError::InvalidInput {
message: format!("|rho_0| must be less than 1, got {rho_0}"),
});
}
if rho_m.abs() >= 1.0 || rho_m.is_nan() {
return Err(VolSurfError::InvalidInput {
message: format!("|rho_m| must be less than 1, got {rho_m}"),
});
}
if !a.is_finite() || a < 0.0 {
return Err(VolSurfError::InvalidInput {
message: format!("a must be non-negative and finite, got {a}"),
});
}
validate_positive(eta, "eta")?;
if !gamma.is_finite() || !(0.0..=1.0).contains(&gamma) {
return Err(VolSurfError::InvalidInput {
message: format!("gamma must be in [0, 1], got {gamma}"),
});
}
if tenors.is_empty() {
return Err(VolSurfError::InvalidInput {
message: "at least one tenor is required".into(),
});
}
if tenors.len() != forwards.len() {
return Err(VolSurfError::InvalidInput {
message: format!(
"tenors and forwards must have the same length, got {} and {}",
tenors.len(),
forwards.len()
),
});
}
if tenors.len() != thetas.len() {
return Err(VolSurfError::InvalidInput {
message: format!(
"tenors and thetas must have the same length, got {} and {}",
tenors.len(),
thetas.len()
),
});
}
for (i, &t) in tenors.iter().enumerate() {
if !t.is_finite() || t <= 0.0 {
return Err(VolSurfError::InvalidInput {
message: format!("tenors must be positive and finite, got tenors[{i}]={t}"),
});
}
}
for (i, &f) in forwards.iter().enumerate() {
if !f.is_finite() || f <= 0.0 {
return Err(VolSurfError::InvalidInput {
message: format!("forwards must be positive and finite, got forwards[{i}]={f}"),
});
}
}
for (i, &th) in thetas.iter().enumerate() {
if !th.is_finite() || th <= 0.0 {
return Err(VolSurfError::InvalidInput {
message: format!("thetas must be positive and finite, got thetas[{i}]={th}"),
});
}
}
for w in tenors.windows(2) {
if w[1] <= w[0] {
return Err(VolSurfError::InvalidInput {
message: format!(
"tenors must be strictly increasing, but {} >= {}",
w[0], w[1]
),
});
}
}
for w in thetas.windows(2) {
if w[1] <= w[0] {
return Err(VolSurfError::InvalidInput {
message: format!(
"thetas must be strictly increasing, but {} >= {}",
w[0], w[1]
),
});
}
}
let theta_max = thetas[thetas.len() - 1];
let rho_diff = rho_m - rho_0;
if rho_diff.abs() > 1e-14 {
let a_max = a_max_eq57(gamma, rho_diff, rho_m);
if a > a_max + 1e-12 {
return Err(VolSurfError::InvalidInput {
message: format!("a={a} exceeds calendar no-arb bound {a_max:.6} (Eq. 5.7)"),
});
}
}
Ok(Self {
rho_0,
rho_m,
a,
eta,
gamma,
tenors,
forwards,
thetas,
theta_max,
})
}
pub fn fit_per_tenor(
market_data: &[Vec<(f64, f64)>],
tenors: &[f64],
forwards: &[f64],
) -> error::Result<Vec<PerTenorFit>> {
Self::fit_per_tenor_with_config(
market_data,
tenors,
forwards,
&DataFilter::default(),
&WeightingScheme::default(),
)
}
pub fn fit_per_tenor_with_config(
market_data: &[Vec<(f64, f64)>],
tenors: &[f64],
forwards: &[f64],
filter: &DataFilter,
weighting: &WeightingScheme,
) -> error::Result<Vec<PerTenorFit>> {
if tenors.len() != forwards.len() || tenors.len() != market_data.len() {
return Err(VolSurfError::InvalidInput {
message: format!(
"tenors, forwards, and market_data must have the same length: {}, {}, {}",
tenors.len(),
forwards.len(),
market_data.len()
),
});
}
for (i, &t) in tenors.iter().enumerate() {
if !t.is_finite() || t <= 0.0 {
return Err(VolSurfError::InvalidInput {
message: format!("tenors[{i}] must be positive and finite, got {t}"),
});
}
}
for (i, &f) in forwards.iter().enumerate() {
if !f.is_finite() || f <= 0.0 {
return Err(VolSurfError::InvalidInput {
message: format!("forwards[{i}] must be positive and finite, got {f}"),
});
}
}
let mut fits = Vec::with_capacity(tenors.len());
for (i, market_vols) in market_data.iter().enumerate() {
let svi = crate::smile::SviSmile::calibrate_with_config(
forwards[i],
tenors[i],
market_vols,
filter,
weighting,
None,
)
.map_err(|e| VolSurfError::CalibrationError {
message: format!(
"per-tenor SVI calibration failed for tenor[{i}]={}: {e}",
tenors[i]
),
model: "eSSVI",
rms_error: None,
})?;
let theta = svi.variance(Strike(forwards[i]))?.0;
let filtered = apply_filter(market_vols, forwards[i], filter);
let eval_data = if filtered.len() >= 5 {
&filtered
} else {
market_vols
};
let mut sum_sq = 0.0;
for &(strike, market_vol) in eval_data.iter() {
let fitted_vol = svi.vol(Strike(strike))?.0;
sum_sq += (fitted_vol - market_vol).powi(2);
}
let rms_error = (sum_sq / eval_data.len() as f64).sqrt();
fits.push(PerTenorFit {
tenor: tenors[i],
forward: forwards[i],
svi,
theta,
rms_error,
market_data: eval_data.to_vec(),
});
}
Ok(fits)
}
pub fn from_per_tenor(fits: &[PerTenorFit]) -> error::Result<Self> {
const MIN_TENORS: usize = 2;
const GRID_N: usize = 15;
const A_SCAN_STEPS: usize = 20;
const A_SCAN_MAX: f64 = 3.0;
if fits.len() < MIN_TENORS {
return Err(VolSurfError::InvalidInput {
message: format!(
"at least {MIN_TENORS} tenors required for eSSVI calibration, got {}",
fits.len()
),
});
}
#[cfg(feature = "logging")]
tracing::debug!(n_tenors = fits.len(), "eSSVI from_per_tenor started");
for (i, fit) in fits.iter().enumerate() {
if !fit.tenor.is_finite() || fit.tenor <= 0.0 {
return Err(VolSurfError::InvalidInput {
message: format!(
"fits[{i}].tenor must be positive and finite, got {}",
fit.tenor
),
});
}
if !fit.forward.is_finite() || fit.forward <= 0.0 {
return Err(VolSurfError::InvalidInput {
message: format!(
"fits[{i}].forward must be positive and finite, got {}",
fit.forward
),
});
}
if !fit.theta.is_finite() || fit.theta <= 0.0 {
return Err(VolSurfError::InvalidInput {
message: format!(
"fits[{i}].theta must be positive and finite, got {}",
fit.theta
),
});
}
}
let tenors: Vec<f64> = fits.iter().map(|f| f.tenor).collect();
let forwards: Vec<f64> = fits.iter().map(|f| f.forward).collect();
let thetas: Vec<f64> = fits.iter().map(|f| f.theta).collect();
let rhos: Vec<f64> = fits.iter().map(|f| f.svi.rho()).collect();
for (i, w) in thetas.windows(2).enumerate() {
if w[1] <= w[0] {
return Err(VolSurfError::CalibrationError {
message: format!(
"per-tenor SVI calibration produced non-monotone ATM variances: \
theta[{i}]={:.6} >= theta[{}]={:.6} (tenors {}, {})",
w[0],
i + 1,
w[1],
tenors[i],
tenors[i + 1]
),
model: "eSSVI",
rms_error: None,
});
}
}
let theta_max = thetas[thetas.len() - 1];
let xs: Vec<f64> = thetas.iter().map(|&t| t / theta_max).collect();
let ln_xs: Vec<f64> = xs.iter().map(|&x| x.ln()).collect();
let fit_a = |r0: f64, rm: f64| -> (f64, f64) {
let d = rm - r0;
if d.abs() < 1e-14 {
let rss: f64 = rhos.iter().map(|&r| (r0 - r).powi(2)).sum();
return (0.0, rss);
}
let mut best_a = 0.5;
let mut best_rss = f64::MAX;
for ia in 0..=A_SCAN_STEPS {
let t = (ia as f64) / (A_SCAN_STEPS as f64);
let a = A_SCAN_MAX * t * t;
let rss: f64 = rhos
.iter()
.enumerate()
.map(|(i, &rho_obs)| {
let rho_pred = r0 + d * (a * ln_xs[i]).exp();
(rho_pred - rho_obs).powi(2)
})
.sum();
if rss < best_rss {
best_rss = rss;
best_a = a;
}
}
(best_a, best_rss)
};
let rho_min = rhos.iter().cloned().fold(f64::INFINITY, f64::min);
let rho_max = rhos.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let rho_lo = (rho_min - 0.15).max(-0.99);
let rho_hi = (rho_max + 0.15).min(0.99);
let mut best_r0 = rho_min.clamp(-0.999, 0.999);
let mut best_rm = rho_max.clamp(-0.999, 0.999);
let mut best_rho_rss = f64::MAX;
for ir0 in 0..GRID_N {
let r0 = rho_lo + (rho_hi - rho_lo) * (ir0 as f64) / ((GRID_N - 1) as f64);
for irm in 0..GRID_N {
let rm = rho_lo + (rho_hi - rho_lo) * (irm as f64) / ((GRID_N - 1) as f64);
let (_, rss) = fit_a(r0, rm);
if rss < best_rho_rss {
best_rho_rss = rss;
best_r0 = r0;
best_rm = rm;
}
}
}
let rho_step = (rho_hi - rho_lo) / (GRID_N as f64) * 0.5;
let nm_config = crate::optim::NelderMeadConfig::calibration();
let nm_rho = crate::optim::nelder_mead_2d(
|r0, rm| {
if r0.abs() >= 0.999 || rm.abs() >= 0.999 || !r0.is_finite() || !rm.is_finite() {
return f64::MAX;
}
fit_a(r0, rm).1
},
best_r0,
best_rm,
rho_step,
rho_step,
&nm_config,
);
let opt_rho_0 = nm_rho.x.clamp(-0.999, 0.999);
let opt_rho_m = nm_rho.y.clamp(-0.999, 0.999);
let (opt_a_fit, _) = fit_a(opt_rho_0, opt_rho_m);
#[cfg(feature = "logging")]
tracing::debug!(
rho_0 = opt_rho_0,
rho_m = opt_rho_m,
a = opt_a_fit,
"eSSVI Stage 2 rho(theta) fit complete"
);
let mut all_points: Vec<(f64, f64, f64, f64)> =
Vec::with_capacity(fits.iter().map(|f| f.market_data.len()).sum());
for fit in fits {
let ln_tr = (fit.theta.max(1e-10) / theta_max).clamp(0.0, 1.0).ln();
for &(strike, vol) in &fit.market_data {
let k = (strike / fit.forward).ln();
let w_obs = vol * vol * fit.tenor;
all_points.push((fit.theta, k, w_obs, ln_tr));
}
}
let rho_diff = opt_rho_m - opt_rho_0;
let objective = |eta: f64, gamma: f64| -> f64 {
if eta <= 0.0 || !eta.is_finite() || !gamma.is_finite() || !(0.0..=1.0).contains(&gamma)
{
return f64::MAX;
}
let a_eff = if rho_diff.abs() > 1e-14 {
let a_mx = a_max_eq57(gamma, rho_diff, opt_rho_m);
if a_mx < 0.0 {
return f64::MAX;
}
opt_a_fit.min(a_mx)
} else {
opt_a_fit
};
let mut rss = 0.0;
for &(theta, k, w_obs, ln_tr) in &all_points {
let theta_c = theta.max(1e-10);
let rho = (opt_rho_0 + (opt_rho_m - opt_rho_0) * (a_eff * ln_tr).exp())
.clamp(-0.999, 0.999);
let phi = eta / theta_c.powf(gamma);
let phi_k = phi * k;
let one_minus_rho_sq = 1.0 - rho * rho;
let w_pred = (theta / 2.0)
* (1.0 + rho * phi_k + ((phi_k + rho).powi(2) + one_minus_rho_sq).sqrt());
if !w_pred.is_finite() {
return f64::MAX;
}
rss += (w_pred - w_obs).powi(2);
}
rss
};
let eta_lo = 0.01_f64;
let eta_hi = 3.0_f64;
let gamma_lo = 0.0_f64;
let gamma_hi = 1.0_f64;
let mut best_eta = 0.5;
let mut best_gamma = 0.5;
let mut best_rss = f64::MAX;
for ie in 0..GRID_N {
let eta = eta_lo + (eta_hi - eta_lo) * (ie as f64) / ((GRID_N - 1) as f64);
for ig in 0..GRID_N {
let gamma = gamma_lo + (gamma_hi - gamma_lo) * (ig as f64) / ((GRID_N - 1) as f64);
let rss = objective(eta, gamma);
if rss < best_rss {
best_rss = rss;
best_eta = eta;
best_gamma = gamma;
}
}
}
if best_rss >= f64::MAX {
return Err(VolSurfError::CalibrationError {
message: "grid search found no valid starting point".into(),
model: "eSSVI",
rms_error: None,
});
}
let step_eta = (eta_hi - eta_lo) / (GRID_N as f64) * 0.5;
let step_gamma = (gamma_hi - gamma_lo) / (GRID_N as f64) * 0.5;
let nm_result = crate::optim::nelder_mead_2d(
objective, best_eta, best_gamma, step_eta, step_gamma, &nm_config,
);
let opt_eta = nm_result.x.max(1e-6);
let opt_gamma = nm_result.y.clamp(0.0, 1.0);
let final_a = if rho_diff.abs() > 1e-14 {
let a_mx = a_max_eq57(opt_gamma, rho_diff, opt_rho_m);
opt_a_fit.min(a_mx.max(0.0))
} else {
opt_a_fit
};
let n_points = all_points.len();
let rms = if n_points > 0 {
(nm_result.fval / n_points as f64).sqrt()
} else {
0.0
};
#[cfg(feature = "logging")]
tracing::debug!(
rho_0 = opt_rho_0,
rho_m = opt_rho_m,
a = final_a,
eta = opt_eta,
gamma = opt_gamma,
rms_total_variance = rms,
"eSSVI calibration complete"
);
Self::new(
opt_rho_0, opt_rho_m, final_a, opt_eta, opt_gamma, tenors, forwards, thetas,
)
.map_err(|e| VolSurfError::CalibrationError {
message: format!("calibrated eSSVI params invalid: {e}"),
model: "eSSVI",
rms_error: Some(rms),
})
}
pub fn calibrate(
market_data: &[Vec<(f64, f64)>],
tenors: &[f64],
forwards: &[f64],
) -> error::Result<Self> {
let fits = Self::fit_per_tenor(market_data, tenors, forwards)?;
Self::from_per_tenor(&fits)
}
pub fn calibrate_with_config(
market_data: &[Vec<(f64, f64)>],
tenors: &[f64],
forwards: &[f64],
filter: &DataFilter,
weighting: &WeightingScheme,
) -> error::Result<Self> {
let fits =
Self::fit_per_tenor_with_config(market_data, tenors, forwards, filter, weighting)?;
Self::from_per_tenor(&fits)
}
pub fn rho(&self, theta: f64) -> f64 {
let t = (theta / self.theta_max).clamp(0.0, 1.0);
let r = self.rho_0 + (self.rho_m - self.rho_0) * t.powf(self.a);
r.clamp(-0.999, 0.999)
}
pub fn rho_0(&self) -> f64 {
self.rho_0
}
pub fn rho_m(&self) -> f64 {
self.rho_m
}
pub fn a(&self) -> f64 {
self.a
}
pub fn eta(&self) -> f64 {
self.eta
}
pub fn gamma(&self) -> f64 {
self.gamma
}
pub fn tenors(&self) -> &[f64] {
&self.tenors
}
pub fn forwards(&self) -> &[f64] {
&self.forwards
}
pub fn thetas(&self) -> &[f64] {
&self.thetas
}
pub fn theta_max(&self) -> f64 {
self.theta_max
}
pub(crate) fn total_variance_at(&self, theta: f64, k: f64) -> f64 {
let rho = self.rho(theta);
let phi = self.eta / theta.powf(self.gamma);
let phi_k = phi * k;
let one_minus_rho_sq = 1.0 - rho * rho;
(theta / 2.0) * (1.0 + rho * phi_k + ((phi_k + rho).powi(2) + one_minus_rho_sq).sqrt())
}
pub(crate) fn theta_and_forward_at(&self, expiry: f64) -> (f64, f64) {
super::interp::interpolate_theta_forward(&self.tenors, &self.thetas, &self.forwards, expiry)
}
pub fn calendar_check_structural(&self) -> Vec<StructuralViolation> {
let gamma_thm = 1.0 - self.gamma;
let mut violations = Vec::new();
for (i, &theta) in self.thetas.iter().enumerate() {
let rho = self.rho(theta);
let t = (theta / self.theta_max).clamp(0.0, 1.0);
let delta = self.a * (self.rho_m - self.rho_0) * t.powf(self.a);
let lhs = (delta + rho * gamma_thm).abs();
if lhs > gamma_thm + CALENDAR_ARB_TOL {
violations.push(StructuralViolation {
tenor: self.tenors[i],
theta,
condition_lhs: lhs,
condition_rhs: gamma_thm,
});
}
}
violations
}
}
impl VolSurface for EssviSurface {
fn black_vol(&self, expiry: Tenor, strike: Strike) -> error::Result<Vol> {
validate_positive(expiry.0, "expiry")?;
let var = self.black_variance(expiry, strike)?;
Ok(Vol((var.0 / expiry.0).sqrt()))
}
fn black_variance(&self, expiry: Tenor, strike: Strike) -> error::Result<Variance> {
validate_positive(expiry.0, "expiry")?;
validate_positive(strike.0, "strike")?;
let (theta, forward) = self.theta_and_forward_at(expiry.0);
let k = (strike.0 / forward).ln();
let w = self.total_variance_at(theta, k);
if w < 0.0 {
return Err(VolSurfError::NumericalError {
message: format!("eSSVI total variance is negative: w({k}) = {w}"),
});
}
Ok(Variance(w))
}
fn smile_at(&self, expiry: Tenor) -> error::Result<Box<dyn SmileSection>> {
validate_positive(expiry.0, "expiry")?;
let (theta, forward) = self.theta_and_forward_at(expiry.0);
let rho = self.rho(theta);
let slice = EssviSlice::new(forward, expiry.0, rho, self.eta, self.gamma, theta)?;
Ok(Box::new(slice))
}
fn diagnostics(&self) -> error::Result<SurfaceDiagnostics> {
self.diagnostics_with(&ArbitrageScanConfig::svi_default())
}
fn diagnostics_with(&self, config: &ArbitrageScanConfig) -> error::Result<SurfaceDiagnostics> {
let mut smile_reports = Vec::with_capacity(self.tenors.len());
for (i, &tenor) in self.tenors.iter().enumerate() {
let rho = self.rho(self.thetas[i]);
let slice = EssviSlice::new(
self.forwards[i],
tenor,
rho,
self.eta,
self.gamma,
self.thetas[i],
)?;
smile_reports.push(slice.is_arbitrage_free_with(config)?);
}
let mut calendar_violations = Vec::new();
for i in 0..self.tenors.len().saturating_sub(1) {
let f_avg = 0.5 * (self.forwards[i] + self.forwards[i + 1]);
let grid = strike_grid(f_avg, CALENDAR_CHECK_GRID_SIZE);
for &strike in &grid {
let k_short = (strike / self.forwards[i]).ln();
let k_long = (strike / self.forwards[i + 1]).ln();
let w_short = self.total_variance_at(self.thetas[i], k_short);
let w_long = self.total_variance_at(self.thetas[i + 1], k_long);
if w_long < w_short - CALENDAR_ARB_TOL {
calendar_violations.push(CalendarViolation {
strike,
tenor_short: self.tenors[i],
tenor_long: self.tenors[i + 1],
variance_short: w_short,
variance_long: w_long,
});
}
}
}
Ok(SurfaceDiagnostics {
smile_reports,
calendar_violations,
})
}
fn tenors(&self) -> &[f64] {
&self.tenors
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_abs_diff_eq;
fn equity_slice() -> EssviSlice {
EssviSlice::new(100.0, 1.0, -0.3, 0.5, 0.5, 0.16).unwrap()
}
#[test]
fn new_valid_params() {
let s = equity_slice();
assert_eq!(s.forward(), 100.0);
assert_eq!(s.expiry(), 1.0);
assert_eq!(s.theta(), 0.16);
assert_eq!(s.rho(), -0.3);
assert_eq!(s.eta(), 0.5);
assert_eq!(s.gamma(), 0.5);
}
#[test]
fn new_rejects_invalid_params() {
assert!(EssviSlice::new(0.0, 1.0, -0.3, 0.5, 0.5, 0.16).is_err());
assert!(EssviSlice::new(-1.0, 1.0, -0.3, 0.5, 0.5, 0.16).is_err());
assert!(EssviSlice::new(100.0, 0.0, -0.3, 0.5, 0.5, 0.16).is_err());
assert!(EssviSlice::new(100.0, -1.0, -0.3, 0.5, 0.5, 0.16).is_err());
assert!(EssviSlice::new(100.0, 1.0, 1.0, 0.5, 0.5, 0.16).is_err());
assert!(EssviSlice::new(100.0, 1.0, -1.0, 0.5, 0.5, 0.16).is_err());
assert!(EssviSlice::new(100.0, 1.0, -0.3, 0.0, 0.5, 0.16).is_err());
assert!(EssviSlice::new(100.0, 1.0, -0.3, -0.1, 0.5, 0.16).is_err());
assert!(EssviSlice::new(100.0, 1.0, -0.3, 0.5, -0.1, 0.16).is_err());
assert!(EssviSlice::new(100.0, 1.0, -0.3, 0.5, 1.5, 0.16).is_err());
assert!(EssviSlice::new(100.0, 1.0, -0.3, 0.5, 0.5, 0.0).is_err());
assert!(EssviSlice::new(100.0, 1.0, -0.3, 0.5, 0.5, -0.01).is_err());
}
#[test]
fn new_rejects_nan_and_inf() {
assert!(EssviSlice::new(f64::NAN, 1.0, -0.3, 0.5, 0.5, 0.16).is_err());
assert!(EssviSlice::new(100.0, f64::INFINITY, -0.3, 0.5, 0.5, 0.16).is_err());
assert!(EssviSlice::new(100.0, 1.0, f64::NAN, 0.5, 0.5, 0.16).is_err());
assert!(EssviSlice::new(100.0, 1.0, -0.3, f64::NEG_INFINITY, 0.5, 0.16).is_err());
assert!(EssviSlice::new(100.0, 1.0, -0.3, 0.5, f64::NAN, 0.16).is_err());
assert!(EssviSlice::new(100.0, 1.0, -0.3, 0.5, 0.5, f64::INFINITY).is_err());
}
#[test]
fn new_gamma_boundaries_accepted() {
assert!(EssviSlice::new(100.0, 1.0, -0.3, 0.5, 0.0, 0.16).is_ok());
assert!(EssviSlice::new(100.0, 1.0, -0.3, 0.5, 1.0, 0.16).is_ok());
}
#[test]
fn vol_atm_equals_sqrt_theta_over_t() {
let s = equity_slice();
let vol = s.vol(Strike(100.0)).unwrap();
assert_abs_diff_eq!(vol.0, 0.4, epsilon = 1e-10);
}
#[test]
fn variance_atm_equals_theta() {
let s = equity_slice();
let var = s.variance(Strike(100.0)).unwrap();
assert_abs_diff_eq!(var.0, 0.16, epsilon = 1e-14);
}
#[test]
fn vol_variance_consistency() {
let s = equity_slice();
for &strike in &[80.0, 90.0, 100.0, 110.0, 120.0] {
let vol = s.vol(Strike(strike)).unwrap();
let var = s.variance(Strike(strike)).unwrap();
assert_abs_diff_eq!(var.0, vol.0 * vol.0 * s.expiry(), epsilon = 1e-14);
}
}
#[test]
fn matches_ssvi_slice() {
let essvi = equity_slice();
let ssvi = SsviSlice::new(100.0, 1.0, -0.3, 0.5, 0.5, 0.16).unwrap();
for &strike in &[70.0, 85.0, 100.0, 115.0, 130.0] {
let v_essvi = essvi.vol(Strike(strike)).unwrap().0;
let v_ssvi = ssvi.vol(Strike(strike)).unwrap().0;
assert_eq!(v_essvi.to_bits(), v_ssvi.to_bits(), "strike={strike}");
}
}
#[test]
fn vol_rejects_non_positive_strikes() {
let s = equity_slice();
assert!(s.vol(Strike(0.0)).is_err());
assert!(s.vol(Strike(-100.0)).is_err());
assert!(s.variance(Strike(0.0)).is_err());
}
#[test]
fn vol_rejects_nan_strike() {
let s = equity_slice();
assert!(s.vol(Strike(f64::NAN)).is_err());
assert!(s.variance(Strike(f64::NAN)).is_err());
}
#[test]
fn vol_rejects_inf_strike() {
let s = equity_slice();
assert!(s.vol(Strike(f64::INFINITY)).is_err());
assert!(s.vol(Strike(f64::NEG_INFINITY)).is_err());
assert!(s.variance(Strike(f64::INFINITY)).is_err());
assert!(s.variance(Strike(f64::NEG_INFINITY)).is_err());
}
#[test]
fn skew_direction() {
let s = equity_slice();
let vol_put = s.vol(Strike(80.0)).unwrap().0;
let vol_call = s.vol(Strike(120.0)).unwrap().0;
assert!(vol_put > vol_call, "negative rho should produce put skew");
}
#[test]
fn density_positive_near_atm() {
let s = equity_slice();
for &strike in &[90.0, 95.0, 100.0, 105.0, 110.0] {
let d = s.density(Strike(strike)).unwrap();
assert!(d > 0.0, "density({strike}) = {d}");
}
}
#[test]
fn arb_free_conservative_params() {
let s = equity_slice();
let report = s.is_arbitrage_free().unwrap();
assert!(report.is_free());
assert!(report.butterfly_violations.is_empty());
}
#[test]
fn arb_detected_extreme_params() {
let s = EssviSlice::new(100.0, 1.0, -0.95, 3.0, 0.5, 0.16).unwrap();
let report = s.is_arbitrage_free().unwrap();
assert!(!report.is_free());
assert!(!report.butterfly_violations.is_empty());
}
#[test]
fn violation_density_matches_density_method() {
let s = EssviSlice::new(100.0, 1.0, -0.95, 3.0, 0.5, 0.16).unwrap();
let report = s.is_arbitrage_free().unwrap();
assert!(!report.butterfly_violations.is_empty());
for v in &report.butterfly_violations {
let expected = s.density(Strike(v.strike)).unwrap();
assert_abs_diff_eq!(v.density, expected, epsilon = 1e-14);
assert_abs_diff_eq!(v.magnitude, expected.abs(), epsilon = 1e-14);
}
}
#[test]
fn is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<EssviSlice>();
}
#[test]
fn serde_round_trip() {
let s = equity_slice();
let json = serde_json::to_string(&s).unwrap();
let s2: EssviSlice = serde_json::from_str(&json).unwrap();
assert_eq!(s.forward(), s2.forward());
assert_eq!(s.expiry(), s2.expiry());
assert_eq!(s.theta(), s2.theta());
assert_eq!(s.rho(), s2.rho());
assert_eq!(s.eta(), s2.eta());
assert_eq!(s.gamma(), s2.gamma());
assert_abs_diff_eq!(
s.vol(Strike(90.0)).unwrap().0,
s2.vol(Strike(90.0)).unwrap().0,
epsilon = 1e-14
);
}
#[test]
fn serde_rejects_invalid_rho() {
let json = r#"{"forward":100,"expiry":1,"rho":1.0,"eta":0.5,"gamma":0.5,"theta":0.04}"#;
assert!(serde_json::from_str::<EssviSlice>(json).is_err());
}
#[test]
fn serde_rejects_negative_forward() {
let json = r#"{"forward":-100,"expiry":1,"rho":-0.3,"eta":0.5,"gamma":0.5,"theta":0.04}"#;
assert!(serde_json::from_str::<EssviSlice>(json).is_err());
}
#[test]
fn serde_rejects_zero_expiry() {
let json = r#"{"forward":100,"expiry":0,"rho":-0.3,"eta":0.5,"gamma":0.5,"theta":0.04}"#;
assert!(serde_json::from_str::<EssviSlice>(json).is_err());
}
#[test]
fn serde_rejects_negative_eta() {
let json = r#"{"forward":100,"expiry":1,"rho":-0.3,"eta":-0.1,"gamma":0.5,"theta":0.04}"#;
assert!(serde_json::from_str::<EssviSlice>(json).is_err());
}
#[test]
fn serde_rejects_gamma_out_of_range() {
let json = r#"{"forward":100,"expiry":1,"rho":-0.3,"eta":0.5,"gamma":2.0,"theta":0.04}"#;
assert!(serde_json::from_str::<EssviSlice>(json).is_err());
}
#[test]
fn serde_rejects_zero_theta() {
let json = r#"{"forward":100,"expiry":1,"rho":-0.3,"eta":0.5,"gamma":0.5,"theta":0.0}"#;
assert!(serde_json::from_str::<EssviSlice>(json).is_err());
}
#[test]
fn serde_error_contains_validation_message() {
let json = r#"{"forward":100,"expiry":1,"rho":1.0,"eta":0.5,"gamma":0.5,"theta":0.04}"#;
let err = serde_json::from_str::<EssviSlice>(json).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("rho"),
"should contain domain message, got: {msg}"
);
}
#[test]
fn short_expiry_slice() {
let t = 7.0 / 365.0;
let theta = 0.04 * t;
let s = EssviSlice::new(100.0, t, -0.5, 0.8, 0.4, theta).unwrap();
let vol = s.vol(Strike(100.0)).unwrap();
assert!(vol.0 > 0.0 && vol.0 < 2.0);
}
#[test]
fn long_expiry_slice() {
let s = EssviSlice::new(100.0, 5.0, -0.2, 0.3, 0.6, 0.50).unwrap();
let vol = s.vol(Strike(100.0)).unwrap();
assert_abs_diff_eq!(vol.0, (0.50 / 5.0_f64).sqrt(), epsilon = 1e-10);
}
fn equity_surface() -> EssviSurface {
EssviSurface::new(
-0.4,
-0.2,
0.5,
0.5,
0.5,
vec![0.25, 0.5, 1.0, 2.0],
vec![100.0, 100.0, 100.0, 100.0],
vec![0.04, 0.08, 0.16, 0.32],
)
.unwrap()
}
fn two_tenor_surface() -> EssviSurface {
EssviSurface::new(
-0.3,
-0.1,
0.3,
0.4,
0.6,
vec![0.5, 1.0],
vec![100.0, 100.0],
vec![0.08, 0.16],
)
.unwrap()
}
#[test]
fn surface_new_valid() {
let s = equity_surface();
assert_eq!(s.rho_0(), -0.4);
assert_eq!(s.rho_m(), -0.2);
assert_eq!(s.a(), 0.5);
assert_eq!(s.eta(), 0.5);
assert_eq!(s.gamma(), 0.5);
assert_eq!(s.tenors(), &[0.25, 0.5, 1.0, 2.0]);
assert_eq!(s.forwards(), &[100.0, 100.0, 100.0, 100.0]);
assert_eq!(s.thetas(), &[0.04, 0.08, 0.16, 0.32]);
assert_eq!(s.theta_max(), 0.32);
}
#[test]
fn surface_new_single_tenor() {
let s = EssviSurface::new(
-0.3,
-0.1,
0.3,
0.5,
0.5,
vec![1.0],
vec![100.0],
vec![0.04],
)
.unwrap();
assert_eq!(s.tenors().len(), 1);
assert_eq!(s.theta_max(), 0.04);
}
#[test]
fn surface_new_rejects_rho_0_at_boundary() {
assert!(
EssviSurface::new(1.0, -0.2, 0.5, 0.5, 0.5, vec![1.0], vec![100.0], vec![0.04],)
.is_err()
);
assert!(
EssviSurface::new(
-1.0,
-0.2,
0.5,
0.5,
0.5,
vec![1.0],
vec![100.0],
vec![0.04],
)
.is_err()
);
}
#[test]
fn surface_new_rejects_rho_m_at_boundary() {
assert!(
EssviSurface::new(-0.3, 1.0, 0.5, 0.5, 0.5, vec![1.0], vec![100.0], vec![0.04],)
.is_err()
);
assert!(
EssviSurface::new(
-0.3,
-1.0,
0.5,
0.5,
0.5,
vec![1.0],
vec![100.0],
vec![0.04],
)
.is_err()
);
}
#[test]
fn surface_new_rejects_negative_a() {
assert!(
EssviSurface::new(
-0.3,
-0.1,
-0.1,
0.5,
0.5,
vec![1.0],
vec![100.0],
vec![0.04],
)
.is_err()
);
}
#[test]
fn surface_new_accepts_a_zero() {
let s = EssviSurface::new(
-0.3,
-0.1,
0.0,
0.5,
0.5,
vec![0.5, 1.0],
vec![100.0, 100.0],
vec![0.04, 0.08],
)
.unwrap();
assert_eq!(s.a(), 0.0);
}
#[test]
fn surface_new_rejects_bad_eta() {
assert!(
EssviSurface::new(
-0.3,
-0.1,
0.3,
0.0,
0.5,
vec![1.0],
vec![100.0],
vec![0.04],
)
.is_err()
);
assert!(
EssviSurface::new(
-0.3,
-0.1,
0.3,
-1.0,
0.5,
vec![1.0],
vec![100.0],
vec![0.04],
)
.is_err()
);
}
#[test]
fn surface_new_rejects_gamma_out_of_range() {
assert!(
EssviSurface::new(
-0.3,
-0.1,
0.3,
0.5,
-0.1,
vec![1.0],
vec![100.0],
vec![0.04],
)
.is_err()
);
assert!(
EssviSurface::new(
-0.3,
-0.1,
0.3,
0.5,
1.5,
vec![1.0],
vec![100.0],
vec![0.04],
)
.is_err()
);
}
#[test]
fn surface_new_gamma_boundaries_accepted() {
assert!(
EssviSurface::new(
-0.3,
-0.3,
0.0,
0.5,
0.0,
vec![1.0],
vec![100.0],
vec![0.04],
)
.is_ok()
);
assert!(
EssviSurface::new(
-0.3,
-0.3,
0.0,
0.5,
1.0,
vec![1.0],
vec![100.0],
vec![0.04],
)
.is_ok()
);
}
#[test]
fn surface_new_rejects_empty_tenors() {
assert!(EssviSurface::new(-0.3, -0.1, 0.3, 0.5, 0.5, vec![], vec![], vec![],).is_err());
}
#[test]
fn surface_new_rejects_length_mismatch() {
assert!(
EssviSurface::new(
-0.3,
-0.1,
0.3,
0.5,
0.5,
vec![0.5, 1.0],
vec![100.0],
vec![0.04, 0.08],
)
.is_err()
);
assert!(
EssviSurface::new(
-0.3,
-0.1,
0.3,
0.5,
0.5,
vec![0.5, 1.0],
vec![100.0, 100.0],
vec![0.04],
)
.is_err()
);
}
#[test]
fn surface_new_rejects_non_increasing_tenors() {
assert!(
EssviSurface::new(
-0.3,
-0.1,
0.3,
0.5,
0.5,
vec![1.0, 0.5],
vec![100.0, 100.0],
vec![0.04, 0.08],
)
.is_err()
);
}
#[test]
fn surface_new_rejects_non_increasing_thetas() {
assert!(
EssviSurface::new(
-0.3,
-0.1,
0.3,
0.5,
0.5,
vec![0.5, 1.0],
vec![100.0, 100.0],
vec![0.16, 0.08],
)
.is_err()
);
}
#[test]
fn surface_new_rejects_nan_in_vectors() {
assert!(
EssviSurface::new(
-0.3,
-0.1,
0.3,
0.5,
0.5,
vec![f64::NAN],
vec![100.0],
vec![0.04],
)
.is_err()
);
assert!(
EssviSurface::new(
-0.3,
-0.1,
0.3,
0.5,
0.5,
vec![1.0],
vec![f64::INFINITY],
vec![0.04],
)
.is_err()
);
assert!(
EssviSurface::new(
-0.3,
-0.1,
0.3,
0.5,
0.5,
vec![1.0],
vec![100.0],
vec![f64::NEG_INFINITY],
)
.is_err()
);
}
#[test]
fn surface_new_rejects_a_exceeding_eq57() {
assert!(
EssviSurface::new(
-0.4,
-0.2,
3.5,
0.5,
0.5,
vec![0.5, 1.0],
vec![100.0, 100.0],
vec![0.04, 0.08],
)
.is_err()
);
assert!(
EssviSurface::new(
-0.4,
-0.2,
3.0,
0.5,
0.5,
vec![0.5, 1.0],
vec![100.0, 100.0],
vec![0.04, 0.08],
)
.is_ok()
);
}
#[test]
fn surface_new_eq57_reverse_rho() {
assert!(
EssviSurface::new(
-0.1,
-0.4,
1.5,
0.5,
0.5,
vec![1.0],
vec![100.0],
vec![0.04],
)
.is_err()
);
assert!(
EssviSurface::new(
-0.1,
-0.4,
1.0,
0.5,
0.5,
vec![1.0],
vec![100.0],
vec![0.04],
)
.is_ok()
);
}
#[test]
fn surface_new_equal_rho_any_a() {
assert!(
EssviSurface::new(
-0.3,
-0.3,
100.0,
0.5,
0.5,
vec![1.0],
vec![100.0],
vec![0.04],
)
.is_ok()
);
}
#[test]
fn rho_at_zero_theta() {
let s = equity_surface();
let r = s.rho(0.0);
assert_abs_diff_eq!(r, -0.4, epsilon = 1e-14);
}
#[test]
fn rho_at_theta_max() {
let s = equity_surface();
let r = s.rho(s.theta_max());
assert_abs_diff_eq!(r, -0.2, epsilon = 1e-14);
}
#[test]
fn rho_at_half_theta_max() {
let s = equity_surface();
let expected = -0.4 + (-0.2 - (-0.4)) * (0.5_f64).sqrt();
let r = s.rho(0.16);
assert_abs_diff_eq!(r, expected, epsilon = 1e-14);
}
#[test]
fn rho_monotone_when_rho_m_greater() {
let s = equity_surface(); let r1 = s.rho(0.04);
let r2 = s.rho(0.16);
let r3 = s.rho(0.32);
assert!(r1 < r2, "rho should increase: {r1} < {r2}");
assert!(r2 < r3, "rho should increase: {r2} < {r3}");
}
#[test]
fn rho_decreasing_when_rho_m_less() {
let s = EssviSurface::new(
-0.1,
-0.4,
0.5,
0.5,
0.5,
vec![0.5, 1.0],
vec![100.0, 100.0],
vec![0.04, 0.08],
)
.unwrap();
assert!(s.rho(0.04) > s.rho(0.08));
}
#[test]
fn rho_constant_when_a_zero() {
let s = EssviSurface::new(
-0.3,
-0.1,
0.0,
0.5,
0.5,
vec![0.5, 1.0],
vec![100.0, 100.0],
vec![0.04, 0.08],
)
.unwrap();
assert_abs_diff_eq!(s.rho(0.04), s.rho(0.08), epsilon = 1e-14);
}
#[test]
fn rho_linear_when_a_one() {
let s = EssviSurface::new(
-0.4,
-0.2,
1.0,
0.5,
0.5,
vec![0.5, 1.0],
vec![100.0, 100.0],
vec![0.04, 0.08],
)
.unwrap();
let expected = -0.4 + 0.2 * (0.04 / 0.08);
assert_abs_diff_eq!(s.rho(0.04), expected, epsilon = 1e-14);
}
#[test]
fn rho_clamped_to_valid_range() {
let s = equity_surface();
let r = s.rho(100.0); assert!(r > -0.999 && r < 0.999);
let r0 = s.rho(0.0);
assert!(r0 > -0.999 && r0 < 0.999);
}
#[test]
fn surface_atm_variance_at_stored_tenor() {
let s = equity_surface();
for (i, &t) in s.tenors().iter().enumerate() {
let var = s.black_variance(Tenor(t), Strike(s.forwards()[i])).unwrap();
assert_abs_diff_eq!(var.0, s.thetas()[i], epsilon = 1e-12);
}
}
#[test]
fn surface_atm_vol_at_stored_tenor() {
let s = equity_surface();
for (i, &t) in s.tenors().iter().enumerate() {
let vol = s.black_vol(Tenor(t), Strike(s.forwards()[i])).unwrap();
let expected = (s.thetas()[i] / t).sqrt();
assert_abs_diff_eq!(vol.0, expected, epsilon = 1e-10);
}
}
#[test]
fn surface_vol_variance_consistency() {
let s = equity_surface();
for &t in &[0.25, 0.5, 1.0, 2.0] {
for &k in &[80.0, 90.0, 100.0, 110.0, 120.0] {
let vol = s.black_vol(Tenor(t), Strike(k)).unwrap();
let var = s.black_variance(Tenor(t), Strike(k)).unwrap();
assert_abs_diff_eq!(var.0, vol.0 * vol.0 * t, epsilon = 1e-12);
}
}
}
#[test]
fn surface_variance_positive() {
let s = equity_surface();
for &t in &[0.25, 0.5, 1.0, 2.0] {
for &k in &[60.0, 80.0, 100.0, 120.0, 150.0] {
let var = s.black_variance(Tenor(t), Strike(k)).unwrap();
assert!(var.0 > 0.0, "variance should be positive at T={t}, K={k}");
}
}
}
#[test]
fn surface_skew_matches_rho_sign() {
let s = equity_surface(); for &t in s.tenors() {
let vol_put = s.black_vol(Tenor(t), Strike(80.0)).unwrap().0;
let vol_call = s.black_vol(Tenor(t), Strike(120.0)).unwrap().0;
assert!(
vol_put > vol_call,
"negative rho should produce put skew at T={t}"
);
}
}
#[test]
fn surface_interpolation_between_tenors() {
let s = equity_surface();
let vol = s.black_vol(Tenor(0.75), Strike(100.0)).unwrap();
assert!(vol.0 > 0.0 && vol.0 < 1.0);
let var = s.black_variance(Tenor(0.75), Strike(100.0)).unwrap().0;
let var_lo = s.black_variance(Tenor(0.5), Strike(100.0)).unwrap().0;
let var_hi = s.black_variance(Tenor(1.0), Strike(100.0)).unwrap().0;
assert!(
var > var_lo && var < var_hi,
"variance should interpolate monotonically"
);
}
#[test]
fn surface_extrapolation_before_first_tenor() {
let s = equity_surface();
let var = s.black_variance(Tenor(0.1), Strike(100.0)).unwrap();
let expected_theta = 0.04 * 0.1 / 0.25;
assert_abs_diff_eq!(var.0, expected_theta, epsilon = 1e-12);
}
#[test]
fn surface_extrapolation_after_last_tenor() {
let s = equity_surface();
let var = s.black_variance(Tenor(3.0), Strike(100.0)).unwrap();
let expected_theta = 0.32 * 3.0 / 2.0;
assert_abs_diff_eq!(var.0, expected_theta, epsilon = 1e-12);
}
#[test]
fn surface_rejects_non_positive_expiry() {
let s = equity_surface();
assert!(s.black_vol(Tenor(0.0), Strike(100.0)).is_err());
assert!(s.black_vol(Tenor(-1.0), Strike(100.0)).is_err());
assert!(s.black_variance(Tenor(0.0), Strike(100.0)).is_err());
}
#[test]
fn surface_rejects_non_positive_strike() {
let s = equity_surface();
assert!(s.black_vol(Tenor(1.0), Strike(0.0)).is_err());
assert!(s.black_vol(Tenor(1.0), Strike(-100.0)).is_err());
assert!(s.black_variance(Tenor(1.0), Strike(0.0)).is_err());
}
#[test]
fn surface_rejects_nan_inputs() {
let s = equity_surface();
assert!(s.black_vol(Tenor(f64::NAN), Strike(100.0)).is_err());
assert!(s.black_vol(Tenor(1.0), Strike(f64::NAN)).is_err());
assert!(s.black_variance(Tenor(f64::NAN), Strike(100.0)).is_err());
}
#[test]
fn surface_rejects_inf_inputs() {
let s = equity_surface();
assert!(s.black_vol(Tenor(f64::INFINITY), Strike(100.0)).is_err());
assert!(
s.black_vol(Tenor(f64::NEG_INFINITY), Strike(100.0))
.is_err()
);
assert!(s.black_vol(Tenor(1.0), Strike(f64::INFINITY)).is_err());
assert!(s.black_vol(Tenor(1.0), Strike(f64::NEG_INFINITY)).is_err());
assert!(s.smile_at(Tenor(f64::INFINITY)).is_err());
}
#[test]
fn surface_smile_at_returns_essvi_slice() {
let s = equity_surface();
let smile = s.smile_at(Tenor(1.0)).unwrap();
assert_eq!(smile.forward(), 100.0);
assert_abs_diff_eq!(smile.expiry(), 1.0, epsilon = 1e-10);
let smile_vol = smile.vol(Strike(100.0)).unwrap().0;
let surface_vol = s.black_vol(Tenor(1.0), Strike(100.0)).unwrap().0;
assert_abs_diff_eq!(smile_vol, surface_vol, epsilon = 1e-14);
}
#[test]
fn surface_smile_at_interpolated_tenor() {
let s = equity_surface();
let smile = s.smile_at(Tenor(0.75)).unwrap();
assert!(smile.expiry() > 0.0);
let smile_vol = smile.vol(Strike(smile.forward())).unwrap().0;
let surface_vol = s.black_vol(Tenor(0.75), Strike(smile.forward())).unwrap().0;
assert_abs_diff_eq!(smile_vol, surface_vol, epsilon = 1e-12);
}
#[test]
fn surface_smile_at_rejects_non_positive() {
let s = equity_surface();
assert!(s.smile_at(Tenor(0.0)).is_err());
assert!(s.smile_at(Tenor(-1.0)).is_err());
}
#[test]
fn surface_diagnostics_clean() {
let s = equity_surface();
let diag = s.diagnostics().unwrap();
assert_eq!(diag.smile_reports.len(), 4);
assert!(diag.is_free(), "conservative params should be arb-free");
assert!(diag.calendar_violations.is_empty());
}
#[test]
fn surface_diagnostics_two_tenor() {
let s = two_tenor_surface();
let diag = s.diagnostics().unwrap();
assert_eq!(diag.smile_reports.len(), 2);
assert!(diag.is_free());
}
#[test]
fn surface_calendar_structural_clean() {
let s = equity_surface();
let violations = s.calendar_check_structural();
assert!(
violations.is_empty(),
"valid params should pass structural check"
);
}
#[test]
fn surface_rho_differs_per_tenor_in_smile() {
let s = equity_surface();
let smile_short = s.smile_at(Tenor(0.25)).unwrap();
let smile_long = s.smile_at(Tenor(2.0)).unwrap();
let vol_short = smile_short.vol(Strike(80.0)).unwrap().0;
let vol_long = smile_long.vol(Strike(80.0)).unwrap().0;
assert!(
(vol_short - vol_long).abs() > 1e-6,
"different rho(theta) should produce different skew"
);
}
#[test]
fn surface_serde_round_trip() {
let s = equity_surface();
let json = serde_json::to_string(&s).unwrap();
let s2: EssviSurface = serde_json::from_str(&json).unwrap();
assert_eq!(s.rho_0(), s2.rho_0());
assert_eq!(s.rho_m(), s2.rho_m());
assert_eq!(s.a(), s2.a());
assert_eq!(s.eta(), s2.eta());
assert_eq!(s.gamma(), s2.gamma());
assert_eq!(s.tenors(), s2.tenors());
assert_eq!(s.forwards(), s2.forwards());
assert_eq!(s.thetas(), s2.thetas());
assert_eq!(s.theta_max(), s2.theta_max());
let v1 = s.black_vol(Tenor(1.0), Strike(90.0)).unwrap().0;
let v2 = s2.black_vol(Tenor(1.0), Strike(90.0)).unwrap().0;
assert_eq!(v1.to_bits(), v2.to_bits());
}
#[test]
fn surface_serde_no_theta_max_in_json() {
let s = equity_surface();
let json = serde_json::to_string(&s).unwrap();
assert!(
!json.contains("theta_max"),
"derived field should not be serialized"
);
}
#[test]
fn surface_serde_rejects_invalid_rho_0() {
let json = r#"{"rho_0":1.0,"rho_m":-0.2,"a":0.5,"eta":0.5,"gamma":0.5,"tenors":[1.0],"forwards":[100.0],"thetas":[0.04]}"#;
assert!(serde_json::from_str::<EssviSurface>(json).is_err());
}
#[test]
fn surface_serde_rejects_invalid_rho_m() {
let json = r#"{"rho_0":-0.3,"rho_m":-1.0,"a":0.5,"eta":0.5,"gamma":0.5,"tenors":[1.0],"forwards":[100.0],"thetas":[0.04]}"#;
assert!(serde_json::from_str::<EssviSurface>(json).is_err());
}
#[test]
fn surface_serde_rejects_negative_eta() {
let json = r#"{"rho_0":-0.3,"rho_m":-0.1,"a":0.3,"eta":-0.5,"gamma":0.5,"tenors":[1.0],"forwards":[100.0],"thetas":[0.04]}"#;
assert!(serde_json::from_str::<EssviSurface>(json).is_err());
}
#[test]
fn surface_serde_rejects_non_increasing_thetas() {
let json = r#"{"rho_0":-0.3,"rho_m":-0.1,"a":0.3,"eta":0.5,"gamma":0.5,"tenors":[0.5,1.0],"forwards":[100.0,100.0],"thetas":[0.16,0.08]}"#;
assert!(serde_json::from_str::<EssviSurface>(json).is_err());
}
#[test]
fn surface_serde_rejects_length_mismatch() {
let json = r#"{"rho_0":-0.3,"rho_m":-0.1,"a":0.3,"eta":0.5,"gamma":0.5,"tenors":[0.5,1.0],"forwards":[100.0],"thetas":[0.04,0.08]}"#;
assert!(serde_json::from_str::<EssviSurface>(json).is_err());
}
#[test]
fn surface_serde_error_contains_field_name() {
let json = r#"{"rho_0":1.5,"rho_m":-0.2,"a":0.5,"eta":0.5,"gamma":0.5,"tenors":[1.0],"forwards":[100.0],"thetas":[0.04]}"#;
let err = serde_json::from_str::<EssviSurface>(json).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("rho_0"), "should mention rho_0, got: {msg}");
}
#[test]
fn surface_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<EssviSurface>();
}
#[test]
fn surface_as_trait_object() {
let s = equity_surface();
let _boxed: Box<dyn VolSurface> = Box::new(s);
}
fn synthetic_essvi_data(
surface: &EssviSurface,
tenors: &[f64],
strikes_per_tenor: &[Vec<f64>],
) -> Vec<Vec<(f64, f64)>> {
tenors
.iter()
.zip(strikes_per_tenor)
.map(|(&t, strikes)| {
strikes
.iter()
.map(|&k| (k, surface.black_vol(Tenor(t), Strike(k)).unwrap().0))
.collect()
})
.collect()
}
fn rms_vol_error(
calibrated: &EssviSurface,
tenors: &[f64],
market_data: &[Vec<(f64, f64)>],
) -> f64 {
let mut total_rss = 0.0;
let mut n_points = 0;
for (i, &t) in tenors.iter().enumerate() {
for &(strike, vol_obs) in &market_data[i] {
let vol_fit = calibrated.black_vol(Tenor(t), Strike(strike)).unwrap().0;
total_rss += (vol_fit - vol_obs).powi(2);
n_points += 1;
}
}
(total_rss / n_points as f64).sqrt()
}
#[test]
fn calibrate_round_trip_equity() {
let original = equity_surface();
let tenors = vec![0.25, 0.5, 1.0, 2.0];
let forwards = vec![100.0; 4];
let strikes: Vec<Vec<f64>> = tenors
.iter()
.map(|_| (0..15).map(|i| 70.0 + 4.0 * i as f64).collect())
.collect();
let market_data = synthetic_essvi_data(&original, &tenors, &strikes);
let calibrated = EssviSurface::calibrate(&market_data, &tenors, &forwards).unwrap();
let rms = rms_vol_error(&calibrated, &tenors, &market_data);
assert!(rms < 0.005, "round-trip RMS {rms} should be < 0.005");
}
#[test]
fn calibrate_round_trip_2_tenors() {
let original = two_tenor_surface();
let tenors = vec![0.5, 1.0];
let forwards = vec![100.0, 100.0];
let strikes: Vec<Vec<f64>> = tenors
.iter()
.map(|_| (0..10).map(|i| 75.0 + 5.0 * i as f64).collect())
.collect();
let market_data = synthetic_essvi_data(&original, &tenors, &strikes);
let calibrated = EssviSurface::calibrate(&market_data, &tenors, &forwards).unwrap();
let rms = rms_vol_error(&calibrated, &tenors, &market_data);
assert!(
rms < 0.005,
"2-tenor round-trip RMS {rms} should be < 0.005"
);
}
#[test]
fn calibrate_round_trip_varying_forwards() {
let original = EssviSurface::new(
-0.4,
-0.2,
0.5,
0.5,
0.5,
vec![0.25, 0.5, 1.0],
vec![100.0, 102.0, 105.0],
vec![0.04, 0.08, 0.16],
)
.unwrap();
let tenors = vec![0.25, 0.5, 1.0];
let forwards = vec![100.0, 102.0, 105.0];
let strikes: Vec<Vec<f64>> = forwards
.iter()
.map(|&f| (0..10).map(|i| f * 0.8 + f * 0.04 * i as f64).collect())
.collect();
let market_data = synthetic_essvi_data(&original, &tenors, &strikes);
let calibrated = EssviSurface::calibrate(&market_data, &tenors, &forwards).unwrap();
let rms = rms_vol_error(&calibrated, &tenors, &market_data);
assert!(
rms < 0.005,
"varying-forwards round-trip RMS {rms} should be < 0.005"
);
}
#[test]
fn calibrate_params_in_valid_range() {
let original = equity_surface();
let tenors = vec![0.25, 0.5, 1.0, 2.0];
let forwards = vec![100.0; 4];
let strikes: Vec<Vec<f64>> = tenors
.iter()
.map(|_| (0..15).map(|i| 70.0 + 4.0 * i as f64).collect())
.collect();
let market_data = synthetic_essvi_data(&original, &tenors, &strikes);
let c = EssviSurface::calibrate(&market_data, &tenors, &forwards).unwrap();
assert!(c.rho_0().abs() < 1.0, "rho_0 out of range: {}", c.rho_0());
assert!(c.rho_m().abs() < 1.0, "rho_m out of range: {}", c.rho_m());
assert!(c.a() >= 0.0, "a must be non-negative: {}", c.a());
assert!(c.eta() > 0.0, "eta must be positive: {}", c.eta());
assert!(
(0.0..=1.0).contains(&c.gamma()),
"gamma out of [0,1]: {}",
c.gamma()
);
assert_eq!(c.tenors().len(), 4);
assert_eq!(c.forwards().len(), 4);
assert_eq!(c.thetas().len(), 4);
}
#[test]
fn calibrate_thetas_strictly_increasing() {
let original = equity_surface();
let tenors = vec![0.25, 0.5, 1.0, 2.0];
let forwards = vec![100.0; 4];
let strikes: Vec<Vec<f64>> = tenors
.iter()
.map(|_| (0..15).map(|i| 70.0 + 4.0 * i as f64).collect())
.collect();
let market_data = synthetic_essvi_data(&original, &tenors, &strikes);
let calibrated = EssviSurface::calibrate(&market_data, &tenors, &forwards).unwrap();
for w in calibrated.thetas().windows(2) {
assert!(
w[1] > w[0],
"thetas must be strictly increasing: {} <= {}",
w[0],
w[1]
);
}
}
#[test]
fn calibrate_diagnostics_clean() {
let original = equity_surface();
let tenors = vec![0.25, 0.5, 1.0, 2.0];
let forwards = vec![100.0; 4];
let strikes: Vec<Vec<f64>> = tenors
.iter()
.map(|_| (0..15).map(|i| 70.0 + 4.0 * i as f64).collect())
.collect();
let market_data = synthetic_essvi_data(&original, &tenors, &strikes);
let calibrated = EssviSurface::calibrate(&market_data, &tenors, &forwards).unwrap();
let diag = calibrated.diagnostics().unwrap();
assert!(
diag.is_free(),
"calibrated eSSVI from conservative input should be arb-free"
);
}
#[test]
fn calibrate_calendar_structural_clean() {
let original = equity_surface();
let tenors = vec![0.25, 0.5, 1.0, 2.0];
let forwards = vec![100.0; 4];
let strikes: Vec<Vec<f64>> = tenors
.iter()
.map(|_| (0..15).map(|i| 70.0 + 4.0 * i as f64).collect())
.collect();
let market_data = synthetic_essvi_data(&original, &tenors, &strikes);
let calibrated = EssviSurface::calibrate(&market_data, &tenors, &forwards).unwrap();
let violations = calibrated.calendar_check_structural();
assert!(
violations.is_empty(),
"calibrated surface should pass structural check, got {} violations",
violations.len()
);
}
#[test]
fn calibrate_serde_round_trip() {
let original = equity_surface();
let tenors = vec![0.25, 0.5, 1.0, 2.0];
let forwards = vec![100.0; 4];
let strikes: Vec<Vec<f64>> = tenors
.iter()
.map(|_| (0..15).map(|i| 70.0 + 4.0 * i as f64).collect())
.collect();
let market_data = synthetic_essvi_data(&original, &tenors, &strikes);
let calibrated = EssviSurface::calibrate(&market_data, &tenors, &forwards).unwrap();
let json = serde_json::to_string(&calibrated).unwrap();
let deserialized: EssviSurface = serde_json::from_str(&json).unwrap();
assert_abs_diff_eq!(calibrated.rho_0(), deserialized.rho_0(), epsilon = 1e-14);
assert_abs_diff_eq!(calibrated.rho_m(), deserialized.rho_m(), epsilon = 1e-14);
assert_abs_diff_eq!(calibrated.a(), deserialized.a(), epsilon = 1e-14);
assert_abs_diff_eq!(calibrated.eta(), deserialized.eta(), epsilon = 1e-14);
assert_abs_diff_eq!(calibrated.gamma(), deserialized.gamma(), epsilon = 1e-14);
assert_eq!(calibrated.tenors(), deserialized.tenors());
for (a, b) in calibrated.thetas().iter().zip(deserialized.thetas()) {
assert_abs_diff_eq!(a, b, epsilon = 1e-14);
}
let v1 = calibrated.black_vol(Tenor(1.0), Strike(90.0)).unwrap().0;
let v2 = deserialized.black_vol(Tenor(1.0), Strike(90.0)).unwrap().0;
assert_abs_diff_eq!(v1, v2, epsilon = 1e-14);
}
#[test]
fn calibrate_rejects_single_tenor() {
let data = vec![vec![
(90.0, 0.2),
(95.0, 0.2),
(100.0, 0.2),
(105.0, 0.2),
(110.0, 0.2),
]];
let result = EssviSurface::calibrate(&data, &[1.0], &[100.0]);
assert!(matches!(result, Err(VolSurfError::InvalidInput { .. })));
}
#[test]
fn calibrate_rejects_empty_data() {
let result = EssviSurface::calibrate(&[], &[], &[]);
assert!(matches!(result, Err(VolSurfError::InvalidInput { .. })));
}
#[test]
fn calibrate_rejects_length_mismatch() {
let data = vec![
vec![
(90.0, 0.2),
(95.0, 0.2),
(100.0, 0.2),
(105.0, 0.2),
(110.0, 0.2),
],
vec![
(90.0, 0.2),
(95.0, 0.2),
(100.0, 0.2),
(105.0, 0.2),
(110.0, 0.2),
],
];
let result = EssviSurface::calibrate(&data, &[0.5, 1.0], &[100.0]);
assert!(result.is_err());
}
#[test]
fn calibrate_rejects_negative_forward() {
let data = vec![
vec![
(90.0, 0.2),
(95.0, 0.2),
(100.0, 0.2),
(105.0, 0.2),
(110.0, 0.2),
],
vec![
(90.0, 0.2),
(95.0, 0.2),
(100.0, 0.2),
(105.0, 0.2),
(110.0, 0.2),
],
];
let result = EssviSurface::calibrate(&data, &[0.5, 1.0], &[-100.0, 100.0]);
assert!(result.is_err());
}
#[test]
fn calibrate_rejects_zero_tenor() {
let data = vec![
vec![
(90.0, 0.2),
(95.0, 0.2),
(100.0, 0.2),
(105.0, 0.2),
(110.0, 0.2),
],
vec![
(90.0, 0.2),
(95.0, 0.2),
(100.0, 0.2),
(105.0, 0.2),
(110.0, 0.2),
],
];
let result = EssviSurface::calibrate(&data, &[0.0, 1.0], &[100.0, 100.0]);
assert!(result.is_err());
}
#[test]
fn calibrate_rejects_non_monotone_thetas() {
let make_smile = |fwd: f64, atm_vol: f64| -> Vec<(f64, f64)> {
let strikes: Vec<f64> = (0..10).map(|i| fwd * (0.85 + 0.03 * i as f64)).collect();
strikes
.iter()
.map(|&k| {
let m = ((k / fwd).ln()).abs();
(k, atm_vol + 0.3 * m)
})
.collect()
};
let data = vec![make_smile(100.0, 0.50), make_smile(100.0, 0.20)];
let result = EssviSurface::calibrate(&data, &[0.25, 0.50], &[100.0, 100.0]);
let err = result.unwrap_err();
assert!(matches!(err, VolSurfError::CalibrationError { .. }));
let msg = err.to_string();
assert!(
msg.contains("non-monotone"),
"error should mention non-monotone: {msg}"
);
}
#[test]
fn calibrate_rejects_too_few_points() {
let data = vec![
vec![(90.0, 0.3), (100.0, 0.25), (110.0, 0.3)],
vec![(90.0, 0.3), (100.0, 0.25), (110.0, 0.3)],
];
let result = EssviSurface::calibrate(&data, &[0.5, 1.0], &[100.0, 100.0]);
assert!(result.is_err());
}
#[test]
fn calibrate_exercises_a_clipping() {
use crate::smile::SviSmile;
let tenors = vec![0.25, 0.5, 1.0, 2.0];
let forwards = vec![100.0; 4];
let svi_params: [(f64, f64, f64); 4] = [
(0.005, 0.06, -0.70),
(0.015, 0.045, -0.68),
(0.035, 0.03, -0.65),
(0.070, 0.02, -0.15),
];
let strikes: Vec<Vec<f64>> = tenors
.iter()
.map(|_| (0..15).map(|i| 70.0 + 4.0 * i as f64).collect())
.collect();
let market_data: Vec<Vec<(f64, f64)>> = tenors
.iter()
.zip(&svi_params)
.zip(&strikes)
.map(|((&t, &(a, b, rho)), ks)| {
let svi = SviSmile::new(100.0, t, a, b, rho, 0.0, 0.1).unwrap();
ks.iter()
.map(|&k| (k, svi.vol(Strike(k)).unwrap().0))
.collect()
})
.collect();
let cal = EssviSurface::calibrate(&market_data, &tenors, &forwards).unwrap();
let rho_diff = cal.rho_m() - cal.rho_0();
let a_bound = a_max_eq57(cal.gamma(), rho_diff, cal.rho_m());
assert!(
a_bound < 2.5,
"a_max={a_bound:.3} should be below scan ceiling"
);
assert!(
cal.a() <= a_bound + 1e-10,
"a={} exceeds a_max={a_bound}",
cal.a()
);
assert!(
(cal.a() - a_bound).abs() < 1e-10,
"a={} should equal a_max={a_bound} (clipping binding)",
cal.a()
);
let rms = rms_vol_error(&cal, &tenors, &market_data);
assert!(rms < 0.02, "RMS {rms} too large");
assert!(
cal.calendar_check_structural().is_empty(),
"calendar structural violations after a-clipping"
);
}
#[test]
fn structural_violation_serde_round_trip() {
let v = StructuralViolation {
tenor: 1.0,
theta: 0.04,
condition_lhs: 0.5,
condition_rhs: 0.3,
};
let json = serde_json::to_string(&v).unwrap();
let roundtrip: StructuralViolation = serde_json::from_str(&json).unwrap();
assert_eq!(roundtrip, v);
}
#[test]
fn calibrate_near_constant_rho_produces_small_a() {
let original = EssviSurface::new(
-0.30,
-0.28,
0.1,
0.5,
0.5,
vec![0.25, 0.5, 1.0, 2.0],
vec![100.0; 4],
vec![0.04, 0.08, 0.16, 0.32],
)
.unwrap();
let tenors = vec![0.25, 0.5, 1.0, 2.0];
let forwards = vec![100.0; 4];
let strikes: Vec<Vec<f64>> = tenors
.iter()
.map(|_| (0..25).map(|i| 70.0 + 2.5 * i as f64).collect())
.collect();
let market_data = synthetic_essvi_data(&original, &tenors, &strikes);
let cal = EssviSurface::calibrate(&market_data, &tenors, &forwards).unwrap();
assert!(
cal.a() < 0.5,
"near-constant rho profile should yield small a, got {}",
cal.a()
);
let rms = rms_vol_error(&cal, &tenors, &market_data);
assert!(rms < 0.005, "round-trip RMS {rms} should be < 0.005");
}
#[test]
fn calibrate_constant_rho_recovers_flat_rho() {
let original = EssviSurface::new(
-0.3,
-0.3,
0.0,
0.5,
0.5,
vec![0.25, 0.5, 1.0, 2.0],
vec![100.0; 4],
vec![0.04, 0.08, 0.16, 0.32],
)
.unwrap();
let tenors = vec![0.25, 0.5, 1.0, 2.0];
let forwards = vec![100.0; 4];
let strikes: Vec<Vec<f64>> = tenors
.iter()
.map(|_| (0..15).map(|i| 70.0 + 4.0 * i as f64).collect())
.collect();
let market_data = synthetic_essvi_data(&original, &tenors, &strikes);
let cal = EssviSurface::calibrate(&market_data, &tenors, &forwards).unwrap();
let rho_first = cal.rho(*cal.thetas().first().unwrap());
let rho_last = cal.rho(*cal.thetas().last().unwrap());
assert_abs_diff_eq!(rho_first, rho_last, epsilon = 0.03);
let rms = rms_vol_error(&cal, &tenors, &market_data);
assert!(
rms < 0.005,
"constant-rho round-trip RMS {rms} should be < 0.005"
);
}
#[test]
fn calibrate_rho_at_theta_max_equals_rho_m() {
let original = equity_surface();
let tenors = vec![0.25, 0.5, 1.0, 2.0];
let forwards = vec![100.0; 4];
let strikes: Vec<Vec<f64>> = tenors
.iter()
.map(|_| (0..15).map(|i| 70.0 + 4.0 * i as f64).collect())
.collect();
let market_data = synthetic_essvi_data(&original, &tenors, &strikes);
let cal = EssviSurface::calibrate(&market_data, &tenors, &forwards).unwrap();
assert_abs_diff_eq!(cal.rho(cal.theta_max()), cal.rho_m(), epsilon = 1e-14);
}
#[test]
fn calibrate_real_data_fail_00() {
let tenors = vec![
0.13523651988744392,
0.21189634192714274,
0.28581831317970946,
];
let forwards = vec![6079.713483566315, 6099.694029589387, 6119.023171024892];
#[rustfmt::skip]
let tenor_0: Vec<(f64, f64)> = vec![
(6100.0, 0.0625982147), (6190.0, 0.0696515411), (6225.0, 0.0714072411),
(6290.0, 0.0741890122), (6375.0, 0.0776351200), (6390.0, 0.0784630171),
(6410.0, 0.0794775855), (6475.0, 0.0835843816),
(4920.0, 0.3110087439), (4940.0, 0.3073343432), (4975.0, 0.3011240882),
(4980.0, 0.3004974430), (4990.0, 0.2985944628), (5010.0, 0.2953695994),
(5120.0, 0.2774264040), (5125.0, 0.2766377347), (5170.0, 0.2697630615),
(5210.0, 0.2639499306), (5280.0, 0.2537524917), (5325.0, 0.2476851188),
(5350.0, 0.2443583765), (5380.0, 0.2404972645), (5410.0, 0.2368659288),
(5425.0, 0.2348995580), (5440.0, 0.2332533279), (5475.0, 0.2295732164),
(5490.0, 0.2279191240), (5500.0, 0.2267898169), (5515.0, 0.2252920491),
(5535.0, 0.2231654284), (5555.0, 0.2211387287), (5590.0, 0.2177619895),
(5625.0, 0.2145773033), (5650.0, 0.2123859532), (5655.0, 0.2119841767),
(5660.0, 0.2115634725), (5720.0, 0.2068946031), (5795.0, 0.2021010369),
(5805.0, 0.2014866825), (5815.0, 0.2009211930), (5870.0, 0.1986755177),
(5875.0, 0.1984272409), (5990.0, 0.1977418265), (6025.0, 0.1995377662),
(6055.0, 0.2015902963),
];
#[rustfmt::skip]
let tenor_1: Vec<(f64, f64)> = vec![
(6110.0, 0.0730505873), (6170.0, 0.0755070638), (6175.0, 0.0756816599),
(6180.0, 0.0758177423), (6190.0, 0.0761682847), (6200.0, 0.0764665067),
(6275.0, 0.0783416580), (6350.0, 0.0797123411),
(4950.0, 0.2817714778), (5000.0, 0.2752316740), (5025.0, 0.2721449775),
(5075.0, 0.2660942206), (5080.0, 0.2654167174), (5120.0, 0.2605702346),
(5140.0, 0.2580952222), (5200.0, 0.2513427505), (5230.0, 0.2480107054),
(5275.0, 0.2430436076), (5325.0, 0.2378082330), (5350.0, 0.2352714337),
(5360.0, 0.2342019069), (5470.0, 0.2235805604), (5525.0, 0.2185461412),
(5625.0, 0.2100749330), (5640.0, 0.2088287216), (5695.0, 0.2045126109),
(5710.0, 0.2033832903), (5750.0, 0.2003749512), (5800.0, 0.1969502219),
(5845.0, 0.1941444803), (5855.0, 0.1935502308), (5870.0, 0.1927241764),
(5875.0, 0.1924352123), (5890.0, 0.1917753903), (5925.0, 0.1902211011),
(5930.0, 0.1899562582), (5935.0, 0.1897236482), (5990.0, 0.1884446645),
(6025.0, 0.1879697111), (6045.0, 0.1880807256),
];
#[rustfmt::skip]
let tenor_2: Vec<(f64, f64)> = vec![
(6190.0, 0.0814246469), (6225.0, 0.0819148928), (6240.0, 0.0820691536),
(6280.0, 0.0825094554), (6300.0, 0.0827143828), (6350.0, 0.0831490853),
(6370.0, 0.0833597327), (6475.0, 0.0843232803), (6480.0, 0.0843073431),
(6600.0, 0.0860975254), (6650.0, 0.0871861196), (6800.0, 0.0926400581),
(5000.0, 0.2657648657), (5050.0, 0.2604295763), (5160.0, 0.2489918077),
(5170.0, 0.2480012836), (5210.0, 0.2440206592), (5260.0, 0.2389792425),
(5290.0, 0.2361005452), (5325.0, 0.2326834608), (5370.0, 0.2284986192),
(5450.0, 0.2211815222), (5480.0, 0.2185285630), (5570.0, 0.2107268980),
(5575.0, 0.2103443738), (5590.0, 0.2091466059), (5600.0, 0.2081954161),
(5640.0, 0.2048951150), (5650.0, 0.2041058793), (5670.0, 0.2025267252),
(5675.0, 0.2021356195), (5710.0, 0.1993980377), (5835.0, 0.1906101322),
(5860.0, 0.1890801527), (5885.0, 0.1876195581), (5915.0, 0.1859675502),
(5990.0, 0.1828973609), (6055.0, 0.1814067206), (6060.0, 0.1813205322),
];
let market_data = vec![tenor_0, tenor_1, tenor_2];
match EssviSurface::calibrate(&market_data, &tenors, &forwards) {
Ok(surface) => {
for (&t, &f) in tenors.iter().zip(forwards.iter()) {
let vol = surface.black_vol(Tenor(t), Strike(f)).unwrap().0;
assert!(
vol.is_finite() && vol > 0.0,
"ATM vol at T={t} should be finite and positive, got {vol}"
);
}
}
Err(crate::VolSurfError::CalibrationError { message, .. }) => {
assert!(
message.contains("Roger Lee")
|| message.contains("ATM total variance")
|| message.contains("grid search")
|| message.contains("SVI"),
"unexpected rejection reason: {message}"
);
}
Err(e) => panic!("unexpected error variant: {e}"),
}
}
#[test]
fn fit_per_tenor_returns_correct_fits() {
let original = equity_surface();
let tenors = vec![0.25, 0.5, 1.0, 2.0];
let forwards = vec![100.0; 4];
let strikes: Vec<Vec<f64>> = tenors
.iter()
.map(|_| (0..15).map(|i| 70.0 + 4.0 * i as f64).collect())
.collect();
let market_data = synthetic_essvi_data(&original, &tenors, &strikes);
let fits = EssviSurface::fit_per_tenor(&market_data, &tenors, &forwards).unwrap();
assert_eq!(fits.len(), 4);
for (i, fit) in fits.iter().enumerate() {
assert_eq!(fit.tenor, tenors[i]);
assert_eq!(fit.forward, forwards[i]);
assert!(fit.theta > 0.0, "theta should be positive");
assert!(
fit.rms_error < 0.005,
"per-tenor RMS {:.4} too high",
fit.rms_error
);
assert!(fit.svi.rho().abs() < 1.0, "rho out of range");
assert_eq!(fit.market_data.len(), 15);
}
for w in fits.windows(2) {
assert!(w[1].theta > w[0].theta, "thetas should be monotone");
}
}
#[test]
fn from_per_tenor_with_pruned_tenors() {
let original = equity_surface();
let tenors = vec![0.25, 0.5, 1.0, 2.0];
let forwards = vec![100.0; 4];
let strikes: Vec<Vec<f64>> = tenors
.iter()
.map(|_| (0..15).map(|i| 70.0 + 4.0 * i as f64).collect())
.collect();
let market_data = synthetic_essvi_data(&original, &tenors, &strikes);
let fits = EssviSurface::fit_per_tenor(&market_data, &tenors, &forwards).unwrap();
let pruned: Vec<_> = fits
.iter()
.enumerate()
.filter(|(i, _)| *i != 1)
.map(|(_, f)| f.clone())
.collect();
assert_eq!(pruned.len(), 3);
let surface = EssviSurface::from_per_tenor(&pruned).unwrap();
let vol = surface.black_vol(Tenor(1.0), Strike(100.0)).unwrap();
assert!(vol.0 > 0.0 && vol.0 < 1.0, "vol {:.4} out of range", vol.0);
}
#[test]
fn calibrate_equivalence_with_two_stage() {
let original = equity_surface();
let tenors = vec![0.25, 0.5, 1.0, 2.0];
let forwards = vec![100.0; 4];
let strikes: Vec<Vec<f64>> = tenors
.iter()
.map(|_| (0..15).map(|i| 70.0 + 4.0 * i as f64).collect())
.collect();
let market_data = synthetic_essvi_data(&original, &tenors, &strikes);
let one_shot = EssviSurface::calibrate(&market_data, &tenors, &forwards).unwrap();
let fits = EssviSurface::fit_per_tenor(&market_data, &tenors, &forwards).unwrap();
let two_stage = EssviSurface::from_per_tenor(&fits).unwrap();
assert_eq!(one_shot.rho_0(), two_stage.rho_0());
assert_eq!(one_shot.rho_m(), two_stage.rho_m());
assert_eq!(one_shot.a(), two_stage.a());
assert_eq!(one_shot.eta(), two_stage.eta());
assert_eq!(one_shot.gamma(), two_stage.gamma());
for &t in &[0.25, 0.5, 1.0, 2.0] {
for &k in &[80.0, 100.0, 120.0] {
let v1 = one_shot.black_vol(Tenor(t), Strike(k)).unwrap();
let v2 = two_stage.black_vol(Tenor(t), Strike(k)).unwrap();
assert_eq!(v1.0, v2.0, "vol mismatch at t={t}, k={k}");
}
}
}
#[test]
fn per_tenor_fit_serde_round_trip() {
let original = equity_surface();
let tenors = vec![0.25, 0.5, 1.0, 2.0];
let forwards = vec![100.0; 4];
let strikes: Vec<Vec<f64>> = tenors
.iter()
.map(|_| (0..15).map(|i| 70.0 + 4.0 * i as f64).collect())
.collect();
let market_data = synthetic_essvi_data(&original, &tenors, &strikes);
let fits = EssviSurface::fit_per_tenor(&market_data, &tenors, &forwards).unwrap();
let json = serde_json::to_string(&fits[0]).unwrap();
let deser: PerTenorFit = serde_json::from_str(&json).unwrap();
assert_eq!(deser.tenor, fits[0].tenor);
assert_eq!(deser.forward, fits[0].forward);
assert_abs_diff_eq!(deser.theta, fits[0].theta, epsilon = 1e-14);
assert_eq!(deser.rms_error, fits[0].rms_error);
assert_eq!(deser.svi, fits[0].svi);
assert_eq!(deser.market_data.len(), fits[0].market_data.len());
}
#[test]
fn from_per_tenor_rejects_empty() {
let err = EssviSurface::from_per_tenor(&[]).unwrap_err();
assert!(matches!(
err,
crate::error::VolSurfError::InvalidInput { .. }
));
}
#[test]
fn from_per_tenor_rejects_single_tenor() {
let original = equity_surface();
let tenors = vec![0.25];
let forwards = vec![100.0];
let strikes: Vec<Vec<f64>> = vec![(0..15).map(|i| 70.0 + 4.0 * i as f64).collect()];
let market_data = synthetic_essvi_data(&original, &tenors, &strikes);
let fits = EssviSurface::fit_per_tenor(&market_data, &tenors, &forwards).unwrap();
let err = EssviSurface::from_per_tenor(&fits).unwrap_err();
assert!(matches!(
err,
crate::error::VolSurfError::InvalidInput { .. }
));
}
#[test]
fn from_per_tenor_rejects_non_monotone_theta() {
let original = equity_surface();
let tenors = vec![0.25, 0.5];
let forwards = vec![100.0, 100.0];
let strikes: Vec<Vec<f64>> = tenors
.iter()
.map(|_| (0..15).map(|i| 70.0 + 4.0 * i as f64).collect())
.collect();
let market_data = synthetic_essvi_data(&original, &tenors, &strikes);
let mut fits = EssviSurface::fit_per_tenor(&market_data, &tenors, &forwards).unwrap();
fits[0].theta = fits[1].theta + 0.01;
let err = EssviSurface::from_per_tenor(&fits).unwrap_err();
assert!(
err.to_string().contains("non-monotone"),
"expected non-monotone error, got: {err}"
);
}
#[test]
fn fit_per_tenor_rejects_length_mismatch() {
let err =
EssviSurface::fit_per_tenor(&[vec![(100.0, 0.2)]], &[0.25, 0.5], &[100.0]).unwrap_err();
assert!(matches!(
err,
crate::error::VolSurfError::InvalidInput { .. }
));
}
#[test]
fn from_per_tenor_rejects_negative_tenor() {
let original = equity_surface();
let tenors = vec![0.25, 0.5];
let forwards = vec![100.0, 100.0];
let strikes: Vec<Vec<f64>> = tenors
.iter()
.map(|_| (0..15).map(|i| 70.0 + 4.0 * i as f64).collect())
.collect();
let market_data = synthetic_essvi_data(&original, &tenors, &strikes);
let mut fits = EssviSurface::fit_per_tenor(&market_data, &tenors, &forwards).unwrap();
fits[0].tenor = -1.0;
let err = EssviSurface::from_per_tenor(&fits).unwrap_err();
assert!(matches!(
err,
crate::error::VolSurfError::InvalidInput { .. }
));
}
#[test]
fn from_per_tenor_rejects_nan_theta() {
let original = equity_surface();
let tenors = vec![0.25, 0.5];
let forwards = vec![100.0, 100.0];
let strikes: Vec<Vec<f64>> = tenors
.iter()
.map(|_| (0..15).map(|i| 70.0 + 4.0 * i as f64).collect())
.collect();
let market_data = synthetic_essvi_data(&original, &tenors, &strikes);
let mut fits = EssviSurface::fit_per_tenor(&market_data, &tenors, &forwards).unwrap();
fits[1].theta = f64::NAN;
let err = EssviSurface::from_per_tenor(&fits).unwrap_err();
assert!(matches!(
err,
crate::error::VolSurfError::InvalidInput { .. }
));
}
#[test]
fn from_per_tenor_rejects_zero_theta() {
let original = equity_surface();
let tenors = vec![0.25, 0.5];
let forwards = vec![100.0, 100.0];
let strikes: Vec<Vec<f64>> = tenors
.iter()
.map(|_| (0..15).map(|i| 70.0 + 4.0 * i as f64).collect())
.collect();
let market_data = synthetic_essvi_data(&original, &tenors, &strikes);
let mut fits = EssviSurface::fit_per_tenor(&market_data, &tenors, &forwards).unwrap();
fits[0].theta = 0.0;
let err = EssviSurface::from_per_tenor(&fits).unwrap_err();
assert!(matches!(
err,
crate::error::VolSurfError::InvalidInput { .. }
));
}
}