Skip to main content

quant_primitives/
precision_boundary.rs

1//! Precision boundary between `FinancialValue` (Decimal) and `StatisticalValue` (f64).
2//!
3//! Every crossing between the two representations passes through one of the two
4//! checked helpers in this module. Silent fallbacks (`d.to_f64().unwrap_or(0.0)`,
5//! `Decimal::try_from(f).unwrap_or(Decimal::ONE)`) are forbidden by the
6//! `specs/precision/financial-precision-boundary.md` published-language spec in
7//! qbot-core — every caller must propagate the error or surface it.
8//!
9//! # When to use
10//!
11//! - `decimal_to_f64_checked`: entering a statistical kernel (covariance,
12//!   regression, HRP, z-score comparison). The kernel consumes `f64` because
13//!   `Decimal` lacks the required math (`sqrt`, `ln`, eigendecomposition).
14//! - `f64_to_decimal_checked`: exiting a statistical kernel back into any
15//!   booked value (position size, hedge ratio, stop multiplier). The guard
16//!   ensures NaN / ±Inf — which every well-behaved estimator produces on
17//!   degenerate inputs — refuses to become a silent zero or one.
18//!
19//! The functions return [`Result`] because the boundary is the correct place
20//! to raise a domain error: a covariance collapse, a regression that failed
21//! to converge, a rate-limiter returning ∞ backoff — all legitimate events
22//! the domain layer must decide how to handle. Converting them to arbitrary
23//! defaults hides real failures.
24//!
25//! # Reference
26//!
27//! - Spec: `specs/precision/financial-precision-boundary.md` (qbot-core).
28//! - Historical scars: `hedge_sizing.rs:109` (NaN β → 1:1 hedge),
29//!   `pair_simulation/cost.rs:34` (silent zero z-score).
30
31use rust_decimal::prelude::{FromPrimitive, ToPrimitive};
32use rust_decimal::Decimal;
33
34/// Classification of a rejected non-finite `f64` at a precision boundary.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum NonFiniteKind {
37    /// `f64::NAN`.
38    Nan,
39    /// `f64::INFINITY`.
40    PositiveInfinity,
41    /// `f64::NEG_INFINITY`.
42    NegativeInfinity,
43}
44
45impl std::fmt::Display for NonFiniteKind {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        match self {
48            Self::Nan => write!(f, "NaN"),
49            Self::PositiveInfinity => write!(f, "+Inf"),
50            Self::NegativeInfinity => write!(f, "-Inf"),
51        }
52    }
53}
54
55/// Error returned by the precision-boundary conversion helpers.
56///
57/// Rich variants — never `bool`, never a sentinel value. Callers must decide
58/// per-variant whether to propagate, substitute a documented default, or
59/// abort the containing operation.
60#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
61pub enum PrecisionBoundaryError {
62    /// An `f64 → Decimal` crossing received a non-finite input.
63    ///
64    /// Produced by every estimator that can diverge: covariance on a zero-variance
65    /// series, regression on a rank-deficient design matrix, log of a non-positive
66    /// return, sqrt of a negative residual.
67    #[error("non-finite f64 at precision boundary: {kind}")]
68    NonFiniteInput {
69        /// Which non-finite value was seen.
70        kind: NonFiniteKind,
71    },
72
73    /// A conversion succeeded in representability but violated a domain constraint.
74    ///
75    /// Example: a Decimal too large for f64, or an f64 whose magnitude exceeds
76    /// the Decimal 96-bit mantissa range.
77    #[error("value out of precision-boundary domain: {reason}")]
78    OutOfDomain {
79        /// Static reason — chosen from a closed set so downstream log
80        /// aggregators can key on it.
81        reason: &'static str,
82    },
83
84    /// Rounding to the configured scale overflowed.
85    ///
86    /// Triggered when an f64 → Decimal conversion produces a value that cannot
87    /// be rounded to `scale` decimal places without exceeding the Decimal range.
88    #[error("rounding overflow at scale {scale}")]
89    RoundingOverflow {
90        /// The scale (decimal places) that was requested.
91        scale: u32,
92    },
93}
94
95/// The canonical rounding scale for `f64 → Decimal` crossings.
96///
97/// Six decimal places is the reference convention established by the original
98/// `hrp::decimal_from_f64` helper in qbot-core domain-indicators — deep enough
99/// for regression coefficients and HRP weights, shallow enough that rounding
100/// reliably cancels IEEE-754 representation noise.
101pub const PRECISION_SCALE: u32 = 6;
102
103/// Canonical `Decimal → f64` conversion at a precision boundary.
104///
105/// Used by any caller that must feed a Decimal into a statistical kernel
106/// (regression, covariance, HRP, z-score math). Returns `Err(OutOfDomain)` if
107/// the Decimal cannot be represented as an `f64` — in practice only extremely
108/// large Decimals (> ~1.7e308) trigger this, but the guard exists so callers
109/// never receive a silent Inf.
110///
111/// Forbidden forms this replaces:
112/// ```ignore
113/// let f = d.to_f64().unwrap_or(0.0);  // silent zero — FORBIDDEN
114/// let f = d.to_f64().unwrap();         // panic — FORBIDDEN
115/// ```
116///
117/// # Example
118/// ```
119/// use quant_primitives::precision_boundary::decimal_to_f64_checked;
120/// use rust_decimal_macros::dec;
121///
122/// let z_score = decimal_to_f64_checked(dec!(1.5)).unwrap();
123/// assert!((z_score - 1.5).abs() < 1e-10);
124/// ```
125pub fn decimal_to_f64_checked(value: Decimal) -> Result<f64, PrecisionBoundaryError> {
126    value.to_f64().ok_or(PrecisionBoundaryError::OutOfDomain {
127        reason: "Decimal outside representable f64 range",
128    })
129}
130
131/// Canonical `f64 → Decimal` conversion at a precision boundary.
132///
133/// Validates finiteness, rejects NaN / ±Inf, rounds to [`PRECISION_SCALE`]
134/// decimal places. Used by any caller that must feed a statistical output
135/// back into a booked value (position size, hedge ratio, stop multiplier).
136///
137/// Forbidden forms this replaces:
138/// ```ignore
139/// let d = Decimal::try_from(f).unwrap_or(Decimal::ONE);  // silent default — FORBIDDEN
140/// let d = Decimal::from_f64(f).unwrap();                  // panic — FORBIDDEN
141/// ```
142///
143/// # Example
144/// ```
145/// use quant_primitives::precision_boundary::f64_to_decimal_checked;
146/// use rust_decimal_macros::dec;
147///
148/// let beta = f64_to_decimal_checked(1.234567891).unwrap();
149/// assert_eq!(beta, dec!(1.234568));  // rounded to 6dp
150///
151/// let err = f64_to_decimal_checked(f64::NAN).unwrap_err();
152/// assert!(matches!(
153///     err,
154///     quant_primitives::precision_boundary::PrecisionBoundaryError::NonFiniteInput { .. },
155/// ));
156/// ```
157pub fn f64_to_decimal_checked(value: f64) -> Result<Decimal, PrecisionBoundaryError> {
158    if !value.is_finite() {
159        return Err(PrecisionBoundaryError::NonFiniteInput {
160            kind: if value.is_nan() {
161                NonFiniteKind::Nan
162            } else if value.is_sign_positive() {
163                NonFiniteKind::PositiveInfinity
164            } else {
165                NonFiniteKind::NegativeInfinity
166            },
167        });
168    }
169    let decimal = Decimal::from_f64(value).ok_or(PrecisionBoundaryError::OutOfDomain {
170        reason: "f64 magnitude exceeds Decimal 96-bit mantissa range",
171    })?;
172    // `round_dp` is infallible on any representable Decimal — rounding truncates
173    // digits without altering magnitude. [`PrecisionBoundaryError::RoundingOverflow`]
174    // is reserved for a future API (e.g. when rust_decimal exposes a fallible
175    // rounding primitive) so callers can already pattern-match on it.
176    Ok(decimal.round_dp(PRECISION_SCALE))
177}
178
179#[cfg(test)]
180#[path = "precision_boundary_tests.rs"]
181mod tests;