Skip to main content

regit_blackscholes/
errors.rs

1// Copyright 2026 Regit.io — Nicolas Koenig
2// SPDX-License-Identifier: Apache-2.0
3
4//! Typed error enums for pricing and implied volatility operations.
5//!
6//! All failure paths return typed `Result` — no `panic!()`, no `unwrap()`,
7//! no string errors. Each variant carries enough context for the caller
8//! to decide how to recover.
9
10use core::fmt;
11
12/// Error returned by pricing functions when input validation fails
13/// or the option is at a degenerate boundary.
14///
15/// # Variants
16///
17/// All variants indicate a precondition violation except `IntrinsicOnly`,
18/// which signals a valid but degenerate edge case (`T == 0`).
19///
20/// # Examples
21///
22/// ```
23/// use regit_blackscholes::errors::PricingError;
24///
25/// let err = PricingError::NegativeSpot;
26/// assert_eq!(format!("{err}"), "spot price must be non-negative");
27/// ```
28#[derive(Debug, Clone, Copy, PartialEq)]
29pub enum PricingError {
30    /// Spot price `S` is negative.
31    NegativeSpot,
32    /// Strike price `K` is negative.
33    NegativeStrike,
34    /// Time to expiry `T` is negative.
35    NegativeTime,
36    /// Volatility `sigma` is negative.
37    NegativeVolatility,
38    /// Time to expiry is zero — the option value equals the discounted
39    /// intrinsic value. The `intrinsic` field carries that value so the
40    /// caller can use it without re-computing.
41    IntrinsicOnly {
42        /// The discounted intrinsic value of the option at expiry.
43        intrinsic: f64,
44    },
45}
46
47impl fmt::Display for PricingError {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        match self {
50            Self::NegativeSpot => write!(f, "spot price must be non-negative"),
51            Self::NegativeStrike => write!(f, "strike price must be non-negative"),
52            Self::NegativeTime => write!(f, "time to expiry must be non-negative"),
53            Self::NegativeVolatility => write!(f, "volatility must be non-negative"),
54            Self::IntrinsicOnly { intrinsic } => {
55                write!(f, "option at expiry: intrinsic value = {intrinsic}")
56            }
57        }
58    }
59}
60
61impl std::error::Error for PricingError {}
62
63/// Error returned by implied volatility solvers when convergence fails
64/// or the market price is inconsistent with the model.
65///
66/// # Variants
67///
68/// Each variant carries diagnostic context so the caller can decide
69/// whether to retry with a different solver or report the failure.
70///
71/// # Examples
72///
73/// ```
74/// use regit_blackscholes::errors::IvError;
75///
76/// let err = IvError::NoSolution;
77/// assert_eq!(format!("{err}"), "no implied volatility solution exists");
78/// ```
79#[derive(Debug, Clone, Copy, PartialEq)]
80pub enum IvError {
81    /// No implied volatility solution exists for the given market price.
82    NoSolution,
83    /// Market price is below the intrinsic value — no valid vol can produce it.
84    BelowIntrinsic {
85        /// The intrinsic value that the market price falls below.
86        intrinsic: f64,
87    },
88    /// Solver reached maximum iteration count without converging.
89    MaxIterationsReached {
90        /// The last volatility estimate before iteration stopped.
91        last_vol: f64,
92        /// The residual (price error) at the last iteration.
93        residual: f64,
94    },
95    /// Vega is near zero — the solver cannot make progress because
96    /// the price surface is flat with respect to volatility.
97    NearZeroVega,
98    /// The implied volatility solution exceeds the search bounds `[1e-8, 100.0]`.
99    BoundsExceeded {
100        /// The volatility value that exceeded the bounds.
101        vol: f64,
102    },
103}
104
105impl fmt::Display for IvError {
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        match self {
108            Self::NoSolution => write!(f, "no implied volatility solution exists"),
109            Self::BelowIntrinsic { intrinsic } => {
110                write!(f, "market price is below intrinsic value ({intrinsic})")
111            }
112            Self::MaxIterationsReached { last_vol, residual } => {
113                write!(
114                    f,
115                    "IV solver did not converge: last_vol = {last_vol}, residual = {residual}"
116                )
117            }
118            Self::NearZeroVega => write!(f, "vega is near zero — solver cannot make progress"),
119            Self::BoundsExceeded { vol } => {
120                write!(f, "implied volatility {vol} exceeds search bounds")
121            }
122        }
123    }
124}
125
126impl std::error::Error for IvError {}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn test_pricing_error_display_negative_spot() {
134        let err = PricingError::NegativeSpot;
135        assert_eq!(format!("{err}"), "spot price must be non-negative");
136    }
137
138    #[test]
139    fn test_pricing_error_display_negative_strike() {
140        let err = PricingError::NegativeStrike;
141        assert_eq!(format!("{err}"), "strike price must be non-negative");
142    }
143
144    #[test]
145    fn test_pricing_error_display_negative_time() {
146        let err = PricingError::NegativeTime;
147        assert_eq!(format!("{err}"), "time to expiry must be non-negative");
148    }
149
150    #[test]
151    fn test_pricing_error_display_negative_volatility() {
152        let err = PricingError::NegativeVolatility;
153        assert_eq!(format!("{err}"), "volatility must be non-negative");
154    }
155
156    #[test]
157    fn test_pricing_error_display_intrinsic_only() {
158        let err = PricingError::IntrinsicOnly {
159            intrinsic: 5.25_f64,
160        };
161        assert_eq!(format!("{err}"), "option at expiry: intrinsic value = 5.25");
162    }
163
164    #[test]
165    fn test_pricing_error_is_error_trait() {
166        let err: &dyn std::error::Error = &PricingError::NegativeSpot;
167        assert!(err.source().is_none());
168    }
169
170    #[test]
171    fn test_pricing_error_clone_copy() {
172        let err = PricingError::NegativeSpot;
173        let err2 = err;
174        assert_eq!(err, err2);
175    }
176
177    #[test]
178    fn test_pricing_error_debug() {
179        let err = PricingError::NegativeSpot;
180        let debug = format!("{err:?}");
181        assert!(debug.contains("NegativeSpot"));
182    }
183
184    #[test]
185    fn test_iv_error_display_no_solution() {
186        let err = IvError::NoSolution;
187        assert_eq!(format!("{err}"), "no implied volatility solution exists");
188    }
189
190    #[test]
191    fn test_iv_error_display_below_intrinsic() {
192        let err = IvError::BelowIntrinsic {
193            intrinsic: 10.0_f64,
194        };
195        assert_eq!(
196            format!("{err}"),
197            "market price is below intrinsic value (10)"
198        );
199    }
200
201    #[test]
202    fn test_iv_error_display_max_iterations() {
203        let err = IvError::MaxIterationsReached {
204            last_vol: 0.25_f64,
205            residual: 0.001_f64,
206        };
207        let msg = format!("{err}");
208        assert!(msg.contains("last_vol = 0.25"));
209        assert!(msg.contains("residual = 0.001"));
210    }
211
212    #[test]
213    fn test_iv_error_display_near_zero_vega() {
214        let err = IvError::NearZeroVega;
215        let msg = format!("{err}");
216        assert!(msg.contains("vega is near zero"));
217    }
218
219    #[test]
220    fn test_iv_error_display_bounds_exceeded() {
221        let err = IvError::BoundsExceeded { vol: 150.0_f64 };
222        let msg = format!("{err}");
223        assert!(msg.contains("150"));
224        assert!(msg.contains("exceeds search bounds"));
225    }
226
227    #[test]
228    fn test_iv_error_is_error_trait() {
229        let err: &dyn std::error::Error = &IvError::NoSolution;
230        assert!(err.source().is_none());
231    }
232
233    #[test]
234    fn test_iv_error_clone_copy() {
235        let err = IvError::NearZeroVega;
236        let err2 = err;
237        assert_eq!(err, err2);
238    }
239
240    #[test]
241    fn test_iv_error_debug() {
242        let err = IvError::BoundsExceeded { vol: 200.0_f64 };
243        let debug = format!("{err:?}");
244        assert!(debug.contains("BoundsExceeded"));
245    }
246
247    #[test]
248    fn test_pricing_error_eq() {
249        assert_eq!(PricingError::NegativeSpot, PricingError::NegativeSpot);
250        assert_ne!(PricingError::NegativeSpot, PricingError::NegativeStrike);
251    }
252
253    #[test]
254    fn test_iv_error_eq() {
255        assert_eq!(IvError::NoSolution, IvError::NoSolution);
256        assert_ne!(IvError::NoSolution, IvError::NearZeroVega);
257    }
258
259    #[test]
260    fn test_pricing_error_intrinsic_only_zero() {
261        let err = PricingError::IntrinsicOnly { intrinsic: 0.0_f64 };
262        if let PricingError::IntrinsicOnly { intrinsic } = err {
263            assert!((intrinsic - 0.0_f64).abs() < 1e-15_f64);
264        }
265    }
266
267    #[test]
268    fn test_iv_error_max_iterations_fields() {
269        let err = IvError::MaxIterationsReached {
270            last_vol: 0.3_f64,
271            residual: 1e-8_f64,
272        };
273        if let IvError::MaxIterationsReached { last_vol, residual } = err {
274            assert!((last_vol - 0.3_f64).abs() < 1e-15_f64);
275            assert!((residual - 1e-8_f64).abs() < 1e-20_f64);
276        }
277    }
278}