diff --git a/docs/plans/2026-02-28-numeric-type-standardization-implementation.md b/docs/plans/2026-02-28-numeric-type-standardization-implementation.md new file mode 100644 index 000000000..97855fdc3 --- /dev/null +++ b/docs/plans/2026-02-28-numeric-type-standardization-implementation.md @@ -0,0 +1,1435 @@ +# 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: + +```rust +// 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** + +```rust +//! 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 { + 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::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::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 { + let v = s.parse::().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 for Price { + type Output = Self; + fn mul(self, rhs: i64) -> Self { + Self(self.0.saturating_mul(rhs)) + } +} + +impl Div 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 for Price { + type Output = Result; + #[allow(clippy::float_arithmetic)] + fn mul(self, rhs: f64) -> Self::Output { + Self::from_f64(self.to_f64() * rhs) + } +} + +impl Div for Price { + type Output = Result; + #[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 for Price { + fn from(d: Decimal) -> Self { + Self::from_decimal(d) + } +} + +impl From for Decimal { + fn from(p: Price) -> Self { + p.to_decimal() + } +} + +// Comparisons with f64 (for migration ease) +impl PartialEq for Price { + fn eq(&self, other: &f64) -> bool { + (self.to_f64() - other).abs() < 1e-6 + } +} + +impl PartialOrd for Price { + fn partial_cmp(&self, other: &f64) -> Option { + 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** + +```bash +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`: + +```rust + #[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** + +```rust +// ============================================================================ +// 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 { + 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 for Quantity { + type Output = Self; + fn mul(self, rhs: i64) -> Self { Self(self.0.saturating_mul(rhs)) } +} + +impl Div 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** + +```bash +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** + +```rust + #[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** + +```rust +// ============================================================================ +// 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 { + 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 for Money { + type Output = Self; + fn mul(self, rhs: i64) -> Self { Self(self.0.saturating_mul(rhs)) } +} + +impl Div 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 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 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** + +```bash +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** + +```rust + #[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** + +```rust +// ============================================================================ +// 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 { + 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** + +```bash +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`: + +```rust +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** + +```bash +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:** + +```rust +// 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 + +```bash +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:** + +```rust +// 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 + +```bash +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:** + +```rust +// 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 + +```bash +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:** + +```rust +// 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 = market_data.prices(); +let tensor_data: Vec = 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 + +```bash +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 `FinancialPrice` → `Price`, `FinancialQuantity` → `Quantity`, `FinancialMoney` → `Money` 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 + +```bash +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 + +```bash +git commit -m "chore: remove unused bigdecimal dependency" +``` + +--- + +## Verification Checklist (run after all phases merged) + +```bash +# 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.