from .math_fractional_power import FIXED_SCALE, fractional_power_fixed from .money_amount import Money #: The largest integer every language holds exactly (2^53 - 1). _MAX_SAFE = 9007199254740991 def _whole_minor(amount: Money) -> int: minor = amount.minor if isinstance(minor, bool) or not isinstance(minor, int): raise ValueError("amounts must be whole minor units, received %r" % (minor,)) return minor def _check_years(name: str, value: int) -> None: if isinstance(value, bool) or not isinstance(value, int) or value < 1 or value > 100000: raise ValueError("%s must be a whole number from 1 to 100000, received %r" % (name, value)) def _round_half_away(n: int, d: int) -> int: """n / d rounded half away from zero; d > 0.""" magnitude = (abs(n) * 2 + d) // (2 * d) return -magnitude if n < 0 else magnitude def cagr(start_value: Money, end_value: Money, years_numerator: int, years_denominator: int) -> int: """Compound annual growth rate, (end / start)^(1 / years) - 1, in basis points rounded half away from zero. Years are years_numerator / years_denominator so no float ever enters the power.""" start = _whole_minor(start_value) end = _whole_minor(end_value) if start_value.currency != end_value.currency: raise ValueError("currency mismatch: %s and %s" % (start_value.currency, end_value.currency)) _check_years("yearsNumerator", years_numerator) _check_years("yearsDenominator", years_denominator) if start <= 0: raise ValueError("startValue must be greater than zero, received %d" % start) if end < 0: raise ValueError("endValue must not be negative, received %d" % end) if end == 0: return -10000 ratio = end * FIXED_SCALE // start # Exponent 1 / years = years_denominator / years_numerator. growth = fractional_power_fixed(ratio, years_denominator, years_numerator) bp = _round_half_away((growth - FIXED_SCALE) * 10000, FIXED_SCALE) if bp > _MAX_SAFE: raise ValueError("the growth rate is too large to express in basis points") return bp