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}