AXLR / Catalog / finance

npv_irr

Appraise a series of cash flows: net present value at a discount rate, internal rate of return (every IRR when the flows change sign more than once), modified IRR with separate finance and reinvestment rates, simple and discounted payback in fractional periods, and the profitability index. NPV in exact decimal arithmetic.

Install

/tools install npv_irr
/tools install npv_irr@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 · npv_irr 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 npv_irr; each run is approved as a local_exec.

Arguments

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

nametyperequireddefaultmeaning
cashflowsarray of ['number', 'string']yesOne amount per period, period 0 first; outflows negative. items ≥ 2
ratenumber | stringnoDiscount rate per period: 0.08 or "8%"
finance_ratenumber | stringnoMIRR rate on the negative flows; default rate
reinvest_ratenumber | stringnoMIRR rate on the positive flows; default rate
Input schema (JSON)
{
  "type": "object",
  "required": [
    "cashflows"
  ],
  "additionalProperties": false,
  "properties": {
    "cashflows": {
      "type": "array",
      "minItems": 2,
      "items": {
        "type": [
          "number",
          "string"
        ]
      },
      "description": "One amount per period, period 0 first; outflows negative"
    },
    "rate": {
      "type": [
        "number",
        "string"
      ],
      "description": "Discount rate per period: 0.08 or \"8%\""
    },
    "finance_rate": {
      "type": [
        "number",
        "string"
      ],
      "description": "MIRR rate on the negative flows; default rate"
    },
    "reinvest_rate": {
      "type": [
        "number",
        "string"
      ],
      "description": "MIRR rate on the positive flows; default rate"
    }
  }
}

Example

Input

{
  "cashflows": [
    -1000,
    300,
    400,
    500
  ],
  "rate": "8%"
}

Output (stdout)

{
  "npv": "17.629426",
  "payback_periods": 2.6
}

Files

The command the install card registers is python3 npv_irr.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.

npv_irr.py · sha256 ea92e482795eec0ed03a2409d2dbb34634d6e66c53cde4080fb0269a7b1a0ab0
#!/usr/bin/env python3
"""Investment appraisal of a cash-flow series: NPV, IRR (Newton's method with a bisection
fallback, every sign change reported), MIRR, payback and discounted payback, profitability
index. NPV and the paybacks are computed in Decimal; IRR to 1e-12.

Input (JSON on stdin):
  cashflows       [-1000, 300, 400, 500]   period 0 first; numbers or decimal strings
  rate            discount rate per period, as a fraction (0.08) or a percent string ("8%")
  finance_rate    MIRR: rate paid on the negative flows (default: rate)
  reinvest_rate   MIRR: rate earned on the positive flows (default: rate)
"""
import json
import sys
from decimal import Decimal, InvalidOperation, getcontext

getcontext().prec = 40


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


def dec(value, field):
    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 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 npv(rate, flows):
    one = 1 + rate
    return sum(cf / one ** t for t, cf in enumerate(flows))


def irr_candidates(flows):
    f = [float(cf) for cf in flows]

    def value(r):
        return sum(cf / (1 + r) ** t for t, cf in enumerate(f))

    def slope(r):
        return sum(-t * cf / (1 + r) ** (t + 1) for t, cf in enumerate(f))

    scale = max(abs(x) for x in f)
    grid = [round(-0.99 + i * 0.01, 2) for i in range(199)] + [round(1 + i * 0.05, 2) for i in range(181)] + [20, 50, 100]
    roots = []
    for lo, hi in zip(grid, grid[1:]):
        try:
            vlo, vhi = value(lo), value(hi)
        except (OverflowError, ZeroDivisionError):
            continue
        if abs(vlo) < 1e-12 * scale:
            if not roots or abs(roots[-1] - lo) > 1e-9:
                roots.append(lo)
            continue
        if vlo * vhi > 0 or abs(vhi) < 1e-12 * scale:
            continue
        r = (lo + hi) / 2
        for _ in range(50):
            d = slope(r)
            nxt = r - value(r) / d if d else None
            if nxt is None or not lo <= nxt <= hi:
                break
            if abs(nxt - r) < 1e-14:
                r = nxt
                break
            r = nxt
        if abs(value(r)) > 1e-9 * max(1.0, scale):
            a, b = lo, hi
            for _ in range(200):
                m = (a + b) / 2
                if value(a) * value(m) <= 0:
                    b = m
                else:
                    a = m
            r = (a + b) / 2
        if not roots or abs(roots[-1] - r) > 1e-9:
            roots.append(r)
    return roots


def paybacks(flows, rate):
    out = {}
    for name, discounted in (("payback_periods", False), ("discounted_payback_periods", True)):
        cumulative, result = Decimal(0), None
        for t, cf in enumerate(flows):
            value = cf / (1 + rate) ** t if discounted else cf
            previous = cumulative
            cumulative += value
            if t > 0 and previous < 0 <= cumulative and value > 0:
                result = Decimal(t - 1) + (-previous / value)
                break
        out[name] = float(round(result, 6)) if result is not None else None
    return out


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")
    raw = request.get("cashflows")
    if not isinstance(raw, list) or len(raw) < 2:
        fail("cashflows must be a list of at least two amounts, period 0 first")
    flows = [dec(v, f"cashflows[{i}]") for i, v in enumerate(raw)]
    if not any(cf < 0 for cf in flows) or not any(cf > 0 for cf in flows):
        fail("cashflows need at least one negative and one positive amount")
    result = {"periods": len(flows) - 1}
    roots = irr_candidates(flows)
    result["irr"] = round(roots[0], 12) if len(roots) == 1 else None
    result["irr_candidates"] = [round(r, 12) for r in roots]
    changes = sum(1 for a, b in zip([c for c in flows if c], [c for c in flows if c][1:]) if (a < 0) != (b < 0))
    result["sign_changes"] = changes
    if len(roots) > 1:
        result["note"] = "more than one IRR: the cash flows change sign more than once; use NPV or MIRR"
    if "rate" in request:
        rate = dec(request["rate"], "rate")
        if rate <= -1:
            fail("rate must be greater than -100%")
        value = npv(rate, flows)
        result["rate"] = str(rate)
        result["npv"] = str(round(value, 6))
        invested = -sum(cf / (1 + rate) ** t for t, cf in enumerate(flows) if cf < 0)
        result["profitability_index"] = float(round((value + invested) / invested, 6)) if invested else None
        result.update(paybacks(flows, rate))
        finance = dec(request.get("finance_rate", request["rate"]), "finance_rate")
        reinvest = dec(request.get("reinvest_rate", request["rate"]), "reinvest_rate")
        n = len(flows) - 1
        future = sum(cf * (1 + reinvest) ** (n - t) for t, cf in enumerate(flows) if cf > 0)
        present = -sum(cf / (1 + finance) ** t for t, cf in enumerate(flows) if cf < 0)
        result["mirr"] = round(float((future / present) ** (Decimal(1) / n)) - 1, 12)
    else:
        result.update(paybacks(flows, Decimal(0)))
        result.pop("discounted_payback_periods")
    print(json.dumps(result))


if __name__ == "__main__":
    main()

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/npv_irr/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/npv_irr/1.0.0/npv_irr.py