Skip to main content

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}