XIRR without floating point
A SIP’s cost-to-value ratio understates its return by roughly half, so the app needed a money-weighted return. The textbook way to compute one reaches for Double and a starting guess — both unusable here.
Compiled from the repository’s decision records and changelog rather than written as a first-person account. The reasoning is the project’s; the summarising is editorial. The record itself is linked at the end.
Showing a holding’s return as value over cost understates a SIP by roughly half, because it ignores when each instalment went in. The honest figure is money-weighted: XIRR. Writing one the usual way turns out to collide with two rules the project had already committed to.
XIRR is normally stated as NPV(r) = Σ cf·(1+r)^(−d/365) = 0 and solved with Newton-Raphson from a guess. The fractional exponent has no exact decimal form, so an implementation reaches for Double and inherits its drift. And Newton’s answer depends on where it started, can diverge on a series with one large early outflow, and stops on a tolerance test. That is three separate places where an innocent refactor quietly changes what the app said last year about a holding nobody touched.
A substitution, and then the slow method on purpose
Let x = (1+r)^(1/365). Then (1+r)^(d/365) becomes x^d with d a whole number of days, and multiplying the equation through by x^dMax clears every division. What is left is a polynomial in x with non-negative integer exponents that shares NPV’s positive roots — so the solve needs only BigDecimal.pow, multiply and add. No fractional powers, so nothing has to be approximated.
// F(x) = Σ cf_i · x^(dMax − d_i) — integer exponents only
private fun bisect(signAtLow: Int, values: List<Long>, exponents: List<Int>): BigDecimal {
var low = X_LOW
var high = X_HIGH
// Fixed iteration count over a literal bracket, no early exit: no guess to depend on
// and no tolerance branch to drift.
repeat(ITERATIONS) {
val mid = low.add(high).divide(TWO, CONTEXT)
if (polynomialAt(mid, values, exponents).signum() == signAtLow) low = mid else high = mid
}
return low.add(high).divide(TWO, CONTEXT)
}The second half is bisection rather than Newton — slower, and chosen for exactly that reason. There is no starting guess to depend on and no tolerance branch to drift, so the same cash flows give the same basis points on every machine and every build. For a figure the app will still be showing in five years, reproducibility is the property that matters and speed is not close.
The constants are a contract
PRECISION, X_LOW, X_HIGH, ITERATIONS and DAYS_PER_YEAR are versioned, not tuned. Changing any one of them changes every historical answer, so it is an engine-version bump rather than an adjustment. DAYS_PER_YEAR is 365 because that is what Excel’s XIRR uses — a user who checks the app against a spreadsheet should get the same number, and a golden file pins it.
- Three unsolvable shapes are refused by name rather than returned as a number: too few flows, all flows the same sign, and a bracket that does not straddle a root.
- Same-day flows are summed before anything else, so the answer does not depend on the order the caller happened to list them in.