high_roller/decimal.rs
1//! # Decimal32
2//!
3//! Decimal32 is a transparent wrapper over an i32.
4//! A const generic declares the number of places
5//! after the decimal point.
6//!
7//! The motivation for such a type is providing lossless
8//! arithmetic guarantees like in the example below.
9//!
10//! ```
11//! use high_roller::decimal::D9;
12//! use num_traits::{CheckedAdd, WrappingAdd, WrappingSub};
13//!
14//! const SMALL: f64 = 0.111000111;
15//! const LARGE: f64 = 2.147483647;
16//!
17//! const CHECKED_SMALL: D9 = D9::checked(SMALL).unwrap();
18//! const CHECKED_LARGE: D9 = D9::checked(LARGE).unwrap();
19//!
20//! // Parity with lossless operations
21//! let sum = const { D9::checked(1.).unwrap() }.checked_add(&CHECKED_SMALL);
22//! assert_eq!(sum.unwrap().get(), 1. + SMALL, "Result fits in f64");
23//!
24//! // Checked operations prevent overflow
25//! let lossy = CHECKED_LARGE.checked_add(&CHECKED_SMALL);
26//! assert_eq!(lossy, None, "Result overflows i32");
27//! assert_ne!(LARGE + SMALL - LARGE, SMALL);
28//!
29//! // Wrapping operations enable loss recovery
30//! let wrapped = CHECKED_LARGE.wrapping_add(&CHECKED_SMALL);
31//! assert_eq!(wrapped.wrapping_sub(&CHECKED_LARGE), CHECKED_SMALL);
32//! ```
33
34use core::f32;
35use core::fmt::Debug;
36use core::fmt::Display;
37use core::ops::Add;
38use core::ops::Neg;
39use core::ops::Sub;
40
41use num_traits::CheckedAdd;
42use num_traits::CheckedSub;
43use num_traits::WrappingAdd;
44use num_traits::WrappingSub;
45use thiserror::Error;
46
47/// A `Decimal32` type with one significant figure
48/// after the decimal point.
49///
50/// ```
51/// use high_roller::decimal::D1;
52///
53/// assert_eq!(D1::MAX.get(), 214748364.7_f64);
54/// assert_eq!(D1::MIN.get(), -214748364.8_f64);
55/// assert_eq!(D1::MIN_UNIT.get(), 0.1_f64);
56/// ```
57pub type D1 = Decimal32<1>;
58
59/// A `Decimal32` type with two significant figures
60/// after the decimal point.
61///
62/// ```
63/// use high_roller::decimal::D2;
64///
65/// assert_eq!(D2::MAX.get(), 21474836.47_f64);
66/// assert_eq!(D2::MIN.get(), -21474836.48_f64);
67/// assert_eq!(D2::MIN_UNIT.get(), 0.01_f64);
68/// ```
69pub type D2 = Decimal32<2>;
70
71/// A `Decimal32` type with three significant figures
72/// after the decimal point.
73///
74/// ```
75/// use high_roller::decimal::D3;
76///
77/// assert_eq!(D3::MAX.get(), 2147483.647_f64);
78/// assert_eq!(D3::MIN.get(), -2147483.648_f64);
79/// assert_eq!(D3::MIN_UNIT.get(), 0.001_f64);
80/// ```
81pub type D3 = Decimal32<3>;
82
83/// A `Decimal32` type with four significant figures
84/// after the decimal point.
85///
86/// ```
87/// use high_roller::decimal::D4;
88///
89/// assert_eq!(D4::MAX.get(), 214748.3647_f64);
90/// assert_eq!(D4::MIN.get(), -214748.3648_f64);
91/// assert_eq!(D4::MIN_UNIT.get(), 0.0001_f64);
92/// ```
93pub type D4 = Decimal32<4>;
94
95/// A `Decimal32` type with five significant figures
96/// after the decimal point.
97///
98/// ```
99/// use high_roller::decimal::D5;
100///
101/// assert_eq!(D5::MAX.get(), 21474.83647_f64);
102/// assert_eq!(D5::MIN.get(), -21474.83648_f64);
103/// assert_eq!(D5::MIN_UNIT.get(), 0.00001_f64);
104/// ```
105pub type D5 = Decimal32<5>;
106
107/// A `Decimal32` type with six significant figures
108/// after the decimal point.
109///
110/// ```
111/// use high_roller::decimal::D6;
112///
113/// assert_eq!(D6::MAX.get(), 2147.483647_f64);
114/// assert_eq!(D6::MIN.get(), -2147.483648_f64);
115/// assert_eq!(D6::MIN_UNIT.get(), 0.000001_f64);
116/// ```
117pub type D6 = Decimal32<6>;
118
119/// A `Decimal32` type with seven significant figures
120/// after the decimal point.
121///
122/// ```
123/// use high_roller::decimal::D7;
124///
125/// assert_eq!(D7::MAX.get(), 214.7483647_f64);
126/// assert_eq!(D7::MIN.get(), -214.7483648_f64);
127/// assert_eq!(D7::MIN_UNIT.get(), 0.0000001_f64);
128/// ```
129pub type D7 = Decimal32<7>;
130
131/// A `Decimal32` type with eight significant figures
132/// after the decimal point.
133///
134/// ```
135/// use high_roller::decimal::D8;
136///
137/// assert_eq!(D8::MAX.get(), 21.47483647_f64);
138/// assert_eq!(D8::MIN.get(), -21.47483648_f64);
139/// assert_eq!(D8::MIN_UNIT.get(), 0.00000001_f64);
140/// ```
141pub type D8 = Decimal32<8>;
142
143/// A `Decimal32` type with nine significant figures
144/// after the decimal point.
145///
146/// ```
147/// use high_roller::decimal::D9;
148///
149/// assert_eq!(D9::MAX.get(), 2.147483647_f64);
150/// assert_eq!(D9::MIN.get(), -2.147483648_f64);
151/// assert_eq!(D9::MIN_UNIT.get(), 0.000000001_f64);
152/// ```
153pub type D9 = Decimal32<9>;
154
155/// A 32-bit lossless number with const-defined decimal precision.
156///
157/// # Design
158///
159/// There are different ways to represent a floating point
160/// number with wrapping and saturating semantics.
161/// This design basically takes the bounds of an i32 and
162/// sticks a decimal point somewhere. So the type itself
163/// serves primarily for self-documentation and convenience.
164///
165/// IEEE 754 floating point debauchery keeps the lossless
166/// range of an f32 in signed 2^24.
167/// So an equally valid design could lock the inner value within
168/// that range and use that bound for wrapping, saturating, and
169/// checked operations.
170/// The benefit is that `get` could return an f32 without loss
171/// of precision. The cost is hardware support for arithmetic.
172/// Since wrapping arithmetic is the primary motivation for
173/// Decimal32, this was not chosen.
174///
175/// A Decimal64 type is not (yet) exposed because at that point,
176/// the [Decimal](https://crates.io/crates/rust_decimal) crate
177/// might be a better fit for your use case.
178#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
179#[repr(transparent)]
180pub struct Decimal32<const PRECISION: u32>(i32);
181
182/// Enumerates the possible errors `Decimal` operations may return.
183#[derive(Error, Debug)]
184#[non_exhaustive]
185pub enum DecimalErr {
186 #[error("The attempted operation would cause a loss of precision.")]
187 Lossy,
188}
189
190impl<const PRECISION: u32> Decimal32<PRECISION> {
191 const _PRECISION_CHECK: () = assert!(
192 PRECISION <= 9,
193 "PRECISION must be <= 9; 10ePRECISION would overflow u32"
194 );
195
196 pub const ZERO: Self = Self(0);
197
198 /// The greatest positive value this type can contain.
199 pub const MAX: Self = Self(i32::MAX);
200
201 /// The most negative value this type can contain.
202 pub const MIN: Self = Self(i32::MIN);
203
204 /// The smallest positive value this type can contain.
205 pub const MIN_UNIT: Self = Self(1);
206
207 /// Constructor that accepts any input. Truncates toward zero when
208 /// the input has more decimal places than `PRECISION`. Use [`Self::checked`]
209 /// to detect when precision is lost.
210 ///
211 /// This function takes an `f64` to prevent sneaky loss of precision.
212 /// `f32` only has a 24-bit mantissa, so values between 2^24 and
213 /// and 2^31 require an f64 to be constructed.
214 ///
215 /// ```
216 /// use high_roller::decimal::D2;
217 ///
218 /// let num = D2::cast(0.125_f64);
219 /// assert_eq!(num.get(), 0.12_f64);
220 /// ```
221 ///
222 /// If this `f64` situation is annoying for your use case,
223 /// you can still escape it entirely at compile time.
224 ///
225 /// ```
226 /// use high_roller::decimal::D3;
227 ///
228 /// const MY_F32: f32 = D3::cast(0.321_f64).get() as f32;
229 /// assert_eq!(MY_F32, 0.321);
230 /// ```
231 #[must_use]
232 #[inline]
233 pub const fn cast(value: f64) -> Self {
234 Self((value * self::scalar(PRECISION) as f64) as i32)
235 }
236
237 /// Const constructor that prevents loss of precision
238 /// from the input value.
239 ///
240 /// ### Succeeds
241 ///
242 /// ```
243 /// use high_roller::decimal::D5;
244 ///
245 /// const GOOD: D5 = D5::checked(-100.12345).unwrap();
246 /// ```
247 ///
248 /// ### Fails
249 ///
250 /// ```compile_fail
251 /// use high_roller::decimal::D9;
252 ///
253 /// const BAD: D9 = D9::checked(-100.)
254 /// .expect("There isn't space in 32 bits for 9 decimal places after -100");
255 /// ```
256 #[must_use]
257 pub const fn checked(value: f64) -> Option<Self> {
258 let dec = Self::cast(value);
259 if dec.get() != value {
260 return None;
261 }
262 Some(dec)
263 }
264
265 /// Returns the inner value as an f64. This conversion is lossless
266 /// because f64's 53-bit mantissa can represent every i32 value exactly.
267 ///
268 /// For f32 output use [`f32::try_from`], which returns `Err(Lossy)` when
269 /// the inner value exceeds f32's 24-bit mantissa.
270 ///
271 /// ```
272 /// use high_roller::decimal::D1;
273 ///
274 /// assert_eq!(D1::cast(1.0_f64).get(), 1.0_f64);
275 /// ```
276 #[must_use]
277 #[inline]
278 pub const fn get(self) -> f64 {
279 self.0 as f64 / self::scalar(PRECISION) as f64
280 }
281}
282
283/// Helper function for the scaling constant on an
284/// inner Decimal value.
285#[inline]
286const fn scalar(precision: u32) -> u32 {
287 10u32.pow(precision)
288}
289
290impl<const P: u32> Debug for Decimal32<P> {
291 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
292 write!(f, "Decimal32<{}>({})", P, self.get())
293 }
294}
295
296impl<const P: u32> Display for Decimal32<P> {
297 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
298 write!(f, "{}", self.get())
299 }
300}
301
302impl<const P: u32> TryFrom<f32> for Decimal32<P> {
303 type Error = DecimalErr;
304
305 /// Constructs a `Decimal32` from an `f32` or returns `Err(DecimalErr::Lossy)`
306 /// if the conversion would lose precision. This might occur if the input
307 /// literal specifies more decimal places than the underlying`Decimal32` type.
308 ///
309 /// ```should_panic
310 /// use high_roller::decimal::D2;
311 ///
312 /// D2::try_from(0.123).expect("resulting decimal is 0.12");
313 /// ```
314 ///
315 /// But take care not to lose precision when constructing the
316 /// f32 input into this function. Since f32 has a 24-bit mantissa,
317 /// it cannot represent some values that Decimal32 can.
318 ///
319 /// In the example below, rustc abbreviates the float before this
320 /// function even sees it. [`Decimal32::cast`] solves this case by
321 /// using an f64 constructor.
322 ///
323 /// ```
324 /// use high_roller::decimal::D9;
325 ///
326 /// const INPUT: f32 = 2.147483647;
327 /// let expected = D9::try_from(INPUT).unwrap();
328 ///
329 /// assert_eq!(INPUT, f32::try_from(expected).unwrap());
330 /// assert_eq!(INPUT, 2.1474836, "two places were dropped");
331 /// ```
332 ///
333 fn try_from(value: f32) -> Result<Self, Self::Error> {
334 // Use f32 arithmetic for the scale step: the caller's value is already
335 // an f32, and f32 multiplication may round (e.g. 7.12f32 * 1000 → 7120)
336 // in ways that f64 arithmetic would not.
337 let dec = Self((value * self::scalar(P) as f32) as i32);
338 if dec.get() as f32 != value {
339 return Err(DecimalErr::Lossy);
340 }
341 Ok(dec)
342 }
343}
344
345impl<const P: u32> TryFrom<Decimal32<P>> for f32 {
346 type Error = DecimalErr;
347
348 /// Converts to f32. Returns `Err(Lossy)` when the inner value exceeds
349 /// f32's 24-bit mantissa. Use [`Decimal32::get`] if unchecked lossless
350 /// output is required.
351 fn try_from(value: Decimal32<P>) -> Result<Self, Self::Error> {
352 if value.0 as Self as i32 != value.0 {
353 return Err(DecimalErr::Lossy);
354 }
355 Ok(value.get() as Self)
356 }
357}
358
359impl<const P: u32> From<Decimal32<P>> for f64 {
360 fn from(val: Decimal32<P>) -> Self {
361 val.get()
362 }
363}
364
365impl<const P: u32> Add for Decimal32<P> {
366 type Output = Self;
367
368 /// Uses wrapping addition.
369 fn add(self, rhs: Self) -> Self::Output {
370 Self(self.0.wrapping_add(rhs.0))
371 }
372}
373
374impl<const P: u32> CheckedAdd for Decimal32<P> {
375 fn checked_add(&self, v: &Self) -> Option<Self> {
376 self.0.checked_add(v.0).map(Self)
377 }
378}
379
380impl<const P: u32> WrappingAdd for Decimal32<P> {
381 fn wrapping_add(&self, v: &Self) -> Self {
382 Self(self.0.wrapping_add(v.0))
383 }
384}
385
386impl<const P: u32> Default for Decimal32<P> {
387 fn default() -> Self {
388 Self::ZERO
389 }
390}
391
392impl<const P: u32> Sub for Decimal32<P> {
393 type Output = Self;
394
395 /// Uses wrapping subtraction.
396 fn sub(self, rhs: Self) -> Self::Output {
397 Self(self.0.wrapping_sub(rhs.0))
398 }
399}
400
401impl<const P: u32> CheckedSub for Decimal32<P> {
402 fn checked_sub(&self, v: &Self) -> Option<Self> {
403 self.0.checked_sub(v.0).map(Self)
404 }
405}
406
407impl<const P: u32> WrappingSub for Decimal32<P> {
408 #[inline]
409 fn wrapping_sub(&self, v: &Self) -> Self {
410 Self(self.0.wrapping_sub(v.0))
411 }
412}
413
414impl<const P: u32> Neg for Decimal32<P> {
415 type Output = Self;
416
417 /// Uses wrapping negation.
418 fn neg(self) -> Self::Output {
419 Self(self.0.wrapping_neg())
420 }
421}
422
423#[cfg(test)]
424impl<const P: u32> From<Decimal32<P>> for num_bigint::BigInt {
425 fn from(val: Decimal32<P>) -> Self {
426 Self::from(val.0)
427 }
428}
429
430#[cfg(test)]
431impl<const P: u32> TryFrom<&num_bigint::BigInt> for Decimal32<P> {
432 type Error = ();
433
434 fn try_from(val: &num_bigint::BigInt) -> Result<Self, Self::Error> {
435 let n: i32 = val.try_into().map_err(|_| ())?;
436 Ok(Self(n))
437 }
438}
439
440#[allow(clippy::missing_panics_doc)]
441#[allow(clippy::expect_used)]
442#[cfg(test)]
443pub mod decimal_tests {
444 use num_traits::CheckedAdd;
445 use num_traits::CheckedSub;
446 use num_traits::WrappingAdd;
447 use num_traits::WrappingSub;
448
449 use crate::decimal::Decimal32;
450 use crate::decimal::DecimalErr;
451 use crate::decimal::D3;
452
453 /// Checks equality within one unit of the given precision (i.e. tolerance = 10^-precision).
454 pub fn assert_eq_f64(left: f64, right: f64, precision: u32) {
455 let tolerance = 1.0 / super::scalar(precision) as f64;
456 assert!(
457 (left - right).abs() < tolerance,
458 "equality failed: {left:?} != {right:?} (tolerance {tolerance})"
459 );
460 }
461
462 // --- Conversion ---
463
464 /// Values exactly representable at P=3 survive a cast_from / get round-trip.
465 #[test]
466 fn cast_from_exact() {
467 assert_eq_f64(D3::cast(0.001).get(), 0.001, 3);
468 assert_eq_f64(D3::cast(7.120).get(), 7.120, 3);
469 assert_eq_f64(D3::cast(-3.500).get(), -3.500, 3);
470 }
471
472 /// cast truncates toward zero rather than rounding.
473 #[test]
474 fn cast_truncates() {
475 // 0.9999 * 1 = 0.9999, cast to i32 truncates to 0
476 let d = Decimal32::<0>::cast(0.9999_f64);
477 assert_eq!(d.get(), 0.0_f64);
478 }
479
480 /// try_from succeeds for values the type can represent without precision loss.
481 #[test]
482 fn try_from_lossless() {
483 assert!(D3::try_from(0.001_f32).is_ok());
484 assert!(D3::try_from(7.120_f32).is_ok());
485 assert!(D3::try_from(0.0_f32).is_ok());
486 }
487
488 /// try_from returns Err(Lossy) when the f32 value can't be represented exactly.
489 #[test]
490 fn try_from_lossy() {
491 // 1/3 is not representable at any finite decimal precision
492 assert!(matches!(
493 D3::try_from(1.0_f32 / 3.0_f32),
494 Err(DecimalErr::Lossy)
495 ));
496
497 // But cast accepts it
498 assert_eq_f64(D3::cast(1. / 3.).get(), 0.333, 3);
499 }
500
501 /// TryFrom<Decimal32<P>> for f32 succeeds for values that round-trip cleanly.
502 #[test]
503 fn decimal_to_f32_ok() {
504 let d = D3::cast(7.120);
505 let f: f32 = f32::try_from(d).expect("should round-trip");
506 assert_eq_f64(f as f64, 7.120, 3);
507 }
508
509 // --- Arithmetic ---
510
511 /// Basic addition produces the correct sum.
512 #[test]
513 fn add_basic() {
514 let sum = D3::cast(0.001) + D3::cast(7.120);
515 assert_eq_f64(sum.get(), 7.121, 3);
516 }
517
518 /// Adding a negative value crosses zero correctly.
519 #[test]
520 fn add_negative() {
521 let sum = D3::cast(1.000) + D3::cast(-3.500);
522 assert_eq_f64(sum.get(), -2.500, 3);
523 }
524
525 /// checked_add returns Some when the result fits in i32.
526 #[test]
527 fn checked_add_ok() {
528 let a = D3::cast(1.000);
529 let b = D3::cast(2.000);
530 assert_eq!(a.checked_add(&b), Some(D3::cast(3.000)));
531 }
532
533 /// checked_add returns None when the internal i32 would overflow.
534 #[test]
535 fn checked_add_overflow() {
536 let max = Decimal32::<0>(i32::MAX);
537 let one = Decimal32::<0>(1);
538 assert_eq!(max.checked_add(&one), None);
539 }
540
541 /// wrapping_add wraps the internal i32 on overflow.
542 #[test]
543 fn wrapping_add_overflow() {
544 let max = Decimal32::<0>(i32::MAX);
545 let one = Decimal32::<0>(1);
546 let expected = Decimal32::<0>(i32::MIN);
547 assert_eq!(max.wrapping_add(&one), expected);
548 }
549
550 /// Basic subtraction produces the correct difference.
551 #[test]
552 fn sub_basic() {
553 let diff = D3::cast(7.121) - D3::cast(0.001);
554 assert_eq_f64(diff.get(), 7.120, 3);
555 }
556
557 /// Subtraction can produce a negative result.
558 #[test]
559 fn sub_to_negative() {
560 let diff = D3::cast(1.000) - D3::cast(3.500);
561 assert_eq_f64(diff.get(), -2.500, 3);
562 }
563
564 /// checked_sub returns Some when the result fits in i32.
565 #[test]
566 fn checked_sub_ok() {
567 let a = D3::cast(5.000);
568 let b = D3::cast(2.000);
569 assert_eq!(a.checked_sub(&b), Some(D3::cast(3.000)));
570 }
571
572 /// checked_sub returns None when the internal i32 would underflow.
573 #[test]
574 fn checked_sub_underflow() {
575 let min = Decimal32::<0>(i32::MIN);
576 let one = Decimal32::<0>(1);
577 assert_eq!(min.checked_sub(&one), None);
578 }
579
580 /// wrapping_sub wraps the internal i32 on underflow.
581 #[test]
582 fn wrapping_sub_underflow() {
583 let min = Decimal32::<0>(i32::MIN);
584 let one = Decimal32::<0>(1);
585 let expected = Decimal32::<0>(i32::MAX);
586 assert_eq!(min.wrapping_sub(&one), expected);
587 }
588
589 // --- Identity / Semantic ---
590
591 /// Adding ZERO leaves the value unchanged from both sides.
592 #[test]
593 fn zero_additive_identity() {
594 let x = D3::cast(4.200);
595 assert_eq!(x + D3::ZERO, x);
596 assert_eq!(D3::ZERO + x, x);
597 }
598
599 /// Subtracting ZERO leaves the value unchanged.
600 #[test]
601 fn zero_subtractive_identity() {
602 let x = D3::cast(4.200);
603 assert_eq!(x - D3::ZERO, x);
604 }
605
606 /// PartialOrd is consistent across negative, zero, and positive values.
607 #[test]
608 fn ordering() {
609 let neg = D3::cast(-1.000);
610 let zero = D3::ZERO;
611 let pos = D3::cast(1.000);
612 assert!(neg < zero);
613 assert!(zero < pos);
614 assert!(neg < pos);
615 assert_eq!(zero, D3::cast(0.000));
616 }
617
618 /// Default returns ZERO.
619 #[test]
620 fn default_is_zero() {
621 assert_eq!(D3::default(), D3::ZERO);
622 }
623
624 // --- Neg ---
625
626 /// Negating a positive value gives the corresponding negative.
627 #[test]
628 fn neg_basic() {
629 let x = D3::cast(4.200);
630 assert_eq!(-x, D3::cast(-4.200));
631 assert_eq!(-(-x), x);
632 }
633
634 /// Negating i32::MIN wraps to i32::MIN (wrapping_neg behaviour).
635 #[test]
636 fn neg_min_wraps() {
637 let min = Decimal32::<0>(i32::MIN);
638 assert_eq!(-min, min);
639 }
640
641 // --- TryFrom<Decimal32<P>> for f32 ---
642
643 /// TryFrom<Decimal32> for f32 returns Err when the inner i32 exceeds f32's
644 /// 24-bit mantissa (~16.7 million), causing the get() → cast() round-trip to
645 /// land on a different inner value.
646 #[test]
647 fn decimal_to_f32_lossy() {
648 // 2^24 + 1 = 16_777_217 cannot be represented exactly as f32 (rounds to
649 // 16_777_216), so cast(get(d)) returns a different inner value.
650 let d = Decimal32::<1>(16_777_217_i32);
651 assert!(f32::try_from(d).is_err());
652
653 let d_neg = Decimal32::<1>(-16_777_217_i32);
654 assert!(f32::try_from(d_neg).is_err());
655 }
656}