finanza/interest

Time-value-of-money helpers built on finanza/decimal.

Every function takes its inputs as decimals, computes in decimal, and rounds the final result with HalfEven (“banker’s”) to the caller-supplied number of decimal places.

Precision

Iterative computations (future_value, present_value, payment, effective_annual_rate, compound_interest) target 7 decimal digits of internal working precision. Before every multiplication inside the iterative growth-factor loop the accumulator is rounded adaptively to the largest digit count for which the resulting product still fits under 2^53 − 1 (the JavaScript safe-integer ceiling enforced by finanza/decimal). For typical inputs the target precision is always reached; only when the growth factor swells (long horizons at very high rates, growth above ~10⁵) does the per-step precision shed digits to keep the multiplication safe.

Honest-precision ceiling. The iterative loop can deliver at most ~7 honest decimal digits regardless of the caller’s digits argument. After issue #25 (decimal.round is trim-only; the final step is now decimal.rescale), the returned value’s exponent matches the requested -digits, so callers asking for digits > 7 get a Decimal with the right shape — but the trailing digits − 7 decimal places are zero padding from the final rescale, not computed precision. The numeric value is unchanged by those trailing zeros; the rendered form is more verbose without being more accurate. If you need more than 7 honest digits, compute the closed form in a higher-precision package (Python decimal with prec=50, for instance).

Concrete consequence: results match textbook 50-digit references (Python decimal, numpy_financial, Excel) to the cent at digits = 2 and to ~10⁻⁶ at digits = 6 for monthly rates and horizons up to about 30 years. For lending-grade work where the answer must be reproducible against external industry tooling, stick to those typical-input ranges or compute the closed form in a higher-precision package.

Types

Errors raised by interest functions.

pub type InterestError {
  NegativePrincipal
  RateBelowMinusOne
  NegativeRate
  PeriodsOutOfRange
  CompoundsOutOfRange
  NegativeDigits
  ArithmeticError(error: decimal.ArithmeticError)
}

Constructors

  • NegativePrincipal

    principal was negative.

  • RateBelowMinusOne

    rate was below -1.0 (i.e. a return of less than -100%). Returns less than -100% have no meaningful interpretation in the TVM formulas — the resulting future value would be negative for a positive principal. Rates in the range [-1.0, ∞) are accepted (deflation, depreciation, total-loss at exactly -1.0).

    The legacy NegativeRate variant is no longer emitted; it is retained so existing pattern matches keep compiling.

  • NegativeRate

    Deprecated alias for RateBelowMinusOne. Retained so callers that pattern-matched on the previous “any negative rate” reject path keep compiling, but the library never returns this variant — it always emits RateBelowMinusOne.

  • PeriodsOutOfRange

    periods was zero or negative, or exceeded the supported range 1..=1200 (100 years of monthly compounding).

  • CompoundsOutOfRange

    compounds_per_year was zero or negative.

  • NegativeDigits

    digits was negative.

  • ArithmeticError(error: decimal.ArithmeticError)

    Underlying decimal arithmetic produced an error.

Values

pub fn compound_interest(
  principal principal: decimal.Decimal,
  annual_rate annual_rate: decimal.Decimal,
  years years: Int,
  compounds_per_year compounds_per_year: Int,
  digits digits: Int,
) -> Result(decimal.Decimal, InterestError)

Future value under compound interest:

FV = principal × (1 + annual_rate / compounds_per_year)^(compounds_per_year × years)

years == 0 is a valid input and returns principal rescaled to digits — the formula reduces to principal × 1 when the exponent is zero, and accepting the trivial case lets callers reuse this function inside schedule loops without inserting a branch for the “as-of year 0” row. Aligns with the zero-periods handling in future_value, present_value, payment, and simple_interest.

pub fn effective_annual_rate(
  nominal_rate nominal_rate: decimal.Decimal,
  compounds_per_year compounds_per_year: Int,
  digits digits: Int,
) -> Result(decimal.Decimal, InterestError)

Effective annual rate from a nominal rate compounded compounds_per_year times per year:

EAR = (1 + nominal_rate / compounds_per_year)^compounds_per_year - 1

See the module-level Precision section for the 7-working-digit target and the adaptive overflow guard.

pub fn future_value(
  present present: decimal.Decimal,
  rate_per_period rate_per_period: decimal.Decimal,
  periods periods: Int,
  digits digits: Int,
) -> Result(decimal.Decimal, InterestError)

Future value of present after periods periods at rate_per_period.

See the module-level Precision section for the 7-working-digit target and the adaptive overflow guard.

pub fn payment(
  principal principal: decimal.Decimal,
  rate_per_period rate_per_period: decimal.Decimal,
  periods periods: Int,
  digits digits: Int,
) -> Result(decimal.Decimal, InterestError)

Periodic payment for a fully-amortising loan:

PMT = principal × rate / (1 - (1 + rate)^(-periods))

When rate_per_period is zero, returns straight-line principal / periods.

See the module-level Precision section for the 7-working-digit target and the adaptive overflow guard.

pub fn present_value(
  future future: decimal.Decimal,
  rate_per_period rate_per_period: decimal.Decimal,
  periods periods: Int,
  digits digits: Int,
) -> Result(decimal.Decimal, InterestError)

Present value of future discounted at rate_per_period for periods periods.

See the module-level Precision section for the 7-working-digit target and the adaptive overflow guard.

pub fn simple_interest(
  principal principal: decimal.Decimal,
  rate rate: decimal.Decimal,
  periods periods: Int,
  digits digits: Int,
) -> Result(decimal.Decimal, InterestError)

Simple interest: I = P × r × t.

periods == 0 is a valid input and returns 0 rescaled to digits — mathematically there is no interest over zero time, and accepting the trivial case lets callers reuse this function inside schedule loops without inserting a branch for the “as-of period 0” row. Negative periods is still rejected with PeriodsOutOfRange.

Search Document