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;