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}