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

//! Calibration limits, tolerances, and constraint policies.

use crate::error::{CalibrationError, ParamError};
use crate::market::quote::Quote;

/// Feasibility policy applied to a calibration result.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConstraintMode {
    /// Return success only when every required feasibility assessment passes.
    Constrained,
    /// Return the best numerical fit with feasibility recorded in its report.
    BestEffort,
}

/// Deterministic initialization policy for local and multi-start searches.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InitializationPolicy {
    /// Use the crate's deterministic, data-scaled multi-start set.
    DeterministicMultiStart,
    /// Use a deterministic single data-scaled starting point.
    DeterministicSingleStart,
}

/// Validated controls for Raw SVI slice calibration.
///
/// # Examples
///
/// ```
/// use regit_svi::{ConstraintMode, SliceCalibrationConfig};
///
/// let config = SliceCalibrationConfig::default()
///     .with_constraint_mode(ConstraintMode::BestEffort);
/// assert_eq!(config.constraint_mode(), ConstraintMode::BestEffort);
/// assert!(config.outer_iterations() > 0);
/// ```
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SliceCalibrationConfig {
    outer_iterations: usize,
    polish_iterations: usize,
    tolerance: f64,
    arbitrage_tolerance: f64,
    distinct_tolerance: f64,
    constraint_mode: ConstraintMode,
    initialization: InitializationPolicy,
}

impl SliceCalibrationConfig {
    /// Creates validated slice-calibration controls.
    ///
    /// # Errors
    ///
    /// Returns [`CalibrationError::InvalidConfig`] when an iteration limit is
    /// zero or a tolerance is non-finite or non-positive.
    #[allow(clippy::too_many_arguments)]
    pub fn new(
        outer_iterations: usize,
        polish_iterations: usize,
        tolerance: f64,
        arbitrage_tolerance: f64,
        distinct_tolerance: f64,
        constraint_mode: ConstraintMode,
        initialization: InitializationPolicy,
    ) -> Result<Self, CalibrationError> {
        if outer_iterations == 0 {
            return Err(CalibrationError::InvalidConfig {
                field: "outer_iterations",
                value: 0.0,
            });
        }
        if polish_iterations == 0 {
            return Err(CalibrationError::InvalidConfig {
                field: "polish_iterations",
                value: 0.0,
            });
        }
        for (field, value) in [
            ("tolerance", tolerance),
            ("arbitrage_tolerance", arbitrage_tolerance),
            ("distinct_tolerance", distinct_tolerance),
        ] {
            if !value.is_finite() || value <= 0.0 {
                return Err(CalibrationError::InvalidConfig { field, value });
            }
        }
        Ok(Self {
            outer_iterations,
            polish_iterations,
            tolerance,
            arbitrage_tolerance,
            distinct_tolerance,
            constraint_mode,
            initialization,
        })
    }

    /// Outer-search iteration limit per start.
    #[must_use]
    pub const fn outer_iterations(self) -> usize {
        self.outer_iterations
    }
    /// Levenberg–Marquardt polish iteration limit.
    #[must_use]
    pub const fn polish_iterations(self) -> usize {
        self.polish_iterations
    }
    /// Optimizer termination tolerance.
    #[must_use]
    pub const fn tolerance(self) -> f64 {
        self.tolerance
    }
    /// Arbitrage classification tolerance.
    #[must_use]
    pub const fn arbitrage_tolerance(self) -> f64 {
        self.arbitrage_tolerance
    }
    /// Minimum separation used to count distinct positive-weight strikes.
    #[must_use]
    pub const fn distinct_tolerance(self) -> f64 {
        self.distinct_tolerance
    }
    /// Requested feasibility policy.
    #[must_use]
    pub const fn constraint_mode(self) -> ConstraintMode {
        self.constraint_mode
    }
    /// Initialization policy.
    #[must_use]
    pub const fn initialization(self) -> InitializationPolicy {
        self.initialization
    }
    /// Returns a copy using the requested feasibility policy.
    #[must_use]
    pub const fn with_constraint_mode(mut self, mode: ConstraintMode) -> Self {
        self.constraint_mode = mode;
        self
    }
}

impl Default for SliceCalibrationConfig {
    fn default() -> Self {
        Self {
            outer_iterations: 2_000,
            polish_iterations: 500,
            tolerance: 1e-12,
            arbitrage_tolerance: 1e-10,
            distinct_tolerance: 1e-12,
            constraint_mode: ConstraintMode::Constrained,
            initialization: InitializationPolicy::DeterministicMultiStart,
        }
    }
}

/// Validated controls for joint SSVI surface calibration.
///
/// # Examples
///
/// ```
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// use regit_svi::{ConstraintMode, InitializationPolicy, SurfaceCalibrationConfig};
///
/// let config = SurfaceCalibrationConfig::new(
///     3_000,
///     1e-12,
///     ConstraintMode::Constrained,
///     InitializationPolicy::DeterministicMultiStart,
/// )?;
/// assert_eq!(config.constraint_mode(), ConstraintMode::Constrained);
/// # Ok(())
/// # }
/// ```
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SurfaceCalibrationConfig {
    outer_iterations: usize,
    tolerance: f64,
    constraint_mode: ConstraintMode,
    initialization: InitializationPolicy,
}

impl SurfaceCalibrationConfig {
    /// Creates validated SSVI calibration controls.
    ///
    /// # Errors
    ///
    /// Returns [`CalibrationError::InvalidConfig`] for a zero iteration limit
    /// or a non-finite/non-positive tolerance.
    pub fn new(
        outer_iterations: usize,
        tolerance: f64,
        constraint_mode: ConstraintMode,
        initialization: InitializationPolicy,
    ) -> Result<Self, CalibrationError> {
        if outer_iterations == 0 {
            return Err(CalibrationError::InvalidConfig {
                field: "outer_iterations",
                value: 0.0,
            });
        }
        if !tolerance.is_finite() || tolerance <= 0.0 {
            return Err(CalibrationError::InvalidConfig {
                field: "tolerance",
                value: tolerance,
            });
        }
        Ok(Self {
            outer_iterations,
            tolerance,
            constraint_mode,
            initialization,
        })
    }

    /// Outer-search iteration limit per start.
    #[must_use]
    pub const fn outer_iterations(self) -> usize {
        self.outer_iterations
    }
    /// Optimizer termination tolerance.
    #[must_use]
    pub const fn tolerance(self) -> f64 {
        self.tolerance
    }
    /// Requested feasibility policy.
    #[must_use]
    pub const fn constraint_mode(self) -> ConstraintMode {
        self.constraint_mode
    }
    /// Initialization policy.
    #[must_use]
    pub const fn initialization(self) -> InitializationPolicy {
        self.initialization
    }
    /// Returns a copy using the requested feasibility policy.
    #[must_use]
    pub const fn with_constraint_mode(mut self, mode: ConstraintMode) -> Self {
        self.constraint_mode = mode;
        self
    }
}

impl Default for SurfaceCalibrationConfig {
    fn default() -> Self {
        Self {
            outer_iterations: 3_000,
            tolerance: 1e-12,
            constraint_mode: ConstraintMode::Constrained,
            initialization: InitializationPolicy::DeterministicMultiStart,
        }
    }
}

pub(crate) fn validate_effective_quotes(
    quotes: &[Quote],
    minimum: usize,
    distinct_tolerance: f64,
) -> Result<(usize, usize), CalibrationError> {
    let total_weight: f64 = quotes.iter().map(|quote| quote.weight()).sum();
    if !total_weight.is_finite() {
        return Err(CalibrationError::Param(ParamError::NonFinite {
            name: "total quote weight",
        }));
    }
    if quotes.is_empty() {
        return Err(CalibrationError::EmptyQuotes);
    }
    let mut strikes: Vec<f64> = quotes
        .iter()
        .filter(|quote| quote.weight() > 0.0)
        .map(|quote| quote.log_moneyness())
        .collect();
    if strikes.is_empty() {
        return Err(CalibrationError::AllWeightsZero);
    }
    strikes.sort_by(f64::total_cmp);
    let usable = strikes.len();
    let mut distinct = 1;
    let mut previous = strikes[0];
    for strike in strikes.into_iter().skip(1) {
        let scale = 1.0 + previous.abs().max(strike.abs());
        if strike - previous > distinct_tolerance * scale {
            distinct += 1;
            previous = strike;
        }
    }
    if usable < minimum || distinct < minimum {
        return Err(CalibrationError::InsufficientEffectiveQuotes {
            usable,
            distinct,
            need: minimum,
        });
    }
    Ok((usable, distinct))
}

#[cfg(test)]
#[allow(clippy::expect_used)] // Validated fixtures use contextual expectations.
mod tests {
    use super::*;

    #[test]
    fn rejects_invalid_controls() {
        assert!(matches!(
            SliceCalibrationConfig::new(
                0,
                10,
                1e-10,
                1e-10,
                1e-12,
                ConstraintMode::Constrained,
                InitializationPolicy::DeterministicMultiStart,
            ),
            Err(CalibrationError::InvalidConfig {
                field: "outer_iterations",
                ..
            })
        ));
        assert!(
            SurfaceCalibrationConfig::new(
                10,
                f64::NAN,
                ConstraintMode::Constrained,
                InitializationPolicy::DeterministicMultiStart,
            )
            .is_err()
        );
    }

    #[test]
    fn effective_count_requires_positive_weight_and_distinct_strikes() {
        let repeated = [
            Quote::new(0.0, 0.04, 1.0).expect("valid test or documentation fixture"),
            Quote::new(0.0, 0.04, 1.0).expect("valid test or documentation fixture"),
            Quote::new(0.1, 0.05, 0.0).expect("valid test or documentation fixture"),
            Quote::new(0.2, 0.06, 0.0).expect("valid test or documentation fixture"),
            Quote::new(0.3, 0.07, 0.0).expect("valid test or documentation fixture"),
        ];
        assert!(matches!(
            validate_effective_quotes(&repeated, 5, 1e-12),
            Err(CalibrationError::InsufficientEffectiveQuotes {
                usable: 2,
                distinct: 1,
                need: 5,
            })
        ));
    }
}