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!(d(19, 1).round(0, RoundingMode::TowardZero).unwrap(), d(1, 0));
210    }
211
212    #[test]
213    fn round_toward_zero_negative() {
214        // -1.9 → -1 (truncate toward zero)
215        assert_eq!(
216            d(-19, 1).round(0, RoundingMode::TowardZero).unwrap(),
217            d(-1, 0)
218        );
219    }
220
221    // ── AwayFromZero ──────────────────────────────────────────────────────
222
223    #[test]
224    fn round_away_from_zero_positive() {
225        assert_eq!(
226            d(11, 1).round(0, RoundingMode::AwayFromZero).unwrap(),
227            d(2, 0)
228        );
229    }
230
231    #[test]
232    fn round_away_from_zero_negative() {
233        assert_eq!(
234            d(-11, 1).round(0, RoundingMode::AwayFromZero).unwrap(),
235            d(-2, 0)
236        );
237    }
238
239    #[test]
240    fn round_away_from_zero_exact() {
241        // 1.0 has no fractional part → unchanged
242        assert_eq!(
243            d(10, 1).round(0, RoundingMode::AwayFromZero).unwrap(),
244            d(1, 0)
245        );
246    }
247
248    // ── Down (floor) ──────────────────────────────────────────────────────
249
250    #[test]
251    fn round_down_positive() {
252        assert_eq!(d(19, 1).round(0, RoundingMode::Down).unwrap(), d(1, 0));
253    }
254
255    #[test]
256    fn round_down_negative() {
257        // Floor of -1.9 is -2
258        assert_eq!(d(-19, 1).round(0, RoundingMode::Down).unwrap(), d(-2, 0));
259    }
260
261    // ── Up (ceiling) ──────────────────────────────────────────────────────
262
263    #[test]
264    fn round_up_positive() {
265        assert_eq!(d(11, 1).round(0, RoundingMode::Up).unwrap(), d(2, 0));
266    }
267
268    #[test]
269    fn round_up_negative() {
270        // Ceiling of -1.1 is -1
271        assert_eq!(d(-11, 1).round(0, RoundingMode::Up).unwrap(), d(-1, 0));
272    }
273
274    // ── HalfUp ────────────────────────────────────────────────────────────
275
276    #[test]
277    fn round_half_up_at_midpoint() {
278        assert_eq!(d(5, 1).round(0, RoundingMode::HalfUp).unwrap(), d(1, 0));
279    }
280
281    #[test]
282    fn round_half_up_below_midpoint() {
283        assert_eq!(d(4, 1).round(0, RoundingMode::HalfUp).unwrap(), d(0, 0));
284    }
285
286    #[test]
287    fn round_half_up_negative_midpoint() {
288        // -0.5 rounds to -1 (away from 0 in HalfUp)
289        assert_eq!(d(-5, 1).round(0, RoundingMode::HalfUp).unwrap(), d(-1, 0));
290    }
291
292    // ── HalfDown ──────────────────────────────────────────────────────────
293
294    #[test]
295    fn round_half_down_at_midpoint() {
296        assert_eq!(d(5, 1).round(0, RoundingMode::HalfDown).unwrap(), d(0, 0));
297    }
298
299    #[test]
300    fn round_half_down_above_midpoint() {
301        assert_eq!(d(6, 1).round(0, RoundingMode::HalfDown).unwrap(), d(1, 0));
302    }
303
304    // ── HalfEven (Banker's) ───────────────────────────────────────────────
305
306    #[test]
307    fn round_half_even_round_to_even_up() {
308        // 1.5 → nearest even = 2
309        assert_eq!(
310            d(15, 1).round(0, RoundingMode::HalfEven).unwrap(),
311            d(2, 0)
312        );
313    }
314
315    #[test]
316    fn round_half_even_round_to_even_down() {
317        // 2.5 → nearest even = 2
318        assert_eq!(
319            d(25, 1).round(0, RoundingMode::HalfEven).unwrap(),
320            d(2, 0)
321        );
322    }
323
324    #[test]
325    fn round_half_even_past_midpoint() {
326        // 1.6 → 2 (past midpoint)
327        assert_eq!(
328            d(16, 1).round(0, RoundingMode::HalfEven).unwrap(),
329            d(2, 0)
330        );
331    }
332
333    #[test]
334    fn round_no_op_when_dp_equals_scale() {
335        let x = d(12345, 3);
336        assert_eq!(x.round(3, RoundingMode::HalfEven).unwrap(), x);
337    }
338
339    #[test]
340    fn round_no_op_when_dp_exceeds_scale() {
341        let x = d(12345, 3);
342        assert_eq!(x.round(5, RoundingMode::HalfEven).unwrap(), x);
343    }
344
345    #[test]
346    fn rescale_up_basic() {
347        let x = d(1, 0); // 1.0
348        let y = x.rescale_up(6).unwrap(); // 1.000000
349        assert_eq!(y.mantissa(), 1_000_000);
350        assert_eq!(y.scale(), 6);
351    }
352
353    #[test]
354    fn rescale_up_noop() {
355        let x = d(1_000_000, 6);
356        assert_eq!(x.rescale_up(3).unwrap(), x); // lower target → no-op
357    }
358}