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.

