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 fn wrapping_sub(&self, v: &Self) -> Self {
409 Self(self.0.wrapping_sub(v.0))
410 }
411}
412
413impl<const P: u32> Neg for Decimal32<P> {
414 type Output = Self;
415
416 /// Uses wrapping negation.
417 fn neg(self) -> Self::Output {
418 Self(self.0.wrapping_neg())
419 }
420}
421
422#[cfg(test)]
423impl<const P: u32> From<Decimal32<P>> for num_bigint::BigInt {
424 fn from(val: Decimal32<P>) -> Self {
425 Self::from(val.0)
426 }
427}
428
429#[cfg(test)]
430impl<const P: u32> TryFrom<&num_bigint::BigInt> for Decimal32<P> {
431 type Error = ();
432
433 fn try_from(val: &num_bigint::BigInt) -> Result<Self, Self::Error> {
434 let n: i32 = val.try_into().map_err(|_| ())?;
435 Ok(Self(n))
436 }
437}
438
439#[allow(clippy::missing_panics_doc)]
440#[allow(clippy::expect_used)]
441#[cfg(test)]
442pub mod decimal_tests {
443 use num_traits::CheckedAdd;
444 use num_traits::CheckedSub;
445 use num_traits::WrappingAdd;
446 use num_traits::WrappingSub;
447
448 use crate::decimal::Decimal32;
449 use crate::decimal::DecimalErr;
450 use crate::decimal::D3;
451
452 #[test]
453 fn basic_math() {
454 assert_eq_f64((D3::cast(0.001) + D3::cast(7.120)).get(), 7.121, 3);
455 assert_eq_f64((D3::cast(1.000) + D3::cast(-3.500)).get(), -2.500, 3);
456
457 // Checked addition.
458 assert_eq!(
459 D3::cast(1.000).checked_add(&D3::cast(2.000)),
460 Some(D3::cast(3.000))
461 );
462 assert_eq!(
463 Decimal32::<0>(i32::MAX).checked_add(&Decimal32::<0>(1)),
464 None
465 );
466
467 // Wrapping addition.
468 assert_eq!(
469 Decimal32::<0>(i32::MAX).wrapping_add(&Decimal32::<0>(1)),
470 Decimal32::<0>(i32::MIN)
471 );
472
473 // Subtraction
474 assert_eq_f64((D3::cast(7.121) - D3::cast(0.001)).get(), 7.120, 3);
475 assert_eq_f64((D3::cast(1.000) - D3::cast(3.500)).get(), -2.500, 3);
476
477 // Checked subtraction
478 assert_eq!(
479 D3::cast(5.000).checked_sub(&D3::cast(2.000)),
480 Some(D3::cast(3.000))
481 );
482 assert_eq!(
483 Decimal32::<0>(i32::MIN).checked_sub(&Decimal32::<0>(1)),
484 None
485 );
486
487 // Wrapping subtraction
488 assert_eq!(
489 Decimal32::<0>(i32::MIN).wrapping_sub(&Decimal32::<0>(1)),
490 Decimal32::<0>(i32::MAX)
491 );
492 }
493
494 #[test]
495 fn identities() {
496 assert_eq!(D3::default(), D3::ZERO);
497
498 // Commutative
499 assert_eq!(D3::cast(3.400) + D3::ZERO, D3::cast(3.400));
500 assert_eq!(D3::ZERO + D3::cast(3.400), D3::cast(3.400));
501
502 // Zero
503 assert_eq!(D3::cast(1.200) - D3::ZERO, D3::cast(1.200));
504 }
505
506 #[test]
507 fn ordering() {
508 let neg = D3::cast(-1.000);
509 let zero = D3::ZERO;
510 let pos = D3::cast(1.000);
511 assert!(neg < zero);
512 assert!(zero < pos);
513 assert!(neg < pos);
514 assert_eq!(zero, D3::cast(0.000));
515 }
516
517 #[test]
518 fn negation() {
519 let num = D3::cast(1.234);
520 assert_eq!(-num, D3::cast(-1.234));
521 assert_eq!(-(-num), num);
522
523 assert_eq!(-Decimal32::<0>(i32::MIN), Decimal32::<0>(i32::MIN));
524 }
525
526 #[test]
527 fn casting() {
528 // Exact
529 assert_eq_f64(D3::cast(0.001).get(), 0.001, 3);
530 assert_eq_f64(D3::cast(7.120).get(), 7.120, 3);
531 assert_eq_f64(D3::cast(-3.500).get(), -3.500, 3);
532
533 // Truncates
534 assert_eq!(Decimal32::<0>::cast(0.9999_f64).get(), 0.0_f64);
535 }
536
537 #[test]
538 fn try_from() {
539 assert!(D3::try_from(0.001_f32).is_ok());
540 assert!(D3::try_from(7.120_f32).is_ok());
541 assert!(D3::try_from(0.0_f32).is_ok());
542
543 // 1/3 is not representable at any finite decimal precision
544 assert!(matches!(
545 D3::try_from(1.0_f32 / 3.0_f32),
546 Err(DecimalErr::Lossy)
547 ));
548 // But cast accepts it
549 assert_eq_f64(D3::cast(1. / 3.).get(), 0.333, 3);
550
551 // f32::try_from permits lossless.
552 let d = D3::cast(7.120);
553 let f: f32 = f32::try_from(d).expect("lossless into f32");
554 assert_eq_f64(f.into(), 7.120, 3);
555
556 // f32::try_from does not permit loss.
557 assert!(f32::try_from(Decimal32::<1>(16_777_217_i32)).is_err());
558 assert!(f32::try_from(Decimal32::<1>(-16_777_217_i32)).is_err());
559 }
560
561 /// Checks equality within the given precision.
562 pub fn assert_eq_f64(left: f64, right: f64, precision: u32) {
563 let tolerance = 1.0 / f64::from(super::scalar(precision));
564 assert!(
565 (left - right).abs() < tolerance,
566 "equality failed: {left:?} != {right:?} (tolerance {tolerance})"
567 );
568 }
569}