Skip to main content

regit_svi/
types.rs

1// Copyright 2026 Regit.io — Nicolas Koenig
2// SPDX-License-Identifier: Apache-2.0
3
4//! Core market types: log-moneyness, total implied variance, and quotes.
5//!
6//! SVI parametrises one maturity slice at a time. Fix a time to expiry
7//! `T > 0` and a forward price `F`. For a strike `K` the **log-moneyness**
8//! is `k = ln(K / F)`, with `k = 0` at-the-money-forward.
9//!
10//! SVI does not parametrise Black implied volatility directly — it
11//! parametrises the **total implied variance**:
12//!
13//! ```text
14//! w(k) = sigma_BS(k)^2 * T
15//! ```
16//!
17//! `w` is the natural object: it is additive in maturity for a flat surface,
18//! the no-arbitrage conditions take their simplest form in `w`, and `w >= 0`
19//! is the only domain requirement. Implied volatility is recovered by
20//! `sigma_BS(k) = sqrt(w(k) / T)`.
21//!
22//! # References
23//!
24//! - Gatheral, J., *The Volatility Surface: A Practitioner's Guide*,
25//!   Wiley (2006), Chapter 3.
26
27use crate::errors::ParamError;
28
29/// A single market quote: a log-moneyness, an observed total implied
30/// variance, and a non-negative fitting weight.
31///
32/// A **slice** is a set of quotes sharing one maturity. The weight is any
33/// non-negative number expressing the relative trust placed in the quote
34/// during calibration — common choices are option vega or the inverse of the
35/// bid-ask spread. A weight of `0.0` excludes the quote from the fit.
36///
37/// # Invariants
38///
39/// Constructed through [`Quote::new`] (checked) or [`Quote::new_unchecked`]
40/// (caller-asserted), a `Quote` always satisfies `w >= 0`, `weight >= 0`, and
41/// all three fields finite.
42///
43/// # Examples
44///
45/// ```
46/// use regit_svi::types::Quote;
47///
48/// let q = Quote::new(-0.10, 0.0432, 1.0).unwrap();
49/// assert!((q.k + 0.10).abs() < 1e-15);
50/// assert!((q.w - 0.0432).abs() < 1e-15);
51/// ```
52#[derive(Debug, Clone, Copy, PartialEq)]
53pub struct Quote {
54    /// Log-moneyness `k = ln(K / F)`.
55    pub k: f64,
56    /// Observed total implied variance `w = sigma_BS^2 * T`.
57    pub w: f64,
58    /// Non-negative fitting weight (e.g. vega or inverse bid-ask spread).
59    pub weight: f64,
60}
61
62impl Quote {
63    /// Creates a validated market quote.
64    ///
65    /// # Errors
66    ///
67    /// - [`ParamError::NonFinite`] if any field is `NaN` or infinite.
68    /// - [`ParamError::NegativeTotalVariance`] if `w < 0`.
69    /// - [`ParamError::NegativeWeight`] if `weight < 0`.
70    ///
71    /// # Examples
72    ///
73    /// ```
74    /// use regit_svi::types::Quote;
75    /// use regit_svi::errors::ParamError;
76    ///
77    /// assert!(Quote::new(0.0, 0.04, 1.0).is_ok());
78    /// assert_eq!(
79    ///     Quote::new(0.0, -0.04, 1.0),
80    ///     Err(ParamError::NegativeTotalVariance { w: -0.04 }),
81    /// );
82    /// ```
83    pub fn new(k: f64, w: f64, weight: f64) -> Result<Self, ParamError> {
84        if !k.is_finite() {
85            return Err(ParamError::NonFinite { name: "k" });
86        }
87        if !w.is_finite() {
88            return Err(ParamError::NonFinite { name: "w" });
89        }
90        if !weight.is_finite() {
91            return Err(ParamError::NonFinite { name: "weight" });
92        }
93        if w < 0.0 {
94            return Err(ParamError::NegativeTotalVariance { w });
95        }
96        if weight < 0.0 {
97            return Err(ParamError::NegativeWeight { weight });
98        }
99        Ok(Self { k, w, weight })
100    }
101
102    /// Creates a quote without validation.
103    ///
104    /// The caller asserts `w >= 0`, `weight >= 0`, and all fields finite.
105    /// Used on hot paths where the inputs are already known to be valid.
106    ///
107    /// # Examples
108    ///
109    /// ```
110    /// use regit_svi::types::Quote;
111    ///
112    /// let q = Quote::new_unchecked(0.0, 0.04, 1.0);
113    /// assert!((q.w - 0.04).abs() < 1e-15);
114    /// ```
115    #[must_use]
116    pub const fn new_unchecked(k: f64, w: f64, weight: f64) -> Self {
117        Self { k, w, weight }
118    }
119
120    /// Returns the Black implied volatility implied by this quote at maturity
121    /// `t`, i.e. `sqrt(w / t)`.
122    ///
123    /// # Errors
124    ///
125    /// Returns [`ParamError::NonPositiveMaturity`] if `t <= 0`.
126    ///
127    /// # Examples
128    ///
129    /// ```
130    /// use regit_svi::types::Quote;
131    ///
132    /// let q = Quote::new(0.0, 0.04, 1.0).unwrap();
133    /// let vol = q.implied_vol(1.0).unwrap();
134    /// assert!((vol - 0.20).abs() < 1e-12);
135    /// ```
136    pub fn implied_vol(&self, t: f64) -> Result<f64, ParamError> {
137        if t <= 0.0 || !t.is_finite() {
138            return Err(ParamError::NonPositiveMaturity { t });
139        }
140        Ok((self.w / t).sqrt())
141    }
142}
143
144/// Builds a slice of quotes from `(k, w, weight)` triples, validating each.
145///
146/// # Errors
147///
148/// Propagates the first [`ParamError`] from [`Quote::new`].
149///
150/// # Examples
151///
152/// ```
153/// use regit_svi::types::quotes_from_triples;
154///
155/// let slice = quotes_from_triples(&[
156///     (-0.10, 0.0432, 1.0),
157///     ( 0.00, 0.0400, 1.0),
158///     ( 0.10, 0.0420, 1.0),
159/// ]).unwrap();
160/// assert_eq!(slice.len(), 3);
161/// ```
162pub fn quotes_from_triples(triples: &[(f64, f64, f64)]) -> Result<Vec<Quote>, ParamError> {
163    triples
164        .iter()
165        .map(|&(k, w, weight)| Quote::new(k, w, weight))
166        .collect()
167}
168
169/// Converts an implied volatility to a total implied variance: `sigma^2 * t`.
170///
171/// # Examples
172///
173/// ```
174/// use regit_svi::types::total_variance_from_vol;
175///
176/// let w = total_variance_from_vol(0.20, 1.0);
177/// assert!((w - 0.04).abs() < 1e-15);
178/// ```
179#[must_use]
180#[inline]
181pub fn total_variance_from_vol(vol: f64, t: f64) -> f64 {
182    vol * vol * t
183}
184
185/// Converts a log-moneyness `k = ln(K / F)` from a strike and forward.
186///
187/// # Examples
188///
189/// ```
190/// use regit_svi::types::log_moneyness;
191///
192/// let k = log_moneyness(110.0, 100.0);
193/// assert!((k - (110.0_f64 / 100.0).ln()).abs() < 1e-15);
194/// ```
195#[must_use]
196#[inline]
197pub fn log_moneyness(strike: f64, forward: f64) -> f64 {
198    (strike / forward).ln()
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use crate::errors::ParamError;
205
206    #[test]
207    fn quote_new_valid() {
208        let q = Quote::new(-0.1, 0.0432, 2.0).unwrap();
209        assert!((q.k + 0.1).abs() < 1e-15);
210        assert!((q.w - 0.0432).abs() < 1e-15);
211        assert!((q.weight - 2.0).abs() < 1e-15);
212    }
213
214    #[test]
215    fn quote_new_rejects_negative_variance() {
216        assert_eq!(
217            Quote::new(0.0, -0.01, 1.0),
218            Err(ParamError::NegativeTotalVariance { w: -0.01 })
219        );
220    }
221
222    #[test]
223    fn quote_new_rejects_negative_weight() {
224        assert_eq!(
225            Quote::new(0.0, 0.04, -1.0),
226            Err(ParamError::NegativeWeight { weight: -1.0 })
227        );
228    }
229
230    #[test]
231    fn quote_new_rejects_non_finite() {
232        assert_eq!(
233            Quote::new(f64::NAN, 0.04, 1.0),
234            Err(ParamError::NonFinite { name: "k" })
235        );
236        assert_eq!(
237            Quote::new(0.0, f64::INFINITY, 1.0),
238            Err(ParamError::NonFinite { name: "w" })
239        );
240        assert_eq!(
241            Quote::new(0.0, 0.04, f64::NAN),
242            Err(ParamError::NonFinite { name: "weight" })
243        );
244    }
245
246    #[test]
247    fn quote_new_unchecked() {
248        let q = Quote::new_unchecked(0.1, 0.05, 0.5);
249        assert!((q.k - 0.1).abs() < 1e-15);
250    }
251
252    #[test]
253    fn quote_implied_vol_roundtrip() {
254        let q = Quote::new(0.0, 0.09, 1.0).unwrap();
255        let vol = q.implied_vol(1.0).unwrap();
256        assert!((vol - 0.30).abs() < 1e-12);
257    }
258
259    #[test]
260    fn quote_implied_vol_rejects_bad_maturity() {
261        let q = Quote::new(0.0, 0.04, 1.0).unwrap();
262        assert!(matches!(
263            q.implied_vol(0.0),
264            Err(ParamError::NonPositiveMaturity { .. })
265        ));
266        assert!(matches!(
267            q.implied_vol(-1.0),
268            Err(ParamError::NonPositiveMaturity { .. })
269        ));
270    }
271
272    #[test]
273    fn quotes_from_triples_builds_slice() {
274        let slice = quotes_from_triples(&[(-0.1, 0.05, 1.0), (0.1, 0.05, 1.0)]).unwrap();
275        assert_eq!(slice.len(), 2);
276    }
277
278    #[test]
279    fn quotes_from_triples_propagates_error() {
280        let bad = quotes_from_triples(&[(0.0, -1.0, 1.0)]);
281        assert!(bad.is_err());
282    }
283
284    #[test]
285    fn total_variance_from_vol_works() {
286        assert!((total_variance_from_vol(0.20, 2.0) - 0.08).abs() < 1e-15);
287    }
288
289    #[test]
290    fn log_moneyness_atm_is_zero() {
291        assert!(log_moneyness(100.0, 100.0).abs() < 1e-15);
292    }
293
294    #[test]
295    fn quote_is_copy() {
296        let q = Quote::new(0.0, 0.04, 1.0).unwrap();
297        let copy = q;
298        assert_eq!(q, copy);
299    }
300}