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}