Skip to main content

regit_svi/
errors.rs

1// Copyright 2026 Regit.io — Nicolas Koenig
2// SPDX-License-Identifier: Apache-2.0
3
4//! Typed error enums for parametrisation, conversion, and calibration.
5//!
6//! All failure paths return a typed `Result` — no `panic!()`, no `unwrap()`,
7//! no string errors. Each variant carries enough context for the caller to
8//! decide how to recover.
9//!
10//! Three enums separate the three failure domains:
11//!
12//! - [`ParamError`] — invalid SVI / SSVI parameters or out-of-domain quotes.
13//! - [`ConvertError`] — a parametrisation conversion has no valid pre-image.
14//! - [`CalibrationError`] — a calibrator could not produce a usable fit.
15
16use core::fmt;
17
18// ─── Parametrisation errors ──────────────────────────────────────────────────
19
20/// Error returned when SVI / SSVI parameters or market quotes fail validation.
21///
22/// Every SVI parametrisation has a validity domain (raw SVI: `b >= 0`,
23/// `|rho| < 1`, `sigma > 0`, `a + b*sigma*sqrt(1-rho^2) >= 0`). A constructor
24/// or `validate` method returns one of these variants when an input lies
25/// outside that domain.
26///
27/// # Examples
28///
29/// ```
30/// use regit_svi::errors::ParamError;
31///
32/// let err = ParamError::NegativeSlope { b: -0.1 };
33/// assert_eq!(format!("{err}"), "raw SVI slope b must be non-negative, got -0.1");
34/// ```
35#[derive(Debug, Clone, Copy, PartialEq)]
36pub enum ParamError {
37    /// Raw SVI slope `b` is negative.
38    NegativeSlope {
39        /// The offending value of `b`.
40        b: f64,
41    },
42    /// Raw SVI / SSVI correlation `rho` is outside `(-1, 1)`.
43    CorrelationOutOfRange {
44        /// The offending value of `rho`.
45        rho: f64,
46    },
47    /// Raw SVI curvature `sigma` is not strictly positive.
48    NonPositiveSigma {
49        /// The offending value of `sigma`.
50        sigma: f64,
51    },
52    /// The minimum total variance `a + b*sigma*sqrt(1-rho^2)` is negative,
53    /// so the slice produces negative variance somewhere.
54    NegativeMinVariance {
55        /// The minimum value of `w` over the slice.
56        w_min: f64,
57    },
58    /// A maturity `t` is not strictly positive.
59    NonPositiveMaturity {
60        /// The offending value of `t`.
61        t: f64,
62    },
63    /// A fitting weight on a market quote is negative.
64    NegativeWeight {
65        /// The offending weight.
66        weight: f64,
67    },
68    /// An observed total variance on a market quote is negative.
69    NegativeTotalVariance {
70        /// The offending total variance.
71        w: f64,
72    },
73    /// An SSVI smoothing-function parameter is outside its valid domain
74    /// (`lambda > 0`, `eta > 0`, `gamma in (0, 1)`).
75    InvalidPhiParameter {
76        /// Human-readable name of the offending parameter.
77        name: &'static str,
78        /// The offending value.
79        value: f64,
80    },
81    /// An SSVI ATM total variance `theta` is not strictly positive.
82    NonPositiveTheta {
83        /// The offending value of `theta`.
84        theta: f64,
85    },
86    /// A non-finite (`NaN` or infinite) value was supplied where a finite
87    /// number is required.
88    NonFinite {
89        /// Human-readable name of the offending input.
90        name: &'static str,
91    },
92}
93
94impl fmt::Display for ParamError {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        match self {
97            Self::NegativeSlope { b } => {
98                write!(f, "raw SVI slope b must be non-negative, got {b}")
99            }
100            Self::CorrelationOutOfRange { rho } => {
101                write!(f, "correlation rho must lie in (-1, 1), got {rho}")
102            }
103            Self::NonPositiveSigma { sigma } => {
104                write!(f, "raw SVI curvature sigma must be positive, got {sigma}")
105            }
106            Self::NegativeMinVariance { w_min } => {
107                write!(
108                    f,
109                    "minimum total variance must be non-negative, got w_min = {w_min}"
110                )
111            }
112            Self::NonPositiveMaturity { t } => {
113                write!(f, "maturity t must be positive, got {t}")
114            }
115            Self::NegativeWeight { weight } => {
116                write!(f, "quote weight must be non-negative, got {weight}")
117            }
118            Self::NegativeTotalVariance { w } => {
119                write!(f, "quoted total variance must be non-negative, got {w}")
120            }
121            Self::InvalidPhiParameter { name, value } => {
122                write!(f, "SSVI phi parameter {name} is out of range: {value}")
123            }
124            Self::NonPositiveTheta { theta } => {
125                write!(f, "SSVI ATM variance theta must be positive, got {theta}")
126            }
127            Self::NonFinite { name } => {
128                write!(f, "input {name} must be a finite number")
129            }
130        }
131    }
132}
133
134impl std::error::Error for ParamError {}
135
136// ─── Conversion errors ───────────────────────────────────────────────────────
137
138/// Error returned when a parametrisation conversion has no valid pre-image.
139///
140/// The Raw <-> Jump-Wings map is bijective only on a subset of JW space: a
141/// JW tuple with `|beta| > 1` (where `beta = rho - 2*psi*sqrt(w)/b`) does not
142/// correspond to any raw SVI slice — see MATH.md §4.
143///
144/// # Examples
145///
146/// ```
147/// use regit_svi::errors::ConvertError;
148///
149/// let err = ConvertError::JwHasNoRawPreimage { beta: 1.4 };
150/// let msg = format!("{err}");
151/// assert!(msg.contains("1.4"));
152/// ```
153#[derive(Debug, Clone, Copy, PartialEq)]
154pub enum ConvertError {
155    /// The Jump-Wings tuple yields `|beta| > 1`, so no raw SVI slice exists.
156    JwHasNoRawPreimage {
157        /// The computed value of `beta`.
158        beta: f64,
159    },
160    /// A wing slope `p_t` or `c_t` is negative, which has no raw pre-image.
161    NegativeWingSlope {
162        /// Human-readable name of the offending wing slope.
163        name: &'static str,
164        /// The offending value.
165        value: f64,
166    },
167    /// The Jump-Wings ATM total variance `v_t * t` is not strictly positive.
168    NonPositiveAtmVariance {
169        /// The computed ATM total variance.
170        w: f64,
171    },
172    /// A degenerate intermediate (`b = 0` or `c_t + p_t = 0`) makes the
173    /// inverse map indeterminate.
174    DegenerateJw,
175    /// A parameter error surfaced while constructing the converted slice.
176    Param(ParamError),
177}
178
179impl fmt::Display for ConvertError {
180    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
181        match self {
182            Self::JwHasNoRawPreimage { beta } => {
183                write!(
184                    f,
185                    "Jump-Wings tuple has no raw SVI pre-image: |beta| > 1, beta = {beta}"
186                )
187            }
188            Self::NegativeWingSlope { name, value } => {
189                write!(
190                    f,
191                    "Jump-Wings wing slope {name} must be non-negative, got {value}"
192                )
193            }
194            Self::NonPositiveAtmVariance { w } => {
195                write!(f, "Jump-Wings ATM total variance must be positive, got {w}")
196            }
197            Self::DegenerateJw => {
198                write!(
199                    f,
200                    "Jump-Wings tuple is degenerate: inverse map is indeterminate"
201                )
202            }
203            Self::Param(e) => write!(f, "converted slice is invalid: {e}"),
204        }
205    }
206}
207
208impl std::error::Error for ConvertError {
209    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
210        match self {
211            Self::Param(e) => Some(e),
212            _ => None,
213        }
214    }
215}
216
217impl From<ParamError> for ConvertError {
218    fn from(e: ParamError) -> Self {
219        Self::Param(e)
220    }
221}
222
223// ─── Calibration errors ──────────────────────────────────────────────────────
224
225/// Error returned when a calibrator cannot produce a usable fit.
226///
227/// Covers insufficient data, non-convergence of the outer optimiser, and any
228/// parameter error surfaced while assembling the calibrated slice.
229///
230/// # Examples
231///
232/// ```
233/// use regit_svi::errors::CalibrationError;
234///
235/// let err = CalibrationError::TooFewQuotes { got: 2, need: 5 };
236/// let msg = format!("{err}");
237/// assert!(msg.contains("2"));
238/// assert!(msg.contains("5"));
239/// ```
240#[derive(Debug, Clone, Copy, PartialEq)]
241pub enum CalibrationError {
242    /// Fewer quotes were supplied than the model has free parameters.
243    TooFewQuotes {
244        /// Number of quotes supplied.
245        got: usize,
246        /// Minimum number of quotes required.
247        need: usize,
248    },
249    /// The supplied quote set is empty.
250    EmptyQuotes,
251    /// The outer optimiser reached its iteration cap without converging.
252    DidNotConverge {
253        /// Number of iterations performed.
254        iterations: usize,
255        /// The final residual norm.
256        residual: f64,
257    },
258    /// All fitting weights are zero, so the objective is identically zero.
259    AllWeightsZero,
260    /// A parameter error surfaced while assembling the calibrated slice.
261    Param(ParamError),
262}
263
264impl fmt::Display for CalibrationError {
265    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
266        match self {
267            Self::TooFewQuotes { got, need } => {
268                write!(
269                    f,
270                    "too few quotes for calibration: got {got}, need at least {need}"
271                )
272            }
273            Self::EmptyQuotes => write!(f, "quote set is empty"),
274            Self::DidNotConverge {
275                iterations,
276                residual,
277            } => {
278                write!(
279                    f,
280                    "calibration did not converge after {iterations} iterations, residual = {residual}"
281                )
282            }
283            Self::AllWeightsZero => write!(f, "all fitting weights are zero"),
284            Self::Param(e) => write!(f, "calibrated slice is invalid: {e}"),
285        }
286    }
287}
288
289impl std::error::Error for CalibrationError {
290    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
291        match self {
292            Self::Param(e) => Some(e),
293            _ => None,
294        }
295    }
296}
297
298impl From<ParamError> for CalibrationError {
299    fn from(e: ParamError) -> Self {
300        Self::Param(e)
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    #[test]
309    fn param_error_display_negative_slope() {
310        let err = ParamError::NegativeSlope { b: -0.1 };
311        assert_eq!(
312            format!("{err}"),
313            "raw SVI slope b must be non-negative, got -0.1"
314        );
315    }
316
317    #[test]
318    fn param_error_display_correlation() {
319        let err = ParamError::CorrelationOutOfRange { rho: 1.5 };
320        assert!(format!("{err}").contains("1.5"));
321    }
322
323    #[test]
324    fn param_error_display_non_positive_sigma() {
325        let err = ParamError::NonPositiveSigma { sigma: 0.0 };
326        assert!(format!("{err}").contains("sigma"));
327    }
328
329    #[test]
330    fn param_error_display_negative_min_variance() {
331        let err = ParamError::NegativeMinVariance { w_min: -0.01 };
332        assert!(format!("{err}").contains("w_min"));
333    }
334
335    #[test]
336    fn param_error_display_remaining_variants() {
337        assert!(format!("{}", ParamError::NonPositiveMaturity { t: 0.0 }).contains("maturity"));
338        assert!(format!("{}", ParamError::NegativeWeight { weight: -1.0 }).contains("weight"));
339        assert!(
340            format!("{}", ParamError::NegativeTotalVariance { w: -0.1 }).contains("total variance")
341        );
342        assert!(
343            format!(
344                "{}",
345                ParamError::InvalidPhiParameter {
346                    name: "eta",
347                    value: -1.0
348                }
349            )
350            .contains("eta")
351        );
352        assert!(format!("{}", ParamError::NonPositiveTheta { theta: 0.0 }).contains("theta"));
353        assert!(format!("{}", ParamError::NonFinite { name: "k" }).contains("finite"));
354    }
355
356    #[test]
357    fn param_error_is_error_trait() {
358        let err: &dyn std::error::Error = &ParamError::NegativeSlope { b: -1.0 };
359        assert!(err.source().is_none());
360    }
361
362    #[test]
363    fn param_error_copy_eq() {
364        let err = ParamError::NonFinite { name: "x" };
365        let copy = err;
366        assert_eq!(err, copy);
367    }
368
369    #[test]
370    fn convert_error_display() {
371        let err = ConvertError::JwHasNoRawPreimage { beta: 1.4 };
372        assert!(format!("{err}").contains("1.4"));
373        let err = ConvertError::NegativeWingSlope {
374            name: "p_t",
375            value: -1.0,
376        };
377        assert!(format!("{err}").contains("p_t"));
378        assert!(format!("{}", ConvertError::DegenerateJw).contains("degenerate"));
379        assert!(
380            format!("{}", ConvertError::NonPositiveAtmVariance { w: -0.1 }).contains("positive")
381        );
382    }
383
384    #[test]
385    fn convert_error_from_param_and_source() {
386        let pe = ParamError::NegativeSlope { b: -1.0 };
387        let ce: ConvertError = pe.into();
388        assert!(matches!(ce, ConvertError::Param(_)));
389        let dyn_err: &dyn std::error::Error = &ce;
390        assert!(dyn_err.source().is_some());
391    }
392
393    #[test]
394    fn calibration_error_display() {
395        let err = CalibrationError::TooFewQuotes { got: 2, need: 5 };
396        let msg = format!("{err}");
397        assert!(msg.contains('2') && msg.contains('5'));
398        assert!(format!("{}", CalibrationError::EmptyQuotes).contains("empty"));
399        assert!(
400            format!(
401                "{}",
402                CalibrationError::DidNotConverge {
403                    iterations: 100,
404                    residual: 1e-3
405                }
406            )
407            .contains("converge")
408        );
409        assert!(format!("{}", CalibrationError::AllWeightsZero).contains("weights"));
410    }
411
412    #[test]
413    fn calibration_error_from_param_and_source() {
414        let pe = ParamError::NonPositiveSigma { sigma: 0.0 };
415        let ce: CalibrationError = pe.into();
416        assert!(matches!(ce, CalibrationError::Param(_)));
417        let dyn_err: &dyn std::error::Error = &ce;
418        assert!(dyn_err.source().is_some());
419    }
420
421    #[test]
422    fn errors_debug() {
423        assert!(format!("{:?}", ParamError::NonFinite { name: "k" }).contains("NonFinite"));
424        assert!(format!("{:?}", ConvertError::DegenerateJw).contains("Degenerate"));
425        assert!(format!("{:?}", CalibrationError::EmptyQuotes).contains("Empty"));
426    }
427}