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}