finance_solution/util/error.rs
1//! Error types and validation for finance calculations.
2//!
3//! # API contract (v0.1+)
4//!
5//! All **public** financial computations return [`FinanceResult`] — that is,
6//! `Result<T, FinanceError>`. Invalid rates, non-finite amounts, empty series, and
7//! similar domain failures are **values**, not panics.
8//!
9//! Compose with `?` or `match` on variants for field-level recovery (e.g. highlight
10//! only the rate widget in a UI).
11//!
12//! ```
13//! use finance_solution::{future_value, FinanceError, FinanceResult};
14//!
15//! fn project(pv: f64, years: u32) -> FinanceResult<f64> {
16//! future_value(0.07, years, pv, false)
17//! }
18//!
19//! assert!(project(-5_000.0, 5).is_ok());
20//!
21//! match future_value(-1.5, 10, 1_000.0, false) {
22//! Err(FinanceError::InvalidRate { rate }) => assert!(rate < -1.0),
23//! other => panic!("expected InvalidRate, got {other:?}"),
24//! }
25//! ```
26//!
27//! # Why structured errors
28//!
29//! 1. **Safe composition** — handlers and batch jobs can skip one bad input without aborting.
30//! 2. **Matchable variants** — `InvalidRate` vs `NonFinite` vs `EmptyInput` for metrics and UX.
31//! 3. **`Display` + `Error` + [`code`](FinanceError::code)** — logs, `?` into app error types, telemetry keys.
32//! 4. **Same formulas** — success paths match the historical math; only failure mode changed.
33//!
34//! # Stable codes
35//!
36//! [`FinanceError::code`] returns a snake_case token suitable for metrics (e.g. `"invalid_rate"`).
37use std::fmt;
38
39/// Result alias for fallible finance functions.
40///
41/// Equivalent to `Result<T, FinanceError>`. Prefer this in public signatures.
42///
43/// # Examples
44/// ```
45/// use finance_solution::{future_value, FinanceResult};
46///
47/// fn grow(pv: f64) -> FinanceResult<f64> {
48/// future_value(0.05, 10, pv, false)
49/// }
50///
51/// assert!(grow(-1_000.0).is_ok());
52/// assert!(grow(f64::NAN).is_err());
53/// ```
54pub type FinanceResult<T> = Result<T, FinanceError>;
55
56/// Domain and input errors from finance calculations.
57///
58/// Marked `non_exhaustive` so new variants can appear in minor releases without
59/// breaking downstream `match` expressions that include a wildcard arm.
60///
61/// # Examples
62///
63/// Pattern-match for recovery or user-facing messages:
64///
65/// ```
66/// use finance_solution::{payment, FinanceError};
67///
68/// match payment(-1.5, 36, 10_000.0, 0.0, false) {
69/// Ok(fv) => println!("fv = {fv}"),
70/// Err(FinanceError::InvalidRate { rate }) => {
71/// assert!(rate < -1.0);
72/// println!("bad rate: {rate} (code={})", FinanceError::InvalidRate { rate }.code());
73/// }
74/// Err(FinanceError::NonFinite { field, value }) => {
75/// println!("{field} was non-finite ({value})");
76/// }
77/// Err(e) => println!("other finance error: {e}"),
78/// }
79/// ```
80///
81/// Propagate with `?`:
82///
83/// ```
84/// use finance_solution::{present_value, future_value, FinanceResult};
85///
86/// fn round_trip(rate: f64, n: u32, fv: f64) -> FinanceResult<f64> {
87/// let pv = present_value(rate, n, fv, false)?;
88/// future_value(rate, n, pv, false)
89/// }
90///
91/// let back = round_trip(0.04, 5, 10_000.0).unwrap();
92/// assert!((back.abs() - 10_000.0).abs() < 1e-6);
93/// assert!(round_trip(-2.0, 5, 10_000.0).is_err());
94/// ```
95///
96/// Zero-value failure (present value of a zero future value is undefined):
97///
98/// ```
99/// use finance_solution::{present_value, FinanceError};
100///
101/// match present_value(0.05, 10, 0.0, false) {
102/// Err(FinanceError::ZeroValue { field }) => assert_eq!(field, "future_value"),
103/// other => panic!("expected ZeroValue, got {other:?}"),
104/// }
105/// ```
106#[non_exhaustive]
107#[derive(Clone, Debug, PartialEq)]
108pub enum FinanceError {
109 /// A numeric field was NaN or infinite.
110 NonFinite { field: &'static str, value: f64 },
111 /// Periodic rate outside the allowed domain for the formula (typically `< -1.0` or `<= -1.0`).
112 InvalidRate { rate: f64 },
113 /// Period index or count is out of range for the calculation.
114 InvalidPeriod {
115 period: u32,
116 periods: u32,
117 message: &'static str,
118 },
119 /// A required money amount was zero (or subnormal) when a nonzero value is required.
120 ZeroValue { field: &'static str },
121 /// Present and future value have the same sign when opposite signs are required.
122 SameSignValues {
123 present_value: f64,
124 future_value: f64,
125 },
126 /// Inputs make the equation unsolvable (e.g. zero periods with nonzero cash difference).
127 Unsolvable { message: &'static str },
128 /// Cashflow / payment constraint violated (sign, missing values, etc.).
129 InvalidCashflow { message: &'static str },
130 /// A required collection or series was empty.
131 EmptyInput { what: &'static str },
132 /// Two series or slices that must align have different lengths.
133 LengthMismatch {
134 left: usize,
135 right: usize,
136 context: &'static str,
137 },
138}
139
140impl FinanceError {
141 /// Stable snake_case code for logs and metrics (not localized).
142 pub fn code(&self) -> &'static str {
143 match self {
144 FinanceError::NonFinite { .. } => "non_finite",
145 FinanceError::InvalidRate { .. } => "invalid_rate",
146 FinanceError::InvalidPeriod { .. } => "invalid_period",
147 FinanceError::ZeroValue { .. } => "zero_value",
148 FinanceError::SameSignValues { .. } => "same_sign_values",
149 FinanceError::Unsolvable { .. } => "unsolvable",
150 FinanceError::InvalidCashflow { .. } => "invalid_cashflow",
151 FinanceError::EmptyInput { .. } => "empty_input",
152 FinanceError::LengthMismatch { .. } => "length_mismatch",
153 }
154 }
155}
156
157impl fmt::Display for FinanceError {
158 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159 match self {
160 FinanceError::NonFinite { field, value } => {
161 write!(
162 f,
163 "{field} must be finite (not NaN or infinity); got {value}"
164 )
165 }
166 FinanceError::InvalidRate { rate } => {
167 write!(
168 f,
169 "rate is outside the allowed domain for this formula; got {rate}"
170 )
171 }
172 FinanceError::InvalidPeriod {
173 period,
174 periods,
175 message,
176 } => {
177 write!(f, "{message} (period={period}, periods={periods})")
178 }
179 FinanceError::ZeroValue { field } => {
180 write!(f, "{field} must be nonzero for this calculation")
181 }
182 FinanceError::SameSignValues {
183 present_value,
184 future_value,
185 } => {
186 write!(
187 f,
188 "present_value ({present_value}) and future_value ({future_value}) must have opposite signs"
189 )
190 }
191 FinanceError::Unsolvable { message } => write!(f, "{message}"),
192 FinanceError::InvalidCashflow { message } => write!(f, "{message}"),
193 FinanceError::EmptyInput { what } => write!(f, "{what} must not be empty"),
194 FinanceError::LengthMismatch {
195 left,
196 right,
197 context,
198 } => {
199 write!(f, "{context}: length mismatch ({left} vs {right})")
200 }
201 }
202 }
203}
204
205impl std::error::Error for FinanceError {}
206
207// ---------------------------------------------------------------------------
208// Validators (compose with ?)
209// ---------------------------------------------------------------------------
210
211/// Ensure a value is finite.
212pub(crate) fn require_finite(field: &'static str, value: f64) -> FinanceResult<()> {
213 if value.is_finite() {
214 Ok(())
215 } else {
216 Err(FinanceError::NonFinite { field, value })
217 }
218}
219
220/// Ensure rate is finite and `>= -1.0` (TVM simple/continuous formulas).
221pub(crate) fn require_rate(rate: f64) -> FinanceResult<()> {
222 require_finite("rate", rate)?;
223 if rate < -1.0 {
224 Err(FinanceError::InvalidRate { rate })
225 } else {
226 Ok(())
227 }
228}
229
230/// Ensure rate is finite and strictly greater than -1.0 (payment / annuity formulas).
231pub(crate) fn require_rate_gt_minus_one(rate: f64) -> FinanceResult<()> {
232 require_finite("rate", rate)?;
233 if rate <= -1.0 {
234 Err(FinanceError::InvalidRate { rate })
235 } else {
236 Ok(())
237 }
238}
239
240/// Ensure a money-like amount is finite (present value, future value, payment, etc.).
241pub(crate) fn require_money(field: &'static str, value: f64) -> FinanceResult<()> {
242 require_finite(field, value)
243}
244
245/// Ensure value is finite and strictly positive (prices for log returns, etc.).
246pub(crate) fn require_positive(field: &'static str, value: f64) -> FinanceResult<()> {
247 require_finite(field, value)?;
248 if value <= 0.0 {
249 Err(FinanceError::InvalidCashflow {
250 message: "value must be strictly positive",
251 })
252 } else {
253 Ok(())
254 }
255}
256
257/// Ensure a slice is non-empty.
258pub(crate) fn require_nonempty<T>(what: &'static str, items: &[T]) -> FinanceResult<()> {
259 if items.is_empty() {
260 Err(FinanceError::EmptyInput { what })
261 } else {
262 Ok(())
263 }
264}
265
266/// Ensure every rate in a schedule is valid for TVM (`>= -1.0`).
267///
268/// Empty schedules are allowed (zero periods → no compounding). Call
269/// [`require_nonempty`] first when empty input is an error (e.g. return series).
270pub(crate) fn require_rates(rates: &[f64]) -> FinanceResult<()> {
271 for &rate in rates {
272 require_rate(rate)?;
273 }
274 Ok(())
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280 use crate::future_value;
281
282 #[test]
283 fn display_and_code_invalid_rate() {
284 let err = FinanceError::InvalidRate { rate: -1.5 };
285 assert!(err.to_string().contains("-1.5"));
286 assert_eq!(err.code(), "invalid_rate");
287 }
288
289 #[test]
290 fn future_value_err_invalid_rate() {
291 match future_value(-1.5, 12, 1000.0, false) {
292 Err(FinanceError::InvalidRate { rate }) => assert_eq!(rate, -1.5),
293 other => panic!("unexpected {other:?}"),
294 }
295 }
296
297 #[test]
298 fn require_rates_empty_ok() {
299 assert!(require_rates(&[]).is_ok());
300 }
301
302 #[test]
303 fn require_rates_invalid() {
304 assert!(matches!(
305 require_rates(&[-1.5]),
306 Err(FinanceError::InvalidRate { .. })
307 ));
308 }
309}