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

//! Validated SSVI ATM total-variance term structures.

use crate::error::ParamError;
use crate::market::units::{Maturity, TotalVariance};

/// Strictly maturity-ordered, non-decreasing ATM total-variance knots.
///
/// # Examples
///
/// ```
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// use regit_svi::TermStructure;
///
/// let term = TermStructure::try_from(vec![(0.5, 0.02), (1.0, 0.04)])?;
/// assert_eq!(term.len(), 2);
/// assert!(TermStructure::try_from(vec![(1.0, 0.04), (0.5, 0.02)]).is_err());
/// # Ok(())
/// # }
/// ```
#[derive(Debug, Clone, PartialEq)]
pub struct TermStructure(Vec<(Maturity, TotalVariance)>);

impl TermStructure {
    /// Creates a term structure without sorting or repairing its input.
    ///
    /// # Errors
    ///
    /// Returns [`ParamError::EmptyCollection`] for empty input,
    /// [`ParamError::NonPositiveTheta`] for a zero theta,
    /// [`ParamError::NotStrictlyIncreasing`] for unordered or duplicate
    /// maturities, or [`ParamError::DecreasingAtmVariance`] for decreasing ATM
    /// total variance. Typed knots already guarantee finiteness and maturity
    /// positivity.
    pub fn new(knots: Vec<(Maturity, TotalVariance)>) -> Result<Self, ParamError> {
        if knots.is_empty() {
            return Err(ParamError::EmptyCollection {
                name: "term structure",
            });
        }
        for &(_, theta) in &knots {
            if theta.get() <= 0.0 {
                return Err(ParamError::NonPositiveTheta { theta: theta.get() });
            }
        }
        for (index, pair) in knots.windows(2).enumerate() {
            if pair[1].0 <= pair[0].0 {
                return Err(ParamError::NotStrictlyIncreasing {
                    name: "maturity",
                    index: index + 1,
                    previous: pair[0].0.get(),
                    value: pair[1].0.get(),
                });
            }
            if pair[1].1 < pair[0].1 {
                return Err(ParamError::DecreasingAtmVariance {
                    index: index + 1,
                    previous: pair[0].1.get(),
                    value: pair[1].1.get(),
                });
            }
        }
        Ok(Self(knots))
    }

    /// Returns the validated knots.
    #[must_use]
    pub fn knots(&self) -> &[(Maturity, TotalVariance)] {
        &self.0
    }

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

    /// Returns whether there are no knots; validated values are never empty.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.0.is_empty()
    }
}

impl TryFrom<Vec<(f64, f64)>> for TermStructure {
    type Error = ParamError;

    fn try_from(knots: Vec<(f64, f64)>) -> Result<Self, Self::Error> {
        let typed = knots
            .into_iter()
            .map(|(t, theta)| Ok((Maturity::new(t)?, TotalVariance::new(theta)?)))
            .collect::<Result<Vec<_>, ParamError>>()?;
        Self::new(typed)
    }
}

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

    #[test]
    fn rejects_unordered_and_decreasing_knots() {
        assert!(TermStructure::try_from(vec![(0.5, 0.02), (1.0, 0.04)]).is_ok());
        assert!(matches!(
            TermStructure::try_from(vec![(1.0, 0.04), (0.5, 0.02)]),
            Err(ParamError::NotStrictlyIncreasing { .. })
        ));
        assert!(matches!(
            TermStructure::try_from(vec![(0.5, 0.04), (1.0, 0.02)]),
            Err(ParamError::DecreasingAtmVariance { .. })
        ));
    }
}