Skip to main content

regit_curves/
errors.rs

1// Copyright 2026 Regit.io — Nicolas Koenig
2// SPDX-License-Identifier: Apache-2.0
3
4//! Typed error enums for the three failure domains of `regit-curves`.
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//! - [`TypeError`] — invalid `Date`, `Tenor`, or `Daycount` queries.
13//! - [`CurveError`] — invalid curve construction or evaluation.
14//! - [`BootstrapError`] — bootstrap engine failures (non-convergence,
15//!   ordering, instrument-level invariants).
16//!
17//! Natural conversions are provided via `From`:
18//!
19//! - `TypeError -> CurveError` (variant [`CurveError::Type`])
20//! - `TypeError -> BootstrapError` (variant [`BootstrapError::Type`])
21//! - `CurveError -> BootstrapError` (variant [`BootstrapError::Curve`])
22//!
23//! # References
24//!
25//! - Hagan, P. S. & West, G., "Interpolation methods for curve construction",
26//!   *Applied Mathematical Finance* 13(2):89-129 (2006). Section 2 motivates
27//!   the curve invariants enforced by [`CurveError`].
28//! - ISDA, *2006 ISDA Definitions*, §4.16. Day-count edge cases that
29//!   [`TypeError`] reports.
30
31use core::fmt;
32
33// ─── Type-construction errors ────────────────────────────────────────────────
34
35/// Error returned when constructing or querying a basic numeric / temporal
36/// type ([`Date`](crate::types::Date), [`Tenor`](crate::types::Tenor),
37/// [`Daycount`](crate::types::Daycount)).
38///
39/// # Examples
40///
41/// ```
42/// use regit_curves::errors::TypeError;
43///
44/// let err = TypeError::InvalidDate { year: 2023, month: 2, day: 30 };
45/// assert_eq!(
46///     format!("{err}"),
47///     "invalid calendar date: 2023-02-30",
48/// );
49/// ```
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
51pub enum TypeError {
52    /// [`Date::from_ymd`](crate::types::Date::from_ymd) received an invalid
53    /// calendar date (out-of-range month/day, or a day that does not exist
54    /// in the given month — e.g. February 30).
55    InvalidDate {
56        /// The supplied year.
57        year: i32,
58        /// The supplied month.
59        month: u32,
60        /// The supplied day.
61        day: u32,
62    },
63    /// A non-positive day count was requested where a positive value is
64    /// required (e.g. day-count year fraction across a zero or negative
65    /// range, or a degenerate compounding `t <= 0` for `rate_from_discount`).
66    NonPositiveRange,
67    /// A non-finite (`NaN` or infinite) number was supplied where a finite
68    /// value is required.
69    NonFinite {
70        /// Human-readable name of the offending input.
71        name: &'static str,
72    },
73    /// A frequency or tenor count is invalid (zero, negative for a unit that
74    /// requires positive, or otherwise out of range — including
75    /// `Daycount::Business252` queried without a calendar).
76    InvalidTenor {
77        /// Human-readable reason.
78        reason: &'static str,
79    },
80}
81
82impl fmt::Display for TypeError {
83    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84        match self {
85            Self::InvalidDate { year, month, day } => {
86                write!(f, "invalid calendar date: {year:04}-{month:02}-{day:02}")
87            }
88            Self::NonPositiveRange => write!(f, "day-count range must be strictly positive"),
89            Self::NonFinite { name } => write!(f, "input {name} must be a finite number"),
90            Self::InvalidTenor { reason } => write!(f, "invalid tenor: {reason}"),
91        }
92    }
93}
94
95impl std::error::Error for TypeError {}
96
97// ─── Curve construction / evaluation errors ──────────────────────────────────
98
99/// Error returned when constructing or evaluating a discount curve.
100///
101/// # Examples
102///
103/// ```
104/// use regit_curves::errors::CurveError;
105///
106/// let err = CurveError::TooFewNodes { found: 1 };
107/// assert_eq!(
108///     format!("{err}"),
109///     "curve needs at least two nodes, found 1",
110/// );
111/// ```
112#[derive(Debug, Clone, Copy, PartialEq)]
113pub enum CurveError {
114    /// Fewer than two nodes were supplied.
115    TooFewNodes {
116        /// Number of nodes that were supplied.
117        found: usize,
118    },
119    /// Node times are not strictly increasing.
120    NodesNotIncreasing {
121        /// Index of the first node that breaks the invariant.
122        at_index: usize,
123    },
124    /// A discount factor at a node was not strictly positive.
125    NonPositiveDiscount {
126        /// Index of the offending node.
127        at_index: usize,
128        /// The offending value.
129        value: f64,
130    },
131    /// The first node is not at `t = 0` or its discount factor is not `1`.
132    AnchorNotUnit,
133    /// `t` is negative or non-finite.
134    InvalidTime {
135        /// The offending value of `t`.
136        t: f64,
137    },
138    /// A day-count query failed.
139    Type(TypeError),
140    /// Two construction points have the same `t` (used in slope / spline
141    /// construction).
142    DuplicateNode {
143        /// The duplicated time.
144        t: f64,
145    },
146}
147
148impl fmt::Display for CurveError {
149    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150        match self {
151            Self::TooFewNodes { found } => {
152                write!(f, "curve needs at least two nodes, found {found}")
153            }
154            Self::NodesNotIncreasing { at_index } => {
155                write!(f, "node times not strictly increasing at index {at_index}")
156            }
157            Self::NonPositiveDiscount { at_index, value } => {
158                write!(
159                    f,
160                    "discount factor at node {at_index} must be positive, got {value}"
161                )
162            }
163            Self::AnchorNotUnit => write!(f, "anchor node must be (t=0, D=1)"),
164            Self::InvalidTime { t } => write!(f, "invalid time t = {t}"),
165            Self::Type(e) => write!(f, "type error in curve query: {e}"),
166            Self::DuplicateNode { t } => write!(f, "duplicate node at t = {t}"),
167        }
168    }
169}
170
171impl std::error::Error for CurveError {
172    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
173        match self {
174            Self::Type(e) => Some(e),
175            _ => None,
176        }
177    }
178}
179
180impl From<TypeError> for CurveError {
181    fn from(e: TypeError) -> Self {
182        Self::Type(e)
183    }
184}
185
186// ─── Bootstrap engine errors ────────────────────────────────────────────────
187
188/// Error returned by the bootstrap engine.
189///
190/// # Examples
191///
192/// ```
193/// use regit_curves::errors::BootstrapError;
194///
195/// let err = BootstrapError::LegDidNotConverge { at_index: 3, residual: 1.2e-9 };
196/// let msg = format!("{err}");
197/// assert!(msg.contains("3"));
198/// assert!(msg.contains("converge"));
199/// ```
200#[derive(Debug, Clone, Copy, PartialEq)]
201pub enum BootstrapError {
202    /// Instruments are not ordered by their primary anchor time, or two
203    /// instruments share an anchor with no resolution rule.
204    InstrumentsNotOrdered {
205        /// Index of the first instrument that breaks the ordering.
206        at_index: usize,
207    },
208    /// An instrument's primary anchor is on or before the previous anchor.
209    NonIncreasingAnchor {
210        /// Index of the offending instrument.
211        at_index: usize,
212    },
213    /// The leg solver did not converge to a discount factor that re-prices
214    /// the instrument within `tolerance` after `max_iterations`.
215    LegDidNotConverge {
216        /// Index of the leg that failed to converge.
217        at_index: usize,
218        /// The final residual reached.
219        residual: f64,
220    },
221    /// Brent could not bracket a root for the leg.
222    NoBracket {
223        /// Index of the leg with no bracket.
224        at_index: usize,
225    },
226    /// An instrument quote violates a domain constraint (rate not finite,
227    /// negative when a positive value is required, etc.).
228    InvalidInstrument {
229        /// Index of the offending instrument.
230        at_index: usize,
231        /// Human-readable reason.
232        reason: &'static str,
233    },
234    /// A curve error surfaced while building the interim curve.
235    Curve(CurveError),
236    /// A type error surfaced.
237    Type(TypeError),
238}
239
240impl fmt::Display for BootstrapError {
241    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
242        match self {
243            Self::InstrumentsNotOrdered { at_index } => {
244                write!(f, "instruments not ordered at index {at_index}")
245            }
246            Self::NonIncreasingAnchor { at_index } => {
247                write!(
248                    f,
249                    "instrument anchor not strictly increasing at index {at_index}"
250                )
251            }
252            Self::LegDidNotConverge { at_index, residual } => {
253                write!(
254                    f,
255                    "bootstrap leg {at_index} did not converge: residual {residual:e}"
256                )
257            }
258            Self::NoBracket { at_index } => {
259                write!(f, "bootstrap leg {at_index} could not bracket a root")
260            }
261            Self::InvalidInstrument { at_index, reason } => {
262                write!(f, "invalid instrument at index {at_index}: {reason}")
263            }
264            Self::Curve(e) => write!(f, "curve error during bootstrap: {e}"),
265            Self::Type(e) => write!(f, "type error during bootstrap: {e}"),
266        }
267    }
268}
269
270impl std::error::Error for BootstrapError {
271    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
272        match self {
273            Self::Curve(e) => Some(e),
274            Self::Type(e) => Some(e),
275            _ => None,
276        }
277    }
278}
279
280impl From<TypeError> for BootstrapError {
281    fn from(e: TypeError) -> Self {
282        Self::Type(e)
283    }
284}
285
286impl From<CurveError> for BootstrapError {
287    fn from(e: CurveError) -> Self {
288        Self::Curve(e)
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    // ─── TypeError ───────────────────────────────────────────────────────
297
298    #[test]
299    fn type_error_display_invalid_date() {
300        let err = TypeError::InvalidDate {
301            year: 2023,
302            month: 2,
303            day: 30,
304        };
305        assert_eq!(format!("{err}"), "invalid calendar date: 2023-02-30");
306    }
307
308    #[test]
309    fn type_error_display_non_positive_range() {
310        let err = TypeError::NonPositiveRange;
311        assert!(format!("{err}").contains("positive"));
312    }
313
314    #[test]
315    fn type_error_display_non_finite() {
316        let err = TypeError::NonFinite { name: "rate" };
317        assert!(format!("{err}").contains("rate"));
318        assert!(format!("{err}").contains("finite"));
319    }
320
321    #[test]
322    fn type_error_display_invalid_tenor() {
323        let err = TypeError::InvalidTenor {
324            reason: "Business252 requires a calendar",
325        };
326        assert!(format!("{err}").contains("Business252"));
327    }
328
329    #[test]
330    fn type_error_is_error_trait() {
331        let err: &dyn std::error::Error = &TypeError::NonPositiveRange;
332        assert!(err.source().is_none());
333    }
334
335    #[test]
336    fn type_error_copy_eq_hash() {
337        let err = TypeError::NonFinite { name: "rate" };
338        let copy = err;
339        assert_eq!(err, copy);
340        // Hash is derived; we use it via a HashSet check.
341        let mut set = std::collections::HashSet::new();
342        set.insert(err);
343        assert!(set.contains(&copy));
344    }
345
346    #[test]
347    fn type_error_debug() {
348        assert!(format!("{:?}", TypeError::NonPositiveRange).contains("NonPositiveRange"));
349    }
350
351    // ─── CurveError ──────────────────────────────────────────────────────
352
353    #[test]
354    fn curve_error_display_all_variants() {
355        assert!(format!("{}", CurveError::TooFewNodes { found: 1 }).contains("two nodes"));
356        assert!(format!("{}", CurveError::NodesNotIncreasing { at_index: 4 }).contains('4'));
357        assert!(
358            format!(
359                "{}",
360                CurveError::NonPositiveDiscount {
361                    at_index: 2,
362                    value: -0.5,
363                }
364            )
365            .contains("-0.5")
366        );
367        assert!(format!("{}", CurveError::AnchorNotUnit).contains("anchor"));
368        assert!(format!("{}", CurveError::InvalidTime { t: -1.0 }).contains("-1"));
369        assert!(format!("{}", CurveError::DuplicateNode { t: 0.5 }).contains("0.5"));
370        assert!(
371            format!("{}", CurveError::Type(TypeError::NonPositiveRange)).contains("type error")
372        );
373    }
374
375    #[test]
376    fn curve_error_from_type_and_source() {
377        let te = TypeError::NonPositiveRange;
378        let ce: CurveError = te.into();
379        assert!(matches!(ce, CurveError::Type(_)));
380        let dyn_err: &dyn std::error::Error = &ce;
381        assert!(dyn_err.source().is_some());
382    }
383
384    #[test]
385    fn curve_error_no_source_for_plain_variants() {
386        let ce = CurveError::AnchorNotUnit;
387        let dyn_err: &dyn std::error::Error = &ce;
388        assert!(dyn_err.source().is_none());
389    }
390
391    #[test]
392    fn curve_error_copy_eq() {
393        let err = CurveError::TooFewNodes { found: 0 };
394        let copy = err;
395        assert_eq!(err, copy);
396    }
397
398    #[test]
399    fn curve_error_debug() {
400        assert!(format!("{:?}", CurveError::AnchorNotUnit).contains("AnchorNotUnit"));
401    }
402
403    // ─── BootstrapError ──────────────────────────────────────────────────
404
405    #[test]
406    fn bootstrap_error_display_all_variants() {
407        assert!(format!("{}", BootstrapError::InstrumentsNotOrdered { at_index: 2 }).contains('2'));
408        assert!(format!("{}", BootstrapError::NonIncreasingAnchor { at_index: 5 }).contains('5'));
409        let m = format!(
410            "{}",
411            BootstrapError::LegDidNotConverge {
412                at_index: 3,
413                residual: 1.2e-9,
414            }
415        );
416        assert!(m.contains('3'));
417        assert!(m.contains("converge"));
418        assert!(format!("{}", BootstrapError::NoBracket { at_index: 7 }).contains('7'));
419        assert!(
420            format!(
421                "{}",
422                BootstrapError::InvalidInstrument {
423                    at_index: 1,
424                    reason: "negative rate",
425                }
426            )
427            .contains("negative rate")
428        );
429        assert!(
430            format!("{}", BootstrapError::Curve(CurveError::AnchorNotUnit)).contains("curve error")
431        );
432        assert!(
433            format!("{}", BootstrapError::Type(TypeError::NonPositiveRange)).contains("type error")
434        );
435    }
436
437    #[test]
438    fn bootstrap_error_from_type() {
439        let te = TypeError::NonPositiveRange;
440        let be: BootstrapError = te.into();
441        assert!(matches!(be, BootstrapError::Type(_)));
442        let dyn_err: &dyn std::error::Error = &be;
443        assert!(dyn_err.source().is_some());
444    }
445
446    #[test]
447    fn bootstrap_error_from_curve() {
448        let ce = CurveError::AnchorNotUnit;
449        let be: BootstrapError = ce.into();
450        assert!(matches!(be, BootstrapError::Curve(_)));
451        let dyn_err: &dyn std::error::Error = &be;
452        assert!(dyn_err.source().is_some());
453    }
454
455    #[test]
456    fn bootstrap_error_no_source_for_plain_variants() {
457        let be = BootstrapError::NoBracket { at_index: 0 };
458        let dyn_err: &dyn std::error::Error = &be;
459        assert!(dyn_err.source().is_none());
460    }
461
462    #[test]
463    fn bootstrap_error_copy_eq() {
464        let err = BootstrapError::InstrumentsNotOrdered { at_index: 0 };
465        let copy = err;
466        assert_eq!(err, copy);
467    }
468
469    #[test]
470    fn bootstrap_error_debug() {
471        assert!(format!("{:?}", BootstrapError::NoBracket { at_index: 0 }).contains("NoBracket"));
472    }
473
474    #[test]
475    fn bootstrap_error_chained_from_type_through_curve_is_not_automatic() {
476        // Validate that we use the direct `From<TypeError>` rather than going
477        // via `CurveError`, so the boxed source is `TypeError` (not wrapped).
478        let te = TypeError::NonPositiveRange;
479        let be: BootstrapError = te.into();
480        assert!(matches!(be, BootstrapError::Type(_)));
481    }
482}