Skip to main content

high_roller/
decimal.rs

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