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.
| name | type | required | default | meaning |
|---|---|---|---|---|
cashflows | array of ['number', 'string'] | yes | One amount per period, period 0 first; outflows negative. items ≥ 2 | |
rate | number | string | no | Discount rate per period: 0.08 or "8%" | |
finance_rate | number | string | no | MIRR rate on the negative flows; default rate | |
reinvest_rate | number | string | no | MIRR 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