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}