// Exchange rates as exact rationals, and conversion that rounds once.
//
// A published rate is a short decimal, so it is stored as an integer and a
// number of decimal places rather than as a Double: 7.2451 is
// `{ scaled: 72451, scale: 4 }`. Conversion multiplies the amount by the rate
// in arbitrary precision and rounds exactly once, so no intermediate rounding
// error can creep in.
///|
/// An exchange rate, exactly `scaled / 10^scale`.
pub(all) struct Rate {
scaled : Int64
scale : Int32
} derive(Eq, Debug)
///|
pub extend Rate with Eq::{not_equal, equal}
///|
pub extend Rate with @debug.Debug::{to_repr}
///|
/// Builds a rate from an integer and a number of decimal places.
///
/// `None` when the number of places is outside `0..=18`.
pub fn Rate::of_scaled(scaled : Int64, scale : Int32) -> Rate? {
match pow10(scale) {
None => None
Some(_) => Some({ scaled, scale, })
}
}
///|
/// Reads a decimal rate such as `7.2451`, up to nine decimal places.
pub fn Rate::parse(text : StringView) -> Rate raise MoneyError {
let raw = text.trim()
if raw.is_empty() {
raise InvalidAmount(raw.to_owned(), "empty rate")
}
let negative = raw.has_prefix("-")
let digits_text = if negative || raw.has_prefix("+") { raw[1:] } else { raw }
let (whole_text, fraction_text) = match digits_text.find(".") {
Some(dot) => (digits_text[0:dot], digits_text[dot + 1:])
None => (digits_text, "")
}
let places = fraction_text.length()
if places > 9 {
raise InvalidAmount(
raw.to_owned(),
"at most nine decimal places are supported",
)
}
let whole = if whole_text.is_empty() {
0L
} else {
parse_int64_or_fail(whole_text, raw)
}
let fraction = if places == 0 {
0L
} else {
parse_int64_or_fail(fraction_text, raw)
}
let power = match pow10(places) {
Some(value) => value
None => raise InvalidAmount(raw.to_owned(), "too many decimal places")
}
let scaled_whole = mul_checked(whole, power, "rate")
let magnitude = add_checked(scaled_whole, fraction, "rate")
let signed = if negative { 0L - magnitude } else { magnitude }
match Rate::of_scaled(signed, places) {
Some(rate) => rate
None => raise InvalidAmount(raw.to_owned(), "too many decimal places")
}
}
///|
pub fn Rate::as_double(self : Rate) -> Double {
self.scaled.to_double() / pow10(self.scale).unwrap_or(1L).to_double()
}
///|
/// The reciprocal, with twelve decimal places and one rounding step.
pub fn Rate::inverse(
self : Rate,
mode~ : RoundingMode = HalfEven,
) -> Rate raise MoneyError {
if self.scaled == 0L {
raise DivisionByZero
}
let target_places = 12
let target_power = match pow10(target_places) {
Some(value) => value
None => raise Overflow("rate inversion")
}
let source_power = match pow10(self.scale) {
Some(value) => value
None => raise Overflow("rate inversion")
}
let numerator = @bigint.BigInt::from_int64(source_power).mul(
@bigint.BigInt::from_int64(target_power),
)
let scaled = big_div_round(
numerator,
@bigint.BigInt::from_int64(self.scaled),
mode,
)
{ scaled, scale: target_places, }
}
///|
/// Converts an amount into another currency with an explicit rate.
///
/// The amount is multiplied by the rate and by the ratio of the two minor
/// units in arbitrary precision, then rounded exactly once, so the result
/// carries no double-rounding error. No rate is fetched from anywhere: the
/// caller supplies it, which keeps conversion deterministic and offline.
pub fn Money::convert(
self : Money,
target : Currency,
rate : Rate,
mode~ : RoundingMode = HalfUp,
) -> Money raise MoneyError {
let source_power = self.currency.scale()
let target_power = target.scale()
let rate_power = match pow10(rate.scale) {
Some(value) => value
None =>
raise InvalidAmount(rate.scale.to_string(), "too many decimal places")
}
let numerator = @bigint.BigInt::from_int64(self.minor)
.mul(@bigint.BigInt::from_int64(rate.scaled))
.mul(@bigint.BigInt::from_int64(target_power))
let denominator = @bigint.BigInt::from_int64(rate_power).mul(
@bigint.BigInt::from_int64(source_power),
)
{ minor: big_div_round(numerator, denominator, mode), currency: target, }
}