invest.money-weighted-return Unreviewed
Money-weighted return (XIRR) of dated cash flows on Excel's actual/365 convention, solved exactly in fixed point.
1.0.1 · published 2026-10-03 by charlie · Anterra
Pinned by 24 tests, run in TypeScript, Python and Rust.
Unreviewed. This capability’s implementations agree in every language and pass its published test vectors, which were worked out from the official sources cited. But no qualified tax adviser has yet checked those vectors, or confirmed that the capability covers the cases it claims. Treat it as a draft. Do not use it for real people, money or decisions without your own expert review. Once a qualified reviewer signs off, this notice is replaced with their name, qualification and the date. Each new version needs fresh sign-off.
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.
pub fn money_weighted_return(flows: &[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.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DatedFlow {
pub date: String,
/// negative paid in, positive received
pub amount: Money,
}
/// The rate, in basis points and as a decimal.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct XirrResult {
/// half away from zero: 3734 = 37.34%
pub basis_points: i64,
/// the annual rate to 9 decimal places, half away from zero: "0.373362534"
pub rate: String,
}
Your code names it in one line, in the file that uses it
fune!(invest.money-weighted-return@^1); // then call money_weighted_return(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::dates_add_days::epoch_day_from_iso; ← from dates.add-days ^1.0.0 · built alongside by fune
use super::math_big_integer::BigInt; ← from math.big-integer ^1.0.0 · built alongside by fune
use super::math_fractional_power::{fixed_scale, pow_fixed}; ← from math.fractional-power ^1.0.0 · built alongside by fune
use super::money_amount::money_from_value; ← from money.amount ^1.0.0 · built alongside by fune
const MAX_SPAN_DAYS: i64 = 36500;
const NO_SINGLE_RATE: &str = "no single rate of return solves these cash flows";
fn sign(value: &BigInt) -> i32 {
if value.is_negative() {
-1
} else if value.is_zero() {
0
} else {
1
}
}
/// n / d rounded half away from zero; d > 0.
fn round_half_away(n: &BigInt, d: &BigInt) -> BigInt {
let two = BigInt::from_i64(2);
let magnitude = n.abs().mul(&two).add(d).div(&two.mul(d));
if n.is_negative() {
magnitude.neg()
} else {
magnitude
}
}
/// Net flow per day, in day order, counted from the earliest date with a nonzero net flow.
fn net_flows(flows: &[DatedFlow]) -> Vec<(i64, i64)> {
if flows.is_empty() {
panic!("flows must not be empty");
}
let currency = flows[0].amount.currency.clone();
let mut net: Vec<(i64, i64)> = Vec::new();
for flow in flows {
if flow.amount.currency != currency {
panic!("currency mismatch: {} and {}", currency, flow.amount.currency);
}
let day = epoch_day_from_iso(&flow.date);
match net.iter_mut().find(|(d, _)| *d == day) {
Some(entry) => entry.1 += flow.amount.minor,
None => net.push((day, flow.amount.minor)),
}
}
net.retain(|(_, a)| *a != 0);
net.sort_by_key(|(d, _)| *d);
if !net.iter().any(|(_, a)| *a > 0) || !net.iter().any(|(_, a)| *a < 0) {
panic!("flows need at least one payment and one receipt");
}
let first = net[0].0;
if net[net.len() - 1].0 - first > MAX_SPAN_DAYS {
panic!("flows must fall within {} days of each other", MAX_SPAN_DAYS);
}
net.into_iter().map(|(d, a)| (d - first, a)).collect()
}
/// Bisection on x in [0, FIXED_SCALE] for a change of sign of f; the two ends' signs differ.
fn bisect(f: &dyn Fn(&BigInt) -> BigInt) -> BigInt {
let one = BigInt::from_i64(1);
let two = BigInt::from_i64(2);
let mut lo = BigInt::zero();
let mut hi = fixed_scale();
let lo_sign = sign(&f(&lo));
while hi.sub(&lo) > one {
let mid = lo.add(&hi).div(&two);
let s = sign(&f(&mid));
if s == 0 {
return mid;
}
if s == lo_sign {
lo = mid;
} else {
hi = mid;
}
}
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.
///
/// # Panics
/// Panics on empty or one-sided flows, mixed currencies, bad dates, a span
/// over 36500 days, or flows with no single rate.
pub fn money_weighted_return(flows: &[DatedFlow]) -> XirrResult {
let net = net_flows(flows);
let total: i128 = net.iter().map(|(_, a)| *a as i128).sum();
let scale = fixed_scale();
let mut rate = BigInt::zero();
if total != 0 {
let total_sign = if total > 0 { 1 } else { -1 };
let last = net[net.len() - 1].0;
let positive_side = net[0].1.signum() as i32 != total_sign;
let negative_side = net[net.len() - 1].1.signum() as i32 != total_sign;
if positive_side == negative_side {
panic!("{}", NO_SINGLE_RATE);
}
if positive_side {
// v = (1 + r)^(-1/365) in (0, 1).
let v = bisect(&|x: &BigInt| {
net.iter().fold(BigInt::zero(), |sum, (t, a)| sum.add(&BigInt::from_i64(*a).mul(&pow_fixed(x, *t as u64))))
});
let growth = pow_fixed(&v, 365);
if growth.is_zero() {
panic!("the rate of return is too large to compute");
}
rate = scale.mul(&scale).div(&growth).sub(&scale);
} else {
// u = (1 + r)^(1/365) in (0, 1); the sum is multiplied through by u^last.
let u = bisect(&|x: &BigInt| {
net.iter().fold(BigInt::zero(), |sum, (t, a)| {
sum.add(&BigInt::from_i64(*a).mul(&pow_fixed(x, (last - *t) as u64)))
})
});
rate = pow_fixed(&u, 365).sub(&scale);
}
}
let settled = round_half_away(&rate, &BigInt::from_i64(1_000_000));
let bp = round_half_away(&settled.mul(&BigInt::from_i64(10000)), &BigInt::from_i64(1_000_000_000_000));
let nine = round_half_away(&settled, &BigInt::from_i64(1000));
let magnitude = nine.abs();
let (whole, fraction) = magnitude.div_rem(&BigInt::from_i64(1_000_000_000));
XirrResult {
basis_points: bp.to_i64(),
rate: format!("{}{}.{:09}", if nine.is_negative() { "-" } else { "" }, whole, fraction.to_i64()),
}
}
pub fn dated_flow_from_value(v: &Value) -> DatedFlow {
if let Value::Float(f) = v.get("amount").get("minor") {
if f.fract() != 0.0 {
panic!("amounts must be whole minor units, received {}", f);
}
}
DatedFlow {
date: v.get("date").as_str().to_string(),
amount: money_from_value(v.get("amount")),
}
}
pub fn xirr_result_to_value(result: &XirrResult) -> Value {
Value::obj(vec![
("basisPoints", Value::Int(result.basis_points)),
("rate", Value::str(&result.rate)),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
let flows: Vec<DatedFlow> = args[0].as_arr().iter().map(dated_flow_from_value).collect();
xirr_result_to_value(&money_weighted_return(&flows))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 4 dependencies, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. 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 Rust implementation. Install it without the registry with fune add ./invest.money-weighted-return-1.0.1-rust.fune, or fetch it from a terminal with fune pull invest.money-weighted-return@1.0.1:rust.
The whole function, every language, is one file too: invest.money-weighted-return-1.0.1.fune, 33,205 bytes, sha256 c88ced54de7fdc34450b449701043a464a3ad3df3fac08b9d062d895b6f53b52. 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.
## Before you rely on this
**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 above, and have a tax adviser review how you use it, before anyone relies on the output. Provided "as is" under its licence, without warranty.
**Unreviewed.** This capability's implementations agree in every language and pass its published test vectors, which were worked out from the official sources cited. But no qualified tax adviser has yet checked those vectors, or confirmed that the capability covers the cases it claims. Treat it as a draft. Do not use it for real people, money or decisions without your own expert review. Once a qualified reviewer signs off, this notice is replaced with their name, qualification and the date. Each new version needs fresh sign-off.
1.0.1 marks it unreviewed. The code and the tests are unchanged.
Files
| Path | Bytes |
|---|---|
| README.md | 4,642 |
| impl/python.py | 3,795 |
| impl/rust.rs | 5,711 |
| impl/typescript.ts | 4,050 |
| vectors.json | 10,291 |