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

//! Core market types: log-moneyness, total implied variance, and quotes.
//!
//! SVI parametrises one maturity slice at a time. Fix a time to expiry
//! `T > 0` and a forward price `F`. For a strike `K` the **log-moneyness**
//! is `k = ln(K / F)`, with `k = 0` at-the-money-forward.
//!
//! SVI does not parametrise Black implied volatility directly — it
//! parametrises the **total implied variance**:
//!
//! ```text
//! w(k) = sigma_BS(k)^2 * T
//! ```
//!
//! `w` is the natural object: it is additive in maturity for a flat surface,
//! the no-arbitrage conditions take their simplest form in `w`, and `w >= 0`
//! is the only domain requirement. Implied volatility is recovered by
//! `sigma_BS(k) = sqrt(w(k) / T)`.
//!
//! # References
//!
//! - Gatheral, J., *The Volatility Surface: A Practitioner's Guide*,
//!   Wiley (2006), Chapter 3.

use crate::error::ParamError;

/// A single market quote: a log-moneyness, an observed total implied
/// variance, and a non-negative fitting weight.
///
/// A **slice** is a set of quotes sharing one maturity. The weight is any
/// non-negative number expressing the relative trust placed in the quote
/// during calibration — common choices are option vega or the inverse of the
/// bid-ask spread. A weight of `0.0` excludes the quote from the fit.
///
/// # Invariants
///
/// Constructed through [`Quote::new`], a `Quote` always satisfies `w >= 0`,
/// `weight >= 0`, and all three fields finite.
///
/// # Examples
///
/// ```
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// use regit_svi::market::quote::Quote;
///
/// let q = Quote::new(-0.10, 0.0432, 1.0)?;
/// assert!((q.log_moneyness() + 0.10).abs() < 1e-15);
/// assert!((q.total_variance() - 0.0432).abs() < 1e-15);
/// # Ok(())
/// # }
/// ```
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Quote {
    /// Log-moneyness `k = ln(K / F)`.
    pub(crate) k: f64,
    /// Observed total implied variance `w = sigma_BS^2 * T`.
    pub(crate) w: f64,
    /// Non-negative fitting weight (e.g. vega or inverse bid-ask spread).
    pub(crate) weight: f64,
}

impl Quote {
    /// Creates a validated market quote.
    ///
    /// # Errors
    ///
    /// - [`ParamError::NonFinite`] if any field is `NaN` or infinite.
    /// - [`ParamError::NegativeTotalVariance`] if `w < 0`.
    /// - [`ParamError::NegativeWeight`] if `weight < 0`.
    ///
    /// # Examples
    ///
    /// ```
    /// use regit_svi::market::quote::Quote;
    /// use regit_svi::error::ParamError;
    ///
    /// assert!(Quote::new(0.0, 0.04, 1.0).is_ok());
    /// assert_eq!(
    ///     Quote::new(0.0, -0.04, 1.0),
    ///     Err(ParamError::NegativeTotalVariance { w: -0.04 }),
    /// );
    /// ```
    pub fn new(k: f64, w: f64, weight: f64) -> Result<Self, ParamError> {
        if !k.is_finite() {
            return Err(ParamError::NonFinite { name: "k" });
        }
        if !w.is_finite() {
            return Err(ParamError::NonFinite { name: "w" });
        }
        if !weight.is_finite() {
            return Err(ParamError::NonFinite { name: "weight" });
        }
        if w < 0.0 {
            return Err(ParamError::NegativeTotalVariance { w });
        }
        if weight < 0.0 {
            return Err(ParamError::NegativeWeight { weight });
        }
        Ok(Self { k, w, weight })
    }

    #[cfg(test)]
    pub(crate) const fn new_unchecked(k: f64, w: f64, weight: f64) -> Self {
        Self { k, w, weight }
    }

    /// Returns the quote's log-moneyness.
    #[must_use]
    pub const fn log_moneyness(self) -> f64 {
        self.k
    }

    /// Returns the observed total implied variance.
    #[must_use]
    pub const fn total_variance(self) -> f64 {
        self.w
    }

    /// Returns the non-negative calibration weight.
    #[must_use]
    pub const fn weight(self) -> f64 {
        self.weight
    }

    /// Returns the Black implied volatility implied by this quote at maturity
    /// `t`, i.e. `sqrt(w / t)`.
    ///
    /// # Errors
    ///
    /// Returns [`ParamError::NonPositiveMaturity`] if `t` is non-finite or
    /// non-positive, and [`ParamError::NonFinite`] if `w / t` cannot produce a
    /// finite implied volatility.
    ///
    /// # Examples
    ///
    /// ```
    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
    /// use regit_svi::market::quote::Quote;
    ///
    /// let q = Quote::new(0.0, 0.04, 1.0)?;
    /// let vol = q.implied_vol(1.0)?;
    /// assert!((vol - 0.20).abs() < 1e-12);
    /// # Ok(())
    /// # }
    /// ```
    pub fn implied_vol(&self, t: f64) -> Result<f64, ParamError> {
        if t <= 0.0 || !t.is_finite() {
            return Err(ParamError::NonPositiveMaturity { t });
        }
        let volatility = (self.w / t).sqrt();
        if volatility.is_finite() {
            Ok(volatility)
        } else {
            Err(ParamError::NonFinite {
                name: "implied_volatility",
            })
        }
    }
}

/// A non-empty quote slice ordered by strictly increasing log-moneyness.
#[derive(Debug, Clone, PartialEq)]
pub struct SliceQuotes(Vec<Quote>);

impl SliceQuotes {
    /// Validates a quote slice without sorting or combining observations.
    ///
    /// # Errors
    ///
    /// Returns [`ParamError::EmptyCollection`] for no quotes and
    /// [`ParamError::NotStrictlyIncreasing`] for duplicate or unordered strikes.
    pub fn new(quotes: Vec<Quote>) -> Result<Self, ParamError> {
        if quotes.is_empty() {
            return Err(ParamError::EmptyCollection {
                name: "slice quotes",
            });
        }
        for (index, pair) in quotes.windows(2).enumerate() {
            if pair[1].k <= pair[0].k {
                return Err(ParamError::NotStrictlyIncreasing {
                    name: "log-moneyness",
                    index: index + 1,
                    previous: pair[0].k,
                    value: pair[1].k,
                });
            }
        }
        Ok(Self(quotes))
    }

    /// Returns the validated quotes.
    #[must_use]
    pub fn as_slice(&self) -> &[Quote] {
        &self.0
    }

    /// Returns the number of quotes.
    #[must_use]
    pub fn len(&self) -> usize {
        self.0.len()
    }

    /// Returns whether the slice is empty; validated instances are never empty.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.0.is_empty()
    }

    /// Consumes the wrapper and returns the quotes.
    #[must_use]
    pub fn into_vec(self) -> Vec<Quote> {
        self.0
    }
}

/// Builds a slice of quotes from `(k, w, weight)` triples, validating each.
///
/// # Errors
///
/// Propagates the first [`ParamError`] from [`Quote::new`].
///
/// # Examples
///
/// ```
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// use regit_svi::market::quote::quotes_from_triples;
///
/// let slice = quotes_from_triples(&[
///     (-0.10, 0.0432, 1.0),
///     ( 0.00, 0.0400, 1.0),
///     ( 0.10, 0.0420, 1.0),
/// ])?;
/// assert_eq!(slice.len(), 3);
/// # Ok(())
/// # }
/// ```
pub fn quotes_from_triples(triples: &[(f64, f64, f64)]) -> Result<Vec<Quote>, ParamError> {
    triples
        .iter()
        .map(|&(k, w, weight)| Quote::new(k, w, weight))
        .collect()
}

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

    #[test]
    fn quote_new_valid() {
        let q = Quote::new(-0.1, 0.0432, 2.0).expect("valid test or documentation fixture");
        assert!((q.k + 0.1).abs() < 1e-15);
        assert!((q.w - 0.0432).abs() < 1e-15);
        assert!((q.weight - 2.0).abs() < 1e-15);
    }

    #[test]
    fn quote_new_rejects_negative_variance() {
        assert_eq!(
            Quote::new(0.0, -0.01, 1.0),
            Err(ParamError::NegativeTotalVariance { w: -0.01 })
        );
    }

    #[test]
    fn quote_new_rejects_negative_weight() {
        assert_eq!(
            Quote::new(0.0, 0.04, -1.0),
            Err(ParamError::NegativeWeight { weight: -1.0 })
        );
    }

    #[test]
    fn quote_new_rejects_non_finite() {
        assert_eq!(
            Quote::new(f64::NAN, 0.04, 1.0),
            Err(ParamError::NonFinite { name: "k" })
        );
        assert_eq!(
            Quote::new(0.0, f64::INFINITY, 1.0),
            Err(ParamError::NonFinite { name: "w" })
        );
        assert_eq!(
            Quote::new(0.0, 0.04, f64::NAN),
            Err(ParamError::NonFinite { name: "weight" })
        );
    }

    #[test]
    fn quote_new_unchecked() {
        let q = Quote::new_unchecked(0.1, 0.05, 0.5);
        assert!((q.k - 0.1).abs() < 1e-15);
    }

    #[test]
    fn quote_implied_vol_roundtrip() {
        let q = Quote::new(0.0, 0.09, 1.0).expect("valid test or documentation fixture");
        let vol = q
            .implied_vol(1.0)
            .expect("valid test or documentation fixture");
        assert!((vol - 0.30).abs() < 1e-12);
    }

    #[test]
    fn quote_implied_vol_rejects_bad_maturity() {
        let q = Quote::new(0.0, 0.04, 1.0).expect("valid test or documentation fixture");
        assert!(matches!(
            q.implied_vol(0.0),
            Err(ParamError::NonPositiveMaturity { .. })
        ));
        assert!(matches!(
            q.implied_vol(-1.0),
            Err(ParamError::NonPositiveMaturity { .. })
        ));
    }

    #[test]
    fn quote_implied_vol_rejects_non_finite_result() {
        let quote = Quote::new(0.0, f64::MAX, 1.0).expect("valid finite quote");
        assert!(quote.implied_vol(f64::MIN_POSITIVE).is_err());
    }

    #[test]
    fn quotes_from_triples_builds_slice() {
        let slice = quotes_from_triples(&[(-0.1, 0.05, 1.0), (0.1, 0.05, 1.0)])
            .expect("valid test or documentation fixture");
        assert_eq!(slice.len(), 2);
    }

    #[test]
    fn quotes_from_triples_propagates_error() {
        let bad = quotes_from_triples(&[(0.0, -1.0, 1.0)]);
        assert!(bad.is_err());
    }

    #[test]
    fn quote_is_copy() {
        let q = Quote::new(0.0, 0.04, 1.0).expect("valid test or documentation fixture");
        let copy = q;
        assert_eq!(q, copy);
    }
}