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>
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 functionscrates/common/tests/types_comprehensive_tests.rs— 34 price/qty test functionscrates/trading_engine/src/types/financial.rs— 41 inline test functionscrates/trading_engine/src/types/tests/financial_tests.rs— 15 test functionscrates/trading_engine/src/types/tests/basic_focused_tests.rs— 19 test functionscrates/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:
- Replace
use common::Pricewithuse common::FinancialPrice as Price(or update field types) - Replace f64 price/qty/pnl fields with the new types
- Replace
as f64casts with.to_f64()calls - Fix compilation errors
- Run tests, fix failures
- 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— removepub mod financial; - Modify: All files importing
IntegerPrice/IntegerQuantity/IntegerMoney— switch tocommon::FinancialPrice/FinancialQuantity/FinancialMoney - Modify: Files with
price: f64fields — switch toPrice - 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_f64was infallible → newPrice::from_f64returnsResultIntegerPrice(pub i64)had public field → newPriceusesfrom_raw()/raw_value()PRICE_SCALEconstant →Price::SCALEIntegerMoney.to_price()→ just usePrice::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— 12price: f64fields - 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_centscrates/ml/src/labeling/triple_barrier.rs:21— PricePoint.price_centscrates/ml/src/features/price_features.rs— price input/outputcrates/ml/src/features/microstructure_features.rs:691— PriceImpactcrates/ml/src/universe/volatility.rs:84— PricePointcrates/ml/src/ensemble/adaptive_ml_integration.rs:92— PricePoint.pricecrates/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 fieldsservices/trading_service/src/core/position_manager.rs— 10 price: f64 fieldsservices/trading_service/src/core/risk_manager.rsservices/trading_service/src/services/enhanced_ml.rsservices/trading_service/src/ensemble_risk_manager.rsservices/trading_service/src/paper_trading_executor.rsservices/trading_service/src/utils.rs- Proto conversion boundary:
f64 ↔ Priceat 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: f64crates/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 builderstesting/integration/unit/financial_property_tests.rs— has its ownQuantity(u64)structtesting/test-common/lib.rs— test helperstesting/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 oldPricestruct (lines 2254-2675), oldQuantitystruct (lines 2677-end) - Modify:
crates/common/src/trading.rs— delete duplicateQuantitystruct (lines 100-145+) - Modify:
crates/common/src/lib.rs— renameFinancialPrice→Price,FinancialQuantity→Quantity,FinancialMoney→Moneyin re-exports - Modify: All consumers — remove
as Pricealiases, usecommon::Pricedirectly
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— removebigdecimalfrom[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.