Skip to main content

brokapi_tbank_protocol/
money.rs

1//! `Quotation` / `MoneyValue` (units + nano) <-> [`Decimal`], exactly.
2//!
3//! T-Bank prices are `units` (integer part) and `nano` (billionths); both carry the sign of the value.
4//! A [`Decimal`] holds every such value without loss, so the conversion is exact in both directions.
5//! There is no `f64` on the way. Going back needs at most 9 decimal places: a value with more is
6//! refused with [`MoneyError::TooManyDecimals`] and never rounded in silence.
7//!
8//! The trailing zeros are dropped on the way in (`Quotation { units: 114, nano: 250_000_000 }` is
9//! `114.25`). A [`Decimal`] compares by value, so the scale does not matter.
10//!
11//! # Examples
12//!
13//! ```
14//! use rust_decimal::Decimal;
15//! use brokapi_tbank_protocol::money::{
16//!     decimal_to_money, decimal_to_parts, decimal_to_quotation, money_to_decimal, quotation_to_decimal,
17//! };
18//!
19//! // 114.25 is 114 units and 250 000 000 nano.
20//! let price = Decimal::new(11425, 2);
21//! let q = decimal_to_quotation(price)?;
22//! assert_eq!((q.units, q.nano), (114, 250_000_000));
23//! assert_eq!(quotation_to_decimal(&q), price);
24//!
25//! // A negative value carries the sign on BOTH parts.
26//! assert_eq!(decimal_to_parts(Decimal::new(-2002, 1))?, (-200, -200_000_000));
27//!
28//! // Money keeps the currency code next to the amount.
29//! let m = decimal_to_money("rub", Decimal::new(15005, 1))?;
30//! assert_eq!((m.currency.as_str(), m.units, m.nano), ("rub", 1500, 500_000_000));
31//! assert_eq!(money_to_decimal(&m), Decimal::new(15005, 1));
32//! # Ok::<(), brokapi_tbank_protocol::money::MoneyError>(())
33//! ```
34
35use std::str::FromStr;
36
37use rust_decimal::Decimal;
38use rust_decimal::prelude::ToPrimitive;
39use thiserror::Error;
40
41use crate::v1::{MoneyValue, Quotation};
42
43const NANO_SCALE: u32 = 9;
44const NANO: i64 = 1_000_000_000;
45
46/// Why a number could not be turned into `units` and `nano`.
47///
48/// # Examples
49///
50/// ```
51/// use rust_decimal::Decimal;
52/// use brokapi_tbank_protocol::money::{MoneyError, decimal_to_parts};
53///
54/// // A tenth of a nano does not exist.
55/// assert_eq!(decimal_to_parts(Decimal::new(1, 10)), Err(MoneyError::TooManyDecimals));
56/// ```
57#[derive(Debug, Clone, PartialEq, Eq, Error)]
58pub enum MoneyError {
59    /// The value has more than 9 decimal places (not counting trailing zeros).
60    #[error("more than 9 decimal places: T-Bank counts in billionths")]
61    TooManyDecimals,
62    /// The integer part does not fit in an `i64`.
63    #[error("the integer part does not fit in an i64")]
64    OutOfRange,
65    /// The text is not a decimal number.
66    #[error("not a decimal number: {0:?}")]
67    Parse(String),
68}
69
70/// Converts a [`Quotation`] (a price or a quantity in pieces) to a [`Decimal`], exactly.
71///
72/// Both parts are added with their own signs, so a negative value works as expected.
73///
74/// # Examples
75///
76/// ```
77/// use rust_decimal::Decimal;
78/// use brokapi_tbank_protocol::money::quotation_to_decimal;
79/// use brokapi_tbank_protocol::v1::Quotation;
80///
81/// assert_eq!(quotation_to_decimal(&Quotation { units: 114, nano: 250_000_000 }), Decimal::new(11425, 2));
82/// assert_eq!(quotation_to_decimal(&Quotation { units: 0, nano: -10_000_000 }), Decimal::new(-1, 2));
83/// ```
84pub fn quotation_to_decimal(q: &Quotation) -> Decimal {
85    parts_to_decimal(q.units, q.nano)
86}
87
88/// Converts a [`MoneyValue`] to a [`Decimal`], ignoring its currency.
89///
90/// Read `m.currency` yourself before adding amounts: this function does not convert currencies.
91///
92/// # Examples
93///
94/// ```
95/// use rust_decimal::Decimal;
96/// use brokapi_tbank_protocol::money::{decimal_to_money, money_to_decimal};
97///
98/// let m = decimal_to_money("usd", Decimal::new(105, 1))?;
99/// assert_eq!(money_to_decimal(&m), Decimal::new(105, 1));
100/// # Ok::<(), brokapi_tbank_protocol::money::MoneyError>(())
101/// ```
102pub fn money_to_decimal(m: &MoneyValue) -> Decimal {
103    parts_to_decimal(m.units, m.nano)
104}
105
106/// Converts raw `units` and `nano` to a [`Decimal`], exactly.
107///
108/// `nano` is in billionths and should carry the same sign as `units` (or be the only non-zero part).
109/// The function does not check the range of `nano`: it is simply added.
110///
111/// # Examples
112///
113/// ```
114/// use rust_decimal::Decimal;
115/// use brokapi_tbank_protocol::money::parts_to_decimal;
116///
117/// assert_eq!(parts_to_decimal(1, 500_000_000), Decimal::new(15, 1));
118/// assert_eq!(parts_to_decimal(-1, -500_000_000), Decimal::new(-15, 1));
119/// ```
120pub fn parts_to_decimal(units: i64, nano: i32) -> Decimal {
121    // units < 2^63 and nano/1e9 with 9 places: well inside the 28 digits of Decimal.
122    (Decimal::from(units) + Decimal::new(i64::from(nano), NANO_SCALE)).normalize()
123}
124
125/// Splits a number into `(units, nano)`. Both parts get the sign of the value.
126///
127/// # Errors
128///
129/// [`MoneyError::TooManyDecimals`] if the value does not fit into billionths without rounding,
130/// [`MoneyError::OutOfRange`] if the integer part does not fit in an `i64`.
131///
132/// # Examples
133///
134/// ```
135/// use rust_decimal::Decimal;
136/// use brokapi_tbank_protocol::money::decimal_to_parts;
137///
138/// assert_eq!(decimal_to_parts(Decimal::new(11425, 2))?, (114, 250_000_000));
139/// assert_eq!(decimal_to_parts(Decimal::new(-1, 2))?, (0, -10_000_000));
140/// // Trailing zeros are not decimals: 0.5000000000 has one.
141/// assert_eq!(decimal_to_parts(Decimal::new(5_000_000_000, 10))?, (0, 500_000_000));
142/// # Ok::<(), brokapi_tbank_protocol::money::MoneyError>(())
143/// ```
144pub fn decimal_to_parts(value: Decimal) -> Result<(i64, i32), MoneyError> {
145    let v = value.normalize();
146    if v.scale() > NANO_SCALE {
147        return Err(MoneyError::TooManyDecimals);
148    }
149    let units = v.trunc().to_i64().ok_or(MoneyError::OutOfRange)?;
150    let mut frac = v.fract();
151    frac.rescale(NANO_SCALE);
152    // |frac| < 1 with 9 places: the mantissa is below 1e9, it fits in an i32.
153    let nano = i32::try_from(frac.mantissa() as i64).map_err(|_| MoneyError::OutOfRange)?;
154    debug_assert!(i64::from(nano).abs() < NANO);
155    Ok((units, nano))
156}
157
158/// Builds a [`Quotation`] from a number.
159///
160/// Use it for the `price` of an order request. This function does not round to the instrument price
161/// step: round with the library first (`Price::round_to_step`).
162///
163/// # Errors
164///
165/// As [`decimal_to_parts`].
166///
167/// # Examples
168///
169/// ```
170/// use rust_decimal::Decimal;
171/// use brokapi_tbank_protocol::money::decimal_to_quotation;
172///
173/// let q = decimal_to_quotation(Decimal::new(27345, 2))?;
174/// assert_eq!((q.units, q.nano), (273, 450_000_000));
175/// # Ok::<(), brokapi_tbank_protocol::money::MoneyError>(())
176/// ```
177pub fn decimal_to_quotation(value: Decimal) -> Result<Quotation, MoneyError> {
178    let (units, nano) = decimal_to_parts(value)?;
179    Ok(Quotation { units, nano })
180}
181
182/// Builds a [`MoneyValue`] with the given currency code from a number.
183///
184/// `currency` is stored as given. T-Bank messages carry lower-case codes such as `"rub"` (not
185/// verified against the live API for every message).
186///
187/// # Errors
188///
189/// As [`decimal_to_parts`].
190///
191/// # Examples
192///
193/// ```
194/// use rust_decimal::Decimal;
195/// use brokapi_tbank_protocol::money::decimal_to_money;
196///
197/// let m = decimal_to_money("rub", Decimal::new(1, 2))?;
198/// assert_eq!((m.units, m.nano), (0, 10_000_000));
199/// assert_eq!(m.currency, "rub");
200/// # Ok::<(), brokapi_tbank_protocol::money::MoneyError>(())
201/// ```
202pub fn decimal_to_money(currency: &str, value: Decimal) -> Result<MoneyValue, MoneyError> {
203    let (units, nano) = decimal_to_parts(value)?;
204    Ok(MoneyValue {
205        currency: currency.to_string(),
206        units,
207        nano,
208    })
209}
210
211/// Parses a decimal text into a [`Quotation`]: `"273.45"`.
212///
213/// # Errors
214///
215/// [`MoneyError::Parse`] for text that is not a number, and as [`decimal_to_parts`].
216///
217/// # Examples
218///
219/// ```
220/// use brokapi_tbank_protocol::money::quotation_from_str;
221///
222/// let q = quotation_from_str("0.0001")?;
223/// assert_eq!((q.units, q.nano), (0, 100_000));
224/// # Ok::<(), brokapi_tbank_protocol::money::MoneyError>(())
225/// ```
226pub fn quotation_from_str(s: &str) -> Result<Quotation, MoneyError> {
227    decimal_to_quotation(parse(s)?)
228}
229
230/// Parses a decimal text into a [`MoneyValue`] of the given currency.
231///
232/// # Errors
233///
234/// As [`quotation_from_str`].
235///
236/// # Examples
237///
238/// ```
239/// use brokapi_tbank_protocol::money::money_from_str;
240///
241/// let m = money_from_str("rub", "1500.50")?;
242/// assert_eq!((m.units, m.nano), (1500, 500_000_000));
243/// # Ok::<(), brokapi_tbank_protocol::money::MoneyError>(())
244/// ```
245pub fn money_from_str(currency: &str, s: &str) -> Result<MoneyValue, MoneyError> {
246    decimal_to_money(currency, parse(s)?)
247}
248
249/// [`quotation_from_str`] for a literal that is known to be right (tests, examples, imitations).
250///
251/// # Panics
252///
253/// If the text is not a number with at most 9 decimals.
254///
255/// # Examples
256///
257/// ```
258/// let q = brokapi_tbank_protocol::money::quotation("114.25");
259/// assert_eq!((q.units, q.nano), (114, 250_000_000));
260/// ```
261pub fn quotation(s: &str) -> Quotation {
262    quotation_from_str(s).unwrap_or_else(|e| panic!("bad quotation literal {s:?}: {e}"))
263}
264
265/// [`money_from_str`] for a literal that is known to be right (tests, examples, imitations).
266///
267/// # Panics
268///
269/// If the text is not a number with at most 9 decimals.
270///
271/// # Examples
272///
273/// ```
274/// let m = brokapi_tbank_protocol::money::money("rub", "-4");
275/// assert_eq!((m.units, m.nano), (-4, 0));
276/// ```
277pub fn money(currency: &str, s: &str) -> MoneyValue {
278    money_from_str(currency, s).unwrap_or_else(|e| panic!("bad money literal {s:?}: {e}"))
279}
280
281fn parse(s: &str) -> Result<Decimal, MoneyError> {
282    let t = s.trim();
283    Decimal::from_str(t)
284        .or_else(|_| Decimal::from_scientific(t))
285        .map_err(|_| MoneyError::Parse(s.to_string()))
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use proptest::prelude::*;
292
293    #[test]
294    fn quotation_positive() {
295        let q = Quotation {
296            units: 114,
297            nano: 250_000_000,
298        };
299        assert_eq!(quotation_to_decimal(&q), Decimal::new(11425, 2));
300    }
301
302    #[test]
303    fn quotation_negative_sign_on_both_parts() {
304        let q = Quotation {
305            units: -200,
306            nano: -200_000_000,
307        };
308        assert_eq!(quotation_to_decimal(&q), Decimal::new(-2002, 1));
309    }
310
311    #[test]
312    fn quotation_sub_unit() {
313        let q = Quotation {
314            units: 0,
315            nano: -10_000_000,
316        };
317        assert_eq!(quotation_to_decimal(&q), Decimal::new(-1, 2));
318    }
319
320    #[test]
321    fn split_keeps_equal_signs() {
322        assert_eq!(
323            decimal_to_parts(Decimal::new(-2002, 1)),
324            Ok((-200, -200_000_000))
325        );
326        assert_eq!(decimal_to_parts(Decimal::new(-1, 2)), Ok((0, -10_000_000)));
327        assert_eq!(
328            decimal_to_parts(Decimal::new(11425, 2)),
329            Ok((114, 250_000_000))
330        );
331    }
332
333    #[test]
334    fn exact_edges() {
335        // Smallest unit and the biggest fraction.
336        assert_eq!(decimal_to_parts(Decimal::new(1, 9)), Ok((0, 1)));
337        assert_eq!(
338            decimal_to_parts(Decimal::new(999_999_999, 9)),
339            Ok((0, 999_999_999))
340        );
341        assert_eq!(
342            decimal_to_parts(Decimal::new(-999_999_999, 9)),
343            Ok((0, -999_999_999))
344        );
345        assert_eq!(
346            decimal_to_parts(Decimal::new(1, 10)),
347            Err(MoneyError::TooManyDecimals)
348        );
349        assert_eq!(
350            decimal_to_parts(Decimal::MAX),
351            Err(MoneyError::TooManyDecimals).or(Err(MoneyError::OutOfRange))
352        );
353        assert_eq!(decimal_to_parts(Decimal::from(i64::MAX)), Ok((i64::MAX, 0)));
354        assert_eq!(
355            decimal_to_parts(Decimal::from(i64::MAX) + Decimal::ONE),
356            Err(MoneyError::OutOfRange)
357        );
358    }
359
360    #[test]
361    fn money_has_currency() {
362        let m = money("rub", "1500.5");
363        assert_eq!(
364            (m.currency.as_str(), m.units, m.nano),
365            ("rub", 1500, 500_000_000)
366        );
367        assert_eq!(money_to_decimal(&m), Decimal::new(15005, 1));
368    }
369
370    #[test]
371    fn literals_and_parse_errors() {
372        assert_eq!(
373            quotation("0.0001"),
374            Quotation {
375                units: 0,
376                nano: 100_000
377            }
378        );
379        assert!(matches!(quotation_from_str("x"), Err(MoneyError::Parse(_))));
380        assert_eq!(
381            quotation_from_str("0.0000000001"),
382            Err(MoneyError::TooManyDecimals)
383        );
384    }
385
386    proptest! {
387        // broker -> type -> broker: the units and nano come back as they were.
388        #[test]
389        fn parts_round_trip(units in -1_000_000_000_000_i64..1_000_000_000_000, nano in 0i32..1_000_000_000, neg in any::<bool>()) {
390            let (units, nano) = if neg { (-units.abs(), -nano) } else { (units.abs(), nano) };
391            let d = parts_to_decimal(units, nano);
392            prop_assert_eq!(decimal_to_parts(d), Ok((units, nano)));
393        }
394
395        // type -> broker -> type: a number with up to 9 places comes back equal.
396        #[test]
397        fn decimal_round_trip(m in -1_000_000_000_000_000_000_i64..1_000_000_000_000_000_000, scale in 0u32..=9) {
398            let d = Decimal::new(m, scale);
399            let q = decimal_to_quotation(d).unwrap();
400            prop_assert_eq!(quotation_to_decimal(&q), d);
401            prop_assert!(i64::from(q.nano.signum()) * q.units.signum() >= 0);
402            let mv = decimal_to_money("rub", d).unwrap();
403            prop_assert_eq!(money_to_decimal(&mv), d);
404        }
405    }
406}