invest.money-weighted-return
Money-weighted return (XIRR) of dated cash flows on Excel's actual/365 convention, solved exactly in fixed point.
1.0.0 (not the latest) · published 2026-10-03 by charlie · Anterra
Pinned by 24 tests, run in TypeScript, Python and Rust.
Not professional advice. This capability calculates investment figures from published rules. It is a software component for developers, not financial advice. Rules change and every rate here has an effective date. Check that the dates cover your case. Verify results against the official sources listed in its README, and have a tax adviser review how you use it, before anyone relies on the output. Provided “as is” under its licence, without warranty.
What it does
The money-weighted return of an investment: the single annual rate at which every dated cash flow, discounted to the first date, sums to zero. This is Excel's `XIRR`:
Σ P_i / (1 + r)^((d_i − d_1) / 365) = 0
For example
money_weighted_return(flows ×5)→ rate 37.34%, rate 0.373362534 Microsoft's XIRR example is 37.34% (exact root 0.37336253352)money_weighted_return(flows ×5)→ rate 37.34%, rate 0.373362534 the same flows in any order give the same ratemoney_weighted_return(flows ×2)→ rate 10%, rate 0.100000000 +10% over a 365-day year is exactly 10%
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
def money_weighted_return(flows: Sequence[DatedFlow]) -> XirrResult
| flows | DatedFlow[] | every cash flow, any order: what the investor pays in is negative, what comes back (and the closing value) positive |
| returns | XirrResult | the annual rate r with Σ amount / (1 + r)^(days / 365) = 0 |
The types it declares, generated into your project
@dataclass(frozen=True)
class DatedFlow:
"""One cash flow on a date."""
date: str
#: negative paid in, positive received
amount: Money
@dataclass(frozen=True)
class XirrResult:
"""The rate, in basis points and as a decimal."""
#: half away from zero: 3734 = 37.34%
basis_points: int
#: the annual rate to 9 decimal places, half away from zero: "0.373362534"
rate: str
Your code names it in one line, in the file that uses it
from fune.invest.money_weighted_return import money_weighted_return # invest.money-weighted-return@^1
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
from typing import Callable, Dict, List, Sequence, Tuple
from .dates_add_days import epoch_day_from_iso ← from dates.add-days ^1.0.0 · built alongside by fune
from .invest_money_weighted_return_types import DatedFlow, XirrResult
from .math_fractional_power import FIXED_SCALE, pow_fixed ← from math.fractional-power ^1.0.0 · built alongside by fune
_MAX_SPAN_DAYS = 36500
_NO_SINGLE_RATE = "no single rate of return solves these cash flows"
def _sign(value: int) -> int:
return (value > 0) - (value < 0)
def _round_half_away(n: int, d: int) -> int:
"""n / d rounded half away from zero; d > 0."""
magnitude = (abs(n) * 2 + d) // (2 * d)
return -magnitude if n < 0 else magnitude
def _net_flows(flows: Sequence[DatedFlow]) -> List[Tuple[int, int]]:
"""Net flow per day, in day order, counted from the earliest date with a nonzero net flow."""
if len(flows) == 0:
raise ValueError("flows must not be empty")
currency = flows[0].amount.currency
net: Dict[int, int] = {}
for flow in flows:
amount = flow.amount
if amount.currency != currency:
raise ValueError("currency mismatch: %s and %s" % (currency, amount.currency))
if isinstance(amount.minor, bool) or not isinstance(amount.minor, int):
raise ValueError("amounts must be whole minor units, received %r" % (amount.minor,))
day = epoch_day_from_iso(flow.date)
net[day] = net.get(day, 0) + amount.minor
days = sorted((d, a) for d, a in net.items() if a != 0)
if not any(a > 0 for _, a in days) or not any(a < 0 for _, a in days):
raise ValueError("flows need at least one payment and one receipt")
first = days[0][0]
if days[-1][0] - first > _MAX_SPAN_DAYS:
raise ValueError("flows must fall within %d days of each other" % _MAX_SPAN_DAYS)
return [(d - first, a) for d, a in days]
def _bisect(f: Callable[[int], int]) -> int:
"""Bisection on x in [0, FIXED_SCALE] for a change of sign of f; the two ends' signs differ."""
lo, hi = 0, FIXED_SCALE
lo_sign = _sign(f(lo))
while hi - lo > 1:
mid = (lo + hi) // 2
s = _sign(f(mid))
if s == 0:
return mid
if s == lo_sign:
lo = mid
else:
hi = mid
return lo
def money_weighted_return(flows: Sequence[DatedFlow]) -> XirrResult:
"""XIRR: the annual rate r at which sum(amount / (1 + r)^(days / 365)) = 0,
days counted from the earliest flow. Solved by bisection in 18-place fixed
point on the per-day discount factor, then settled to 12 places and rounded
half away from zero to 9 places and to a basis point."""
net = _net_flows(flows)
total = sum(a for _, a in net)
rate = 0
if total != 0:
last = net[-1][0]
positive_side = _sign(net[0][1]) != _sign(total)
negative_side = _sign(net[-1][1]) != _sign(total)
if positive_side == negative_side:
raise ValueError(_NO_SINGLE_RATE)
if positive_side:
# v = (1 + r)^(-1/365) in (0, 1).
v = _bisect(lambda x: sum(a * pow_fixed(x, t) for t, a in net))
growth = pow_fixed(v, 365)
if growth == 0:
raise ValueError("the rate of return is too large to compute")
rate = FIXED_SCALE * FIXED_SCALE // growth - FIXED_SCALE
else:
# u = (1 + r)^(1/365) in (0, 1); the sum is multiplied through by u^last.
u = _bisect(lambda x: sum(a * pow_fixed(x, last - t) for t, a in net))
rate = pow_fixed(u, 365) - FIXED_SCALE
settled = _round_half_away(rate, 10 ** 6)
bp = _round_half_away(settled * 10000, 10 ** 12)
nine = _round_half_away(settled, 1000)
magnitude = abs(nine)
return XirrResult(
basis_points=bp,
rate="%s%d.%09d" % ("-" if nine < 0 else "", magnitude // 10 ** 9, magnitude % 10 ** 9),
)Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 4 dependencies, pins them in fune.lock, downloads only the Python package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:
fune add invest.money-weighted-return
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./invest.money-weighted-return-1.0.0-python.fune, or fetch it from a terminal with fune pull invest.money-weighted-return@1.0.0:python.
The whole function, every language, is one file too: invest.money-weighted-return-1.0.0.fune, 32,086 bytes, sha256 31cb736fea92e05d1712b04f4261f2f544f9e4e9cf5f8cb4b7f702295c68ec97. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
# fune: before invest.money-weighted-return
after — your function gets the result and the arguments, and returns the final result.
# fune: after invest.money-weighted-return
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
# fune: replace dates.add-days in invest.money-weighted-return
# fune: replace math.big-integer in invest.money-weighted-return
# fune: replace math.fractional-power in invest.money-weighted-return
# fune: replace money.amount in invest.money-weighted-return
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show invest.money-weighted-return --steps.
# fune: step invest.money-weighted-return after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| Microsoft's XIRR example is 37.34% (exact root 0.37336253352) | flows ×5 | → | rate 37.34%, rate 0.373362534 |
| the same flows in any order give the same rate | flows ×5 | → | rate 37.34%, rate 0.373362534 |
| +10% over a 365-day year is exactly 10% | flows ×2 | → | rate 10%, rate 0.100000000 |
| +10% over 2024, a 366-day year, is 9.97% on a 365-day year | flows ×2 | → | rate 9.97%, rate 0.099713586 |
| a loss over two years (731 days) is negative | flows ×2 | → | rate -9.99%, rate -0.099870272 |
| two deposits then a closing value | flows ×4 | → | rate 7.49%, rate 0.074868597 |
| a deposit, a withdrawal, another deposit and the closing value | flows ×4 | → | rate 6.48%, rate 0.064752089 |
| a loan seen from the borrower: in first, out later | flows ×2 | → | rate 9.97%, rate 0.099713586 |
| 30 days at 1% compounds to 12.87% a year | flows ×2 | → | rate 12.87%, rate 0.128695294 |
| a tenfold gain in a leap year | flows ×2 | → | rate 893.73%, rate 8.937285322 |
Show the other 14 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| almost everything lost | flows ×2 | → | rate -100%, rate -0.999989680 |
| getting back exactly what was paid in is 0 | flows ×2 | → | rate 0%, rate 0.000000000 |
| two payments on one day are netted | flows ×3 | → | rate 10%, rate 0.100000000 |
| flows netting to zero on the first date are dropped | flows ×4 | → | rate 10%, rate 0.100000000 |
| amounts in yen | flows ×2 | → | rate 10%, rate 0.100000000 |
| 10% and 20% both solve -100, +230, -132: refused | flows ×3 | → | error: no single rate of return solves these cash flows |
| more paid in than out, at every date, has no rate | flows ×3 | → | error: no single rate of return solves these cash flows |
| payments only is Excel's #NUM | flows ×2 | → | error: flows need at least one payment and one receipt |
| flows that net to nothing on one side | flows ×2 | → | error: flows need at least one payment and one receipt |
| no flows | → | error: flows must not be empty | |
| mixed currencies | flows ×2 | → | error: currency mismatch |
| an impossible date | flows ×2 | → | error: is not a real calendar date |
| flows more than 100 years apart | flows ×2 | → | error: flows must fall within 36500 days of each other |
| fractional minor units | flows ×2 | → | error: amounts must be whole minor units |
More from the author
"XIRR uses a 365-day year" and needs "at least one positive cash flow and one negative cash flow". Microsoft, *XIRR function*, https://support.microsoft.com/en-us/office/xirr-function-de1242ec-6477-445b-b11b-a303ad9adc9d (read 2026-09-23). Its example (−10,000 on 2008-01-01, 2,750 on 2008-03-01, 4,250 on 2008-10-30, 3,250 on 2009-02-15, 2,750 on 2009-04-01) is 37.34%, and is a vector here. The same rate is the internal rate of return (IRR) used as the money-weighted rate of return in the CFA Institute's GIPS standards.
## Signs and dates
Money the investor puts in is negative; money taken out, and the closing value of the holding on the last date, are positive. Flows may be in any order and several may share a date; flows on the same date are netted, and a date whose flows net to zero is dropped. Time is counted from the earliest remaining date. (Excel wants the first listed date to be the earliest; moving the reference date only multiplies the equation by a positive constant, so the root is the same.) Days are actual days, divided by 365 even across a 29 February: a year from 2024-01-01 is 366 days, so +10% over it is 9.97%, not 10%. Flows must fall within 36,500 days of each other.
## How it is solved
With v = (1 + r)^(−1/365), a per-day discount factor, the equation becomes a polynomial with whole-day exponents, Σ P_i v^t_i = 0, and is solved by bisection in `math.fractional-power`'s 18-place fixed point (whole powers only, the same floors in the same order in every language), about 60 halvings.
- A positive rate means v in (0, 1). At v = 0 the sum is the first flow, at v = 1 it is the plain total of the flows. - A negative rate means v > 1, where v^t can overflow, so that side is solved in u = 1/v in (0, 1), multiplying through by u^T (T the last day), which does not change the sign: Σ P_i u^(T − t_i). At u = 0 that is the last flow.
The side whose two ends have opposite signs holds the root. If the plain total is zero the rate is exactly 0. Then r = v^−365 − 1 (or u^365 − 1). Excel instead runs Newton's method from a guess until the result is accurate within 0.000001 percent, so its last printed digits can differ from the exact root: the Microsoft example's exact rate is 0.3733625335..., which this gives as `0.373362534`.
**Precision.** The rate is first settled to 12 decimal places (the fixed point's errors are below 10^-13 for any flows within the limits), then rounded half away from zero to 9 decimal places (`rate`) and to a whole basis point (`basisPoints`).
## When there is no single answer
When the flows change sign once (pay in, then take out) there is exactly one rate. When they change sign several times (deposits, withdrawals, more deposits) the polynomial may have several roots, or none. This returns the root when exactly one side of r = 0 brackets a change of sign. When both sides do (two roots at least), or neither does (no root, or an even number of roots on one side), it refuses with "no single rate of return solves these cash flows" rather than return whichever root a guess happens to find, as Excel does. A rate so large that v^365 underflows the fixed point (above roughly 10^5 %) is an error.
## Errors
At least one payment and one receipt after netting; one currency; real ISO dates; whole minor units.
Files
| Path | Bytes |
|---|---|
| README.md | 3,561 |
| impl/python.py | 3,795 |
| impl/rust.rs | 5,711 |
| impl/typescript.ts | 4,050 |
| vectors.json | 10,291 |