Source code for metricengine.calculations.growth

"""
Growth and CAGR calculations.

This module defines growth-rate calculations using namespaced registration.
It returns ratios (0..1) by default for composability, with a separate
percent-returning wrapper if needed.
"""

from __future__ import annotations

from decimal import Context, Decimal, DivisionByZero, InvalidOperation, getcontext
from typing import TypeVar

from ..exceptions import CalculationError
from ..policy import DEFAULT_POLICY
from ..policy_context import get_policy
from ..registry_collections import Collection
from ..units import Dimensionless, Percent, Ratio, Unit  # phantom units
from ..value import FV

U = TypeVar("U", bound=Unit)

growth = Collection("growth")


[docs] @growth.calc("simple_growth_rate", depends_on=("initial_value", "final_value")) def simple_growth_rate( initial_value: FV[U], final_value: FV[U], ) -> FV[Ratio]: """ Simple growth rate as a ratio: (final - initial) / initial. Returns: FV[Ratio] in 0..1 (can be negative if shrinking), or None if inputs invalid. """ pol = initial_value.policy or final_value.policy or get_policy() or DEFAULT_POLICY # Propagate None if initial_value.is_none() or final_value.is_none(): return FV.none(pol).ratio() # Use raw decimals to avoid premature quantization i = initial_value._value # internal raw Decimal or None f = final_value._value if i is None or f is None: return FV.none(pol).ratio() # Domain checks: initial must be non-zero (cannot divide by 0) if i == 0: if pol.arithmetic_strict: raise CalculationError("Simple growth undefined for initial_value == 0") return FV.none(pol).ratio() # Compute as Decimal, then wrap as Ratio g = (f - i) / i return FV(g, policy=pol, unit=Ratio)
[docs] @growth.calc( "compound_growth_rate", depends_on=("initial_value", "final_value", "periods") ) def compound_growth_rate( initial_value: FV[U], final_value: FV[U], periods: FV[Dimensionless], ) -> FV[Ratio]: """ Compound annual growth rate (CAGR) as a ratio (0..1): CAGR = exp( ln(final / initial) / periods ) - 1 Notes: - Requires strictly positive initial, final, and periods. - Returns None on invalid inputs unless policy.arithmetic_strict, in which case raises. """ pol = ( initial_value.policy or final_value.policy or periods.policy or get_policy() or DEFAULT_POLICY ) # Propagate None if initial_value.is_none() or final_value.is_none() or periods.is_none(): return FV.none(pol).ratio() # Use raw decimals to avoid early rounding i = initial_value._value f = final_value._value n = periods._value if i is None or f is None or n is None: return FV.none(pol).ratio() # Domain: strictly positive if i <= 0 or f <= 0 or n <= 0: if pol.arithmetic_strict: raise CalculationError( "CAGR undefined for non-positive initial/final/periods" ) return FV.none(pol).ratio() # High-precision Decimal ln/exp (avoid float pow fallback) ctx: Context = getcontext().copy() ctx.prec = max(ctx.prec, pol.decimal_places + 10, 28) # headroom over display dp try: ratio = f / i # Decimal > 0 ln_ratio = ctx.ln(ratio) # Decimal inv_n = ctx.divide(Decimal(1), n) # Decimal growth_factor = ctx.exp(ln_ratio * inv_n) cagr = growth_factor - Decimal(1) except (InvalidOperation, DivisionByZero) as e: if pol.arithmetic_strict: raise CalculationError(f"CAGR computation failed: {e}") from e return FV.none(pol).ratio() return FV(cagr, policy=pol, unit=Ratio)
# Convenience wrapper returning percent
[docs] @growth.calc("compound_growth_rate_percent", depends_on=("compound_growth_rate",)) def compound_growth_rate_percent(compound_growth_rate: FV[Ratio]) -> FV[Percent]: """ CAGR as a percent (e.g., 0.10 -> '10%'). """ pol = compound_growth_rate.policy or get_policy() or DEFAULT_POLICY if compound_growth_rate.is_none(): return FV.none(pol).as_percentage() return compound_growth_rate.as_percentage()