ccxt_core/
precision.rs

1//! Financial-grade precision calculation utilities.
2//!
3//! Provides high-precision numerical operations for financial calculations:
4//! - Rounding modes (Round, RoundUp, RoundDown)
5//! - Precision counting modes (DecimalPlaces, SignificantDigits, TickSize)
6//! - Number formatting and parsing
7//! - `Decimal` type helper functions
8
9use rust_decimal::Decimal;
10use rust_decimal::prelude::ToPrimitive;
11use std::str::FromStr;
12
13use crate::error::{Error, ParseError, Result};
14
15/// Rounding mode for precision calculations.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum RoundingMode {
18    /// Standard rounding (rounds to nearest, ties away from zero)
19    Round = 1,
20    /// Round up (away from zero)
21    RoundUp = 2,
22    /// Round down (toward zero, truncate)
23    RoundDown = 3,
24}
25
26/// Precision counting mode.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum CountingMode {
29    /// Count decimal places
30    DecimalPlaces = 2,
31    /// Count significant digits
32    SignificantDigits = 3,
33    /// Use minimum price tick size
34    TickSize = 4,
35}
36
37/// Output padding mode.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum PaddingMode {
40    /// No padding (remove trailing zeros)
41    NoPadding = 5,
42    /// Pad with zeros to precision
43    PadWithZero = 6,
44}
45
46/// Converts a decimal number to string, handling scientific notation.
47///
48/// # Examples
49///
50/// ```
51/// use ccxt_core::precision::number_to_string;
52/// use rust_decimal::Decimal;
53/// use std::str::FromStr;
54///
55/// let num = Decimal::from_str("0.00000123").unwrap();
56/// assert_eq!(number_to_string(num), "0.00000123");
57///
58/// let large = Decimal::from_str("1234567890").unwrap();
59/// assert_eq!(number_to_string(large), "1234567890");
60/// ```
61pub fn number_to_string(value: Decimal) -> String {
62    let s = value.to_string();
63
64    if s.contains('.') {
65        s.trim_end_matches('0').trim_end_matches('.').to_string()
66    } else {
67        s
68    }
69}
70
71/// Extracts precision (number of decimal places) from a string representation.
72///
73/// # Examples
74///
75/// ```
76/// use ccxt_core::precision::precision_from_string;
77///
78/// assert_eq!(precision_from_string("0.001"), 3);
79/// assert_eq!(precision_from_string("0.01"), 2);
80/// assert_eq!(precision_from_string("1.2345"), 4);
81/// assert_eq!(precision_from_string("100"), 0);
82/// assert_eq!(precision_from_string("1e-8"), 8);
83/// ```
84pub fn precision_from_string(s: &str) -> i32 {
85    if (s.contains('e') || s.contains('E'))
86        && let Some(e_pos) = s.find(['e', 'E'])
87    {
88        let exp_str = &s[e_pos + 1..];
89        if let Ok(exp) = exp_str.parse::<i32>() {
90            return -exp;
91        }
92    }
93
94    let trimmed = s.trim_end_matches('0');
95    if let Some(dot_pos) = trimmed.find('.') {
96        #[allow(clippy::cast_possible_truncation)]
97        let res = (trimmed.len() - dot_pos - 1) as i32;
98        res
99    } else {
100        0
101    }
102}
103
104/// Truncates a decimal to the specified number of decimal places without rounding.
105///
106/// # Examples
107///
108/// ```
109/// use ccxt_core::precision::truncate_to_string;
110/// use rust_decimal::Decimal;
111/// use std::str::FromStr;
112///
113/// let num = Decimal::from_str("123.456789").unwrap();
114/// assert_eq!(truncate_to_string(num, 2), "123.45");
115/// assert_eq!(truncate_to_string(num, 4), "123.4567");
116/// assert_eq!(truncate_to_string(num, 0), "123");
117/// ```
118pub fn truncate_to_string(value: Decimal, precision: i32) -> String {
119    if precision <= 0 {
120        return value.trunc().to_string();
121    }
122
123    let s = value.to_string();
124
125    if let Some(dot_pos) = s.find('.') {
126        let end_pos = std::cmp::min(dot_pos + 1 + precision as usize, s.len());
127        s[..end_pos].to_string()
128    } else {
129        s
130    }
131}
132
133/// Truncates a decimal to the specified number of decimal places, returning a `Decimal`.
134///
135/// # Examples
136///
137/// ```
138/// use ccxt_core::precision::truncate;
139/// use rust_decimal::Decimal;
140/// use std::str::FromStr;
141///
142/// let num = Decimal::from_str("123.456789").unwrap();
143/// let truncated = truncate(num, 2);
144/// assert_eq!(truncated.to_string(), "123.45");
145/// ```
146pub fn truncate(value: Decimal, precision: i32) -> Decimal {
147    let s = truncate_to_string(value, precision);
148    Decimal::from_str(&s).unwrap_or(value)
149}
150
151/// Formats a decimal to a specific precision with configurable rounding and counting modes.
152///
153/// Core precision formatting function equivalent to Go's `DecimalToPrecision` method.
154///
155/// # Arguments
156///
157/// * `value` - The decimal value to format
158/// * `rounding_mode` - Rounding behavior to apply
159/// * `num_precision_digits` - Number of precision digits
160/// * `counting_mode` - How to count precision (default: `DecimalPlaces`)
161/// * `padding_mode` - Output padding behavior (default: `NoPadding`)
162///
163/// # Examples
164///
165/// ```
166/// use ccxt_core::precision::{decimal_to_precision, RoundingMode, CountingMode, PaddingMode};
167/// use rust_decimal::Decimal;
168/// use std::str::FromStr;
169///
170/// let num = Decimal::from_str("123.456789").unwrap();
171///
172/// // 保留2位小数,四舍五入
173/// let result = decimal_to_precision(
174///     num,
175///     RoundingMode::Round,
176///     2,
177///     Some(CountingMode::DecimalPlaces),
178///     Some(PaddingMode::NoPadding),
179/// ).unwrap();
180/// assert_eq!(result, "123.46");
181///
182/// // 截断到2位小数
183/// let result = decimal_to_precision(
184///     num,
185///     RoundingMode::RoundDown,
186///     2,
187///     Some(CountingMode::DecimalPlaces),
188///     Some(PaddingMode::NoPadding),
189/// ).unwrap();
190/// assert_eq!(result, "123.45");
191/// ```
192pub fn decimal_to_precision(
193    value: Decimal,
194    rounding_mode: RoundingMode,
195    num_precision_digits: i32,
196    counting_mode: Option<CountingMode>,
197    padding_mode: Option<PaddingMode>,
198) -> Result<String> {
199    let counting_mode = counting_mode.unwrap_or(CountingMode::DecimalPlaces);
200    let padding_mode = padding_mode.unwrap_or(PaddingMode::NoPadding);
201
202    // Handle negative precision (round to powers of 10)
203    if num_precision_digits < 0 {
204        let to_nearest =
205            Decimal::from_i128_with_scale(10_i128.pow(num_precision_digits.unsigned_abs()), 0);
206
207        match rounding_mode {
208            RoundingMode::Round => {
209                let divided = value / to_nearest;
210                let rounded = round_decimal(divided, 0, RoundingMode::Round);
211                let result = to_nearest * rounded;
212                return Ok(format_decimal(result, padding_mode));
213            }
214            RoundingMode::RoundDown => {
215                let modulo = value % to_nearest;
216                let result = value - modulo;
217                return Ok(format_decimal(result, padding_mode));
218            }
219            RoundingMode::RoundUp => {
220                let modulo = value % to_nearest;
221                let result = if modulo.is_zero() {
222                    value
223                } else {
224                    value - modulo + to_nearest
225                };
226                return Ok(format_decimal(result, padding_mode));
227            }
228        }
229    }
230
231    match counting_mode {
232        CountingMode::DecimalPlaces => Ok(decimal_to_precision_decimal_places(
233            value,
234            rounding_mode,
235            num_precision_digits,
236            padding_mode,
237        )),
238        CountingMode::SignificantDigits => Ok(decimal_to_precision_significant_digits(
239            value,
240            rounding_mode,
241            num_precision_digits,
242            padding_mode,
243        )),
244        CountingMode::TickSize => {
245            let tick_size = Decimal::from_str(&format!(
246                "0.{}",
247                "0".repeat((num_precision_digits - 1) as usize) + "1"
248            ))
249            .map_err(|e| {
250                ParseError::invalid_format("tick_size", format!("Invalid tick size: {e}"))
251            })?;
252
253            decimal_to_precision_tick_size(value, rounding_mode, tick_size, padding_mode)
254        }
255    }
256}
257
258/// Formats decimal by counting decimal places.
259fn decimal_to_precision_decimal_places(
260    value: Decimal,
261    rounding_mode: RoundingMode,
262    decimal_places: i32,
263    padding_mode: PaddingMode,
264) -> String {
265    let rounded = round_decimal(value, decimal_places, rounding_mode);
266    format_decimal_with_places(rounded, decimal_places, padding_mode)
267}
268
269/// Formats decimal by counting significant digits.
270fn decimal_to_precision_significant_digits(
271    value: Decimal,
272    rounding_mode: RoundingMode,
273    sig_digits: i32,
274    padding_mode: PaddingMode,
275) -> String {
276    if value.is_zero() {
277        return "0".to_string();
278    }
279
280    let abs_value = value.abs();
281    let value_f64 = abs_value.to_f64().unwrap_or(0.0);
282    let log10 = value_f64.log10();
283    #[allow(clippy::cast_possible_truncation)]
284    let magnitude = log10.floor() as i32;
285
286    let decimal_places = sig_digits - magnitude - 1;
287
288    let rounded = round_decimal(value, decimal_places, rounding_mode);
289    format_decimal_with_places(rounded, decimal_places, padding_mode)
290}
291
292/// Formats decimal to align with tick size increments.
293fn decimal_to_precision_tick_size(
294    value: Decimal,
295    rounding_mode: RoundingMode,
296    tick_size: Decimal,
297    padding_mode: PaddingMode,
298) -> Result<String> {
299    if tick_size <= Decimal::ZERO {
300        return Err(Error::invalid_request("Tick size must be positive"));
301    }
302
303    let ticks = match rounding_mode {
304        RoundingMode::Round => (value / tick_size).round(),
305        RoundingMode::RoundDown => (value / tick_size).floor(),
306        RoundingMode::RoundUp => (value / tick_size).ceil(),
307    };
308
309    let result = ticks * tick_size;
310
311    let tick_precision = precision_from_string(&tick_size.to_string());
312    Ok(format_decimal_with_places(
313        result,
314        tick_precision,
315        padding_mode,
316    ))
317}
318
319/// Rounds a decimal value to the specified number of decimal places.
320fn round_decimal(value: Decimal, decimal_places: i32, mode: RoundingMode) -> Decimal {
321    if decimal_places < 0 {
322        let scale = Decimal::from_i128_with_scale(10_i128.pow(decimal_places.unsigned_abs()), 0);
323        let divided = value / scale;
324        let rounded = match mode {
325            RoundingMode::Round => divided.round(),
326            RoundingMode::RoundDown => divided.floor(),
327            RoundingMode::RoundUp => divided.ceil(),
328        };
329        return rounded * scale;
330    }
331
332    let scale = Decimal::from_i128_with_scale(10_i128.pow(decimal_places as u32), 0);
333    let scaled = value * scale;
334
335    let rounded = match mode {
336        RoundingMode::Round => scaled.round(),
337        RoundingMode::RoundDown => scaled.floor(),
338        RoundingMode::RoundUp => scaled.ceil(),
339    };
340
341    rounded / scale
342}
343
344/// Formats decimal by removing trailing zeros.
345fn format_decimal(value: Decimal, _padding_mode: PaddingMode) -> String {
346    let s = value.to_string();
347
348    if s.contains('.') {
349        s.trim_end_matches('0').trim_end_matches('.').to_string()
350    } else {
351        s
352    }
353}
354
355/// Formats decimal to the specified number of decimal places.
356fn format_decimal_with_places(
357    value: Decimal,
358    decimal_places: i32,
359    padding_mode: PaddingMode,
360) -> String {
361    match padding_mode {
362        PaddingMode::NoPadding => format_decimal(value, padding_mode),
363        PaddingMode::PadWithZero => {
364            if decimal_places > 0 {
365                format!("{:.prec$}", value, prec = decimal_places as usize)
366            } else {
367                value.trunc().to_string()
368            }
369        }
370    }
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    #[test]
378    fn test_number_to_string() {
379        let num = Decimal::from_str("0.00000123").unwrap();
380        assert_eq!(number_to_string(num), "0.00000123");
381
382        let large = Decimal::from_str("1234567890").unwrap();
383        assert_eq!(number_to_string(large), "1234567890");
384
385        let with_trailing = Decimal::from_str("123.4500").unwrap();
386        assert_eq!(number_to_string(with_trailing), "123.45");
387    }
388
389    #[test]
390    fn test_precision_from_string() {
391        assert_eq!(precision_from_string("0.001"), 3);
392        assert_eq!(precision_from_string("0.01"), 2);
393        assert_eq!(precision_from_string("1.2345"), 4);
394        assert_eq!(precision_from_string("100"), 0);
395        assert_eq!(precision_from_string("1.0000"), 0);
396    }
397
398    #[test]
399    fn test_truncate() {
400        let num = Decimal::from_str("123.456789").unwrap();
401
402        assert_eq!(truncate_to_string(num, 2), "123.45");
403        assert_eq!(truncate_to_string(num, 4), "123.4567");
404        assert_eq!(truncate_to_string(num, 0), "123");
405
406        let truncated = truncate(num, 2);
407        assert_eq!(truncated.to_string(), "123.45");
408    }
409
410    #[test]
411    fn test_decimal_to_precision_round() {
412        let num = Decimal::from_str("123.456").unwrap();
413
414        let result = decimal_to_precision(
415            num,
416            RoundingMode::Round,
417            2,
418            Some(CountingMode::DecimalPlaces),
419            Some(PaddingMode::NoPadding),
420        )
421        .unwrap();
422        assert_eq!(result, "123.46");
423    }
424
425    #[test]
426    fn test_decimal_to_precision_round_down() {
427        let num = Decimal::from_str("123.456").unwrap();
428
429        let result = decimal_to_precision(
430            num,
431            RoundingMode::RoundDown,
432            2,
433            Some(CountingMode::DecimalPlaces),
434            Some(PaddingMode::NoPadding),
435        )
436        .unwrap();
437        assert_eq!(result, "123.45");
438    }
439
440    #[test]
441    fn test_decimal_to_precision_round_up() {
442        let num = Decimal::from_str("123.451").unwrap();
443
444        let result = decimal_to_precision(
445            num,
446            RoundingMode::RoundUp,
447            2,
448            Some(CountingMode::DecimalPlaces),
449            Some(PaddingMode::NoPadding),
450        )
451        .unwrap();
452        assert_eq!(result, "123.46");
453    }
454
455    #[test]
456    fn test_decimal_to_precision_with_padding() {
457        let num = Decimal::from_str("123.4").unwrap();
458
459        let result = decimal_to_precision(
460            num,
461            RoundingMode::Round,
462            3,
463            Some(CountingMode::DecimalPlaces),
464            Some(PaddingMode::PadWithZero),
465        )
466        .unwrap();
467        assert_eq!(result, "123.400");
468    }
469
470    #[test]
471    fn test_decimal_to_precision_negative_precision() {
472        let num = Decimal::from_str("123.456").unwrap();
473
474        let result = decimal_to_precision(
475            num,
476            RoundingMode::Round,
477            -1,
478            Some(CountingMode::DecimalPlaces),
479            Some(PaddingMode::NoPadding),
480        )
481        .unwrap();
482        assert_eq!(result, "120");
483    }
484
485    #[test]
486    fn test_decimal_to_precision_tick_size() {
487        let num = Decimal::from_str("123.456").unwrap();
488        let tick = Decimal::from_str("0.05").unwrap();
489
490        let result =
491            decimal_to_precision_tick_size(num, RoundingMode::Round, tick, PaddingMode::NoPadding)
492                .unwrap();
493
494        // 123.456 / 0.05 = 2469.12, round = 2469, * 0.05 = 123.45
495        assert_eq!(result, "123.45");
496    }
497
498    #[test]
499    fn test_round_decimal() {
500        let num = Decimal::from_str("123.456").unwrap();
501
502        let rounded = round_decimal(num, 2, RoundingMode::Round);
503        assert_eq!(rounded.to_string(), "123.46");
504
505        let truncated = round_decimal(num, 2, RoundingMode::RoundDown);
506        assert_eq!(truncated.to_string(), "123.45");
507    }
508
509    #[test]
510    fn test_format_decimal() {
511        let num = Decimal::from_str("123.4500").unwrap();
512        let formatted = format_decimal(num, PaddingMode::NoPadding);
513        assert_eq!(formatted, "123.45");
514
515        let formatted_padded = format_decimal_with_places(num, 4, PaddingMode::PadWithZero);
516        assert_eq!(formatted_padded, "123.4500");
517    }
518}