AXLR / Catalog / finance

compound_interest

Compound-interest arithmetic: future and present value of a sum and of a level annuity (ordinary or due), the effective annual rate of a nominal rate compounded m times a year or continuously, the nominal rate of an effective one, doubling time exactly and by the rules of 72, 70 and 69.3, and the real rate by Fisher's equation. Decimal arithmetic, positive amounts.

Install

/tools install compound_interest
/tools install compound_interest@1.0.0

In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · compound_interest 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool compound_interest; each run is approved as a local_exec.

Arguments

One JSON object on stdin, validated against the schema before the program starts.

nametyperequireddefaultmeaning
operationstringyesone of "future_value", "present_value", "effective_rate", "nominal_rate", "doubling_time", "real_rate"
ratenumber | stringnoNominal annual rate: 0.05 or "5%"
compoundinginteger | stringnoCompoundings (and payments) a year, default 1, or "continuous"
yearsnumber | stringno
periodsintegernoInstead of years: years x compounding. ≥ 0
present_valuenumber | stringnofuture_value: sum invested now
future_valuenumber | stringnopresent_value: sum wanted at the end
paymentnumber | stringnoLevel payment each period
duebooleannofalsePayments at the start of each period
effective_ratenumber | stringnonominal_rate: the effective annual rate
inflationnumber | stringnoreal_rate: the inflation rate
Input schema (JSON)
{
  "type": "object",
  "required": [
    "operation"
  ],
  "additionalProperties": false,
  "properties": {
    "operation": {
      "type": "string",
      "enum": [
        "future_value",
        "present_value",
        "effective_rate",
        "nominal_rate",
        "doubling_time",
        "real_rate"
      ]
    },
    "rate": {
      "type": [
        "number",
        "string"
      ],
      "description": "Nominal annual rate: 0.05 or \"5%\""
    },
    "compounding": {
      "type": [
        "integer",
        "string"
      ],
      "description": "Compoundings (and payments) a year, default 1, or \"continuous\""
    },
    "years": {
      "type": [
        "number",
        "string"
      ]
    },
    "periods": {
      "type": "integer",
      "minimum": 0,
      "description": "Instead of years: years x compounding"
    },
    "present_value": {
      "type": [
        "number",
        "string"
      ],
      "description": "future_value: sum invested now"
    },
    "future_value": {
      "type": [
        "number",
        "string"
      ],
      "description": "present_value: sum wanted at the end"
    },
    "payment": {
      "type": [
        "number",
        "string"
      ],
      "description": "Level payment each period"
    },
    "due": {
      "type": "boolean",
      "default": false,
      "description": "Payments at the start of each period"
    },
    "effective_rate": {
      "type": [
        "number",
        "string"
      ],
      "description": "nominal_rate: the effective annual rate"
    },
    "inflation": {
      "type": [
        "number",
        "string"
      ],
      "description": "real_rate: the inflation rate"
    }
  }
}

Example

Input

{
  "operation": "effective_rate",
  "rate": "12%",
  "compounding": 12
}

Output (stdout)

{
  "effective_rate": "0.1268250301",
  "effective_rate_pct": "12.6825"
}

Files

The command the install card registers is python3 compound_interest.py, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.

compound_interest.py · sha256 e6da51a2c144896daa79d6109310f70af3cf70bb5e1f7b953d493d9294864ae6
#!/usr/bin/env python3
"""Compound-interest arithmetic in Decimal: future and present value of a sum and of a level
annuity (ordinary or due), effective annual rate from a nominal rate with m compoundings a year
or continuous compounding, the nominal rate from an effective one, the doubling time (exact and
by the rules of 72, 70 and 69.3) and the real rate by Fisher's equation.

Input (JSON on stdin):
  operation        "future_value" | "present_value" | "effective_rate" | "nominal_rate"
                   | "doubling_time" | "real_rate"
  rate             nominal annual rate: 0.05 or "5%"
  compounding      compoundings (and payments) a year, default 1, or "continuous"
  years | periods  term (periods = years x compounding)                    value operations
  present_value    future_value: the sum invested now (default 0)
  future_value     present_value: the sum wanted at the end (default 0)
  payment          level payment each period (default 0)
  due              true: payments at the start of each period (annuity due)
  effective_rate   nominal_rate: the effective annual rate to convert
  inflation        real_rate: the inflation rate
Amounts are positive; the sign convention of spreadsheets is not used.
"""
import json
import sys
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation, getcontext

getcontext().prec = 50
CENT = Decimal("0.01")
OPERATIONS = ("future_value", "present_value", "effective_rate", "nominal_rate", "doubling_time", "real_rate")


def fail(message):
    print(json.dumps({"error": message}))
    sys.exit(2)


def dec(value, field, percent=False):
    if isinstance(value, bool) or not isinstance(value, (int, float, str)):
        fail(f"{field} must be a number or a decimal string")
    text = str(value).strip()
    scale = Decimal(1)
    if percent and text.endswith("%"):
        text, scale = text[:-1], Decimal("0.01")
    try:
        number = Decimal(text) * scale
    except InvalidOperation:
        fail(f"{field} must be a number or a decimal string")
    if not number.is_finite():
        fail(f"{field} must be finite")
    return number


def money(x):
    return format(x.quantize(CENT, ROUND_HALF_UP), "f")


def rate_out(x):
    """A rate as a fraction to 10 places and as a percent to 4."""
    return format(x.quantize(Decimal("1e-10"), ROUND_HALF_UP), "f"), format((x * 100).quantize(Decimal("0.0001"), ROUND_HALF_UP), "f")


def fixed(x, places):
    return format(x.quantize(Decimal(1).scaleb(-places), ROUND_HALF_UP), "f")


def compounding_of(request):
    m = request.get("compounding", 1)
    if m == "continuous":
        return None
    if isinstance(m, bool) or not isinstance(m, int) or not 1 <= m <= 100000:
        fail("compounding must be an integer from 1 to 100000 or \"continuous\"")
    return m


def need(request, field, percent=False):
    if field not in request:
        fail(f"{field} is required for this operation")
    return dec(request[field], field, percent)


def ear(rate, m):
    return rate.exp() - 1 if m is None else (1 + rate / m) ** m - 1


def main():
    try:
        request = json.load(sys.stdin)
    except ValueError as error:
        fail(f"stdin is not JSON: {error}")
    if not isinstance(request, dict):
        fail("input must be a JSON object")
    known = {"operation", "rate", "compounding", "years", "periods", "present_value", "future_value", "payment", "due",
             "effective_rate", "inflation"}
    extra = sorted(set(request) - known)
    if extra:
        fail(f"unknown field: {extra[0]}")
    operation = request.get("operation")
    if operation not in OPERATIONS:
        fail("operation must be one of " + ", ".join(OPERATIONS))
    m = compounding_of(request)
    result = {"operation": operation, "compounding": "continuous" if m is None else m}

    if operation in ("future_value", "present_value"):
        rate = need(request, "rate", True)
        if rate <= -1:
            fail("rate must be greater than -100%")
        if ("years" in request) == ("periods" in request):
            fail("give either years or periods")
        if "periods" in request:
            if m is None:
                fail("continuous compounding takes years, not periods")
            periods = request["periods"]
            if isinstance(periods, bool) or not isinstance(periods, int) or periods < 0:
                fail("periods must be a non-negative integer")
            n = Decimal(periods)
            years = n / m
        else:
            years = dec(request["years"], "years")
            if years < 0:
                fail("years must not be negative")
            n = years * m if m is not None else None
        sum_field = "present_value" if operation == "future_value" else "future_value"
        lump = dec(request.get(sum_field, 0), sum_field)
        payment = dec(request.get("payment", 0), "payment")
        due = request.get("due", False)
        if not isinstance(due, bool):
            fail("due must be true or false")
        if m is None:
            if payment:
                fail("payments need a whole number of periods: give compounding as an integer")
            growth = (rate * years).exp()
            annuity_factor = Decimal(0)
        else:
            i = rate / m
            if payment and n != n.to_integral_value():
                fail("with a payment, years x compounding must be a whole number of periods")
            growth = (1 + i) ** n
            if payment:
                annuity_factor = n if i == 0 else (growth - 1) / i
                if due:
                    annuity_factor *= 1 + i
            else:
                annuity_factor = Decimal(0)
            result["period_rate"] = rate_out(i)[0]
            result["periods"] = format(n.normalize(), "f")
        result["years"] = format(years.quantize(Decimal("1e-6"), ROUND_HALF_UP).normalize(), "f")
        if operation == "future_value":
            lump_part = lump * growth
            annuity_part = payment * annuity_factor
        else:
            lump_part = lump / growth
            annuity_part = payment * annuity_factor / growth
        result[operation] = money(lump_part + annuity_part)
        result["from_lump_sum"] = money(lump_part)
        result["from_payments"] = money(annuity_part)
        if payment:
            result["annuity"] = "due" if due else "ordinary"
            result["total_payments"] = money(payment * (n if m is not None else 0))
        result["growth_factor"] = fixed(growth, 10)

    elif operation == "effective_rate":
        rate = need(request, "rate", True)
        if m is not None and rate <= -m:
            fail("rate is too negative for this compounding")
        effective = ear(rate, m)
        result["nominal_rate"] = rate_out(rate)[0]
        result["effective_rate"], result["effective_rate_pct"] = rate_out(effective)

    elif operation == "nominal_rate":
        effective = need(request, "effective_rate", True)
        if effective <= -1:
            fail("effective_rate must be greater than -100%")
        if m is None:
            nominal = (1 + effective).ln()
        else:
            nominal = m * ((1 + effective) ** (Decimal(1) / m) - 1)
        result["effective_rate"] = rate_out(effective)[0]
        result["nominal_rate"], result["nominal_rate_pct"] = rate_out(nominal)

    elif operation == "doubling_time":
        rate = need(request, "rate", True)
        if rate <= 0:
            fail("rate must be positive to double")
        two = Decimal(2).ln()
        if m is None:
            exact = two / rate
        else:
            exact = two / (m * (1 + rate / m).ln())
        pct = rate * 100
        result["rate_pct"] = fixed(pct, 6).rstrip("0").rstrip(".")
        result["exact_years"] = fixed(exact, 6)
        rules = {}
        for k in ("72", "70", "69.3"):
            estimate = Decimal(k) / pct
            rules[f"rule_of_{k.replace('.', '_')}"] = {"years": fixed(estimate, 6), "error_years": fixed(estimate - exact, 6)}
        result.update(rules)
        result["effective_rate"] = rate_out(ear(rate, m))[0]

    else:  # real_rate
        rate = need(request, "rate", True)
        inflation = need(request, "inflation", True)
        if inflation <= -1:
            fail("inflation must be greater than -100%")
        real = (1 + rate) / (1 + inflation) - 1
        result["nominal_rate"] = rate_out(rate)[0]
        result["inflation"] = rate_out(inflation)[0]
        result["real_rate"], result["real_rate_pct"] = rate_out(real)
        result["approximation"], result["approximation_pct"] = rate_out(rate - inflation)
    print(json.dumps(result))


if __name__ == "__main__":
    try:
        main()
    except ArithmeticError:
        fail("the numbers are out of the range this tool can compute")

Where it is served

The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:

curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/compound_interest/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/compound_interest/1.0.0/compound_interest.py