Brokerage apps show a return number, but if you've been adding money at random times, a simple "gain divided by deposits" figure is misleading. The right tool is the money-weighted return, the same thing spreadsheet users get from XIRR. Here's how it works and how to compute it yourself.
The problem
Say you deposited:
- $5,000 on Jan 1
- $3,000 on Jul 1
- $2,000 on Oct 1
and on Dec 31 the account is worth $10,900. You put in $10,000 and gained $900. Is that 9%? No. Most of the money wasn't invested for the whole year, so the annualized return is higher.
The definition
XIRR finds the annual rate r that makes the present value of all cash flows equal zero:
sum( CF_i / (1 + r) ^ ((d_i - d_0) / 365) ) = 0
Deposits are negative (money out of your pocket), and the ending value is positive (money you could take out).
The code
No libraries, just a bisection search:
from datetime import date
def xnpv(rate, flows):
d0 = flows[0][0]
return sum(cf / (1 + rate) ** ((d - d0).days / 365) for d, cf in flows)
def xirr(flows, lo=-0.99, hi=10.0, tol=1e-10):
# bisection: robust, and fast enough for personal-finance sizes
f_lo, f_hi = xnpv(lo, flows), xnpv(hi, flows)
if f_lo * f_hi > 0:
raise ValueError("no sign change; check your cash flows")
for _ in range(200):
mid = (lo + hi) / 2
f_mid = xnpv(mid, flows)
if abs(f_mid) < tol:
return mid
if f_lo * f_mid < 0:
hi, f_hi = mid, f_mid
else:
lo, f_lo = mid, f_mid
return mid
flows = [
(date(2025, 1, 1), -5000),
(date(2025, 7, 1), -3000),
(date(2025, 10, 1), -2000),
(date(2025, 12, 31), 10900),
]
print(f"{xirr(flows):.2%}")
Output
13.01%
The annualized money-weighted return is about 13%, not 9%. The later deposits had less time to earn, so the same $900 gain represents a higher rate.
Money-weighted vs. time-weighted
Fund companies usually report time-weighted returns, which strip out the effect of when you added money. That's the right way to judge a fund manager. Money-weighted returns (XIRR) answer a different question: how did my money do, given when I actually put it in? If you tend to buy after prices rise, your XIRR can come in below the fund's published return.
Edge cases to handle
- Multiple sign changes (deposits, withdrawals, deposits) can produce more than one mathematical solution. Bisection over a sensible range usually finds the meaningful one.
- Very short periods annualize wildly. A 2% gain in two weeks is not a useful "annual return."
- Fees and dividends: count fees already taken out of the balance as part of the ending value, and don't double-count reinvested dividends as deposits.
If you want to extend this, add withdrawals as positive flows mid-stream, or compare XIRR against a benchmark by running the same dates through an index's prices.
Example numbers are hypothetical. Not investment advice.
Top comments (0)