rangebar_core/
fixed_point.rs

1//! Fixed-point arithmetic for precise decimal calculations without floating point errors
2
3#[cfg(feature = "python")]
4use pyo3::prelude::*;
5use serde::{Deserialize, Serialize};
6use std::fmt;
7use std::str::FromStr;
8
9/// Scale factor for 8 decimal places (100,000,000)
10pub const SCALE: i64 = 100_000_000;
11
12/// Scale factor for basis points calculations (v3.0.0: tenths of bps, 100,000)
13/// Prior to v3.0.0, this was 10,000 (1bps units). Now 100,000 (0.1bps units).
14/// Migration: multiply all threshold_bps values by 10.
15pub const BASIS_POINTS_SCALE: u32 = 100_000;
16
17/// Fixed-point decimal representation using i64 with 8 decimal precision
18///
19/// This avoids floating point rounding errors while maintaining performance.
20/// All prices and volumes are stored as integers scaled by SCALE (1e8).
21///
22/// Example:
23/// - 50000.12345678 → 5000012345678
24/// - 1.5 → 150000000
25#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
26#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
27pub struct FixedPoint(pub i64);
28
29impl FixedPoint {
30    /// Create FixedPoint from string representation
31    ///
32    /// # Arguments
33    ///
34    /// * `s` - Decimal string (e.g., "50000.12345678")
35    ///
36    /// # Returns
37    ///
38    /// Result containing FixedPoint or parse error
39    #[allow(clippy::should_implement_trait)]
40    pub fn from_str(s: &str) -> Result<Self, FixedPointError> {
41        // Handle empty string
42        if s.is_empty() {
43            return Err(FixedPointError::InvalidFormat);
44        }
45
46        // Split on decimal point
47        let parts: Vec<&str> = s.split('.').collect();
48        if parts.len() > 2 {
49            return Err(FixedPointError::InvalidFormat);
50        }
51
52        // Parse integer part
53        let integer_part: i64 = parts[0]
54            .parse()
55            .map_err(|_| FixedPointError::InvalidFormat)?;
56
57        // Parse fractional part (if exists)
58        let fractional_part = if parts.len() == 2 {
59            let frac_str = parts[1];
60            if frac_str.len() > 8 {
61                return Err(FixedPointError::TooManyDecimals);
62            }
63
64            // Pad with zeros to get exactly 8 decimals
65            let padded = format!("{:0<8}", frac_str);
66            padded
67                .parse::<i64>()
68                .map_err(|_| FixedPointError::InvalidFormat)?
69        } else {
70            0
71        };
72
73        // Combine parts with proper sign handling
74        let result = if integer_part >= 0 {
75            integer_part * SCALE + fractional_part
76        } else {
77            integer_part * SCALE - fractional_part
78        };
79
80        Ok(FixedPoint(result))
81    }
82
83    /// Convert FixedPoint to string representation with 8 decimal places
84    #[allow(clippy::inherent_to_string_shadow_display)]
85    pub fn to_string(&self) -> String {
86        let abs_value = self.0.abs();
87        let integer_part = abs_value / SCALE;
88        let fractional_part = abs_value % SCALE;
89
90        let sign = if self.0 < 0 { "-" } else { "" };
91        format!("{}{}.{:08}", sign, integer_part, fractional_part)
92    }
93
94    /// Compute range thresholds for given basis points
95    ///
96    /// # Arguments
97    ///
98    /// * `threshold_bps` - Threshold in **tenths of basis points** (0.1bps units)
99    ///   - Example: `250` → 25bps = 0.25%
100    ///   - Example: `10` → 1bps = 0.01%
101    ///   - Minimum: `1` → 0.1bps = 0.001%
102    ///
103    /// # Returns
104    ///
105    /// Tuple of (upper_threshold, lower_threshold)
106    ///
107    /// # Breaking Change (v3.0.0)
108    ///
109    /// Prior to v3.0.0, `threshold_bps` was in 1bps units.
110    /// **Migration**: Multiply all threshold values by 10.
111    pub fn compute_range_thresholds(&self, threshold_bps: u32) -> (FixedPoint, FixedPoint) {
112        // Calculate threshold delta: price * (threshold_bps / 100,000)
113        // v3.0.0: threshold_bps now in 0.1bps units (e.g., 250 = 25bps)
114        let delta = (self.0 as i128 * threshold_bps as i128) / BASIS_POINTS_SCALE as i128;
115        let delta = delta as i64;
116
117        let upper = FixedPoint(self.0 + delta);
118        let lower = FixedPoint(self.0 - delta);
119
120        (upper, lower)
121    }
122
123    /// Convert to f64 for user-friendly output
124    pub fn to_f64(&self) -> f64 {
125        self.0 as f64 / SCALE as f64
126    }
127}
128
129impl fmt::Display for FixedPoint {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        write!(f, "{}", self.to_string())
132    }
133}
134
135impl FromStr for FixedPoint {
136    type Err = FixedPointError;
137
138    fn from_str(s: &str) -> Result<Self, Self::Err> {
139        FixedPoint::from_str(s)
140    }
141}
142
143/// Fixed-point arithmetic errors
144#[derive(Debug, Clone, PartialEq)]
145pub enum FixedPointError {
146    /// Invalid number format
147    InvalidFormat,
148    /// Too many decimal places (>8)
149    TooManyDecimals,
150    /// Arithmetic overflow
151    Overflow,
152}
153
154impl fmt::Display for FixedPointError {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        match self {
157            FixedPointError::InvalidFormat => write!(f, "Invalid number format"),
158            FixedPointError::TooManyDecimals => write!(f, "Too many decimal places (max 8)"),
159            FixedPointError::Overflow => write!(f, "Arithmetic overflow"),
160        }
161    }
162}
163
164impl std::error::Error for FixedPointError {}
165
166#[cfg(feature = "python")]
167impl From<FixedPointError> for PyErr {
168    fn from(err: FixedPointError) -> PyErr {
169        match err {
170            FixedPointError::InvalidFormat => {
171                pyo3::exceptions::PyValueError::new_err("Invalid number format")
172            }
173            FixedPointError::TooManyDecimals => {
174                pyo3::exceptions::PyValueError::new_err("Too many decimal places (max 8)")
175            }
176            FixedPointError::Overflow => {
177                pyo3::exceptions::PyOverflowError::new_err("Arithmetic overflow")
178            }
179        }
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn test_from_string() {
189        assert_eq!(FixedPoint::from_str("0").unwrap().0, 0);
190        assert_eq!(FixedPoint::from_str("1").unwrap().0, SCALE);
191        assert_eq!(FixedPoint::from_str("1.5").unwrap().0, SCALE + SCALE / 2);
192        assert_eq!(
193            FixedPoint::from_str("50000.12345678").unwrap().0,
194            5000012345678
195        );
196        assert_eq!(FixedPoint::from_str("-1.5").unwrap().0, -SCALE - SCALE / 2);
197    }
198
199    #[test]
200    fn test_to_string() {
201        assert_eq!(FixedPoint(0).to_string(), "0.00000000");
202        assert_eq!(FixedPoint(SCALE).to_string(), "1.00000000");
203        assert_eq!(FixedPoint(SCALE + SCALE / 2).to_string(), "1.50000000");
204        assert_eq!(FixedPoint(5000012345678).to_string(), "50000.12345678");
205        assert_eq!(FixedPoint(-SCALE).to_string(), "-1.00000000");
206    }
207
208    #[test]
209    fn test_round_trip() {
210        let test_values = [
211            "0",
212            "1",
213            "1.5",
214            "50000.12345678",
215            "999999.99999999",
216            "-1.5",
217            "-50000.12345678",
218        ];
219
220        for val in &test_values {
221            let fp = FixedPoint::from_str(val).unwrap();
222            let back = fp.to_string();
223
224            // Verify round-trip conversion works correctly
225            let fp2 = FixedPoint::from_str(&back).unwrap();
226            assert_eq!(fp.0, fp2.0, "Round trip failed for {}", val);
227        }
228    }
229
230    #[test]
231    fn test_compute_thresholds() {
232        let price = FixedPoint::from_str("50000.0").unwrap();
233        let (upper, lower) = price.compute_range_thresholds(250); // 250 × 0.1bps = 25bps
234
235        // 50000 * 0.0025 = 125 (25bps = 0.25%)
236        assert_eq!(upper.to_string(), "50125.00000000");
237        assert_eq!(lower.to_string(), "49875.00000000");
238    }
239
240    #[test]
241    fn test_error_cases() {
242        assert!(FixedPoint::from_str("").is_err());
243        assert!(FixedPoint::from_str("not_a_number").is_err());
244        assert!(FixedPoint::from_str("1.123456789").is_err()); // Too many decimals
245        assert!(FixedPoint::from_str("1.2.3").is_err()); // Multiple decimal points
246    }
247
248    #[test]
249    fn test_comparison() {
250        let a = FixedPoint::from_str("50000.0").unwrap();
251        let b = FixedPoint::from_str("50000.1").unwrap();
252        let c = FixedPoint::from_str("49999.9").unwrap();
253
254        assert!(a < b);
255        assert!(b > a);
256        assert!(c < a);
257        assert_eq!(a, a);
258    }
259}