Files
foxhunt/docs/plans/2026-02-28-numeric-type-standardization-implementation.md
jgrusewski 43754bf5d5 docs: numeric type standardization implementation plan
12-worktree phased migration: foundation types → core crates →
ML boundary → services → peripheral → cleanup. Full code for
Phase 1 (Price/Quantity/Money/Ratio in common/financial_types),
migration patterns for Phases 2-6.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 02:37:53 +01:00

41 KiB

Numeric Type Standardization Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Replace all raw f64 financial values with domain-typed i64 fixed-point wrappers (Price, Quantity, Money, Ratio) to eliminate precision-lossy casting and enforce type safety across 37+ crates.

Architecture: Four newtype wrappers in crates/common/src/financial_types.rs — Price/Quantity/Money use i64 with 6dp scale (1_000_000), Ratio wraps f64 with NaN/Inf rejection. All cross-type arithmetic is explicit. The existing u64-based Price/Quantity in common/src/types.rs and i64-based IntegerPrice/IntegerQuantity/IntegerMoney in trading_engine/src/types/financial.rs are consolidated and deleted.

Tech Stack: Rust, rust_decimal (Decimal interop), serde (serialization), saturating arithmetic

Scale: ~11,000 f64 occurrences, ~23,730 as casts, ~670 test functions touching price/quantity across 161 files. Phased bottom-up migration with worktree isolation.


Existing Types Being Replaced

Type Location Internal Scale Adoption Fate
Price common/src/types.rs:2256 u64 100_000_000 (8dp) ~30 files Replace (can't go negative)
Quantity common/src/types.rs:2679 u64 100_000_000 (8dp) ~30 files Replace (can't go negative)
Quantity common/src/trading.rs:102 u64 1_000_000 (6dp) ~5 files Delete (duplicate)
Money common/src/types.rs:3793 Decimal + Currency arbitrary 1 file Replace (wrong shape)
IntegerPrice trading_engine/src/types/financial.rs:42 i64 1_000_000 (6dp) 3 files Absorb (becomes canonical Price)
IntegerQuantity trading_engine/src/types/financial.rs:227 i64 1_000_000 (6dp) 3 files Absorb (becomes canonical Quantity)
IntegerMoney trading_engine/src/types/financial.rs:372 i64 1_000_000 (6dp) 3 files Absorb (becomes canonical Money)

Key Files With Tests

  • crates/common/tests/helper_functions_comprehensive_tests.rs — 49 price/qty test functions
  • crates/common/tests/types_comprehensive_tests.rs — 34 price/qty test functions
  • crates/trading_engine/src/types/financial.rs — 41 inline test functions
  • crates/trading_engine/src/types/tests/financial_tests.rs — 15 test functions
  • crates/trading_engine/src/types/tests/basic_focused_tests.rs — 19 test functions
  • crates/trading_engine/src/types/financial_safe.rs — 36 test functions

Phase 1: Foundation Types in common/

Worktree: single worktree, sequential tasks Goal: Create the canonical financial types module, wire it into common's public API

Task 1.1: Create financial_types module with Price

Files:

  • Create: crates/common/src/financial_types.rs
  • Modify: crates/common/src/lib.rs

Step 1: Write the failing test

Create the test inline at the bottom of the new module:

// At bottom of crates/common/src/financial_types.rs
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn price_from_f64_roundtrip() {
        let p = Price::from_f64(123.456789).unwrap();
        let diff = (p.to_f64() - 123.456789).abs();
        assert!(diff < 1e-6, "Roundtrip failed: diff={diff}");
    }

    #[test]
    fn price_from_f64_rejects_negative() {
        assert!(Price::from_f64(-1.0).is_err());
    }

    #[test]
    fn price_from_f64_rejects_nan() {
        assert!(Price::from_f64(f64::NAN).is_err());
    }

    #[test]
    fn price_from_f64_rejects_inf() {
        assert!(Price::from_f64(f64::INFINITY).is_err());
    }

    #[test]
    fn price_zero_constant() {
        assert_eq!(Price::ZERO.to_f64(), 0.0);
    }

    #[test]
    fn price_add_saturating() {
        let a = Price::from_f64(100.0).unwrap();
        let b = Price::from_f64(200.0).unwrap();
        let c = a + b;
        let diff = (c.to_f64() - 300.0).abs();
        assert!(diff < 1e-6);
    }

    #[test]
    fn price_sub_saturating() {
        let a = Price::from_f64(100.0).unwrap();
        let b = Price::from_f64(300.0).unwrap();
        // i64 saturating_sub: 100M - 300M = -200M (allowed, i64 is signed)
        let c = a - b;
        let diff = (c.to_f64() - (-200.0)).abs();
        assert!(diff < 1e-6);
    }

    #[test]
    fn price_mul_i64() {
        let p = Price::from_f64(50.0).unwrap();
        let result = p * 3;
        let diff = (result.to_f64() - 150.0).abs();
        assert!(diff < 1e-6);
    }

    #[test]
    fn price_div_i64() {
        let p = Price::from_f64(150.0).unwrap();
        let result = p / 3;
        let diff = (result.to_f64() - 50.0).abs();
        assert!(diff < 1e-6);
    }

    #[test]
    fn price_div_by_zero_returns_zero() {
        let p = Price::from_f64(100.0).unwrap();
        assert_eq!((p / 0_i64).to_f64(), 0.0);
    }

    #[test]
    fn price_from_decimal_roundtrip() {
        let d = Decimal::new(123_456_789, 6); // 123.456789
        let p = Price::from_decimal(d);
        let back = p.to_decimal();
        assert_eq!(d, back);
    }

    #[test]
    fn price_display() {
        let p = Price::from_f64(123.456789).unwrap();
        let s = format!("{p}");
        assert!(s.starts_with("123.456"));
    }

    #[test]
    fn price_from_str() {
        let p: Price = "123.456".parse().unwrap();
        let diff = (p.to_f64() - 123.456).abs();
        assert!(diff < 1e-6);
    }

    #[test]
    fn price_ord() {
        let a = Price::from_f64(100.0).unwrap();
        let b = Price::from_f64(200.0).unwrap();
        assert!(a < b);
    }

    #[test]
    fn price_abs() {
        let p = Price((-100 * Price::SCALE) as i64);
        assert_eq!(p.abs().to_f64(), 100.0);
    }

    #[test]
    fn price_to_f32() {
        let p = Price::from_f64(123.456).unwrap();
        let f = p.to_f32();
        assert!((f - 123.456_f32).abs() < 0.01);
    }

    #[test]
    fn price_from_f32() {
        let p = Price::from_f32(123.456_f32).unwrap();
        let diff = (p.to_f64() - 123.456).abs();
        assert!(diff < 0.01); // f32 precision
    }
}

Step 2: Write the Price implementation

//! Canonical financial types with fixed-point i64 arithmetic (6 decimal places).
//!
//! These types enforce type safety between different financial quantities:
//! - `Price` — bid, ask, mid, OHLCV, fill price
//! - `Quantity` — contracts, shares, lots (signed for short positions)
//! - `Money` — P&L, margin, notional, fees
//! - `Ratio` — returns, volatility, Sharpe (f64 newtype with NaN/Inf rejection)

use std::fmt;
use std::ops::{Add, AddAssign, Div, Mul, Neg, Sub, SubAssign};
use std::str::FromStr;

use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};

use crate::types::CommonTypeError;

/// Fixed-point scale factor: 6 decimal places
pub const SCALE: i64 = 1_000_000;

// ============================================================================
// Price
// ============================================================================

/// Exact-arithmetic price type using i64 with 6 decimal places.
///
/// Represents: bid, ask, mid, last, VWAP, limit, stop, fill, OHLCV.
/// Signed to support price deltas and negative mark-to-market.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct Price(i64);

impl Price {
    pub const ZERO: Self = Self(0);
    pub const SCALE: i64 = SCALE;

    /// Create from f64. Rejects negative, NaN, Inf.
    #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
    pub fn from_f64(value: f64) -> Result<Self, CommonTypeError> {
        if !value.is_finite() || value < 0.0 {
            return Err(CommonTypeError::InvalidPrice {
                value: value.to_string(),
                reason: "Price must be non-negative and finite".to_owned(),
            });
        }
        Ok(Self((value * SCALE as f64).round() as i64))
    }

    /// Create from f32 (ML boundary).
    pub fn from_f32(value: f32) -> Result<Self, CommonTypeError> {
        Self::from_f64(f64::from(value))
    }

    /// Convert to f64 for display or math.
    #[must_use]
    #[allow(clippy::cast_precision_loss)]
    pub fn to_f64(self) -> f64 {
        self.0 as f64 / SCALE as f64
    }

    /// Convert to f32 (ML tensor input).
    #[must_use]
    #[allow(clippy::cast_precision_loss)]
    pub fn to_f32(self) -> f32 {
        self.to_f64() as f32
    }

    /// Convert to Decimal (DB/serialization boundary).
    #[must_use]
    pub fn to_decimal(self) -> Decimal {
        Decimal::new(self.0, 6)
    }

    /// Create from Decimal.
    #[must_use]
    #[allow(clippy::arithmetic_side_effects)]
    pub fn from_decimal(decimal: Decimal) -> Self {
        let scaled = decimal * Decimal::new(SCALE, 0);
        Self(i64::try_from(scaled.mantissa()).unwrap_or(0))
    }

    /// Alias for `from_f64`.
    pub fn new(value: f64) -> Result<Self, CommonTypeError> {
        Self::from_f64(value)
    }

    /// Raw i64 value (scaled).
    #[must_use]
    pub const fn raw_value(self) -> i64 {
        self.0
    }

    /// Create from raw scaled i64.
    #[must_use]
    pub const fn from_raw(value: i64) -> Self {
        Self(value)
    }

    /// Absolute value.
    #[must_use]
    pub fn abs(self) -> Self {
        Self(self.0.saturating_abs())
    }

    /// Square root (for volatility on price diffs).
    #[must_use]
    #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
    pub fn sqrt(self) -> Self {
        let f = (self.0 as f64 / SCALE as f64).sqrt() * SCALE as f64;
        Self(f as i64)
    }

    #[must_use]
    pub const fn is_zero(self) -> bool {
        self.0 == 0
    }

    #[must_use]
    pub const fn is_positive(self) -> bool {
        self.0 > 0
    }

    #[must_use]
    pub const fn is_negative(self) -> bool {
        self.0 < 0
    }
}

impl Default for Price {
    fn default() -> Self {
        Self::ZERO
    }
}

impl fmt::Display for Price {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{:.6}", self.to_f64())
    }
}

impl FromStr for Price {
    type Err = CommonTypeError;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let v = s.parse::<f64>().map_err(|e| CommonTypeError::InvalidPrice {
            value: s.to_owned(),
            reason: format!("Cannot parse as price: {e}"),
        })?;
        Self::from_f64(v)
    }
}

impl Add for Price {
    type Output = Self;
    fn add(self, rhs: Self) -> Self {
        Self(self.0.saturating_add(rhs.0))
    }
}

impl AddAssign for Price {
    fn add_assign(&mut self, rhs: Self) {
        self.0 = self.0.saturating_add(rhs.0);
    }
}

impl Sub for Price {
    type Output = Self;
    fn sub(self, rhs: Self) -> Self {
        Self(self.0.saturating_sub(rhs.0))
    }
}

impl SubAssign for Price {
    fn sub_assign(&mut self, rhs: Self) {
        self.0 = self.0.saturating_sub(rhs.0);
    }
}

impl Mul<i64> for Price {
    type Output = Self;
    fn mul(self, rhs: i64) -> Self {
        Self(self.0.saturating_mul(rhs))
    }
}

impl Div<i64> for Price {
    type Output = Self;
    fn div(self, rhs: i64) -> Self {
        if rhs == 0 { Self(0) } else { Self(self.0.saturating_div(rhs)) }
    }
}

impl Neg for Price {
    type Output = Self;
    fn neg(self) -> Self {
        Self(self.0.saturating_neg())
    }
}

// f64 arithmetic (returns Result to prevent silent precision loss)
impl Mul<f64> for Price {
    type Output = Result<Self, CommonTypeError>;
    #[allow(clippy::float_arithmetic)]
    fn mul(self, rhs: f64) -> Self::Output {
        Self::from_f64(self.to_f64() * rhs)
    }
}

impl Div<f64> for Price {
    type Output = Result<Self, CommonTypeError>;
    #[allow(clippy::float_arithmetic)]
    fn div(self, rhs: f64) -> Self::Output {
        if rhs == 0.0 {
            return Err(CommonTypeError::ConversionError {
                message: "Division by zero".to_owned(),
            });
        }
        Self::from_f64(self.to_f64() / rhs)
    }
}

// Decimal interop
impl From<Decimal> for Price {
    fn from(d: Decimal) -> Self {
        Self::from_decimal(d)
    }
}

impl From<Price> for Decimal {
    fn from(p: Price) -> Self {
        p.to_decimal()
    }
}

// Comparisons with f64 (for migration ease)
impl PartialEq<f64> for Price {
    fn eq(&self, other: &f64) -> bool {
        (self.to_f64() - other).abs() < 1e-6
    }
}

impl PartialOrd<f64> for Price {
    fn partial_cmp(&self, other: &f64) -> Option<std::cmp::Ordering> {
        self.to_f64().partial_cmp(other)
    }
}

Step 3: Run tests

Run: SQLX_OFFLINE=true cargo test -p common --lib financial_types -- --nocapture Expected: All 17 tests PASS

Step 4: Wire into common/src/lib.rs

Add pub mod financial_types; and add Price to the re-export block. Do NOT remove the old types::Price yet — that breaks all downstream. We'll add the new module alongside, then migrate consumers, then delete the old.

Step 5: Commit

git add crates/common/src/financial_types.rs crates/common/src/lib.rs
git commit -m "feat(common): add Price newtype (i64/6dp) in financial_types module"

Task 1.2: Add Quantity to financial_types

Files:

  • Modify: crates/common/src/financial_types.rs

Step 1: Write the failing tests

Append to the tests module in financial_types.rs:

    #[test]
    fn quantity_from_f64_roundtrip() {
        let q = Quantity::from_f64(10.5).unwrap();
        let diff = (q.to_f64() - 10.5).abs();
        assert!(diff < 1e-6);
    }

    #[test]
    fn quantity_negative_for_short() {
        let q = Quantity::from_i64(-5);
        assert!(q.is_negative());
        assert_eq!(q.to_f64(), -5.0);
    }

    #[test]
    fn quantity_from_f64_rejects_nan() {
        assert!(Quantity::from_f64(f64::NAN).is_err());
    }

    #[test]
    fn quantity_add_sub() {
        let a = Quantity::from_i64(10);
        let b = Quantity::from_i64(3);
        assert_eq!((a + b).raw_value(), 13 * SCALE);
        assert_eq!((a - b).raw_value(), 7 * SCALE);
    }

    #[test]
    fn quantity_abs() {
        let q = Quantity::from_i64(-5);
        assert_eq!(q.abs().to_f64(), 5.0);
    }

Step 2: Write the Quantity implementation

// ============================================================================
// Quantity
// ============================================================================

/// Exact-arithmetic quantity type using i64 with 6 decimal places.
///
/// Represents: contracts, shares, lots. Signed for short positions.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct Quantity(i64);

impl Quantity {
    pub const ZERO: Self = Self(0);
    pub const SCALE: i64 = SCALE;

    #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
    pub fn from_f64(value: f64) -> Result<Self, CommonTypeError> {
        if !value.is_finite() {
            return Err(CommonTypeError::InvalidQuantity {
                value: value.to_string(),
                reason: "Quantity must be finite".to_owned(),
            });
        }
        Ok(Self((value * SCALE as f64).round() as i64))
    }

    /// Create from whole units (e.g., 5 contracts = from_i64(5)).
    #[must_use]
    pub const fn from_i64(value: i64) -> Self {
        Self(value.saturating_mul(SCALE))
    }

    /// Create from raw scaled value.
    #[must_use]
    pub const fn from_raw(value: i64) -> Self {
        Self(value)
    }

    #[must_use]
    #[allow(clippy::cast_precision_loss)]
    pub fn to_f64(self) -> f64 {
        self.0 as f64 / SCALE as f64
    }

    #[must_use]
    #[allow(clippy::cast_precision_loss)]
    pub fn to_f32(self) -> f32 {
        self.to_f64() as f32
    }

    #[must_use]
    pub fn to_decimal(self) -> Decimal {
        Decimal::new(self.0, 6)
    }

    #[must_use]
    pub const fn raw_value(self) -> i64 {
        self.0
    }

    #[must_use]
    pub fn abs(self) -> Self {
        Self(self.0.saturating_abs())
    }

    #[must_use]
    pub const fn is_zero(self) -> bool {
        self.0 == 0
    }

    #[must_use]
    pub const fn is_positive(self) -> bool {
        self.0 > 0
    }

    #[must_use]
    pub const fn is_negative(self) -> bool {
        self.0 < 0
    }
}

impl Default for Quantity {
    fn default() -> Self { Self::ZERO }
}

impl fmt::Display for Quantity {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{:.6}", self.to_f64())
    }
}

impl Add for Quantity {
    type Output = Self;
    fn add(self, rhs: Self) -> Self { Self(self.0.saturating_add(rhs.0)) }
}

impl Sub for Quantity {
    type Output = Self;
    fn sub(self, rhs: Self) -> Self { Self(self.0.saturating_sub(rhs.0)) }
}

impl Mul<i64> for Quantity {
    type Output = Self;
    fn mul(self, rhs: i64) -> Self { Self(self.0.saturating_mul(rhs)) }
}

impl Div<i64> for Quantity {
    type Output = Self;
    fn div(self, rhs: i64) -> Self {
        if rhs == 0 { Self(0) } else { Self(self.0.saturating_div(rhs)) }
    }
}

impl Neg for Quantity {
    type Output = Self;
    fn neg(self) -> Self { Self(self.0.saturating_neg()) }
}

impl AddAssign for Quantity {
    fn add_assign(&mut self, rhs: Self) { self.0 = self.0.saturating_add(rhs.0); }
}

impl SubAssign for Quantity {
    fn sub_assign(&mut self, rhs: Self) { self.0 = self.0.saturating_sub(rhs.0); }
}

Step 3: Run tests

Run: SQLX_OFFLINE=true cargo test -p common --lib financial_types -- --nocapture Expected: All 22 tests PASS

Step 4: Commit

git add crates/common/src/financial_types.rs
git commit -m "feat(common): add Quantity newtype (i64/6dp, signed for shorts)"

Task 1.3: Add Money to financial_types

Files:

  • Modify: crates/common/src/financial_types.rs

Step 1: Write the failing tests

    #[test]
    fn money_from_f64_roundtrip() {
        let m = Money::from_f64(-1234.56).unwrap();
        let diff = (m.to_f64() - (-1234.56)).abs();
        assert!(diff < 1e-6);
    }

    #[test]
    fn money_allows_negative() {
        let m = Money::from_f64(-500.0).unwrap();
        assert!(m.is_negative());
    }

    #[test]
    fn money_rejects_nan() {
        assert!(Money::from_f64(f64::NAN).is_err());
    }

    #[test]
    fn money_add_sub() {
        let a = Money::from_f64(100.0).unwrap();
        let b = Money::from_f64(250.0).unwrap();
        let diff = ((a + b).to_f64() - 350.0).abs();
        assert!(diff < 1e-6);
    }

    #[test]
    fn price_times_quantity_is_money() {
        let p = Price::from_f64(100.50).unwrap();
        let q = Quantity::from_i64(10);
        let m: Money = p * q;
        let diff = (m.to_f64() - 1005.0).abs();
        assert!(diff < 1e-6);
    }

    #[test]
    fn money_div_quantity_is_price() {
        let m = Money::from_f64(1005.0).unwrap();
        let q = Quantity::from_i64(10);
        let p: Price = m / q;
        let diff = (p.to_f64() - 100.5).abs();
        assert!(diff < 1e-6);
    }

Step 2: Write the Money implementation and cross-type ops

// ============================================================================
// Money
// ============================================================================

/// Exact-arithmetic money type using i64 with 6 decimal places.
///
/// Represents: P&L, margin, notional value, fees, commissions, balance.
/// Always signed (losses are negative).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct Money(i64);

impl Money {
    pub const ZERO: Self = Self(0);
    pub const SCALE: i64 = SCALE;

    #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
    pub fn from_f64(value: f64) -> Result<Self, CommonTypeError> {
        if !value.is_finite() {
            return Err(CommonTypeError::ConversionError {
                message: format!("Money must be finite, got {value}"),
            });
        }
        Ok(Self((value * SCALE as f64).round() as i64))
    }

    #[must_use]
    pub const fn from_raw(value: i64) -> Self {
        Self(value)
    }

    #[must_use]
    #[allow(clippy::cast_precision_loss)]
    pub fn to_f64(self) -> f64 {
        self.0 as f64 / SCALE as f64
    }

    #[must_use]
    pub fn to_decimal(self) -> Decimal {
        Decimal::new(self.0, 6)
    }

    #[must_use]
    pub const fn raw_value(self) -> i64 {
        self.0
    }

    #[must_use]
    pub fn abs(self) -> Self {
        Self(self.0.saturating_abs())
    }

    #[must_use]
    pub const fn is_zero(self) -> bool {
        self.0 == 0
    }

    #[must_use]
    pub const fn is_positive(self) -> bool {
        self.0 > 0
    }

    #[must_use]
    pub const fn is_negative(self) -> bool {
        self.0 < 0
    }
}

impl Default for Money {
    fn default() -> Self { Self::ZERO }
}

impl fmt::Display for Money {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{:.6}", self.to_f64())
    }
}

impl Add for Money {
    type Output = Self;
    fn add(self, rhs: Self) -> Self { Self(self.0.saturating_add(rhs.0)) }
}

impl Sub for Money {
    type Output = Self;
    fn sub(self, rhs: Self) -> Self { Self(self.0.saturating_sub(rhs.0)) }
}

impl Mul<i64> for Money {
    type Output = Self;
    fn mul(self, rhs: i64) -> Self { Self(self.0.saturating_mul(rhs)) }
}

impl Div<i64> for Money {
    type Output = Self;
    fn div(self, rhs: i64) -> Self {
        if rhs == 0 { Self(0) } else { Self(self.0.saturating_div(rhs)) }
    }
}

impl Neg for Money {
    type Output = Self;
    fn neg(self) -> Self { Self(self.0.saturating_neg()) }
}

impl AddAssign for Money {
    fn add_assign(&mut self, rhs: Self) { self.0 = self.0.saturating_add(rhs.0); }
}

impl SubAssign for Money {
    fn sub_assign(&mut self, rhs: Self) { self.0 = self.0.saturating_sub(rhs.0); }
}

// ============================================================================
// Cross-type arithmetic
// ============================================================================

/// Price * Quantity = Money (notional value)
impl Mul<Quantity> for Price {
    type Output = Money;
    #[allow(clippy::cast_precision_loss)]
    fn mul(self, rhs: Quantity) -> Money {
        // (price_scaled * qty_scaled) / SCALE = money_scaled
        // Use i128 to avoid overflow in intermediate product
        let product = (self.0 as i128).saturating_mul(rhs.0 as i128);
        let money_scaled = product / SCALE as i128;
        Money(i64::try_from(money_scaled.clamp(i64::MIN as i128, i64::MAX as i128)).unwrap_or(0))
    }
}

/// Money / Quantity = Price (average price)
impl Div<Quantity> for Money {
    type Output = Price;
    fn div(self, rhs: Quantity) -> Price {
        if rhs.0 == 0 {
            Price::ZERO
        } else {
            let result = (self.0 as i128).saturating_mul(SCALE as i128) / rhs.0 as i128;
            Price(i64::try_from(result.clamp(i64::MIN as i128, i64::MAX as i128)).unwrap_or(0))
        }
    }
}

Step 3: Run tests

Run: SQLX_OFFLINE=true cargo test -p common --lib financial_types -- --nocapture Expected: All 28 tests PASS

Step 4: Commit

git add crates/common/src/financial_types.rs
git commit -m "feat(common): add Money newtype and cross-type arithmetic (Price * Qty = Money)"

Task 1.4: Add Ratio to financial_types

Files:

  • Modify: crates/common/src/financial_types.rs

Step 1: Write the failing tests

    #[test]
    fn ratio_new_rejects_nan() {
        assert!(Ratio::new(f64::NAN).is_err());
    }

    #[test]
    fn ratio_new_rejects_inf() {
        assert!(Ratio::new(f64::INFINITY).is_err());
    }

    #[test]
    fn ratio_new_allows_negative() {
        let r = Ratio::new(-0.5).unwrap();
        assert!(r.is_negative());
    }

    #[test]
    fn ratio_arithmetic() {
        let a = Ratio::new(0.5).unwrap();
        let b = Ratio::new(0.3).unwrap();
        let sum = a + b;
        assert!((sum.to_f64() - 0.8).abs() < 1e-10);
    }

    #[test]
    fn ratio_sqrt() {
        let r = Ratio::new(4.0).unwrap();
        assert!((r.sqrt().to_f64() - 2.0).abs() < 1e-10);
    }

    #[test]
    fn ratio_ln_exp_roundtrip() {
        let r = Ratio::new(2.5).unwrap();
        let back = r.ln().exp();
        assert!((back.to_f64() - 2.5).abs() < 1e-10);
    }

    #[test]
    fn ratio_clamp() {
        let r = Ratio::new(1.5).unwrap();
        let clamped = r.clamp(0.0, 1.0);
        assert_eq!(clamped.to_f64(), 1.0);
    }

    #[test]
    fn ratio_mul_price_scales() {
        let p = Price::from_f64(100.0).unwrap();
        let r = Ratio::new(0.05).unwrap(); // 5% return
        let result = p.scale_by(r);
        let diff = (result.to_f64() - 5.0).abs();
        assert!(diff < 1e-6);
    }

Step 2: Write the Ratio implementation

// ============================================================================
// Ratio
// ============================================================================

/// NaN/Inf-rejecting f64 newtype for continuous mathematical quantities.
///
/// Represents: returns, volatility, Sharpe, correlation, Kelly fraction,
/// feature importance, confidence scores.
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)]
pub struct Ratio(f64);

impl Ratio {
    pub const ZERO: Self = Self(0.0);
    pub const ONE: Self = Self(1.0);

    /// Create a Ratio, rejecting NaN and Inf.
    pub fn new(value: f64) -> Result<Self, CommonTypeError> {
        if !value.is_finite() {
            return Err(CommonTypeError::ConversionError {
                message: format!("Ratio must be finite, got {value}"),
            });
        }
        Ok(Self(value))
    }

    /// Create without validation (for const contexts and trusted internal use).
    #[must_use]
    pub const fn from_raw(value: f64) -> Self {
        Self(value)
    }

    #[must_use]
    pub fn to_f64(self) -> f64 {
        self.0
    }

    #[must_use]
    #[allow(clippy::cast_possible_truncation)]
    pub fn to_f32(self) -> f32 {
        self.0 as f32
    }

    #[must_use]
    pub fn abs(self) -> Self {
        Self(self.0.abs())
    }

    #[must_use]
    pub fn sqrt(self) -> Self {
        Self(self.0.sqrt())
    }

    #[must_use]
    pub fn ln(self) -> Self {
        Self(self.0.ln())
    }

    #[must_use]
    pub fn exp(self) -> Self {
        Self(self.0.exp())
    }

    #[must_use]
    pub fn powi(self, n: i32) -> Self {
        Self(self.0.powi(n))
    }

    #[must_use]
    pub fn is_positive(self) -> bool {
        self.0 > 0.0
    }

    #[must_use]
    pub fn is_negative(self) -> bool {
        self.0 < 0.0
    }

    #[must_use]
    pub fn clamp(self, min: f64, max: f64) -> Self {
        Self(self.0.clamp(min, max))
    }
}

impl Default for Ratio {
    fn default() -> Self { Self::ZERO }
}

impl fmt::Display for Ratio {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.0)
    }
}

impl Add for Ratio {
    type Output = Self;
    fn add(self, rhs: Self) -> Self { Self(self.0 + rhs.0) }
}

impl Sub for Ratio {
    type Output = Self;
    fn sub(self, rhs: Self) -> Self { Self(self.0 - rhs.0) }
}

impl Mul for Ratio {
    type Output = Self;
    fn mul(self, rhs: Self) -> Self { Self(self.0 * rhs.0) }
}

impl Div for Ratio {
    type Output = Self;
    fn div(self, rhs: Self) -> Self {
        if rhs.0 == 0.0 { Self(0.0) } else { Self(self.0 / rhs.0) }
    }
}

impl Neg for Ratio {
    type Output = Self;
    fn neg(self) -> Self { Self(-self.0) }
}

impl AddAssign for Ratio {
    fn add_assign(&mut self, rhs: Self) { self.0 += rhs.0; }
}

impl SubAssign for Ratio {
    fn sub_assign(&mut self, rhs: Self) { self.0 -= rhs.0; }
}

// ============================================================================
// Price * Ratio scaling
// ============================================================================

impl Price {
    /// Scale a price by a ratio (e.g., price * 5% return).
    #[must_use]
    #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
    pub fn scale_by(self, ratio: Ratio) -> Self {
        Self((self.0 as f64 * ratio.0).round() as i64)
    }
}

Step 3: Run tests

Run: SQLX_OFFLINE=true cargo test -p common --lib financial_types -- --nocapture Expected: All 36 tests PASS

Step 4: Commit

git add crates/common/src/financial_types.rs
git commit -m "feat(common): add Ratio newtype (f64 with NaN/Inf rejection)"

Task 1.5: Wire financial_types into common's public API

Files:

  • Modify: crates/common/src/lib.rs

Step 1: Add the module declaration and re-exports

Add to lib.rs:

pub mod financial_types;

// Re-export canonical financial types at crate root
pub use financial_types::{
    Money as FinancialMoney,
    Price as FinancialPrice,
    Quantity as FinancialQuantity,
    Ratio,
    SCALE as FINANCIAL_SCALE,
};

Note: We use aliased re-exports (FinancialPrice, etc.) temporarily to avoid name collision with the existing types::Price and types::Quantity. Once migration is complete (Phase 2+), we'll rename these to Price/Quantity/Money and delete the old types.

Step 2: Run workspace check

Run: SQLX_OFFLINE=true cargo check -p common Expected: PASS (no downstream impact since re-exports are additive)

Step 3: Commit

git add crates/common/src/lib.rs
git commit -m "feat(common): re-export financial_types as FinancialPrice/Quantity/Money/Ratio"

Phase 2: Core Crate Migration (3 parallel worktrees)

Strategy: Each crate gets its own worktree. All three can run in parallel since they only depend on common/ (which is done). Each worktree follows the same pattern:

  1. Replace use common::Price with use common::FinancialPrice as Price (or update field types)
  2. Replace f64 price/qty/pnl fields with the new types
  3. Replace as f64 casts with .to_f64() calls
  4. Fix compilation errors
  5. Run tests, fix failures
  6. Clippy clean

Task 2.1: Migrate trading_engine/

Worktree: feature/numeric-types-trading-engine

Files:

  • Delete: crates/trading_engine/src/types/financial.rs (IntegerPrice/IntegerQuantity/IntegerMoney absorbed into common)
  • Modify: crates/trading_engine/src/types/mod.rs — remove pub mod financial;
  • Modify: All files importing IntegerPrice/IntegerQuantity/IntegerMoney — switch to common::FinancialPrice/FinancialQuantity/FinancialMoney
  • Modify: Files with price: f64 fields — switch to Price
  • Modify: crates/trading_engine/src/types/financial_safe.rs — update to use new types
  • Modify: crates/trading_engine/src/types/tests/financial_tests.rs — update assertions for new scale/API

Migration pattern per file:

// BEFORE
use crate::types::financial::IntegerPrice;
let price = IntegerPrice::from_f64(100.5);
let val = price.to_f64();

// AFTER
use common::FinancialPrice as Price;
let price = Price::from_f64(100.5).unwrap_or(Price::ZERO);
let val = price.to_f64();

Key differences to handle:

  • IntegerPrice::from_f64 was infallible → new Price::from_f64 returns Result
  • IntegerPrice(pub i64) had public field → new Price uses from_raw()/raw_value()
  • PRICE_SCALE constant → Price::SCALE
  • IntegerMoney.to_price() → just use Price::from_raw(money.raw_value())

Step 1: Replace all IntegerPrice/IntegerQuantity/IntegerMoney imports and usages Step 2: Replace remaining price: f64 fields in order types, position types Step 3: Run SQLX_OFFLINE=true cargo check -p trading_engine, fix errors Step 4: Run SQLX_OFFLINE=true cargo test -p trading_engine --lib, fix failures Step 5: Run cargo clippy -p trading_engine, fix warnings Step 6: Delete types/financial.rs once no imports remain Step 7: Commit

git commit -m "refactor(trading_engine): migrate to common financial types, delete IntegerPrice/Quantity/Money"

Task 2.2: Migrate risk/

Worktree: feature/numeric-types-risk

Files:

  • Modify: crates/risk/src/risk_engine.rs — 12 price: f64 fields
  • Modify: crates/risk/src/position_tracker.rs — position price/qty fields
  • Modify: crates/risk/src/risk_types.rs — risk limit types
  • Modify: crates/risk/src/safety/position_limiter.rs
  • Modify: VaR calculation files (keep f64 for statistical math, use Price for price inputs/outputs)

Migration pattern:

// BEFORE
pub struct PositionInfo {
    pub avg_price: f64,
    pub quantity: f64,
    pub unrealized_pnl: f64,
}

// AFTER
use common::{FinancialPrice as Price, FinancialQuantity as Quantity, FinancialMoney as Money};
pub struct PositionInfo {
    pub avg_price: Price,
    pub quantity: Quantity,
    pub unrealized_pnl: Money,
}

Key: VaR/Kelly calculations use sqrt, ln, exp — these stay as Ratio or raw f64 internally. Only the input prices and output dollar amounts use Price/Money.

Step 1: Replace f64 fields in structs with Price/Quantity/Money Step 2: Update method signatures and implementations Step 3: Run SQLX_OFFLINE=true cargo check -p risk, fix errors Step 4: Run SQLX_OFFLINE=true cargo test -p risk --lib, fix failures Step 5: Clippy clean Step 6: Commit

git commit -m "refactor(risk): migrate price/qty/pnl fields to financial types"

Task 2.3: Migrate data/

Worktree: feature/numeric-types-data

Files:

  • Modify: crates/data/src/features.rs — PricePoint (line 538)
  • Modify: crates/data/src/validation.rs — PricePoint (line 234), PriceBounds (line 217), PriceValidator
  • Modify: crates/data/src/providers/common.rs — PriceLevelChange (line 1130)
  • Modify: crates/data/src/unified_feature_extractor.rs — PriceReaction (line 206)
  • Modify: OHLCV struct fields wherever they use f64

Migration pattern:

// BEFORE
pub struct PriceBounds {
    pub min_price: f64,
    pub max_price: f64,
}

// AFTER
use common::FinancialPrice as Price;
pub struct PriceBounds {
    pub min_price: Price,
    pub max_price: Price,
}

Step 1: Replace f64 price fields in all data structs Step 2: Update validation logic (comparisons use Price ordering) Step 3: Run SQLX_OFFLINE=true cargo check -p data, fix errors Step 4: Run SQLX_OFFLINE=true cargo test -p data --lib, fix failures Step 5: Clippy clean Step 6: Commit

git commit -m "refactor(data): migrate price fields to financial types"

Phase 3: ML Boundary (1 worktree)

Task 3.1: Migrate ml/ price-adjacent code

Worktree: feature/numeric-types-ml

Files to migrate (price fields only — NOT weights/gradients/losses):

  • crates/ml/src/labeling/concurrent_tracking.rs:18 — PricePoint.price_cents
  • crates/ml/src/labeling/triple_barrier.rs:21 — PricePoint.price_cents
  • crates/ml/src/features/price_features.rs — price input/output
  • crates/ml/src/features/microstructure_features.rs:691 — PriceImpact
  • crates/ml/src/universe/volatility.rs:84 — PricePoint
  • crates/ml/src/ensemble/adaptive_ml_integration.rs:92 — PricePoint.price
  • crates/ml/src/training/unified_data_loader.rs:148 — PriceData (already uses Price!)

Files to NOT migrate (these use f64/f32 correctly):

  • All src/dqn/, src/ppo/, src/trainers/ — weights, gradients, losses
  • All src/models/ — neural network internals
  • Tensor operations — Candle requires f32/f64

Boundary pattern:

// ML model output → Price
let prediction_f32: f32 = model.forward(&input)?;
let predicted_price = Price::from_f32(prediction_f32)?;

// Price → ML model input
let prices: Vec<Price> = market_data.prices();
let tensor_data: Vec<f32> = prices.iter().map(|p| p.to_f32()).collect();
let input = Tensor::from_vec(tensor_data, &[batch, seq_len], device)?;

Step 1: Replace f64 price fields in labeling/features structs Step 2: Add .to_f32() / Price::from_f32() at tensor boundaries Step 3: Run SQLX_OFFLINE=true cargo check -p ml, fix errors Step 4: Run SQLX_OFFLINE=true cargo test -p ml --lib, fix failures (2390 tests) Step 5: Clippy clean Step 6: Commit

git commit -m "refactor(ml): migrate price fields to financial types, add tensor boundary conversions"

Phase 4: Services (4 parallel worktrees)

Each service follows the identical migration pattern. All four can run in parallel.

Task 4.1: Migrate trading_service/

Worktree: feature/numeric-types-trading-service

Key files:

  • services/trading_service/src/assets.rs — asset price fields
  • services/trading_service/src/core/position_manager.rs — 10 price: f64 fields
  • services/trading_service/src/core/risk_manager.rs
  • services/trading_service/src/services/enhanced_ml.rs
  • services/trading_service/src/ensemble_risk_manager.rs
  • services/trading_service/src/paper_trading_executor.rs
  • services/trading_service/src/utils.rs
  • Proto conversion boundary: f64 ↔ Price at gRPC handler layer

Step 1: Replace f64 fields → Price/Quantity/Money Step 2: Add proto boundary conversions (price.to_f64() in response, Price::from_f64(req.price)? in handler) Step 3: Run SQLX_OFFLINE=true cargo check -p trading_service, fix errors Step 4: Run SQLX_OFFLINE=true cargo test -p trading_service --lib, fix failures (211 tests) Step 5: Clippy clean Step 6: Commit

Task 4.2: Migrate backtesting_service/

Worktree: feature/numeric-types-backtesting-service

Same pattern. Key files: dbn_data_source.rs, strategy engine, equity curve tracking.

Task 4.3: Migrate trading_agent_service/

Worktree: feature/numeric-types-trading-agent-service

Same pattern. Key files: dynamic_stop_loss.rs, orders.rs, assets.rs.

Task 4.4: Migrate remaining services

Worktree: feature/numeric-types-remaining-services

Covers: api_gateway, broker_gateway_service, ml_training_service, data_acquisition_service.


Phase 5: Peripheral (2 parallel worktrees)

Task 5.1: Migrate bin/fxt/ and web-gateway/

Worktree: feature/numeric-types-fxt-gateway

Key files:

  • bin/fxt/src/types.rs — CLI types with price: f64
  • crates/web-gateway/src/routes/trading.rs — order validation
  • Proto boundary: gRPC ↔ Price conversions

Task 5.2: Migrate testing/ crates

Worktree: feature/numeric-types-testing

Key files:

  • testing/integration/fixtures/ — test data builders
  • testing/integration/unit/financial_property_tests.rs — has its own Quantity(u64) struct
  • testing/test-common/lib.rs — test helpers
  • testing/e2e/ — end-to-end test types

Phase 6: Cleanup (1 worktree, after all merges)

Task 6.1: Delete old types and rename re-exports

Files:

  • Modify: crates/common/src/types.rs — delete old Price struct (lines 2254-2675), old Quantity struct (lines 2677-end)
  • Modify: crates/common/src/trading.rs — delete duplicate Quantity struct (lines 100-145+)
  • Modify: crates/common/src/lib.rs — rename FinancialPricePrice, FinancialQuantityQuantity, FinancialMoneyMoney in re-exports
  • Modify: All consumers — remove as Price aliases, use common::Price directly

Step 1: Delete old Price/Quantity from types.rs Step 2: Delete duplicate Quantity from trading.rs Step 3: Update lib.rs re-exports to canonical names Step 4: Run SQLX_OFFLINE=true cargo check --workspace, fix all errors Step 5: Run SQLX_OFFLINE=true cargo test --workspace --lib (4500+ tests) Step 6: Clippy workspace clean Step 7: Commit

git commit -m "refactor(common): delete legacy Price/Quantity types, promote financial_types to canonical names"

Task 6.2: Remove dead bigdecimal dependency

Files:

  • Modify: workspace Cargo.toml — remove bigdecimal from [workspace.dependencies]
  • Modify: any crate Cargo.toml still referencing it (check sqlx features)

Step 1: Remove bigdecimal where unused Step 2: Verify sqlx still compiles (it may need rust_decimal feature but not bigdecimal) Step 3: cargo check --workspace Step 4: Commit

git commit -m "chore: remove unused bigdecimal dependency"

Verification Checklist (run after all phases merged)

# Full workspace compile
SQLX_OFFLINE=true cargo check --workspace

# Full test suite
SQLX_OFFLINE=true cargo test --workspace --lib

# Clippy clean
SQLX_OFFLINE=true cargo clippy --workspace -- -D warnings

# Verify no raw f64 price fields remain (should be 0 or near-0)
rg 'price:\s*f64' crates/ services/ --type rust | wc -l

# Verify IntegerPrice is gone
rg 'IntegerPrice|IntegerQuantity|IntegerMoney' crates/ services/ --type rust | wc -l

# Verify old Price/Quantity scale (100_000_000) is gone
rg '100_000_000' crates/common/src/types.rs | wc -l

Worktree Swarm Summary

Phase Worktrees Can Parallel? Depends On
1 1 (foundation) Nothing
2 3 (trading_engine, risk, data) Yes, all 3 Phase 1
3 1 (ml) Phase 1
4 4 (services) Yes, all 4 Phases 1-3
5 2 (fxt+gateway, testing) Yes, both Phases 1-4
6 1 (cleanup) All above

Total: 12 worktrees, max 4 concurrent. Phases 2+3 can run together (4 worktrees). Phase 4 runs 4 worktrees. Phase 5 runs 2.