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
moneyWeightedReturn(flows ×5)→ rate 37.34%, rate 0.373362534 Microsoft's XIRR example is 37.34% (exact root 0.37336253352)moneyWeightedReturn(flows ×5)→ rate 37.34%, rate 0.373362534 the same flows in any order give the same ratemoneyWeightedReturn(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.
export function moneyWeightedReturn(flows: readonly 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
/** One cash flow on a date. */
export interface DatedFlow {
readonly date: string;
/** negative paid in, positive received */
readonly amount: Money;
}
/** The rate, in basis points and as a decimal. */
export interface XirrResult {
/** half away from zero: 3734 = 37.34% */
readonly basisPoints: number;
/** the annual rate to 9 decimal places, half away from zero: "0.373362534" */
readonly rate: string;
}
Your code names it in one line, in the file that uses it
import { moneyWeightedReturn } from "#fune/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.
import { type Money } from "./money_amount.ts"; ← from money.amount ^1.0.0 · built alongside by fune
import { epochDayFromIso } from "./dates_add_days.ts"; ← from dates.add-days ^1.0.0 · built alongside by fune
import { FIXED_SCALE, powFixed } from "./math_fractional_power.ts"; ← from math.fractional-power ^1.0.0 · built alongside by fune
import { type DatedFlow, type XirrResult } from "./invest_money_weighted_return_types.ts";
const MAX_SPAN_DAYS = 36500;
const NO_SINGLE_RATE = "no single rate of return solves these cash flows";
function sign(value: bigint): number {
return value > 0n ? 1 : value < 0n ? -1 : 0;
}
/** n / d rounded half away from zero; d > 0. */
function roundHalfAway(n: bigint, d: bigint): bigint {
const magnitude = ((n < 0n ? -n : n) * 2n + d) / (2n * d);
return n < 0n ? -magnitude : magnitude;
}
/** Net flow per day, in day order, counted from the earliest date with a nonzero net flow. */
function netFlows(flows: readonly DatedFlow[]): [number, bigint][] {
if (flows.length === 0) throw new RangeError("flows must not be empty");
const currency = flows[0].amount.currency;
const net = new Map<number, bigint>();
for (const flow of flows) {
const amount: Money = flow.amount;
if (amount.currency !== currency) throw new RangeError(`currency mismatch: ${currency} and ${amount.currency}`);
if (!Number.isInteger(amount.minor)) throw new RangeError(`amounts must be whole minor units, received ${amount.minor}`);
const day = epochDayFromIso(flow.date);
net.set(day, (net.get(day) ?? 0n) + BigInt(amount.minor));
}
const days = [...net.entries()].filter(([, a]) => a !== 0n).sort((a, b) => a[0] - b[0]);
if (!days.some(([, a]) => a > 0n) || !days.some(([, a]) => a < 0n)) {
throw new RangeError("flows need at least one payment and one receipt");
}
const first = days[0][0];
if (days[days.length - 1][0] - first > MAX_SPAN_DAYS) {
throw new RangeError(`flows must fall within ${MAX_SPAN_DAYS} days of each other`);
}
return days.map(([day, amount]) => [day - first, amount]);
}
/** Bisection on x in [0, FIXED_SCALE] for a change of sign of f; the two ends' signs differ. */
function bisect(f: (x: bigint) => bigint): bigint {
let lo = 0n;
let hi = FIXED_SCALE;
const loSign = sign(f(lo));
while (hi - lo > 1n) {
const mid = (lo + hi) / 2n;
const s = sign(f(mid));
if (s === 0) return mid;
if (s === loSign) lo = mid;
else hi = mid;
}
return lo;
}
/**
* XIRR: the annual rate r at which Σ 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.
*/
export function moneyWeightedReturn(flows: readonly DatedFlow[]): XirrResult {
const net = netFlows(flows);
const total = net.reduce((sum, [, a]) => sum + a, 0n);
let rate = 0n;
if (total !== 0n) {
const last = net[net.length - 1][0];
const positiveSide = sign(net[0][1]) !== sign(total);
const negativeSide = sign(net[net.length - 1][1]) !== sign(total);
if (positiveSide === negativeSide) throw new RangeError(NO_SINGLE_RATE);
if (positiveSide) {
// v = (1 + r)^(-1/365) in (0, 1).
const v = bisect((x) => net.reduce((sum, [t, a]) => sum + a * powFixed(x, t), 0n));
const growth = powFixed(v, 365);
if (growth === 0n) throw new RangeError("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.
const u = bisect((x) => net.reduce((sum, [t, a]) => sum + a * powFixed(x, last - t), 0n));
rate = powFixed(u, 365) - FIXED_SCALE;
}
}
const settled = roundHalfAway(rate, 1000000n);
const bp = roundHalfAway(settled * 10000n, 1000000000000n);
const nine = roundHalfAway(settled, 1000n);
const magnitude = nine < 0n ? -nine : nine;
const fraction = (magnitude % 1000000000n).toString().padStart(9, "0");
return {
basisPoints: Number(bp),
rate: `${nine < 0n ? "-" : ""}${magnitude / 1000000000n}.${fraction}`,
};
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 4 dependencies, pins them in fune.lock, downloads only the TypeScript 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 TypeScript implementation. Install it without the registry with fune add ./invest.money-weighted-return-1.0.0-typescript.fune, or fetch it from a terminal with fune pull invest.money-weighted-return@1.0.0:typescript.
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 |