use crate::calibration::config::{ConstraintMode, SliceCalibrationConfig};
use crate::calibration::report::{
CalibrationReport, ParameterizationEvidence, RawParameterMargins, ResidualDiagnostics,
TerminationReason,
};
use crate::calibration::{least_squares, quasi_explicit};
use crate::error::CalibrationError;
use crate::market::quote::Quote;
use crate::no_arb::butterfly::assess_raw;
use crate::no_arb::evidence::ArbitrageAssessment;
use crate::smile::raw::RawSvi;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CalibrationResult {
pub(crate) slice: RawSvi,
report: CalibrationReport,
}
impl CalibrationResult {
#[allow(clippy::too_many_arguments)]
pub(crate) fn from_parts(
slice: RawSvi,
residuals: ResidualDiagnostics,
iterations: usize,
evaluations: usize,
termination: TerminationReason,
config: SliceCalibrationConfig,
algorithm: &'static str,
starts: usize,
selected_start: Option<usize>,
condition_estimate: Option<f64>,
parameterization: ParameterizationEvidence,
) -> Result<Self, CalibrationError> {
let assessment = assess_raw(&slice, config.arbitrage_tolerance());
if config.constraint_mode() == ConstraintMode::Constrained
&& assessment.status() != crate::ArbitrageStatus::NoViolationDetected
{
return Err(CalibrationError::Infeasible {
condition: "Raw SVI butterfly constraints",
margin: assessment.margin(),
});
}
Ok(Self {
slice,
report: CalibrationReport::new(
iterations,
evaluations,
termination,
residuals,
assessment,
algorithm,
starts,
selected_start,
config.tolerance(),
condition_estimate,
parameterization,
RawParameterMargins::new(
slice.b(),
1.0 - slice.rho().abs(),
slice.sigma(),
slice.w_min(),
assessment.margin(),
),
),
})
}
#[must_use]
pub const fn slice(self) -> RawSvi {
self.slice
}
#[must_use]
pub const fn rmse(self) -> f64 {
self.report.residuals().rmse()
}
#[must_use]
pub const fn arbitrage_assessment(self) -> ArbitrageAssessment {
self.report.arbitrage_assessment()
}
#[must_use]
pub const fn report(self) -> CalibrationReport {
self.report
}
}
pub(crate) fn residual_diagnostics(
slice: &RawSvi,
quotes: &[Quote],
distinct_tolerance: f64,
) -> ResidualDiagnostics {
let total_weight: f64 = quotes.iter().map(|quote| quote.weight()).sum();
let mut objective = 0.0;
let mut max_absolute = 0.0_f64;
let mut strikes: Vec<f64> = Vec::new();
for quote in quotes.iter().filter(|quote| quote.weight() > 0.0) {
let residual = slice.total_variance(quote.log_moneyness()) - quote.total_variance();
objective += quote.weight() * residual * residual;
max_absolute = max_absolute.max(residual.abs());
strikes.push(quote.log_moneyness());
}
strikes.sort_by(f64::total_cmp);
let mut distinct = usize::from(!strikes.is_empty());
let mut previous = match strikes.first() {
Some(value) => *value,
None => 0.0,
};
for strike in strikes.iter().copied().skip(1) {
if strike - previous > distinct_tolerance * (1.0 + strike.abs().max(previous.abs())) {
distinct += 1;
previous = strike;
}
}
let rmse = if total_weight > 0.0 {
(objective / total_weight).sqrt()
} else {
f64::NAN
};
ResidualDiagnostics::new(
objective,
rmse,
max_absolute,
strikes.len(),
distinct,
quotes.len().saturating_sub(strikes.len()),
total_weight,
)
}
pub fn calibrate_slice(quotes: &[Quote]) -> Result<CalibrationResult, CalibrationError> {
calibrate_slice_with_config(quotes, SliceCalibrationConfig::default())
}
pub fn calibrate_slice_with_config(
quotes: &[Quote],
config: SliceCalibrationConfig,
) -> Result<CalibrationResult, CalibrationError> {
let qe = quasi_explicit::calibrate_with_config(quotes, config)?;
match least_squares::refine_with_config(quotes, &qe.slice(), config) {
Ok(lm) if lm.rmse() < qe.rmse() => Ok(lm),
_ => Ok(qe),
}
}
#[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 calibration_result_enforces_constraint_mode() {
let benign =
RawSvi::new(0.04, 0.1, -0.2, 0.0, 0.3).expect("valid test or documentation fixture");
let residuals = ResidualDiagnostics::new(1e-12, 1e-6, 1e-6, 5, 5, 0, 5.0);
assert_eq!(
CalibrationResult::from_parts(
benign,
residuals,
1,
1,
TerminationReason::ObjectiveConverged,
SliceCalibrationConfig::default(),
"test fixture",
1,
Some(0),
None,
ParameterizationEvidence::new("test fixture", "no repair"),
)
.expect("valid test or documentation fixture")
.arbitrage_assessment()
.status(),
crate::ArbitrageStatus::NoViolationDetected
);
let vogt = RawSvi::new(-0.0410, 0.1331, 0.3060, 0.3586, 0.4153)
.expect("valid test or documentation fixture");
assert!(matches!(
CalibrationResult::from_parts(
vogt,
residuals,
1,
1,
TerminationReason::ObjectiveConverged,
SliceCalibrationConfig::default(),
"test fixture",
1,
Some(0),
None,
ParameterizationEvidence::new("test fixture", "no repair"),
),
Err(CalibrationError::Infeasible { .. })
));
}
#[test]
fn calibrate_slice_recovers_synthetic() {
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 fit = calibrate_slice("es).expect("valid test or documentation fixture");
assert!(fit.rmse() < 1e-5, "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_slice_polish_never_worsens() {
let truth =
RawSvi::new(0.03, 0.3, 0.0, 0.0, 0.2).expect("valid test or documentation fixture");
let ks = [-0.5, -0.3, -0.1, 0.0, 0.1, 0.3, 0.5];
let quotes = synthetic(&truth, &ks);
let qe = quasi_explicit::calibrate("es).expect("valid test or documentation fixture");
let pipeline = calibrate_slice("es).expect("valid test or documentation fixture");
assert!(pipeline.rmse() <= qe.rmse() + 1e-12);
}
#[test]
fn calibrate_slice_propagates_errors() {
assert!(matches!(
calibrate_slice(&[]),
Err(CalibrationError::EmptyQuotes)
));
}
}