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 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-02-28 09:39:22 +01:00
parent 85eab3eca2
commit 0d68bfdd11

View File

@@ -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]