Skip to main content

fermat_core/
rounding.rs

1//! IEEE 754-2008 rounding modes and the `Decimal::round` method.
2//!
3//! ## Rounding Modes
4//!
5//! | Mode          | Description                                    | DeFi Use Case              |
6//! |---------------|------------------------------------------------|----------------------------|
7//! | `Down`        | Toward −∞                                      | User withdrawals (safe)    |
8//! | `Up`          | Toward +∞                                      | Protocol fees (maximize)   |
9//! | `TowardZero`  | Truncate (toward 0)                            | Display / read-only        |
10//! | `AwayFromZero`| Away from 0 (magnify)                          | Collateral requirements    |
11//! | `HalfUp`      | Round half toward +∞ ("school" rounding)       | Retail calculations        |
12//! | `HalfDown`    | Round half toward −∞                           | Interest accrual           |
13//! | `HalfEven`    | Round half to even digit (banker's rounding)   | Statistical neutrality (default) |
14
15use crate::arithmetic::pow10;
16use crate::decimal::Decimal;
17use crate::error::ArithmeticError;
18
19/// Rounding mode selector (7 modes per IEEE 754-2008).
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
21pub enum RoundingMode {
22    /// Round toward negative infinity (floor).
23    ///
24    /// `-1.5` → `-2`, `1.5` → `1`
25    Down,
26
27    /// Round toward positive infinity (ceiling).
28    ///
29    /// `-1.5` → `-1`, `1.5` → `2`
30    Up,
31
32    /// Round toward zero (truncate).
33    ///
34    /// `-1.9` → `-1`, `1.9` → `1`
35    TowardZero,
36
37    /// Round away from zero.
38    ///
39    /// `-1.1` → `-2`, `1.1` → `2`
40    AwayFromZero,
41
42    /// Round half toward positive infinity ("school" rounding).
43    ///
44    /// `0.5` → `1`, `-0.5` → `0`
45    HalfUp,
46
47    /// Round half toward negative infinity.
48    ///
49    /// `0.5` → `0`, `-0.5` → `-1`
50    HalfDown,
51
52    /// Round half to nearest even digit (banker's rounding) — **default**.
53    ///
54    /// `0.5` → `0`, `1.5` → `2`, `2.5` → `2`, `3.5` → `4`
55    ///
56    /// Chosen as default because it is statistically unbiased across large
57    /// numbers of operations — critical for interest accrual and index updates.
58    #[default]
59    HalfEven,
60}
61
62impl Decimal {
63    /// Round `self` to `dp` decimal places using the given rounding `mode`.
64    ///
65    /// If `dp >= self.scale` no rounding is needed and `self` is returned
66    /// unchanged (possibly with a different scale representation).
67    ///
68    /// # Errors
69    ///
70    /// Returns `Err(ScaleExceeded)` if `dp > MAX_SCALE`.
71    pub fn round(self, dp: u8, mode: RoundingMode) -> Result<Self, ArithmeticError> {
72        use crate::decimal::MAX_SCALE;
73        if dp > MAX_SCALE {
74            return Err(ArithmeticError::ScaleExceeded);
75        }
76        if dp >= self.scale {
77            // No precision is lost — just return as-is.
78            return Ok(self);
79        }
80
81        let diff = self.scale - dp;
82        let factor = pow10(diff)?; // 10^diff, always ≥ 10
83        let half = factor / 2;
84
85        let quotient = self.mantissa / factor;
86        let remainder = self.mantissa % factor; // sign follows dividend
87
88        let abs_rem = remainder.unsigned_abs() as i128; // magnitude of remainder
89
90        let adjusted = match mode {
91            RoundingMode::TowardZero => quotient,
92
93            RoundingMode::AwayFromZero => {
94                if remainder != 0 {
95                    // Move away from zero: add +1 if positive, -1 if negative
96                    quotient + quotient.signum().max(1) * remainder.signum()
97                } else {
98                    quotient
99                }
100            }
101
102            RoundingMode::Down => {
103                // Floor: subtract 1 when the original value was negative AND
104                // there is a fractional part (remainder < 0).
105                if remainder < 0 {
106                    quotient - 1
107                } else {
108                    quotient
109                }
110            }
111
112            RoundingMode::Up => {
113                // Ceiling: add 1 when the original value was positive AND
114                // there is a fractional part (remainder > 0).
115                if remainder > 0 {
116                    quotient + 1
117                } else {
118                    quotient
119                }
120            }
121
122            RoundingMode::HalfUp => {
123                if abs_rem >= half {
124                    if self.mantissa >= 0 {
125                        quotient + 1
126                    } else {
127                        quotient - 1
128                    }
129                } else {
130                    quotient
131                }
132            }
133
134            RoundingMode::HalfDown => {
135                if abs_rem > half {
136                    if self.mantissa >= 0 {
137                        quotient + 1
138                    } else {
139                        quotient - 1
140                    }
141                } else {
142                    quotient
143                }
144            }
145
146            RoundingMode::HalfEven => {
147                if abs_rem > half {
148                    // Past the midpoint → always round away
149                    if self.mantissa >= 0 {
150                        quotient + 1
151                    } else {
152                        quotient - 1
153                    }
154                } else if abs_rem == half {
155                    // Exactly at midpoint → round to even
156                    if quotient % 2 != 0 {
157                        if self.mantissa >= 0 {
158                            quotient + 1
159                        } else {
160                            quotient - 1
161                        }
162                    } else {
163                        quotient
164                    }
165                } else {
166                    quotient
167                }
168            }
169        };
170
171        Decimal::new(adjusted, dp)
172    }
173
174    /// Rescale `self` to a higher number of decimal places by padding zeros.
175    ///
176    /// Only increases scale; use `round` to decrease it.
177    /// Returns `Err(ScaleExceeded)` if `new_scale > MAX_SCALE` or `Err(Overflow)`
178    /// if the mantissa multiplication overflows.
179    pub fn rescale_up(self, new_scale: u8) -> Result<Self, ArithmeticError> {
180        if new_scale <= self.scale {
181            return Ok(self);
182        }
183        let diff = new_scale - self.scale;
184        let factor = pow10(diff)?;
185        let mantissa = self
186            .mantissa
187            .checked_mul(factor)
188            .ok_or(ArithmeticError::Overflow)?;
189        Decimal::new(mantissa, new_scale)
190    }
191}
192
193// ─── Tests ───────────────────────────────────────────────────────────────────
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use crate::decimal::Decimal;
199
200    fn d(mantissa: i128, scale: u8) -> Decimal {
201        Decimal::new(mantissa, scale).unwrap()
202    }
203
204    // ── TowardZero ────────────────────────────────────────────────────────
205
206    #[test]
207    fn round_toward_zero_positive() {
208        // 1.9 → 1 (truncate)
209        assert_eq!(
210            d(19, 1).round(0, RoundingMode::TowardZero).unwrap(),
211            d(1, 0)
212        );
213    }
214
215    #[test]
216    fn round_toward_zero_negative() {
217        // -1.9 → -1 (truncate toward zero)
218        assert_eq!(
219            d(-19, 1).round(0, RoundingMode::TowardZero).unwrap(),
220            d(-1, 0)
221        );
222    }
223
224    // ── AwayFromZero ──────────────────────────────────────────────────────
225
226    #[test]
227    fn round_away_from_zero_positive() {
228        assert_eq!(
229            d(11, 1).round(0, RoundingMode::AwayFromZero).unwrap(),
230            d(2, 0)
231        );
232    }
233
234    #[test]
235    fn round_away_from_zero_negative() {
236        assert_eq!(
237            d(-11, 1).round(0, RoundingMode::AwayFromZero).unwrap(),
238            d(-2, 0)
239        );
240    }
241
242    #[test]
243    fn round_away_from_zero_exact() {
244        // 1.0 has no fractional part → unchanged
245        assert_eq!(
246            d(10, 1).round(0, RoundingMode::AwayFromZero).unwrap(),
247            d(1, 0)
248        );
249    }
250
251    // ── Down (floor) ──────────────────────────────────────────────────────
252
253    #[test]
254    fn round_down_positive() {
255        assert_eq!(d(19, 1).round(0, RoundingMode::Down).unwrap(), d(1, 0));
256    }
257
258    #[test]
259    fn round_down_negative() {
260        // Floor of -1.9 is -2
261        assert_eq!(d(-19, 1).round(0, RoundingMode::Down).unwrap(), d(-2, 0));
262    }
263
264    // ── Up (ceiling) ──────────────────────────────────────────────────────
265
266    #[test]
267    fn round_up_positive() {
268        assert_eq!(d(11, 1).round(0, RoundingMode::Up).unwrap(), d(2, 0));
269    }
270
271    #[test]
272    fn round_up_negative() {
273        // Ceiling of -1.1 is -1
274        assert_eq!(d(-11, 1).round(0, RoundingMode::Up).unwrap(), d(-1, 0));
275    }
276
277    // ── HalfUp ────────────────────────────────────────────────────────────
278
279    #[test]
280    fn round_half_up_at_midpoint() {
281        assert_eq!(d(5, 1).round(0, RoundingMode::HalfUp).unwrap(), d(1, 0));
282    }
283
284    #[test]
285    fn round_half_up_below_midpoint() {
286        assert_eq!(d(4, 1).round(0, RoundingMode::HalfUp).unwrap(), d(0, 0));
287    }
288
289    #[test]
290    fn round_half_up_negative_midpoint() {
291        // -0.5 rounds to -1 (away from 0 in HalfUp)
292        assert_eq!(d(-5, 1).round(0, RoundingMode::HalfUp).unwrap(), d(-1, 0));
293    }
294
295    // ── HalfDown ──────────────────────────────────────────────────────────
296
297    #[test]
298    fn round_half_down_at_midpoint() {
299        assert_eq!(d(5, 1).round(0, RoundingMode::HalfDown).unwrap(), d(0, 0));
300    }
301
302    #[test]
303    fn round_half_down_above_midpoint() {
304        assert_eq!(d(6, 1).round(0, RoundingMode::HalfDown).unwrap(), d(1, 0));
305    }
306
307    // ── HalfEven (Banker's) ───────────────────────────────────────────────
308
309    #[test]
310    fn round_half_even_round_to_even_up() {
311        // 1.5 → nearest even = 2
312        assert_eq!(d(15, 1).round(0, RoundingMode::HalfEven).unwrap(), d(2, 0));
313    }
314
315    #[test]
316    fn round_half_even_round_to_even_down() {
317        // 2.5 → nearest even = 2
318        assert_eq!(d(25, 1).round(0, RoundingMode::HalfEven).unwrap(), d(2, 0));
319    }
320
321    #[test]
322    fn round_half_even_past_midpoint() {
323        // 1.6 → 2 (past midpoint)
324        assert_eq!(d(16, 1).round(0, RoundingMode::HalfEven).unwrap(), d(2, 0));
325    }
326
327    #[test]
328    fn round_no_op_when_dp_equals_scale() {
329        let x = d(12345, 3);
330        assert_eq!(x.round(3, RoundingMode::HalfEven).unwrap(), x);
331    }
332
333    #[test]
334    fn round_no_op_when_dp_exceeds_scale() {
335        let x = d(12345, 3);
336        assert_eq!(x.round(5, RoundingMode::HalfEven).unwrap(), x);
337    }
338
339    #[test]
340    fn rescale_up_basic() {
341        let x = d(1, 0); // 1.0
342        let y = x.rescale_up(6).unwrap(); // 1.000000
343        assert_eq!(y.mantissa(), 1_000_000);
344        assert_eq!(y.scale(), 6);
345    }
346
347    #[test]
348    fn rescale_up_noop() {
349        let x = d(1_000_000, 6);
350        assert_eq!(x.rescale_up(3).unwrap(), x); // lower target → no-op
351    }
352}