Skip to main content

regit_svi/
error.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::{convert::Infallible, 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::error::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    /// A collection that must contain at least one observation was empty.
38    EmptyCollection {
39        /// Name of the empty collection.
40        name: &'static str,
41    },
42    /// Raw SVI slope `b` is negative.
43    NegativeSlope {
44        /// The offending value of `b`.
45        b: f64,
46    },
47    /// Raw SVI / SSVI correlation `rho` is outside `(-1, 1)`.
48    CorrelationOutOfRange {
49        /// The offending value of `rho`.
50        rho: f64,
51    },
52    /// Raw SVI curvature `sigma` is not strictly positive.
53    NonPositiveSigma {
54        /// The offending value of `sigma`.
55        sigma: f64,
56    },
57    /// The minimum total variance `a + b*sigma*sqrt(1-rho^2)` is negative,
58    /// so the slice produces negative variance somewhere.
59    NegativeMinVariance {
60        /// The minimum value of `w` over the slice.
61        w_min: f64,
62    },
63    /// A maturity `t` is not strictly positive.
64    NonPositiveMaturity {
65        /// The offending value of `t`.
66        t: f64,
67    },
68    /// A fitting weight on a market quote is negative.
69    NegativeWeight {
70        /// The offending weight.
71        weight: f64,
72    },
73    /// An observed total variance on a market quote is negative.
74    NegativeTotalVariance {
75        /// The offending total variance.
76        w: f64,
77    },
78    /// An SSVI smoothing-function parameter is outside its valid domain
79    /// (`lambda > 0`, `eta > 0`, `gamma in (0, 1)`).
80    InvalidPhiParameter {
81        /// Human-readable name of the offending parameter.
82        name: &'static str,
83        /// The offending value.
84        value: f64,
85    },
86    /// An SSVI ATM total variance `theta` is not strictly positive.
87    NonPositiveTheta {
88        /// The offending value of `theta`.
89        theta: f64,
90    },
91    /// A non-finite (`NaN` or infinite) value was supplied where a finite
92    /// number is required.
93    NonFinite {
94        /// Human-readable name of the offending input.
95        name: &'static str,
96    },
97    /// Values expected in strictly increasing order were unordered or duplicated.
98    NotStrictlyIncreasing {
99        /// Name of the coordinate.
100        name: &'static str,
101        /// Index of the offending value.
102        index: usize,
103        /// Previous value.
104        previous: f64,
105        /// Offending value.
106        value: f64,
107    },
108    /// An ATM total-variance term structure decreased with maturity.
109    DecreasingAtmVariance {
110        /// Index of the offending knot.
111        index: usize,
112        /// Previous ATM total variance.
113        previous: f64,
114        /// Offending ATM total variance.
115        value: f64,
116    },
117}
118
119impl fmt::Display for ParamError {
120    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121        match self {
122            Self::EmptyCollection { name } => write!(f, "{name} must not be empty"),
123            Self::NegativeSlope { b } => {
124                write!(f, "raw SVI slope b must be non-negative, got {b}")
125            }
126            Self::CorrelationOutOfRange { rho } => {
127                write!(f, "correlation rho must lie in (-1, 1), got {rho}")
128            }
129            Self::NonPositiveSigma { sigma } => {
130                write!(f, "raw SVI curvature sigma must be positive, got {sigma}")
131            }
132            Self::NegativeMinVariance { w_min } => {
133                write!(
134                    f,
135                    "minimum total variance must be non-negative, got w_min = {w_min}"
136                )
137            }
138            Self::NonPositiveMaturity { t } => {
139                write!(f, "maturity t must be positive, got {t}")
140            }
141            Self::NegativeWeight { weight } => {
142                write!(f, "quote weight must be non-negative, got {weight}")
143            }
144            Self::NegativeTotalVariance { w } => {
145                write!(f, "quoted total variance must be non-negative, got {w}")
146            }
147            Self::InvalidPhiParameter { name, value } => {
148                write!(f, "SSVI phi parameter {name} is out of range: {value}")
149            }
150            Self::NonPositiveTheta { theta } => {
151                write!(f, "SSVI ATM variance theta must be positive, got {theta}")
152            }
153            Self::NonFinite { name } => {
154                write!(f, "input {name} must be a finite number")
155            }
156            Self::NotStrictlyIncreasing {
157                name,
158                index,
159                previous,
160                value,
161            } => write!(
162                f,
163                "{name} must be strictly increasing; index {index} has {value} after {previous}"
164            ),
165            Self::DecreasingAtmVariance {
166                index,
167                previous,
168                value,
169            } => write!(
170                f,
171                "ATM total variance must be non-decreasing; index {index} has {value} after {previous}"
172            ),
173        }
174    }
175}
176
177impl std::error::Error for ParamError {}
178
179impl From<Infallible> for ParamError {
180    fn from(value: Infallible) -> Self {
181        match value {}
182    }
183}
184
185// ─── Conversion errors ───────────────────────────────────────────────────────
186
187/// Error returned when a parametrisation conversion has no valid pre-image.
188///
189/// The Raw <-> Jump-Wings map is bijective only on a subset of JW space: a
190/// JW tuple with `|beta| > 1` (where `beta = rho - 2*psi*sqrt(w)/b`) does not
191/// correspond to any raw SVI slice — see MATH.md §4.
192///
193/// # Examples
194///
195/// ```
196/// use regit_svi::error::ConvertError;
197///
198/// let err = ConvertError::JwHasNoRawPreimage { beta: 1.4 };
199/// let msg = format!("{err}");
200/// assert!(msg.contains("1.4"));
201/// ```
202#[derive(Debug, Clone, Copy, PartialEq)]
203pub enum ConvertError {
204    /// The Jump-Wings tuple yields `|beta| > 1`, so no raw SVI slice exists.
205    JwHasNoRawPreimage {
206        /// The computed value of `beta`.
207        beta: f64,
208    },
209    /// A wing slope `p_t` or `c_t` is negative, which has no raw pre-image.
210    NegativeWingSlope {
211        /// Human-readable name of the offending wing slope.
212        name: &'static str,
213        /// The offending value.
214        value: f64,
215    },
216    /// The Jump-Wings ATM total variance `v_t * t` is not strictly positive.
217    NonPositiveAtmVariance {
218        /// The computed ATM total variance.
219        w: f64,
220    },
221    /// A degenerate intermediate (`b = 0` or `c_t + p_t = 0`) makes the
222    /// inverse map indeterminate.
223    DegenerateJw,
224    /// A parameter error surfaced while constructing the converted slice.
225    Param(ParamError),
226}
227
228impl fmt::Display for ConvertError {
229    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
230        match self {
231            Self::JwHasNoRawPreimage { beta } => {
232                write!(
233                    f,
234                    "Jump-Wings tuple has no raw SVI pre-image: |beta| > 1, beta = {beta}"
235                )
236            }
237            Self::NegativeWingSlope { name, value } => {
238                write!(
239                    f,
240                    "Jump-Wings wing slope {name} must be non-negative, got {value}"
241                )
242            }
243            Self::NonPositiveAtmVariance { w } => {
244                write!(f, "Jump-Wings ATM total variance must be positive, got {w}")
245            }
246            Self::DegenerateJw => {
247                write!(
248                    f,
249                    "Jump-Wings tuple is degenerate: inverse map is indeterminate"
250                )
251            }
252            Self::Param(e) => write!(f, "converted slice is invalid: {e}"),
253        }
254    }
255}
256
257impl std::error::Error for ConvertError {
258    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
259        match self {
260            Self::Param(e) => Some(e),
261            _ => None,
262        }
263    }
264}
265
266impl From<ParamError> for ConvertError {
267    fn from(e: ParamError) -> Self {
268        Self::Param(e)
269    }
270}
271
272// ─── Calibration errors ──────────────────────────────────────────────────────
273
274/// Error returned when a calibrator cannot produce a usable fit.
275///
276/// Covers insufficient data, non-convergence of the outer optimiser, and any
277/// parameter error surfaced while assembling the calibrated slice.
278///
279/// # Examples
280///
281/// ```
282/// use regit_svi::error::CalibrationError;
283///
284/// let err = CalibrationError::InsufficientEffectiveQuotes { usable: 2, distinct: 2, need: 5 };
285/// let msg = format!("{err}");
286/// assert!(msg.contains("2"));
287/// assert!(msg.contains("5"));
288/// ```
289#[derive(Debug, Clone, Copy, PartialEq)]
290pub enum CalibrationError {
291    /// The supplied quote set is empty.
292    EmptyQuotes,
293    /// The outer optimiser reached its iteration cap without converging.
294    DidNotConverge {
295        /// Number of iterations performed.
296        iterations: usize,
297        /// The final residual norm.
298        residual: f64,
299    },
300    /// All fitting weights are zero, so the objective is identically zero.
301    AllWeightsZero,
302    /// Too few usable or sufficiently distinct positive-weight observations.
303    InsufficientEffectiveQuotes {
304        /// Number of positive-weight observations.
305        usable: usize,
306        /// Number of sufficiently distinct strikes.
307        distinct: usize,
308        /// Required minimum for both counts.
309        need: usize,
310    },
311    /// Too few distinct positive ATM-variance levels identify the requested
312    /// surface family.
313    InsufficientThetaLevels {
314        /// Number of distinct positive theta levels supplied.
315        got: usize,
316        /// Minimum number required by the surface family.
317        need: usize,
318    },
319    /// A calibration control lies outside its supported domain.
320    InvalidConfig {
321        /// Name of the invalid control.
322        field: &'static str,
323        /// Offending value.
324        value: f64,
325    },
326    /// A constrained search ended without a model satisfying a hard condition.
327    Infeasible {
328        /// Failed feasibility condition.
329        condition: &'static str,
330        /// Signed condition margin when available.
331        margin: f64,
332    },
333    /// A parameter error surfaced while assembling the calibrated slice.
334    Param(ParamError),
335}
336
337impl fmt::Display for CalibrationError {
338    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
339        match self {
340            Self::EmptyQuotes => write!(f, "quote set is empty"),
341            Self::DidNotConverge {
342                iterations,
343                residual,
344            } => {
345                write!(
346                    f,
347                    "calibration did not converge after {iterations} iterations, residual = {residual}"
348                )
349            }
350            Self::AllWeightsZero => write!(f, "all fitting weights are zero"),
351            Self::InsufficientEffectiveQuotes {
352                usable,
353                distinct,
354                need,
355            } => write!(
356                f,
357                "insufficient effective quotes: {usable} positive-weight, {distinct} distinct, need {need}"
358            ),
359            Self::InsufficientThetaLevels { got, need } => write!(
360                f,
361                "insufficient distinct ATM-variance levels: got {got}, need at least {need}"
362            ),
363            Self::InvalidConfig { field, value } => {
364                write!(f, "invalid calibration control {field} = {value}")
365            }
366            Self::Infeasible { condition, margin } => {
367                write!(
368                    f,
369                    "constrained calibration failed {condition}, margin = {margin}"
370                )
371            }
372            Self::Param(e) => write!(f, "calibrated slice is invalid: {e}"),
373        }
374    }
375}
376
377impl std::error::Error for CalibrationError {
378    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
379        match self {
380            Self::Param(e) => Some(e),
381            _ => None,
382        }
383    }
384}
385
386impl From<ParamError> for CalibrationError {
387    fn from(e: ParamError) -> Self {
388        Self::Param(e)
389    }
390}
391
392#[cfg(test)]
393#[allow(clippy::expect_used)] // Validated fixtures use contextual expectations.
394mod tests {
395    use super::*;
396
397    #[test]
398    fn param_error_display_negative_slope() {
399        let err = ParamError::NegativeSlope { b: -0.1 };
400        assert_eq!(
401            format!("{err}"),
402            "raw SVI slope b must be non-negative, got -0.1"
403        );
404    }
405
406    #[test]
407    fn param_error_display_correlation() {
408        let err = ParamError::CorrelationOutOfRange { rho: 1.5 };
409        assert!(format!("{err}").contains("1.5"));
410    }
411
412    #[test]
413    fn param_error_display_non_positive_sigma() {
414        let err = ParamError::NonPositiveSigma { sigma: 0.0 };
415        assert!(format!("{err}").contains("sigma"));
416    }
417
418    #[test]
419    fn param_error_display_negative_min_variance() {
420        let err = ParamError::NegativeMinVariance { w_min: -0.01 };
421        assert!(format!("{err}").contains("w_min"));
422    }
423
424    #[test]
425    fn param_error_display_remaining_variants() {
426        assert!(format!("{}", ParamError::NonPositiveMaturity { t: 0.0 }).contains("maturity"));
427        assert!(format!("{}", ParamError::NegativeWeight { weight: -1.0 }).contains("weight"));
428        assert!(
429            format!("{}", ParamError::NegativeTotalVariance { w: -0.1 }).contains("total variance")
430        );
431        assert!(
432            format!(
433                "{}",
434                ParamError::InvalidPhiParameter {
435                    name: "eta",
436                    value: -1.0
437                }
438            )
439            .contains("eta")
440        );
441        assert!(format!("{}", ParamError::NonPositiveTheta { theta: 0.0 }).contains("theta"));
442        assert!(format!("{}", ParamError::NonFinite { name: "k" }).contains("finite"));
443    }
444
445    #[test]
446    fn param_error_is_error_trait() {
447        let err: &dyn std::error::Error = &ParamError::NegativeSlope { b: -1.0 };
448        assert!(err.source().is_none());
449    }
450
451    #[test]
452    fn param_error_copy_eq() {
453        let err = ParamError::NonFinite { name: "x" };
454        let copy = err;
455        assert_eq!(err, copy);
456    }
457
458    #[test]
459    fn convert_error_display() {
460        let err = ConvertError::JwHasNoRawPreimage { beta: 1.4 };
461        assert!(format!("{err}").contains("1.4"));
462        let err = ConvertError::NegativeWingSlope {
463            name: "p_t",
464            value: -1.0,
465        };
466        assert!(format!("{err}").contains("p_t"));
467        assert!(format!("{}", ConvertError::DegenerateJw).contains("degenerate"));
468        assert!(
469            format!("{}", ConvertError::NonPositiveAtmVariance { w: -0.1 }).contains("positive")
470        );
471    }
472
473    #[test]
474    fn convert_error_from_param_and_source() {
475        let pe = ParamError::NegativeSlope { b: -1.0 };
476        let ce: ConvertError = pe.into();
477        assert!(matches!(ce, ConvertError::Param(_)));
478        let dyn_err: &dyn std::error::Error = &ce;
479        assert!(dyn_err.source().is_some());
480    }
481
482    #[test]
483    fn calibration_error_display() {
484        let err = CalibrationError::InsufficientEffectiveQuotes {
485            usable: 2,
486            distinct: 2,
487            need: 5,
488        };
489        let msg = format!("{err}");
490        assert!(msg.contains('2') && msg.contains('5'));
491        assert!(format!("{}", CalibrationError::EmptyQuotes).contains("empty"));
492        assert!(
493            format!(
494                "{}",
495                CalibrationError::DidNotConverge {
496                    iterations: 100,
497                    residual: 1e-3
498                }
499            )
500            .contains("converge")
501        );
502        assert!(format!("{}", CalibrationError::AllWeightsZero).contains("weights"));
503    }
504
505    #[test]
506    fn calibration_error_from_param_and_source() {
507        let pe = ParamError::NonPositiveSigma { sigma: 0.0 };
508        let ce: CalibrationError = pe.into();
509        assert!(matches!(ce, CalibrationError::Param(_)));
510        let dyn_err: &dyn std::error::Error = &ce;
511        assert!(dyn_err.source().is_some());
512    }
513
514    #[test]
515    fn errors_debug() {
516        assert!(format!("{:?}", ParamError::NonFinite { name: "k" }).contains("NonFinite"));
517        assert!(format!("{:?}", ConvertError::DegenerateJw).contains("Degenerate"));
518        assert!(format!("{:?}", CalibrationError::EmptyQuotes).contains("Empty"));
519    }
520}