From 0d68bfdd11502f507bb0d799398b0f4ca983ea9b Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Sat, 28 Feb 2026 09:39:22 +0100 Subject: [PATCH] fix(common): add serde derives, preserve Ratio finite invariant - Add Serialize/Deserialize to all four financial types - Ratio::ln returns 0.0 for non-positive inputs (prevents NaN leak) - Ratio::exp clamps to f64::MAX on overflow (prevents Inf leak) - Ratio::powi clamps to f64::MAX/MIN on overflow - Add 4 invariant-preservation tests (80 total) Co-Authored-By: Claude Opus 4.6 --- crates/common/src/financial_types.rs | 63 ++++++++++++++++++++++++---- 1 file changed, 54 insertions(+), 9 deletions(-) diff --git a/crates/common/src/financial_types.rs b/crates/common/src/financial_types.rs index 762169b90..a8e44a95e 100644 --- a/crates/common/src/financial_types.rs +++ b/crates/common/src/financial_types.rs @@ -16,6 +16,7 @@ use crate::types::CommonTypeError; use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; use std::fmt; use std::ops::{Add, AddAssign, Div, Mul, Neg, Sub, SubAssign}; use std::str::FromStr; @@ -31,7 +32,7 @@ pub const SCALE: i64 = 1_000_000; /// /// Internally stored as `i64` with a scale of 1,000,000. /// Rejects negative, NaN, and Inf on construction from floats. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub struct Price(i64); impl Price { @@ -323,7 +324,7 @@ impl Default for Price { /// Signed fixed-point quantity with 6 decimal places. /// /// Negative values represent short positions. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub struct Quantity(i64); impl Quantity { @@ -504,7 +505,7 @@ impl Default for Quantity { /// Signed fixed-point monetary amount with 6 decimal places. /// /// Negative values represent losses. Rejects NaN/Inf. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub struct Money(i64); impl Money { @@ -662,7 +663,7 @@ impl Default for Money { /// Validated `f64` newtype. Rejects NaN and Inf on construction. /// /// Division by zero returns `Ratio(0.0)` instead of panicking. -#[derive(Debug, Clone, Copy, PartialOrd)] +#[derive(Debug, Clone, Copy, PartialOrd, Serialize, Deserialize)] pub struct Ratio(f64); impl Ratio { @@ -721,25 +722,41 @@ impl Ratio { Self(self.0.sqrt()) } - /// Natural logarithm. + /// Natural logarithm. Returns `Ratio(0.0)` for non-positive inputs + /// (preserves the finite invariant — `ln(-1)` would be NaN). #[must_use] #[allow(clippy::float_arithmetic)] pub fn ln(self) -> Self { + if self.0 <= 0.0 { + return Self(0.0); + } Self(self.0.ln()) } - /// Exponential (`e^self`). + /// Exponential (`e^self`). Clamps to `f64::MAX` to preserve the finite invariant. #[must_use] #[allow(clippy::float_arithmetic)] pub fn exp(self) -> Self { - Self(self.0.exp()) + let result = self.0.exp(); + if result.is_finite() { + Self(result) + } else { + Self(f64::MAX) + } } - /// Raise to an integer power. + /// Raise to an integer power. Clamps to `f64::MAX`/`f64::MIN` to preserve finite invariant. #[must_use] #[allow(clippy::float_arithmetic)] pub fn powi(self, n: i32) -> Self { - Self(self.0.powi(n)) + let result = self.0.powi(n); + if result.is_finite() { + Self(result) + } else if result.is_sign_positive() { + Self(f64::MAX) + } else { + Self(f64::MIN) + } } /// Clamp value to `[min, max]`. @@ -1416,6 +1433,34 @@ mod tests { assert!((result.to_f64() - (-10.0)).abs() < 1e-6); } + // ===== Ratio invariant preservation ===== + + #[test] + fn ratio_ln_negative_returns_zero() { + let r = Ratio::new(-1.0).unwrap(); + assert_eq!(r.ln().to_f64(), 0.0); // ln(-1) would be NaN; we return 0.0 + } + + #[test] + fn ratio_ln_zero_returns_zero() { + assert_eq!(Ratio::ZERO.ln().to_f64(), 0.0); // ln(0) would be -Inf; we return 0.0 + } + + #[test] + fn ratio_exp_overflow_clamps() { + let r = Ratio::new(1000.0).unwrap(); + let result = r.exp(); + assert!(result.to_f64().is_finite()); // would be Inf; clamped to f64::MAX + assert_eq!(result.to_f64(), f64::MAX); + } + + #[test] + fn ratio_powi_overflow_clamps() { + let r = Ratio::new(1e200).unwrap(); + let result = r.powi(2); + assert!(result.to_f64().is_finite()); // would be Inf; clamped + } + // ===== Module-level SCALE ===== #[test]