quant-primitives 0.7.0

Pure trading primitives — candles, intervals, symbols, currencies, asset taxonomy
Documentation
//! Precision boundary between `FinancialValue` (Decimal) and `StatisticalValue` (f64).
//!
//! Every crossing between the two representations passes through one of the two
//! checked helpers in this module. Silent fallbacks (`d.to_f64().unwrap_or(0.0)`,
//! `Decimal::try_from(f).unwrap_or(Decimal::ONE)`) are forbidden by the
//! `specs/precision/financial-precision-boundary.md` published-language spec in
//! qbot-core — every caller must propagate the error or surface it.
//!
//! # When to use
//!
//! - `decimal_to_f64_checked`: entering a statistical kernel (covariance,
//!   regression, HRP, z-score comparison). The kernel consumes `f64` because
//!   `Decimal` lacks the required math (`sqrt`, `ln`, eigendecomposition).
//! - `f64_to_decimal_checked`: exiting a statistical kernel back into any
//!   booked value (position size, hedge ratio, stop multiplier). The guard
//!   ensures NaN / ±Inf — which every well-behaved estimator produces on
//!   degenerate inputs — refuses to become a silent zero or one.
//!
//! The functions return [`Result`] because the boundary is the correct place
//! to raise a domain error: a covariance collapse, a regression that failed
//! to converge, a rate-limiter returning ∞ backoff — all legitimate events
//! the domain layer must decide how to handle. Converting them to arbitrary
//! defaults hides real failures.
//!
//! # Reference
//!
//! - Spec: `specs/precision/financial-precision-boundary.md` (qbot-core).
//! - Historical scars: `hedge_sizing.rs:109` (NaN β → 1:1 hedge),
//!   `pair_simulation/cost.rs:34` (silent zero z-score).

use rust_decimal::prelude::{FromPrimitive, ToPrimitive};
use rust_decimal::Decimal;

/// Classification of a rejected non-finite `f64` at a precision boundary.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NonFiniteKind {
    /// `f64::NAN`.
    Nan,
    /// `f64::INFINITY`.
    PositiveInfinity,
    /// `f64::NEG_INFINITY`.
    NegativeInfinity,
}

impl std::fmt::Display for NonFiniteKind {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Nan => write!(f, "NaN"),
            Self::PositiveInfinity => write!(f, "+Inf"),
            Self::NegativeInfinity => write!(f, "-Inf"),
        }
    }
}

/// Error returned by the precision-boundary conversion helpers.
///
/// Rich variants — never `bool`, never a sentinel value. Callers must decide
/// per-variant whether to propagate, substitute a documented default, or
/// abort the containing operation.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum PrecisionBoundaryError {
    /// An `f64 → Decimal` crossing received a non-finite input.
    ///
    /// Produced by every estimator that can diverge: covariance on a zero-variance
    /// series, regression on a rank-deficient design matrix, log of a non-positive
    /// return, sqrt of a negative residual.
    #[error("non-finite f64 at precision boundary: {kind}")]
    NonFiniteInput {
        /// Which non-finite value was seen.
        kind: NonFiniteKind,
    },

    /// A conversion succeeded in representability but violated a domain constraint.
    ///
    /// Example: a Decimal too large for f64, or an f64 whose magnitude exceeds
    /// the Decimal 96-bit mantissa range.
    #[error("value out of precision-boundary domain: {reason}")]
    OutOfDomain {
        /// Static reason — chosen from a closed set so downstream log
        /// aggregators can key on it.
        reason: &'static str,
    },

    /// Rounding to the configured scale overflowed.
    ///
    /// Triggered when an f64 → Decimal conversion produces a value that cannot
    /// be rounded to `scale` decimal places without exceeding the Decimal range.
    #[error("rounding overflow at scale {scale}")]
    RoundingOverflow {
        /// The scale (decimal places) that was requested.
        scale: u32,
    },
}

/// The canonical rounding scale for `f64 → Decimal` crossings.
///
/// Six decimal places is the reference convention established by the original
/// `hrp::decimal_from_f64` helper in qbot-core domain-indicators — deep enough
/// for regression coefficients and HRP weights, shallow enough that rounding
/// reliably cancels IEEE-754 representation noise.
pub const PRECISION_SCALE: u32 = 6;

/// Canonical `Decimal → f64` conversion at a precision boundary.
///
/// Used by any caller that must feed a Decimal into a statistical kernel
/// (regression, covariance, HRP, z-score math). Returns `Err(OutOfDomain)` if
/// the Decimal cannot be represented as an `f64` — in practice only extremely
/// large Decimals (> ~1.7e308) trigger this, but the guard exists so callers
/// never receive a silent Inf.
///
/// Forbidden forms this replaces:
/// ```ignore
/// let f = d.to_f64().unwrap_or(0.0);  // silent zero — FORBIDDEN
/// let f = d.to_f64().unwrap();         // panic — FORBIDDEN
/// ```
///
/// # Example
/// ```
/// use quant_primitives::precision_boundary::decimal_to_f64_checked;
/// use rust_decimal_macros::dec;
///
/// let z_score = decimal_to_f64_checked(dec!(1.5)).unwrap();
/// assert!((z_score - 1.5).abs() < 1e-10);
/// ```
pub fn decimal_to_f64_checked(value: Decimal) -> Result<f64, PrecisionBoundaryError> {
    value.to_f64().ok_or(PrecisionBoundaryError::OutOfDomain {
        reason: "Decimal outside representable f64 range",
    })
}

/// Canonical `f64 → Decimal` conversion at a precision boundary.
///
/// Validates finiteness, rejects NaN / ±Inf, rounds to [`PRECISION_SCALE`]
/// decimal places. Used by any caller that must feed a statistical output
/// back into a booked value (position size, hedge ratio, stop multiplier).
///
/// Forbidden forms this replaces:
/// ```ignore
/// let d = Decimal::try_from(f).unwrap_or(Decimal::ONE);  // silent default — FORBIDDEN
/// let d = Decimal::from_f64(f).unwrap();                  // panic — FORBIDDEN
/// ```
///
/// # Example
/// ```
/// use quant_primitives::precision_boundary::f64_to_decimal_checked;
/// use rust_decimal_macros::dec;
///
/// let beta = f64_to_decimal_checked(1.234567891).unwrap();
/// assert_eq!(beta, dec!(1.234568));  // rounded to 6dp
///
/// let err = f64_to_decimal_checked(f64::NAN).unwrap_err();
/// assert!(matches!(
///     err,
///     quant_primitives::precision_boundary::PrecisionBoundaryError::NonFiniteInput { .. },
/// ));
/// ```
pub fn f64_to_decimal_checked(value: f64) -> Result<Decimal, PrecisionBoundaryError> {
    if !value.is_finite() {
        return Err(PrecisionBoundaryError::NonFiniteInput {
            kind: if value.is_nan() {
                NonFiniteKind::Nan
            } else if value.is_sign_positive() {
                NonFiniteKind::PositiveInfinity
            } else {
                NonFiniteKind::NegativeInfinity
            },
        });
    }
    let decimal = Decimal::from_f64(value).ok_or(PrecisionBoundaryError::OutOfDomain {
        reason: "f64 magnitude exceeds Decimal 96-bit mantissa range",
    })?;
    // `round_dp` is infallible on any representable Decimal — rounding truncates
    // digits without altering magnitude. [`PrecisionBoundaryError::RoundingOverflow`]
    // is reserved for a future API (e.g. when rust_decimal exposes a fallible
    // rounding primitive) so callers can already pattern-match on it.
    Ok(decimal.round_dp(PRECISION_SCALE))
}

#[cfg(test)]
#[path = "precision_boundary_tests.rs"]
mod tests;