regit-svi 2.0.0

Arbitrage-free SVI volatility surfaces in pure Rust. Raw, Jump-Wings and SSVI parametrisations, calibration, and static-arbitrage checks. Zero dependencies.
Documentation
// Copyright 2026 Regit.io — Nicolas Koenig
// SPDX-License-Identifier: Apache-2.0

//! Slice and surface calibration.
//!
//! Two complementary slice calibrators and a joint SSVI surface fit:
//!
//! - [`quasi_explicit`] — de Marco & Martini / Zeliade quasi-explicit method.
//!   The inner problem is convex for fixed nonlinear parameters; the remaining
//!   2-D search is non-convex and uses deterministic multi-start heuristics.
//! - [`least_squares`] — direct Levenberg-Marquardt over the five raw
//!   parameters with an analytic Jacobian. Fast local polish from a good seed.
//! - [`crate::calibration::surface`] — joint SSVI surface calibration with the Theorem 4.1 / 4.2
//!   no-arbitrage conditions enforced throughout.
//!
//! The default slice pipeline is [`calibrate_slice`]: a multi-start quasi-explicit
//! fit followed by an optional Levenberg-Marquardt polish, keeping a lower-RMSE
//! polish only when it satisfies the configured convergence and feasibility
//! contract.
//!
//! # References
//!
//! - De Marco, S. & Martini, C., Zeliade Systems White Paper ZWP-0005 (2009).
//! - Levenberg (1944); Marquardt (1963).
//! - Gatheral, J. & Jacquier, A., *Quantitative Finance* 14(1):59-71 (2014).

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;

/// The outcome of a slice calibration: the fitted slice, its fit quality, and
/// its butterfly-arbitrage status.
///
/// # Examples
///
/// ```
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// use regit_svi::market::quote::Quote;
/// use regit_svi::calibration::calibrate_slice;
///
/// let quotes = [-0.2, -0.1, 0.0, 0.1, 0.2]
///     .into_iter()
///     .map(|k| Quote::new(k, 0.04 + 0.1 * k * k, 1.0))
///     .collect::<Result<Vec<_>, _>>()?;
/// let result = calibrate_slice(&quotes)?;
/// assert_eq!(result.arbitrage_assessment().status(), regit_svi::ArbitrageStatus::NoViolationDetected);
/// # Ok(())
/// # }
/// ```
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CalibrationResult {
    /// The calibrated raw SVI slice.
    pub(crate) slice: RawSvi,
    /// Weighted root-mean-square fit residual `sqrt(F / sum_i weight_i)`.
    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(),
                ),
            ),
        })
    }

    /// Returns the fitted Raw SVI slice.
    #[must_use]
    pub const fn slice(self) -> RawSvi {
        self.slice
    }

    /// Returns the weighted root-mean-square residual.
    #[must_use]
    pub const fn rmse(self) -> f64 {
        self.report.residuals().rmse()
    }

    /// Returns the evidence-bearing arbitrage assessment.
    #[must_use]
    pub const fn arbitrage_assessment(self) -> ArbitrageAssessment {
        self.report.arbitrage_assessment()
    }

    /// Returns optimizer termination, residual, and feasibility evidence.
    #[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,
    )
}

/// Calibrates a raw SVI slice with the default pipeline.
///
/// Runs the deterministic multi-start quasi-explicit calibrator, then attempts
/// a Levenberg-Marquardt polish seeded from that result. Whichever fit has
/// the lower RMSE is returned, so the polish can only help.
///
/// # Errors
///
/// Propagates [`CalibrationError::EmptyQuotes`],
/// [`CalibrationError::AllWeightsZero`],
/// [`CalibrationError::InsufficientEffectiveQuotes`],
/// [`CalibrationError::Param`], [`CalibrationError::DidNotConverge`], or
/// [`CalibrationError::Infeasible`] from the quasi-explicit stage. An LM
/// polish failure is non-fatal because the valid quasi-explicit fit is kept.
///
/// # Examples
///
/// ```
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// use regit_svi::market::quote::Quote;
/// use regit_svi::calibration::calibrate_slice;
///
/// let quotes = [
///     Quote::new(-0.20, 0.0512, 1.0)?,
///     Quote::new(-0.10, 0.0432, 1.0)?,
///     Quote::new( 0.00, 0.0400, 1.0)?,
///     Quote::new( 0.10, 0.0420, 1.0)?,
///     Quote::new( 0.20, 0.0480, 1.0)?,
/// ];
/// let fit = calibrate_slice(&quotes)?;
/// assert!(fit.rmse() < 1e-2);
/// # Ok(())
/// # }
/// ```
pub fn calibrate_slice(quotes: &[Quote]) -> Result<CalibrationResult, CalibrationError> {
    calibrate_slice_with_config(quotes, SliceCalibrationConfig::default())
}

/// Calibrates a Raw SVI slice with explicit limits and feasibility policy.
///
/// # Errors
///
/// Returns the same quasi-explicit-stage variants as [`calibrate_slice`]:
/// [`CalibrationError::EmptyQuotes`], [`CalibrationError::AllWeightsZero`],
/// [`CalibrationError::InsufficientEffectiveQuotes`],
/// [`CalibrationError::Param`], [`CalibrationError::DidNotConverge`], and
/// [`CalibrationError::Infeasible`]. An LM polish error does not replace a
/// valid quasi-explicit result.
pub fn calibrate_slice_with_config(
    quotes: &[Quote],
    config: SliceCalibrationConfig,
) -> Result<CalibrationResult, CalibrationError> {
    let qe = quasi_explicit::calibrate_with_config(quotes, config)?;
    // Attempt an LM polish; keep it only if it strictly improves the RMSE.
    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)] // Validated fixtures use contextual expectations.
mod tests {
    use super::*;

    /// Generates a synthetic slice of quotes from known raw SVI parameters.
    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(&quotes).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() {
        // The pipeline returns the better of the two fits.
        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(&quotes).expect("valid test or documentation fixture");
        let pipeline = calibrate_slice(&quotes).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)
        ));
    }
}