Skip to main content

opendeviationbar_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 decimal basis points (v3.0.0: 100,000)
13/// Prior to v3.0.0, this was 10,000 (1 dbps units). Now 100,000 (dbps).
14/// Migration: multiply all threshold_decimal_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, Default, 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) — zero-allocation path
58        // Issue #96: Avoid format!() String allocation per parse (2 allocs/trade eliminated)
59        let fractional_part = if parts.len() == 2 {
60            let frac_str = parts[1];
61            let frac_len = frac_str.len();
62            if frac_len > 8 {
63                return Err(FixedPointError::TooManyDecimals);
64            }
65
66            // Parse digits directly and scale by 10^(8-len) instead of String padding
67            let frac_digits: i64 = frac_str
68                .parse()
69                .map_err(|_| FixedPointError::InvalidFormat)?;
70
71            // Multiply by appropriate power of 10 to get 8 decimal places
72            // e.g., "5" (1 digit) → 5 * 10^7 = 50_000_000
73            // e.g., "12345678" (8 digits) → 12345678 * 10^0 = 12345678
74            const POWERS: [i64; 9] = [
75                100_000_000, 10_000_000, 1_000_000, 100_000, 10_000,
76                1_000, 100, 10, 1,
77            ];
78            frac_digits * POWERS[frac_len]
79        } else {
80            0
81        };
82
83        // Combine parts with proper sign handling
84        let result = if integer_part >= 0 {
85            integer_part * SCALE + fractional_part
86        } else {
87            integer_part * SCALE - fractional_part
88        };
89
90        Ok(FixedPoint(result))
91    }
92
93    /// Convert FixedPoint to string representation with 8 decimal places
94    #[allow(clippy::inherent_to_string_shadow_display)]
95    pub fn to_string(&self) -> String {
96        let abs_value = self.0.abs();
97        let integer_part = abs_value / SCALE;
98        let fractional_part = abs_value % SCALE;
99
100        let sign = if self.0 < 0 { "-" } else { "" };
101        format!("{}{}.{:08}", sign, integer_part, fractional_part)
102    }
103
104    /// Compute range thresholds for given basis points
105    ///
106    /// # Arguments
107    ///
108    /// * `threshold_decimal_bps` - Threshold in **decimal basis points**
109    ///   - Example: `250` → 25bps = 0.25%
110    ///   - Example: `10` → 1bps = 0.01%
111    ///   - Minimum: `1` → 0.1bps = 0.001%
112    ///
113    /// # Returns
114    ///
115    /// Tuple of (upper_threshold, lower_threshold)
116    ///
117    /// # Breaking Change (v3.0.0)
118    ///
119    /// Prior to v3.0.0, `threshold_decimal_bps` was in 1 dbps units.
120    /// **Migration**: Multiply all threshold values by 10.
121    pub fn compute_range_thresholds(&self, threshold_decimal_bps: u32) -> (FixedPoint, FixedPoint) {
122        // Calculate threshold delta: price * (threshold_decimal_bps / 100,000)
123        // v3.0.0: threshold now in dbps (e.g., 250 dbps = 0.25%)
124        let delta = (self.0 as i128 * threshold_decimal_bps as i128) / BASIS_POINTS_SCALE as i128;
125        let delta = delta as i64;
126
127        let upper = FixedPoint(self.0 + delta);
128        let lower = FixedPoint(self.0 - delta);
129
130        (upper, lower)
131    }
132
133    /// Issue #96 Task #98: Fast threshold computation using pre-computed ratio
134    ///
135    /// Avoids repeated division by BASIS_POINTS_SCALE in hot path (every bar creation).
136    /// Instead of: delta = (price * threshold_dbps) / 100_000
137    /// We use: delta = (price * ratio) / SCALE, where ratio is pre-computed.
138    ///
139    /// # Arguments
140    /// * `threshold_ratio` - Pre-computed (threshold_dbps * SCALE) / 100_000
141    ///   This should be computed once at OpenDeviationBarProcessor initialization.
142    #[inline]
143    pub fn compute_range_thresholds_cached(&self, threshold_ratio: i64) -> (FixedPoint, FixedPoint) {
144        // Calculate threshold delta using cached ratio: delta = (price * ratio) / SCALE
145        // Avoids division in hot path, only does multiplication
146        let delta = (self.0 as i128 * threshold_ratio as i128) / SCALE as i128;
147        let delta = delta as i64;
148
149        let upper = FixedPoint(self.0 + delta);
150        let lower = FixedPoint(self.0 - delta);
151
152        (upper, lower)
153    }
154
155    /// Convert to f64 for user-friendly output
156    /// Issue #96: #[inline] for hot-path conversion (called 100s of times per bar)
157    #[inline]
158    pub fn to_f64(&self) -> f64 {
159        self.0 as f64 / SCALE as f64
160    }
161}
162
163impl fmt::Display for FixedPoint {
164    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165        write!(f, "{}", self.to_string())
166    }
167}
168
169impl FromStr for FixedPoint {
170    type Err = FixedPointError;
171
172    fn from_str(s: &str) -> Result<Self, Self::Err> {
173        FixedPoint::from_str(s)
174    }
175}
176
177/// Fixed-point arithmetic errors
178#[derive(Debug, Clone, PartialEq)]
179pub enum FixedPointError {
180    /// Invalid number format
181    InvalidFormat,
182    /// Too many decimal places (>8)
183    TooManyDecimals,
184    /// Arithmetic overflow
185    Overflow,
186}
187
188impl fmt::Display for FixedPointError {
189    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
190        match self {
191            FixedPointError::InvalidFormat => write!(f, "Invalid number format"),
192            FixedPointError::TooManyDecimals => write!(f, "Too many decimal places (max 8)"),
193            FixedPointError::Overflow => write!(f, "Arithmetic overflow"),
194        }
195    }
196}
197
198impl std::error::Error for FixedPointError {}
199
200#[cfg(feature = "python")]
201impl From<FixedPointError> for PyErr {
202    fn from(err: FixedPointError) -> PyErr {
203        match err {
204            FixedPointError::InvalidFormat => {
205                pyo3::exceptions::PyValueError::new_err("Invalid number format")
206            }
207            FixedPointError::TooManyDecimals => {
208                pyo3::exceptions::PyValueError::new_err("Too many decimal places (max 8)")
209            }
210            FixedPointError::Overflow => {
211                pyo3::exceptions::PyOverflowError::new_err("Arithmetic overflow")
212            }
213        }
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn test_from_string() {
223        assert_eq!(FixedPoint::from_str("0").unwrap().0, 0);
224        assert_eq!(FixedPoint::from_str("1").unwrap().0, SCALE);
225        assert_eq!(FixedPoint::from_str("1.5").unwrap().0, SCALE + SCALE / 2);
226        assert_eq!(
227            FixedPoint::from_str("50000.12345678").unwrap().0,
228            5000012345678
229        );
230        assert_eq!(FixedPoint::from_str("-1.5").unwrap().0, -SCALE - SCALE / 2);
231    }
232
233    #[test]
234    fn test_to_string() {
235        assert_eq!(FixedPoint(0).to_string(), "0.00000000");
236        assert_eq!(FixedPoint(SCALE).to_string(), "1.00000000");
237        assert_eq!(FixedPoint(SCALE + SCALE / 2).to_string(), "1.50000000");
238        assert_eq!(FixedPoint(5000012345678).to_string(), "50000.12345678");
239        assert_eq!(FixedPoint(-SCALE).to_string(), "-1.00000000");
240    }
241
242    #[test]
243    fn test_round_trip() {
244        let test_values = [
245            "0",
246            "1",
247            "1.5",
248            "50000.12345678",
249            "999999.99999999",
250            "-1.5",
251            "-50000.12345678",
252        ];
253
254        for val in &test_values {
255            let fp = FixedPoint::from_str(val).unwrap();
256            let back = fp.to_string();
257
258            // Verify round-trip conversion works correctly
259            let fp2 = FixedPoint::from_str(&back).unwrap();
260            assert_eq!(fp.0, fp2.0, "Round trip failed for {}", val);
261        }
262    }
263
264    #[test]
265    fn test_compute_thresholds() {
266        let price = FixedPoint::from_str("50000.0").unwrap();
267        let (upper, lower) = price.compute_range_thresholds(250); // 250 × 0.1bps = 25bps
268
269        // 50000 * 0.0025 = 125 (25bps = 0.25%)
270        assert_eq!(upper.to_string(), "50125.00000000");
271        assert_eq!(lower.to_string(), "49875.00000000");
272    }
273
274    #[test]
275    fn test_error_cases() {
276        assert!(FixedPoint::from_str("").is_err());
277        assert!(FixedPoint::from_str("not_a_number").is_err());
278        assert!(FixedPoint::from_str("1.123456789").is_err()); // Too many decimals
279        assert!(FixedPoint::from_str("1.2.3").is_err()); // Multiple decimal points
280    }
281
282    #[test]
283    fn test_comparison() {
284        let a = FixedPoint::from_str("50000.0").unwrap();
285        let b = FixedPoint::from_str("50000.1").unwrap();
286        let c = FixedPoint::from_str("49999.9").unwrap();
287
288        assert!(a < b);
289        assert!(b > a);
290        assert!(c < a);
291        assert_eq!(a, a);
292    }
293
294    // Issue #96 Task #91: Edge case tests for arithmetic correctness
295
296    #[test]
297    fn test_from_str_too_many_decimals() {
298        let err = FixedPoint::from_str("0.000000001").unwrap_err();
299        assert_eq!(err, FixedPointError::TooManyDecimals);
300    }
301
302    #[test]
303    fn test_from_str_negative_fractional() {
304        // Known edge case: "-0.5" parses as +0.5 because "-0" → 0 (non-negative)
305        // The sign is lost when integer_part == 0. This only affects (-1, 0) range.
306        // Real Binance prices are always positive, so this is acceptable behavior.
307        let fp = FixedPoint::from_str("-0.5").unwrap();
308        assert_eq!(fp.0, 50_000_000); // "-0" parsed as 0 (non-negative), so +0.5
309
310        // Negative values with non-zero integer part work correctly
311        let fp2 = FixedPoint::from_str("-1.5").unwrap();
312        assert_eq!(fp2.0, -150_000_000); // -1.5 * SCALE
313        assert_eq!(fp2.to_f64(), -1.5);
314    }
315
316    #[test]
317    fn test_from_str_leading_zeros() {
318        // "000.123" should parse — integer part "000" is valid i64
319        let fp = FixedPoint::from_str("000.123").unwrap();
320        assert_eq!(fp.0, 12_300_000); // 0.123 * SCALE
321    }
322
323    #[test]
324    fn test_to_f64_extreme_values() {
325        // i64::MAX / SCALE = 92233720368.54775807
326        let max_fp = FixedPoint(i64::MAX);
327        let max_f64 = max_fp.to_f64();
328        assert!(max_f64 > 92_233_720_368.0);
329        assert!(max_f64.is_finite());
330
331        // i64::MIN / SCALE = -92233720368.54775808
332        let min_fp = FixedPoint(i64::MIN);
333        let min_f64 = min_fp.to_f64();
334        assert!(min_f64 < -92_233_720_368.0);
335        assert!(min_f64.is_finite());
336    }
337
338    #[test]
339    fn test_threshold_zero_ratio() {
340        let price = FixedPoint::from_str("100.0").unwrap();
341        let (upper, lower) = price.compute_range_thresholds_cached(0);
342        assert_eq!(upper, price);
343        assert_eq!(lower, price);
344    }
345
346    #[test]
347    fn test_threshold_small_price_small_bps() {
348        // Very small price (0.01) with smallest threshold (1 dbps = 0.001%)
349        let price = FixedPoint::from_str("0.01").unwrap();
350        let (upper, lower) = price.compute_range_thresholds(1);
351        // delta = (1_000_000 * 1) / 100_000 = 10
352        // So upper = 1_000_010, lower = 999_990
353        assert!(upper > price);
354        assert!(lower < price);
355    }
356
357    #[test]
358    fn test_fixedpoint_zero() {
359        let zero = FixedPoint(0);
360        assert_eq!(zero.to_f64(), 0.0);
361        assert_eq!(zero.to_string(), "0.00000000");
362        let (upper, lower) = zero.compute_range_thresholds(250);
363        assert_eq!(upper, zero); // 0 * anything = 0
364        assert_eq!(lower, zero);
365    }
366
367    #[test]
368    fn test_fixedpoint_error_display() {
369        assert_eq!(
370            FixedPointError::InvalidFormat.to_string(),
371            "Invalid number format"
372        );
373        assert_eq!(
374            FixedPointError::TooManyDecimals.to_string(),
375            "Too many decimal places (max 8)"
376        );
377        assert_eq!(
378            FixedPointError::Overflow.to_string(),
379            "Arithmetic overflow"
380        );
381    }
382}