Skip to main content

optionstratlib_pricing/error/
volatility.rs

1/******************************************************************************
2   Author: Joaquín Béjar García
3   Email: jb@taunais.com
4   Date: 19/1/25
5******************************************************************************/
6
7//! Target crate (ADR-0001 D6, roadmap M1-14): **pricing**. Owns `VolatilityError`.
8
9use optionstratlib_core::error::OptionsError;
10use optionstratlib_core::model::Positive;
11use thiserror::Error;
12
13/// Represents errors that can occur during volatility-related calculations.
14///
15/// This enum encapsulates various error conditions that may arise during implied volatility
16/// calculations, volatility surface generation, or other volatility-related operations.
17/// It provides detailed context about what went wrong, including invalid inputs, numerical
18/// issues, and convergence failures.
19///
20/// `VolatilityError` is particularly useful for diagnosing problems in option pricing
21/// models that rely on volatility parameters, such as Black-Scholes or binomial models.
22#[derive(Error, Debug)]
23pub enum VolatilityError {
24    /// Error indicating that a price value is invalid for volatility calculations.
25    ///
26    /// This variant is used when a price input doesn't meet the requirements
27    /// for volatility calculation, such as being negative or outside valid bounds.
28    #[error("Invalid price {price}: {reason}")]
29    InvalidPrice {
30        /// The invalid price value that caused the error.
31        price: Positive,
32        /// A description explaining why the price is invalid.
33        reason: String,
34    },
35
36    /// Error indicating that a time value is invalid for volatility calculations.
37    ///
38    /// This occurs when time parameters (such as time to expiration) are invalid
39    /// for volatility calculations, for example being negative or too large.
40    #[error("Invalid time {time}: {reason}")]
41    InvalidTime {
42        /// The invalid time value that caused the error.
43        time: Positive,
44        /// A description explaining why the time value is invalid.
45        reason: String,
46    },
47
48    /// Error indicating that the vega value is zero.
49    ///
50    /// This occurs when attempting to calculate implied volatility using the
51    /// Newton-Raphson method and vega is zero, making it impossible to converge.
52    #[error("Vega is zero, cannot calculate implied volatility")]
53    ZeroVega,
54
55    /// Error related to option calculations.
56    #[error(transparent)]
57    Options(#[from] OptionsError),
58
59    /// Error related to vega calculations or usage.
60    ///
61    /// This represents more general issues with vega calculations beyond just
62    /// zero values.
63    #[error("Error calculating vega: {reason}")]
64    VegaError {
65        /// A description of what went wrong with the vega calculation.
66        reason: String,
67    },
68
69    /// Error related to option calculations or parameters.
70    ///
71    /// This represents issues with the underlying option model or parameters
72    /// that prevent proper volatility calculation.
73    #[error("Option error: {reason}")]
74    OptionError {
75        /// A description of what went wrong with the option calculation.
76        reason: String,
77    },
78
79    /// Error indicating that an iterative volatility calculation failed to converge.
80    ///
81    /// This typically occurs in numerical methods like Newton-Raphson or bisection
82    /// when trying to solve for implied volatility.
83    #[error("No convergence after {iterations} iterations. Last volatility: {last_volatility}")]
84    NoConvergence {
85        /// The number of iterations that were performed before giving up.
86        iterations: u32,
87        /// The last volatility value that was calculated before giving up.
88        last_volatility: Positive,
89    },
90
91    /// No implied-volatility candidate produced a price match after the grid search.
92    #[error("implied volatility not found within search grid")]
93    IvNotFound,
94
95    /// None of the generated volatility samples produced a valid price.
96    #[error("no valid volatility sample produced a finite price")]
97    NoValidVolatility,
98
99    /// A numerical precision or representation failure inside a Heston-style
100    /// volatility simulation kernel (e.g. `sqrt` overflow, `f64`/`Decimal` bridge).
101    #[error("volatility simulation numerical failure: {reason}")]
102    NumericalFailure {
103        /// Human-readable description of which numerical step failed.
104        reason: String,
105    },
106
107    /// The ATM implied volatility is unavailable; the boxed inner source
108    /// describes the failing lookup.
109    #[error("ATM implied volatility is not available: {source}")]
110    AtmIvUnavailable {
111        /// Underlying error that explains why the ATM IV could not be retrieved.
112        #[source]
113        source: Box<VolatilityError>,
114    },
115
116    /// Positive value errors
117    #[error(transparent)]
118    PositiveError(#[from] optionstratlib_core::model::PositiveError),
119
120    /// Decimal arithmetic failures propagated from monetary-flow
121    /// computations in volatility kernels (overflow, division by
122    /// zero, conversion).
123    ///
124    /// Produced when a checked Decimal helper in `model::decimal`
125    /// surfaces a [`optionstratlib_core::error::DecimalError`] from inside a
126    /// volatility routine (for example `constant_volatility`,
127    /// `ewma_volatility`, or `garch_volatility`). Propagated
128    /// transparently via the `#[from]` cascade so callers keep
129    /// matching `VolatilityError`.
130    #[error(transparent)]
131    DecimalError(#[from] optionstratlib_core::error::DecimalError),
132
133    /// A volatility kernel produced a non-finite `f64` value (`NaN` /
134    /// `±∞`) at an `f64` → `Decimal` boundary.
135    ///
136    /// Emitted by the Newton-Raphson implied-volatility solver, the
137    /// bisection fallback, Heston simulation discretisations, and
138    /// any other volatility routine that bridges `f64`
139    /// `sqrt`/`ln`/`exp` results back into `Decimal`. `context` is a
140    /// static call-site tag following the same convention as
141    /// [`optionstratlib_core::error::DecimalError::Overflow`].
142    #[error("volatility non-finite {context}: {value}")]
143    NonFinite {
144        /// Static tag identifying the kernel and step that produced
145        /// the non-finite value.
146        context: &'static str,
147        /// The offending `f64` value (`NaN`, `+∞`, or `-∞`).
148        value: f64,
149    },
150}
151
152impl VolatilityError {
153    /// Creates a [`VolatilityError::NonFinite`] from a static call-site
154    /// tag and the offending `f64` value.
155    #[cold]
156    #[inline(never)]
157    #[must_use]
158    pub fn non_finite(context: &'static str, value: f64) -> Self {
159        VolatilityError::NonFinite { context, value }
160    }
161}
162
163#[cfg(test)]
164mod tests_volatility_errors {
165    use super::*;
166    use crate::error::GreeksError;
167    use crate::error::greeks::InputErrorKind;
168    use optionstratlib_core::error::OptionsError;
169    use optionstratlib_core::pos_or_panic;
170
171    #[test]
172    fn test_invalid_price_error() {
173        let error = VolatilityError::InvalidPrice {
174            price: Positive::ZERO,
175            reason: "Price cannot be zero".to_string(),
176        };
177
178        assert_eq!(error.to_string(), "Invalid price 0: Price cannot be zero");
179    }
180
181    #[test]
182    fn test_invalid_time_error() {
183        let error = VolatilityError::InvalidTime {
184            time: Positive::ZERO,
185            reason: "Time cannot be zero".to_string(),
186        };
187
188        assert_eq!(error.to_string(), "Invalid time 0: Time cannot be zero");
189    }
190
191    #[test]
192    fn test_zero_vega_error() {
193        let error = VolatilityError::ZeroVega;
194
195        assert_eq!(
196            error.to_string(),
197            "Vega is zero, cannot calculate implied volatility"
198        );
199    }
200
201    #[test]
202    fn test_vega_error() {
203        let error = VolatilityError::VegaError {
204            reason: "Failed to calculate vega".to_string(),
205        };
206
207        assert_eq!(
208            error.to_string(),
209            "Error calculating vega: Failed to calculate vega"
210        );
211    }
212
213    #[test]
214    fn test_option_error() {
215        let error = VolatilityError::OptionError {
216            reason: "Invalid option parameters".to_string(),
217        };
218
219        assert_eq!(error.to_string(), "Option error: Invalid option parameters");
220    }
221
222    #[test]
223    fn test_no_convergence_error() {
224        let error = VolatilityError::NoConvergence {
225            iterations: 100,
226            last_volatility: pos_or_panic!(0.5),
227        };
228
229        assert_eq!(
230            error.to_string(),
231            "No convergence after 100 iterations. Last volatility: 0.5"
232        );
233    }
234
235    #[test]
236    fn test_a_greeks_failure_is_reported_as_a_numerical_failure() {
237        // Volatility and Greeks are both pricing capabilities, but the IV
238        // solver reports its own failure rather than re-wrapping the Greek
239        // one (#511); the reason still reaches the caller.
240        let greeks_error = GreeksError::InputError(InputErrorKind::InvalidVolatility {
241            value: 0.0,
242            reason: "Volatility cannot be zero".to_string(),
243        });
244
245        let implied_vol_error = VolatilityError::NumericalFailure {
246            reason: format!("vega: {greeks_error}"),
247        };
248
249        match implied_vol_error {
250            VolatilityError::NumericalFailure { reason } => {
251                assert!(reason.contains("Volatility cannot be zero"), "{reason}");
252            }
253            other => panic!("expected NumericalFailure, got {other:?}"),
254        }
255    }
256
257    #[test]
258    fn test_from_options_error() {
259        let options_error = OptionsError::validation_error("strike", "Invalid option parameters");
260
261        let implied_vol_error: VolatilityError = options_error.into();
262
263        match implied_vol_error {
264            VolatilityError::Options(_) => {
265                // Conversion successful
266            }
267            _ => panic!("Wrong error variant"),
268        }
269    }
270
271    #[test]
272    fn test_iv_not_found() {
273        let error = VolatilityError::IvNotFound;
274        assert_eq!(
275            error.to_string(),
276            "implied volatility not found within search grid"
277        );
278    }
279
280    #[test]
281    fn test_no_valid_volatility() {
282        let error = VolatilityError::NoValidVolatility;
283        assert_eq!(
284            error.to_string(),
285            "no valid volatility sample produced a finite price"
286        );
287    }
288
289    #[test]
290    fn test_atm_iv_unavailable() {
291        let error = VolatilityError::AtmIvUnavailable {
292            source: Box::new(VolatilityError::IvNotFound),
293        };
294        assert!(
295            error
296                .to_string()
297                .contains("ATM implied volatility is not available")
298        );
299    }
300
301    #[test]
302    fn test_error_is_send() {
303        fn assert_send<T: Send>() {}
304        assert_send::<VolatilityError>();
305    }
306
307    #[test]
308    fn test_error_is_sync() {
309        fn assert_sync<T: Sync>() {}
310        assert_sync::<VolatilityError>();
311    }
312}