diff --git a/.gitignore b/.gitignore index 2f7896d..f9e7ede 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ +__pycache__ + target/ diff --git a/Makefile b/Makefile index 0a29a54..f758939 100644 --- a/Makefile +++ b/Makefile @@ -13,6 +13,14 @@ rs-lint: ## Lint rust code rs-test: ## Run rust tests with all features @cargo test --all-features +.PHONY: docs +docs: ## build the rust documentation + @cargo doc --all-features --no-deps + +.PHONY: docs-open +docs-open: ## build the rust documentation and open it in the browser + @cargo doc --all-features --no-deps --open + .PHONY: rs-publish rs-publish: ## publish the crate to crates.io @cargo publish --token $(CARGO_REGISTRY_TOKEN) diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..80935e8 --- /dev/null +++ b/docs/design.md @@ -0,0 +1,217 @@ +This page records the decisions behind [`Dec`](crate::Dec) and the reasoning +that led to them. It is the place to look before changing how a value is +represented, how an operation reports failure, or how two values compare. + +## Goal + +Troy provides numeric primitives for high frequency trading, where a decimal +operation sits on the critical path between a market data message and an order. +Correctness and latency are the two priorities, and where they conflict the +design prefers a representation that makes the fast case branch-free over one +that makes the failing case convenient. + +`Dec` is deliberately not a general purpose decimal. It carries a fixed scale, +so no operation ever renegotiates it, and no value carries a scale field that +has to be read, compared, or aligned. + +## Representation + +A `Dec` is a single `i128` holding the value scaled by `10^SCALE`, where +[`Dec::SCALE`](crate::Dec::SCALE) is 18. The type is `Copy`, 16 bytes, and has +no niche beyond the one described below. The +[memory layout](crate::memory_layout) page compares those 16 bytes with the +other decimals the benchmarks measure. + +```rust +use troy::{Dec, dec}; + +assert_eq!(dec!(1.5).into_raw(), 1_500_000_000_000_000_000); +assert_eq!(Dec::ONE.into_raw(), 1_000_000_000_000_000_000); +``` + +Eighteen places is enough for every venue's price and size increment with room +left for intermediate products, and it leaves a finite range of roughly +±1.7e20 — seven orders of magnitude above any notional that will ever be +priced. + +## The finite range is symmetric + +`i128` is asymmetric: `i128::MIN` has no positive counterpart, so `-x` and +`x.abs()` are partial functions on it. Rather than special case `2^127` in +every magnitude check, `Dec` holds `i128::MIN` out of the finite range: + +| constant | raw | +| --- | --- | +| [`Dec::MAX`](crate::Dec::MAX) | `i128::MAX` | +| [`Dec::MIN`](crate::Dec::MIN) | `-i128::MAX` | +| [`Dec::NAN`](crate::Dec::NAN) | `i128::MIN` | + +Reserving one value in `2^128` buys a range where negation and absolute value +are total and exact, and it leaves exactly one spare bit pattern. + +```rust +use troy::Dec; + +assert_eq!(-Dec::MIN, Dec::MAX); +assert_eq!(Dec::MIN.into_raw(), -Dec::MAX.into_raw()); +``` + +## The spare pattern is NaN + +That spare pattern is [`Dec::NAN`](crate::Dec::NAN), the not-a-number state. +Every operation that leaves the finite range returns it, and every operation +handed it returns it, so a fault propagates to wherever the result is finally +examined instead of being resolved at the point it occurs. + +The alternatives were considered and rejected: + +- **Saturating** answers an overflow with a plausible looking figure. A price + clamped to `Dec::MAX` passes every downstream sanity check and reaches the + wire as an order. +- **Panicking** puts an abort in a matching engine's hot path, which is a worse + failure mode than a wrong number in most trading systems. +- **`Option` everywhere** costs an unwrap at every step of an expression and + makes the common path read like the failing one. + +```rust +use troy::{Dec, dec}; + +// overflow poisons the result rather than clamping it +assert!((Dec::MAX + Dec::ONE).is_nan()); +assert!((Dec::MAX * dec!(2)).is_nan()); + +// and the poison survives everything downstream +assert!((Dec::NAN * dec!(0)).is_nan()); +assert!((Dec::NAN - Dec::NAN).is_nan()); +assert!(Dec::NAN.abs().is_nan()); +assert!(Dec::NAN.round_dp(2).is_nan()); +``` + +Callers who want the fault resolved on the spot still have the explicit forms: +`checked_*` returns `None`, `saturating_*` clamps to the finite bounds. Both +treat a NaN operand as a failure. + +```rust +use troy::Dec; + +assert_eq!(Dec::MAX.checked_add(Dec::ONE), None); +assert_eq!(Dec::MAX.saturating_add(Dec::ONE), Dec::MAX); +assert_eq!(Dec::NAN.checked_add(Dec::ONE), None); +assert!(Dec::NAN.saturating_add(Dec::ONE).is_nan()); +``` + +## Comparison keeps a total order + +This is the one place `Dec` deliberately departs from IEEE 754. `Dec::NAN` +**equals itself** and orders **below** [`Dec::MIN`](crate::Dec::MIN): + +```rust +use troy::Dec; + +assert_eq!(Dec::NAN, Dec::NAN); +assert!(Dec::NAN < Dec::MIN); +``` + +An IEEE NaN is unordered, which would force `PartialEq` to be irreflexive and +so remove `Eq`, and `Ord` with it. For a decimal whose primary container is a +sorted book that is too high a price: `BTreeMap`, `HashMap` keys, +`sort`, `binary_search`, `min`/`max` and `#[derive(Eq)]` on any struct holding +a `Dec` all depend on those traits. Comparison is also the cheapest operation +in the crate at roughly one `i128` compare, and IEEE semantics would double it +for no benefit on the path that matters. + +The justification is that a `Dec` NaN is not an IEEE NaN. It never arises from +a legitimate calculation like `0.0 / 0.0`; it means an overflow happened, which +is a bug in the inputs or in an accumulation. Treating it as an ordered +sentinel is both cheaper and easier to reason about. + +The consequence to be aware of: **a NaN wins any `min` reduction and sorts to +the front.** Ordering it below `Dec::MIN` rather than above `Dec::MAX` is +chosen so that a poisoned value is conspicuous at the head of a sorted book +rather than buried at the tail. + +```rust +use troy::Dec; + +let values = [Dec::ZERO, Dec::NAN, Dec::MIN]; +assert_eq!(values.iter().min(), Some(&Dec::NAN)); +``` + +## Check at the boundary + +Because arithmetic propagates rather than reports, the check belongs where a +value leaves the system — an order about to be sent, a figure about to be +persisted or published — not at every intermediate step. +[`is_finite`](crate::Dec::is_finite) is that check. + +```rust +use troy::{Dec, dec}; + +fn submit(price: Dec, size: Dec) -> Option { + let notional = price * size; + notional.is_finite().then_some(notional) +} + +assert_eq!(submit(dec!(104_237.25), dec!(0.5)), Some(dec!(52_118.625))); +assert_eq!(submit(Dec::MAX, Dec::MAX), None); +``` + +A NaN is neither positive, negative, nor zero, so the sign predicates cannot be +used as a finiteness test: + +```rust +use troy::Dec; + +assert!(!Dec::NAN.is_zero()); +assert!(!Dec::NAN.is_sign_negative()); +assert!(!Dec::NAN.is_sign_positive()); +``` + +## Cost: fold the check into an existing one + +The NaN state was measured, not assumed, and the result shaped the +implementation. Testing `is_nan()` at the top of every operation cost 15% on +multiplication, 43% on `ceil` and 51% on `to_f64`. Nearly all of that was +recovered by observing that **most operations already have a check that a NaN +fails**: + +- `floor` needs no test. Flooring `2^127` rounds away from zero and the + reconstruction then overflows, which is already the NaN answer. +- `ceil` is written as `-floor(-x)`. Wrapping negation maps NaN to itself and + every finite raw to its exact opposite, so it inherits floor's handling. +- `abs` and `neg` use `wrapping_abs`/`wrapping_neg`, which fix `i128::MIN` to + itself. Propagation is free and branchless. +- multiplication tests inside `mul_wide` rather than on the fast path. A NaN + has magnitude `2^127`, which `10^9` does not divide, so it can never take the + exact-division fast path. +- `to_f64` tests after the fast path, for the same reason. + +Where no existing check catches it, the test is real and stays: `trunc`, +`round_dp`, and `round_to_step` all need one. The last is load-bearing rather +than defensive — with a step of `0.001` the fraction rounds *down*, so a NaN +would otherwise reconstruct into the finite range and read as +`-170141183460469231731.687`. + +The general rule for new operations: **look for an overflow or exactness check +that a `2^127` magnitude already fails, and let it do the work.** + +## Prefer a branch to a select + +Overflow handling should be written so the compiler emits a predicted branch +rather than a conditional move. A never-taken branch is speculated through and +never joins the loop-carried dependency chain; a `cmov` sits on it and adds a +cycle to every iteration. + +This is worth more than it sounds. Replacing `saturating_add` — whose +sign-dependent select was on the dependency chain — with a checked add that +branches to NaN made addition **57% faster**, from 2.03 ns to 0.87 ns per +operation, moving `Dec` past `rust_decimal` on that benchmark. + +## Open questions + +- `Display` renders NaN as `"NaN"` but `FromStr` does not accept it, so a NaN + does not round trip through text, and `serde` will serialise a value it + cannot read back. +- `checked_*` returns `None` both for an overflowing result and for a NaN + operand, conflating "too big" with "already invalid". +- `Dec` has no division yet. diff --git a/docs/memory-layout.md b/docs/memory-layout.md new file mode 100644 index 0000000..4d0d4c2 --- /dev/null +++ b/docs/memory-layout.md @@ -0,0 +1,90 @@ +# Memory Layout + +`Dec` is a fixed-scale decimal: the scale is a compile-time constant +([`Dec::SCALE`]), not a field. That single decision is what lets it carry a +full 128-bit coefficient in the same 16 bytes a 96-bit decimal needs, which +matters when a book of quotes has to stay in cache. + +The types below are the ones the [benchmarks](https://github.com/quantmind/troy) +compare against. One character is one byte: + +```text + 0 8 16 24 + │ │ │ │ + troy::Dec [MMMMMMMMMMMMMMMM] 16 B + rust_decimal [FFFFMMMMMMMMMMMM] 16 B + fastnum::D128 [MMMMMMMMMMMMMMMMCCCCCCCC] 24 B + f64 [VVVVVVVV] 8 B + + M = mantissa/coefficient F = flags C = control block +``` + +| type | coefficient | scale | size | +| ---- | ----------- | ----- | ---- | +| `troy::Dec` | 128-bit signed | compile-time constant | 16 B | +| `rust_decimal::Decimal` | 96-bit | runtime, packed into spare `flags` bits | 16 B | +| `fastnum::D128` | 128-bit unsigned | runtime, in a separate control block | 24 B | +| `f64` | 53-bit (binary, inexact) | binary exponent | 8 B | + +``` +assert_eq!(size_of::(), 16); +``` + +## Where the bytes go + +`Dec` is a newtype over `i128`. Every bit is significant digits and sign; the +18 decimal places are implied by [`Dec::SCALE`] and occupy no space at all. + +```text + troy::Dec = i128 + ┌────────────────────────────────────────────┐ + │ sign + 127 mantissa bits │ scale is NOT here: + └────────────────────────────────────────────┘ const SCALE = 18 + 0 127 +``` + +`rust_decimal` uses the .NET `System.Decimal` layout: four `u32` words, three +of which form a 96-bit integer. Because the coefficient stops at 96 bits, +there is a whole spare word for the scale and sign — 23 bits of it unused. + +```text + rust_decimal::Decimal = 4 × u32 + ┌─────────┬─────────┬─────────┬─────────┐ + │ flags │ hi │ lo │ mid │ + └─────────┴─────────┴─────────┴─────────┘ + │ └────────┬────────┘ + │ 96-bit mantissa + │ + └── bits 0-15 unused + bits 16-23 scale (0-28) + bits 24-30 unused + bit 31 sign +``` + +`fastnum` cannot play that trick. A full 128-bit coefficient already fills +16 bytes, so the scale, sign, accumulated signals and rounding mode need a +further 8-byte control block. + +```text + fastnum::Decimal<2> = UInt<2> + ControlBlock + ┌───────────────────────────┬──────────────┐ + │ digits: 2 × u64 = 128 bit │ cb: u64 │ + └───────────────────────────┴──────────────┘ + └── scale (16 bits) + sign + + signals + rounding mode +``` + +## The trade-off + +Against `rust_decimal`, `Dec` buys 32 extra coefficient bits — 38 significant +digits instead of about 28 — for free. Against `fastnum::D128` it holds the +same 128-bit coefficient in two thirds of the space. + +What it gives up is dynamic range. A `rust_decimal` at scale 0 reaches roughly +7.9 × 10^28, because it can spend all 96 bits on the integer part. A `Dec` +always spends 18 digits on the fraction, so [`Dec::MAX`] is `i128::MAX` scaled +down: a little over 1.7 × 10^20. For prices, sizes and notionals that is ample; +for astronomy it is not. + +[`Dec::SCALE`]: crate::Dec::SCALE +[`Dec::MAX`]: crate::Dec::MAX diff --git a/docs/release-notes.md b/docs/release-notes.md index d21d049..672615c 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -11,3 +11,10 @@ pushed, the matching section is extracted by First release: `Dec`, a fixed-scale decimal backed by an `i128` with `Dec::SCALE` decimal places, with parsing, formatting, arithmetic, rounding, and optional `serde` and `rust_decimal` support. + +Arithmetic is exact over a symmetric finite range of roughly +/-1.7e20. +Anything leaving it becomes `Dec::NAN`, which propagates through every later +operation so the fault reaches the boundary where `Dec::is_finite` is checked, +rather than being clamped to a plausible number or raised as a panic on a hot +path. The ordering stays total, so `Dec` remains usable as a map key and in a +sorted book. See the `design` module for the reasoning. diff --git a/src/dec/core.rs b/src/dec/core.rs index 94a38b9..95e2c12 100644 --- a/src/dec/core.rs +++ b/src/dec/core.rs @@ -7,12 +7,18 @@ pub(crate) const ONE_U: u128 = 1_000_000_000_000_000_000; pub(crate) const MAX_RAW: i128 = i128::MAX; -// i128::MIN has no positive counterpart, so admitting it would make negation -// and abs partial and force a 2^127 special case into every magnitude check. -// Reserving it costs one value in 2^128 and buys a symmetric range. +// i128::MIN has no positive counterpart, so admitting it as a finite value +// would make negation and abs partial. Held out of the finite range it becomes +// the one spare bit pattern, and costs one value in 2^128 to reserve. pub(crate) const MIN_RAW: i128 = -i128::MAX; -// i128::MIN is the only raw outside the range, so one compare settles it +// The not-a-number state: overflow, and any operation touching one. It is the +// only raw below MIN_RAW, so a single compare recognises it, and because the +// finite range is symmetric it is also the only raw that wrapping negation and +// wrapping abs map to themselves - propagation through `-` and `abs` is free. +pub(crate) const NAN_RAW: i128 = i128::MIN; + +// the finite range is symmetric, so NAN_RAW is the only raw outside it #[inline(always)] pub(crate) const fn clamp_raw(raw: i128) -> i128 { match raw < MIN_RAW { @@ -21,6 +27,16 @@ pub(crate) const fn clamp_raw(raw: i128) -> i128 { } } +// the result of a multiply that may overflow, or may land on the one raw the +// finite range excludes; either way it is not a number +#[inline(always)] +pub(crate) const fn dec_or_nan(raw: Option) -> Dec { + match raw { + Some(raw) if raw != NAN_RAW => Dec(raw), + _ => Dec::NAN, + } +} + #[inline(always)] pub(crate) const fn check_raw(raw: i128) -> Option { match raw < MIN_RAW { @@ -41,6 +57,25 @@ pub(crate) const POW10: [i128; 39] = { table }; +/// A decimal carrying a fixed [`Dec::SCALE`] decimal places, backed by an +/// `i128` holding the value scaled by `10^SCALE`. +/// +/// Arithmetic is exact between [`Dec::MIN`] and [`Dec::MAX`]. Anything that +/// leaves that range becomes [`Dec::NAN`] and stays NaN through every later +/// operation, so the fault reaches the boundary where [`Dec::is_finite`] is +/// checked rather than being clamped to a plausible number or raised as a +/// panic on a hot path. +/// +/// The ordering is total: NaN equals itself and sorts below [`Dec::MIN`], +/// which keeps `Eq`, `Ord` and `Hash` derivable and so keeps `Dec` usable as a +/// `BTreeMap` or `HashMap` key. See the [design](crate::design) notes for why. +/// +/// ``` +/// use troy::{Dec, dec}; +/// +/// assert_eq!(dec!(2.5) + dec!(0.25), dec!(2.75)); +/// assert!((Dec::MAX + Dec::ONE).is_nan()); +/// ``` #[derive(Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct Dec(pub(crate) i128); @@ -51,24 +86,62 @@ impl Dec { pub const ZERO: Self = Self(0); pub const ONE: Self = Self(ONE_RAW); pub const NEG_ONE: Self = Self(-ONE_RAW); + + /// The smallest finite value, `-Dec::MAX`. The finite range is symmetric, + /// so negation and [`Dec::abs`] are total and exact on it. pub const MIN: Self = Self(MIN_RAW); + + /// The largest finite value, roughly `1.7e20`. pub const MAX: Self = Self(MAX_RAW); + + /// The smallest positive value, one unit in the last place. pub const EPSILON: Self = Self(1); - /// Wrap a raw scaled integer, or `None` for `i128::MIN`, the one value - /// outside the range. Every raw that [`Dec::into_raw`] returns round trips. + /// The not-a-number state. Every operation that leaves the finite range + /// returns it, and every operation given it returns it, so an invalid + /// result carries its own invalidity to wherever it is finally checked + /// with [`Dec::is_finite`]. + /// + /// Unlike an IEEE NaN this one is ordered and reflexive: it equals itself + /// and sorts below [`Dec::MIN`], which is what keeps `Eq` and `Ord` + /// available. It therefore wins any `min` reduction and sorts to the front + /// of a collection. + /// + /// ``` + /// use troy::Dec; + /// + /// assert_eq!(Dec::NAN, Dec::NAN); + /// assert!(Dec::NAN < Dec::MIN); + /// assert!(!Dec::NAN.is_finite()); + /// ``` + pub const NAN: Self = Self(NAN_RAW); + + /// Wrap a raw scaled integer. Total: `i128::MIN` is [`Dec::NAN`], so every + /// raw round trips through [`Dec::into_raw`]. #[inline(always)] - pub const fn from_raw(raw: i128) -> Option { - match check_raw(raw) { - Some(raw) => Some(Self(raw)), - None => None, - } + pub const fn from_raw(raw: i128) -> Self { + Self(raw) } - /// Wrap a raw scaled integer, clamping `i128::MIN` to [`Dec::MIN`]. + /// Whether this is [`Dec::NAN`], the state every overflow collapses to. + #[inline(always)] + pub const fn is_nan(self) -> bool { + self.0 == NAN_RAW + } + + /// Whether this is an ordinary number, the check to make where a value + /// leaves the system. + /// + /// ``` + /// use troy::{Dec, dec}; + /// + /// assert!(dec!(1.5).is_finite()); + /// assert!(Dec::MAX.is_finite()); + /// assert!(!(Dec::MAX * Dec::MAX).is_finite()); + /// ``` #[inline(always)] - pub const fn from_raw_saturating(raw: i128) -> Self { - Self(clamp_raw(raw)) + pub const fn is_finite(self) -> bool { + self.0 != NAN_RAW } #[inline(always)] @@ -94,31 +167,46 @@ impl Dec { } } + /// Whether this is exactly zero. [`Dec::NAN`] is not. #[inline(always)] pub const fn is_zero(self) -> bool { self.0 == 0 } + /// Whether this is a finite value below zero. [`Dec::NAN`] is neither + /// negative nor positive, so this is not a finiteness test. #[inline(always)] pub const fn is_sign_negative(self) -> bool { - self.0 < 0 + self.0 < 0 && self.is_finite() } + /// Whether this is a value above zero. [`Dec::NAN`] is not. #[inline(always)] pub const fn is_sign_positive(self) -> bool { self.0 > 0 } + /// The magnitude, exact for every finite value because the range is + /// symmetric. [`Dec::NAN`] stays NaN. #[inline(always)] pub const fn abs(self) -> Self { - Self(self.0.saturating_abs()) + // wrapping_abs maps NAN_RAW to itself; every finite raw has an exact + // magnitude, so this is exact and propagates without a branch + Self(self.0.wrapping_abs()) } + /// [`Dec::ONE`], [`Dec::NEG_ONE`] or [`Dec::ZERO`] by sign. [`Dec::NAN`] + /// stays NaN. #[inline(always)] pub const fn signum(self) -> Self { - Self(self.0.signum() * ONE_RAW) + match self.is_nan() { + true => Self::NAN, + false => Self(self.0.signum() * ONE_RAW), + } } + /// Convert to `f64`, rounding to the nearest representable double. + /// [`Dec::NAN`] becomes `f64::NAN`. #[inline(always)] pub fn to_f64(self) -> f64 { // i128 -> f64 is a __floattidf libcall, i64 -> f64 is one instruction. @@ -134,9 +222,18 @@ impl Dec { }; return signed as f64 / CHUNK_F64; } + // 10^9 does not divide 2^127, so NaN never takes the path above and + // the test costs nothing on it + if self.is_nan() { + return f64::NAN; + } self.0 as f64 / ONE_RAW as f64 } + /// Convert from `f64`, or `None` when the value is not finite or does not + /// fit the finite range. This never yields [`Dec::NAN`]: a conversion + /// reports failure directly, since there is no earlier computation for a + /// NaN to have propagated from. #[inline(always)] pub fn from_f64(value: f64) -> Option { // NaN would otherwise cast to zero; every other out-of-range value, @@ -154,53 +251,85 @@ impl Dec { .map(Self) } + /// The largest whole number at or below this value, or [`Dec::NAN`] when + /// that leaves the finite range, as it does for [`Dec::MIN`]. #[inline(always)] pub const fn floor(self) -> Self { - Self(clamp_raw( - self.0.div_euclid(ONE_RAW).saturating_mul(ONE_RAW), - )) + // NaN needs no test of its own: flooring 2^127 rounds away from zero, + // and the product then overflows, which is already the NaN answer + dec_or_nan(self.0.div_euclid(ONE_RAW).checked_mul(ONE_RAW)) } + /// The smallest whole number at or above this value, or [`Dec::NAN`] when + /// that leaves the finite range, as it does for [`Dec::MAX`]. #[inline(always)] pub const fn ceil(self) -> Self { - // the leading negation is exact: the range is symmetric - Self(clamp_raw( - self.0 - .saturating_neg() - .div_euclid(ONE_RAW) - .saturating_mul(ONE_RAW) - .saturating_neg(), - )) + // ceil(x) = -floor(-x). Wrapping negation maps NaN to itself and every + // finite raw to its exact opposite, so this inherits floor's handling + let floor = Self(self.0.wrapping_neg()).floor(); + Self(floor.0.wrapping_neg()) } + /// The whole part, rounding towards zero. Always finite for a finite + /// input; [`Dec::NAN`] stays NaN. #[inline(always)] pub const fn trunc(self) -> Self { - Self(clamp_raw((self.0 / ONE_RAW).saturating_mul(ONE_RAW))) + match self.is_nan() { + true => Self::NAN, + // the magnitude only shrinks, so the product cannot overflow + false => Self((self.0 / ONE_RAW) * ONE_RAW), + } } + /// The sum, or `None` if it leaves the finite range or either side is + /// [`Dec::NAN`]. Use this where an overflow should be handled on the spot + /// rather than propagated. #[inline(always)] pub const fn checked_add(self, rhs: Self) -> Option { + if self.is_nan() || rhs.is_nan() { + return None; + } match self.0.checked_add(rhs.0) { - Some(raw) => Self::from_raw(raw), + Some(raw) => match check_raw(raw) { + Some(raw) => Some(Self(raw)), + None => None, + }, None => None, } } + /// The difference, or `None` if it leaves the finite range or either side + /// is [`Dec::NAN`]. #[inline(always)] pub const fn checked_sub(self, rhs: Self) -> Option { + if self.is_nan() || rhs.is_nan() { + return None; + } match self.0.checked_sub(rhs.0) { - Some(raw) => Self::from_raw(raw), + Some(raw) => match check_raw(raw) { + Some(raw) => Some(Self(raw)), + None => None, + }, None => None, } } + /// The product, or `None` if it leaves the finite range or either side is + /// [`Dec::NAN`]. Exact, with the excess below [`Dec::SCALE`] rounded half + /// away from zero. #[inline(always)] pub fn checked_mul(self, rhs: Self) -> Option { mul_raw(self.0, rhs.0).map(Self) } + /// The product, clamped to [`Dec::MIN`] or [`Dec::MAX`] on overflow. + /// A [`Dec::NAN`] operand still gives NaN: there is no sign to clamp + /// towards, and clamping an unknown would invent one. #[inline(always)] pub fn saturating_mul(self, rhs: Self) -> Self { + if self.is_nan() || rhs.is_nan() { + return Self::NAN; + } match self.checked_mul(rhs) { Some(value) => value, None => match (self.0 < 0) != (rhs.0 < 0) { @@ -210,22 +339,37 @@ impl Dec { } } + /// The sum, clamped to [`Dec::MIN`] or [`Dec::MAX`] on overflow. A + /// [`Dec::NAN`] operand still gives NaN. #[inline(always)] pub const fn saturating_add(self, rhs: Self) -> Self { - Self(clamp_raw(self.0.saturating_add(rhs.0))) + match self.is_nan() || rhs.is_nan() { + true => Self::NAN, + false => Self(clamp_raw(self.0.saturating_add(rhs.0))), + } } + /// The difference, clamped to [`Dec::MIN`] or [`Dec::MAX`] on overflow. A + /// [`Dec::NAN`] operand still gives NaN. #[inline(always)] pub const fn saturating_sub(self, rhs: Self) -> Self { - Self(clamp_raw(self.0.saturating_sub(rhs.0))) + match self.is_nan() || rhs.is_nan() { + true => Self::NAN, + false => Self(clamp_raw(self.0.saturating_sub(rhs.0))), + } } // cannot overflow: the sum is formed in a wider space than either operand. // A half unit in the last place rounds towards zero rather than away from // it, the one place this type does not round halves away from zero. + /// The value halfway between the two, which cannot overflow because the + /// sum is formed in a wider space. [`Dec::NAN`] on either side gives NaN. #[inline(always)] pub const fn midpoint(self, rhs: Self) -> Self { - Self(self.0.midpoint(rhs.0)) + match self.is_nan() || rhs.is_nan() { + true => Self::NAN, + false => Self(self.0.midpoint(rhs.0)), + } } } @@ -319,7 +463,7 @@ mod tests { ); assert_eq!( Dec::from_str("0.0000000000000000015").unwrap(), - Dec::from_raw(2).unwrap() + Dec::from_raw(2) ); } @@ -327,7 +471,7 @@ mod tests { fn test_parse_exponent_form() { assert_eq!( Dec::from_str("1e-8").unwrap(), - Dec::from_raw(10_000_000_000).unwrap() + Dec::from_raw(10_000_000_000) ); assert_eq!( Dec::from_str("1.5E3").unwrap(), @@ -350,7 +494,7 @@ mod tests { fn test_macro_is_const() { const HALF: Dec = dec!(0.5); const NEGATIVE: Dec = dec!(-1.25); - assert_eq!(HALF, Dec::from_raw(500_000_000_000_000_000).unwrap()); + assert_eq!(HALF, Dec::from_raw(500_000_000_000_000_000)); assert_eq!(NEGATIVE.to_string(), "-1.25"); } @@ -421,12 +565,22 @@ mod tests { } #[test] - fn test_extremes_do_not_overflow() { + fn test_extremes_that_stay_in_range_are_exact() { assert_eq!(Dec::MIN.abs(), Dec::MAX); assert_eq!(-Dec::MIN, Dec::MAX); - assert_eq!(Dec::MIN.floor(), Dec::MIN); - assert_eq!(Dec::MAX.ceil(), Dec::MAX); - assert_eq!(Dec::MIN.round_dp(0), Dec::MIN); + // rounding towards zero cannot leave the range + assert_eq!(Dec::MIN.ceil(), Dec::MIN.trunc()); + assert_eq!(Dec::MAX.floor(), Dec::MAX.trunc()); + } + + // the whole unit past either extreme is outside the finite range, and + // saturating back onto it would report a value a full unit from the truth + #[test] + fn test_extremes_that_leave_the_range_become_nan() { + assert!(Dec::MIN.floor().is_nan()); + assert!(Dec::MAX.ceil().is_nan()); + assert!(Dec::MIN.round_dp(0).is_nan()); + assert!(Dec::MAX.round_dp(0).is_nan()); } } @@ -495,16 +649,12 @@ mod midpoint { #[test] fn test_midpoint_of_an_odd_last_place_rounds_towards_zero() { assert_eq!( + Dec::from_raw(1).midpoint(Dec::from_raw(2)), Dec::from_raw(1) - .unwrap() - .midpoint(Dec::from_raw(2).unwrap()), - Dec::from_raw(1).unwrap() ); assert_eq!( + Dec::from_raw(-1).midpoint(Dec::from_raw(-2)), Dec::from_raw(-1) - .unwrap() - .midpoint(Dec::from_raw(-2).unwrap()), - Dec::from_raw(-1).unwrap() ); } } @@ -684,7 +834,7 @@ mod to_f64_accuracy { let scale = POW10[(state % 10) as usize]; let sign = if state & 1 == 0 { 1 } else { -1 }; let raw = ((state as i128) * scale).saturating_mul(sign); - worst = worst.max(ulps_from_truth(Dec::from_raw(raw).unwrap())); + worst = worst.max(ulps_from_truth(Dec::from_raw(raw))); } assert!(worst <= 1, "drifted {worst} ulp from the nearest f64"); } diff --git a/src/dec/decimal.rs b/src/dec/decimal.rs index 6fcecd9..1bdd066 100644 --- a/src/dec/decimal.rs +++ b/src/dec/decimal.rs @@ -1,4 +1,4 @@ -use super::core::{POW10, div_round}; +use super::core::{POW10, check_raw, div_round}; use super::{Dec, ParseDecError}; use rust_decimal::Decimal; @@ -11,13 +11,14 @@ impl Dec { if scale <= Dec::SCALE { return mantissa .checked_mul(POW10[(Dec::SCALE - scale) as usize]) - .and_then(Self::from_raw); + .and_then(check_raw) + .map(Self::from_raw); } let factor = match POW10.get((scale - Dec::SCALE) as usize) { Some(factor) => *factor, None => return Some(Self::ZERO), }; - Self::from_raw(div_round(mantissa, factor)) + check_raw(div_round(mantissa, factor)).map(Self::from_raw) } /// Widen to a [`Decimal`] at [`Dec::SCALE`] decimal places, or `None` when the diff --git a/src/dec/format.rs b/src/dec/format.rs index 6485360..947afd9 100644 --- a/src/dec/format.rs +++ b/src/dec/format.rs @@ -85,6 +85,9 @@ fn render(buffer: &mut [u8; BUFFER_LEN], raw: i128) -> usize { impl fmt::Display for Dec { #[inline(always)] fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.is_nan() { + return f.write_str("NaN"); + } let mut buffer = [0_u8; BUFFER_LEN]; let length = render(&mut buffer, self.0); f.write_str(std::str::from_utf8(&buffer[..length]).unwrap_or("")) diff --git a/src/dec/mul.rs b/src/dec/mul.rs index be87d00..9209276 100644 --- a/src/dec/mul.rs +++ b/src/dec/mul.rs @@ -43,6 +43,12 @@ pub(crate) fn mul_raw(a: i128, b: i128) -> Option { // finer than 9 decimals: split both at the decimal point, which leaves two // fractions below 10^18 that finish in 64-bit lanes fn mul_wide(a: u128, b: u128, negative: bool) -> Option { + // a NaN operand has magnitude 2^127, which 10^9 does not divide, so it + // always arrives here rather than on the fast path above. Nothing finite + // reaches this magnitude, so one bound recognises it for both operands. + if (a | b) > MAX_RAW as u128 { + return None; + } let (a_int, a_frac) = (a / ONE_U, (a % ONE_U) as u64); let (b_int, b_frac) = (b / ONE_U, (b % ONE_U) as u64); let (tail, remainder) = mul_frac(a_frac, b_frac); @@ -127,7 +133,7 @@ mod multiply { let fine = Dec::from_str("0.0000000001").unwrap(); assert!(div_exact_1e9(fine.into_raw().unsigned_abs()).is_none()); assert_eq!(fine * dec!(3), Dec::from_str("0.0000000003").unwrap()); - assert_eq!(Dec::EPSILON * dec!(1000), Dec::from_raw(1_000).unwrap()); + assert_eq!(Dec::EPSILON * dec!(1000), Dec::from_raw(1_000)); } #[test] diff --git a/src/dec/ops.rs b/src/dec/ops.rs index ea1dd2b..032d5e8 100644 --- a/src/dec/ops.rs +++ b/src/dec/ops.rs @@ -2,19 +2,15 @@ use super::core::Dec; use std::iter::Sum; use std::ops::{Add, AddAssign, Mul, MulAssign, Neg, Sub, SubAssign}; -// The range is +/-1.7e20 and no price, size or notional lives near it, so an -// overflow here is a bug rather than a number: bad input, or an accumulation -// that ran away. Saturating would answer it with a plausible looking figure -// that survives every downstream check, so the operators panic instead. -// `checked_*` reports it, `saturating_*` clamps it, for callers who want to -// decide for themselves. -#[cold] -#[inline(never)] -#[allow(clippy::panic)] -fn overflowed(operation: &str, lhs: Dec, rhs: Dec) -> ! { - panic!("Dec {operation} overflowed: {lhs}, {rhs}") -} - +/// The product, or [`Dec::NAN`] on overflow or from a NaN operand. +/// +/// The finite range is +/-1.7e20 and no price, size or notional lives near it, +/// so an overflow here is a bug rather than a number: bad input, or an +/// accumulation that ran away. Saturating would answer it with a plausible +/// looking figure that survives every downstream check, so the operators +/// return NaN instead and carry the fault to wherever the result is finally +/// examined. [`Dec::checked_mul`] reports it, [`Dec::saturating_mul`] clamps +/// it, for callers who would rather decide on the spot. impl Mul for Dec { type Output = Self; @@ -22,7 +18,7 @@ impl Mul for Dec { fn mul(self, rhs: Self) -> Self { match self.checked_mul(rhs) { Some(value) => value, - None => overflowed("multiplication", self, rhs), + None => Self::NAN, } } } @@ -34,39 +30,42 @@ impl MulAssign for Dec { } } -// exact and total: the range is symmetric, so every value has a negation +/// Negation, which is exact and total: the finite range is symmetric, so every +/// value has a negation, and the NaN pattern is its own. impl Neg for Dec { type Output = Self; #[inline(always)] fn neg(self) -> Self { - Self(-self.0) + Self(self.0.wrapping_neg()) } } +/// The sum, or [`Dec::NAN`] on overflow or from a NaN operand. See +/// [`Dec::checked_add`] and [`Dec::saturating_add`] to handle it on the spot. impl Add for Dec { type Output = Self; #[inline(always)] fn add(self, rhs: Self) -> Self { - debug_assert!( - self.checked_add(rhs).is_some(), - "Dec addition overflowed: {self} + {rhs}" - ); - self.saturating_add(rhs) + match self.checked_add(rhs) { + Some(value) => value, + None => Self::NAN, + } } } +/// The difference, or [`Dec::NAN`] on overflow or from a NaN operand. See +/// [`Dec::checked_sub`] and [`Dec::saturating_sub`] to handle it on the spot. impl Sub for Dec { type Output = Self; #[inline(always)] fn sub(self, rhs: Self) -> Self { - debug_assert!( - self.checked_sub(rhs).is_some(), - "Dec subtraction overflowed: {self} - {rhs}" - ); - self.saturating_sub(rhs) + match self.checked_sub(rhs) { + Some(value) => value, + None => Self::NAN, + } } } @@ -147,22 +146,25 @@ mod conversions { #[test] fn test_raw_round_trip() { - assert_eq!(Dec::from_raw(dec!(1.5).into_raw()), Some(dec!(1.5))); + assert_eq!(Dec::from_raw(dec!(1.5).into_raw()), dec!(1.5)); assert_eq!(Dec::ONE.into_raw(), 1_000_000_000_000_000_000); } #[test] - fn test_i128_min_is_the_one_raw_outside_the_range() { - assert_eq!(Dec::from_raw(i128::MIN), None); - assert_eq!(Dec::from_raw(i128::MAX), Some(Dec::MAX)); - assert_eq!(Dec::from_raw(Dec::MIN.into_raw()), Some(Dec::MIN)); - - assert_eq!(Dec::from_raw_saturating(i128::MIN), Dec::MIN); - assert_eq!(Dec::from_raw_saturating(i128::MAX), Dec::MAX); + fn test_the_reserved_pattern_is_nan() { + assert_eq!(Dec::from_raw(i128::MIN), Dec::NAN); + assert_eq!(Dec::from_raw(i128::MAX), Dec::MAX); + assert!(Dec::NAN.is_nan()); + assert!(!Dec::NAN.is_finite()); + assert!(Dec::MIN.is_finite()); + assert!(Dec::MAX.is_finite()); + assert!(!Dec::NAN.is_sign_negative()); + assert!(!Dec::NAN.is_sign_positive()); + assert!(!Dec::NAN.is_zero()); } #[test] - fn test_the_range_is_symmetric() { + fn test_the_finite_range_is_symmetric() { assert_eq!(-Dec::MIN, Dec::MAX); assert_eq!(-Dec::MAX, Dec::MIN); assert_eq!(Dec::MIN.into_raw(), -Dec::MAX.into_raw()); @@ -220,24 +222,57 @@ mod arithmetic_methods { // saturating would answer an overflow with a plausible looking figure #[test] - #[should_panic(expected = "Dec multiplication overflowed")] - fn test_the_multiplication_operator_panics_on_overflow() { - let _ = Dec::MAX * dec!(2); + fn test_overflow_becomes_nan() { + assert!((Dec::MAX * dec!(2)).is_nan()); + assert!((Dec::MAX * Dec::MAX).is_nan()); + assert!((Dec::MAX + Dec::ONE).is_nan()); + assert!((Dec::MIN - Dec::ONE).is_nan()); } - // addition and subtraction check only in debug, keeping the hot path branchless - #[cfg(debug_assertions)] #[test] - #[should_panic(expected = "Dec addition overflowed")] - fn test_the_addition_operator_panics_on_overflow_in_debug() { - let _ = Dec::MAX + Dec::ONE; + fn test_nan_propagates_through_every_operation() { + for value in [dec!(1), dec!(-1), Dec::ZERO, Dec::MAX, Dec::MIN] { + assert!((Dec::NAN + value).is_nan(), "{value} + NaN"); + assert!((value + Dec::NAN).is_nan(), "NaN + {value}"); + assert!((Dec::NAN - value).is_nan(), "NaN - {value}"); + assert!((value - Dec::NAN).is_nan(), "{value} - NaN"); + assert!((Dec::NAN * value).is_nan(), "NaN * {value}"); + assert!((value * Dec::NAN).is_nan(), "{value} * NaN"); + assert!(Dec::NAN.midpoint(value).is_nan()); + assert!(Dec::NAN.saturating_add(value).is_nan()); + assert!(Dec::NAN.saturating_sub(value).is_nan()); + assert!(Dec::NAN.saturating_mul(value).is_nan()); + assert_eq!(Dec::NAN.checked_add(value), None); + assert_eq!(Dec::NAN.checked_sub(value), None); + assert_eq!(Dec::NAN.checked_mul(value), None); + } + assert!((-Dec::NAN).is_nan()); + assert!(Dec::NAN.abs().is_nan()); + assert!(Dec::NAN.signum().is_nan()); + assert!(Dec::NAN.floor().is_nan()); + assert!(Dec::NAN.ceil().is_nan()); + assert!(Dec::NAN.trunc().is_nan()); } - #[cfg(debug_assertions)] + // NaN times zero is still NaN: the fault outranks the annihilator #[test] - #[should_panic(expected = "Dec subtraction overflowed")] - fn test_the_subtraction_operator_panics_on_overflow_in_debug() { - let _ = Dec::MIN - Dec::ONE; + fn test_nan_times_zero_is_nan() { + assert!((Dec::NAN * Dec::ZERO).is_nan()); + assert!((Dec::ZERO * Dec::NAN).is_nan()); + } + + #[test] + fn test_nan_formats_orders_and_converts() { + assert_eq!(Dec::NAN.to_string(), "NaN"); + assert_eq!(format!("{:?}", Dec::NAN), "NaN"); + assert!(Dec::NAN.to_f64().is_nan()); + // unlike an IEEE NaN this one is reflexive, which keeps Eq and Ord + assert_eq!(Dec::NAN, Dec::NAN); + assert!(Dec::NAN < Dec::MIN); + assert_eq!( + [Dec::ZERO, Dec::NAN, Dec::MIN].iter().min(), + Some(&Dec::NAN) + ); } #[test] diff --git a/src/dec/parse.rs b/src/dec/parse.rs index f1a398e..288369b 100644 --- a/src/dec/parse.rs +++ b/src/dec/parse.rs @@ -213,11 +213,11 @@ mod errors { fn test_const_parsing_reports_failure_without_panicking() { assert_eq!( Dec::parse_const("1.5"), - Dec::from_raw(1_500_000_000_000_000_000) + Some(Dec::from_raw(1_500_000_000_000_000_000)) ); assert_eq!( Dec::parse_const("-0.25"), - Dec::from_raw(-250_000_000_000_000_000) + Some(Dec::from_raw(-250_000_000_000_000_000)) ); assert_eq!(Dec::parse_const(""), None); assert_eq!(Dec::parse_const("1.2.3"), None); diff --git a/src/dec/round.rs b/src/dec/round.rs index 77c2845..20e710c 100644 --- a/src/dec/round.rs +++ b/src/dec/round.rs @@ -1,22 +1,52 @@ -use super::core::{Dec, MAX_RAW, ONE_U, POW10, clamp_raw, div_round}; +use super::core::{Dec, MAX_RAW, NAN_RAW, ONE_U, POW10, dec_or_nan, div_round}; impl Dec { - #[inline(always)] + /// [`Dec::from_f64`] followed by [`Dec::round_dp`], which is how a float + /// carrying binary representation error is best pinned to a known scale. + #[inline(always)] pub fn from_f64_round(value: f64, dp: u32) -> Option { Self::from_f64(value).map(|value| value.round_dp(dp)) } + /// Round to `dp` decimal places, halves away from zero. A no-op once `dp` + /// reaches [`Dec::SCALE`]. Returns [`Dec::NAN`] when the rounded value + /// leaves the finite range, as it does for [`Dec::MIN`] at `dp` 0, and + /// when the input is already NaN. + /// + /// ``` + /// use troy::{Dec, dec}; + /// + /// assert_eq!(dec!(2.5).round_dp(0), dec!(3)); + /// assert_eq!(dec!(-2.5).round_dp(0), dec!(-3)); + /// assert!(Dec::MAX.round_dp(0).is_nan()); + /// ``` #[inline(always)] pub const fn round_dp(self, dp: u32) -> Self { if dp >= Dec::SCALE { + // a no-op at the native scale, NaN included return self; } + if self.is_nan() { + return Self::NAN; + } let factor = POW10[(Dec::SCALE - dp) as usize]; - Self(clamp_raw(div_round(self.0, factor).saturating_mul(factor))) - } - + dec_or_nan(div_round(self.0, factor).checked_mul(factor)) + } + + /// Round to the nearest multiple of `step`, halves away from zero. A + /// non-positive step is a no-op. Returns [`Dec::NAN`] when the result + /// leaves the finite range, and when either side is already NaN. + /// + /// ``` + /// use troy::dec; + /// + /// assert_eq!(dec!(104_237.28).round_to_step(dec!(0.25)), dec!(104_237.25)); + /// ``` #[inline(always)] pub const fn round_to_step(self, step: Self) -> Self { + if self.is_nan() || step.is_nan() { + return Self::NAN; + } if step.0 <= 0 { return self; } @@ -24,7 +54,7 @@ impl Dec { Some(exponent) if exponent <= Dec::SCALE => { Self(round_to_places(self.0, Dec::SCALE - exponent)) } - _ => Self(clamp_raw(div_round(self.0, step.0).saturating_mul(step.0))), + _ => dec_or_nan(div_round(self.0, step.0).checked_mul(step.0)), } } } @@ -60,12 +90,13 @@ const fn round_to_places(raw: i128, dp: u32) -> i128 { }, None => u128::MAX, }; - // the range is symmetric, so one limit serves both signs - let limit = MAX_RAW as u128; - let clamped = if scaled > limit { limit } else { scaled }; + // the finite range is symmetric, so one limit serves both signs + if scaled > MAX_RAW as u128 { + return NAN_RAW; + } match raw < 0 { - true => -(clamped as i128), - false => clamped as i128, + true => -(scaled as i128), + false => scaled as i128, } } @@ -130,7 +161,7 @@ mod round_to_step { fn test_powers_of_ten_match_round_dp() { for value in VALUES { for dp in 0..=Dec::SCALE { - let step = Dec::from_raw(POW10[(Dec::SCALE - dp) as usize]).unwrap(); + let step = Dec::from_raw(POW10[(Dec::SCALE - dp) as usize]); assert_eq!( value.round_to_step(step), value.round_dp(dp), @@ -177,11 +208,20 @@ mod round_to_step { } #[test] - fn test_the_extremes_saturate() { - assert_eq!(Dec::MAX.round_to_step(Dec::ONE), Dec::MAX); - assert_eq!(Dec::MIN.round_to_step(Dec::ONE), Dec::MIN); - assert_eq!(Dec::MAX.round_to_step(dec!(0.25)), Dec::MAX); - assert_eq!(Dec::MIN.round_to_step(dec!(0.25)), Dec::MIN); + fn test_the_extremes_become_nan() { + assert!(Dec::MAX.round_to_step(Dec::ONE).is_nan()); + assert!(Dec::MIN.round_to_step(Dec::ONE).is_nan()); + assert!(Dec::MAX.round_to_step(dec!(0.25)).is_nan()); + assert!(Dec::MIN.round_to_step(dec!(0.25)).is_nan()); + } + + #[test] + fn test_nan_survives_both_rounding_paths() { + assert!(Dec::NAN.round_to_step(Dec::ONE).is_nan()); + assert!(Dec::NAN.round_to_step(dec!(0.25)).is_nan()); + assert!(dec!(1.5).round_to_step(Dec::NAN).is_nan()); + assert!(Dec::NAN.round_dp(2).is_nan()); + assert!(Dec::NAN.round_dp(Dec::SCALE).is_nan()); } #[test] diff --git a/src/mod.rs b/src/mod.rs index 215a145..106f147 100644 --- a/src/mod.rs +++ b/src/mod.rs @@ -1,3 +1,25 @@ +//! Superfast numeric primitives for high frequency trading. +//! +//! The crate is built around [`Dec`], a fixed-scale decimal backed by an +//! `i128` carrying [`Dec::SCALE`] decimal places. Arithmetic is exact within a +//! finite range of roughly +/-1.7e20; anything that leaves that range becomes +//! [`Dec::NAN`], which propagates through every subsequent operation so the +//! fault reaches the boundary where [`Dec::is_finite`] is checked. +//! +//! ``` +//! use troy::{Dec, dec}; +//! +//! let notional = dec!(104_237.25) * dec!(0.00135); +//! assert_eq!(notional, dec!(140.72028750)); +//! assert!(notional.is_finite()); +//! +//! // overflow poisons rather than clamping or panicking +//! assert!((Dec::MAX + Dec::ONE).is_nan()); +//! ``` +//! +//! See the [`design`] module for the reasoning behind the representation, the +//! NaN state, and the ordering. + #![forbid(unsafe_code)] #![deny( clippy::panic, @@ -11,3 +33,16 @@ mod dec; pub use dec::*; + +/// How `Dec` is represented, what the NaN state means, and the rules every +/// operation follows. +#[doc = include_str!("../docs/design.md")] +pub mod design {} + +/// How `Dec` is laid out in memory, and how that compares with other decimals. +#[doc = include_str!("../docs/memory-layout.md")] +pub mod memory_layout {} + +/// Release notes, one section per tagged release. +#[doc = include_str!("../docs/release-notes.md")] +pub mod release_notes {} diff --git a/table.py b/table.py index c0da78e..c2d3301 100644 --- a/table.py +++ b/table.py @@ -11,7 +11,7 @@ def run() -> str: - command = ["cargo", "bench", "-p", "bpt-dec", "--bench", "arithmetic"] + command = ["cargo", "bench", "--bench", "arithmetic"] command += sys.argv[1:] print(" ".join(command), file=sys.stderr) result = subprocess.run(command, capture_output=True, text=True)