Skip to main content

regit_svi/surface/
term_structure.rs

1// Copyright 2026 Regit.io — Nicolas Koenig
2// SPDX-License-Identifier: Apache-2.0
3
4//! Validated SSVI ATM total-variance term structures.
5
6use crate::error::ParamError;
7use crate::market::units::{Maturity, TotalVariance};
8
9/// Strictly maturity-ordered, non-decreasing ATM total-variance knots.
10///
11/// # Examples
12///
13/// ```
14/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
15/// use regit_svi::TermStructure;
16///
17/// let term = TermStructure::try_from(vec![(0.5, 0.02), (1.0, 0.04)])?;
18/// assert_eq!(term.len(), 2);
19/// assert!(TermStructure::try_from(vec![(1.0, 0.04), (0.5, 0.02)]).is_err());
20/// # Ok(())
21/// # }
22/// ```
23#[derive(Debug, Clone, PartialEq)]
24pub struct TermStructure(Vec<(Maturity, TotalVariance)>);
25
26impl TermStructure {
27    /// Creates a term structure without sorting or repairing its input.
28    ///
29    /// # Errors
30    ///
31    /// Returns [`ParamError::EmptyCollection`] for empty input,
32    /// [`ParamError::NonPositiveTheta`] for a zero theta,
33    /// [`ParamError::NotStrictlyIncreasing`] for unordered or duplicate
34    /// maturities, or [`ParamError::DecreasingAtmVariance`] for decreasing ATM
35    /// total variance. Typed knots already guarantee finiteness and maturity
36    /// positivity.
37    pub fn new(knots: Vec<(Maturity, TotalVariance)>) -> Result<Self, ParamError> {
38        if knots.is_empty() {
39            return Err(ParamError::EmptyCollection {
40                name: "term structure",
41            });
42        }
43        for &(_, theta) in &knots {
44            if theta.get() <= 0.0 {
45                return Err(ParamError::NonPositiveTheta { theta: theta.get() });
46            }
47        }
48        for (index, pair) in knots.windows(2).enumerate() {
49            if pair[1].0 <= pair[0].0 {
50                return Err(ParamError::NotStrictlyIncreasing {
51                    name: "maturity",
52                    index: index + 1,
53                    previous: pair[0].0.get(),
54                    value: pair[1].0.get(),
55                });
56            }
57            if pair[1].1 < pair[0].1 {
58                return Err(ParamError::DecreasingAtmVariance {
59                    index: index + 1,
60                    previous: pair[0].1.get(),
61                    value: pair[1].1.get(),
62                });
63            }
64        }
65        Ok(Self(knots))
66    }
67
68    /// Returns the validated knots.
69    #[must_use]
70    pub fn knots(&self) -> &[(Maturity, TotalVariance)] {
71        &self.0
72    }
73
74    /// Returns the number of knots.
75    #[must_use]
76    pub fn len(&self) -> usize {
77        self.0.len()
78    }
79
80    /// Returns whether there are no knots; validated values are never empty.
81    #[must_use]
82    pub fn is_empty(&self) -> bool {
83        self.0.is_empty()
84    }
85}
86
87impl TryFrom<Vec<(f64, f64)>> for TermStructure {
88    type Error = ParamError;
89
90    fn try_from(knots: Vec<(f64, f64)>) -> Result<Self, Self::Error> {
91        let typed = knots
92            .into_iter()
93            .map(|(t, theta)| Ok((Maturity::new(t)?, TotalVariance::new(theta)?)))
94            .collect::<Result<Vec<_>, ParamError>>()?;
95        Self::new(typed)
96    }
97}
98
99#[cfg(test)]
100#[allow(clippy::expect_used)] // Validated fixtures use contextual expectations.
101mod tests {
102    use super::*;
103
104    #[test]
105    fn rejects_unordered_and_decreasing_knots() {
106        assert!(TermStructure::try_from(vec![(0.5, 0.02), (1.0, 0.04)]).is_ok());
107        assert!(matches!(
108            TermStructure::try_from(vec![(1.0, 0.04), (0.5, 0.02)]),
109            Err(ParamError::NotStrictlyIncreasing { .. })
110        ));
111        assert!(matches!(
112            TermStructure::try_from(vec![(0.5, 0.04), (1.0, 0.02)]),
113            Err(ParamError::DecreasingAtmVariance { .. })
114        ));
115    }
116}