Part of the Sanchayam series.
What XIRR Is
XIRR (extended internal rate of return) is an annualized return that accounts for the timing of cash flows. Simple absolute return tells you what percentage you gained on an investment. XIRR tells you what annual rate of return is equivalent to the actual cash flows you made - buys, sells, and the current value - given exactly when each one happened.
A 50% gain over 10 years is very different from a 50% gain over 2 years. XIRR captures that difference. It is the standard metric for evaluating the performance of an investment portfolio with irregular cash flows over time.
XIRR as Newton-Raphson
XIRR is computed by finding the rate r that makes the net present value (NPV) of all cash flows equal to zero:
NPV(r) = sum(cashflow_i / (1 + r)^t_i) = 0
where t_i is the time in years from the first cash flow to cash flow i. There is no closed-form solution. Sanchayam solves this numerically using Newton-Raphson iteration:
export function xirr(cashflows: CashFlow[]): number | null {
if (cashflows.length < 2) return null
const hasPos = cashflows.some(c => c.amount > 0)
const hasNeg = cashflows.some(c => c.amount < 0)
if (!hasPos || !hasNeg) return null
const t0 = cashflows[0].date.getTime()
const yrs = cashflows.map(c => (c.date.getTime() - t0) / (365.25 * 86_400_000))
const amt = cashflows.map(c => c.amount)
const npv = (r: number) => amt.reduce((s, a, i) => s + a / (1 + r) ** yrs[i], 0)
const dnpv = (r: number) => amt.reduce((s, a, i) => s - yrs[i] * a / (1 + r) ** (yrs[i] + 1), 0)
for (const guess of [0.1, 0.0, -0.1, 0.5, -0.5, 1.5]) {
let r = guess
for (let i = 0; i < 300; i++) {
const f = npv(r)
const d = dnpv(r)
if (!isFinite(f) || !isFinite(d) || Math.abs(d) < 1e-12) break
const nr = r - f / d
if (nr <= -1) break
if (Math.abs(nr - r) < 1e-7) return nr
r = nr
}
}
return null
}
Newton-Raphson can converge to different local solutions depending on the starting guess, and some portfolios have unusual cash flow patterns that make convergence difficult. Six starting guesses (0.1, 0.0, -0.1, 0.5, -0.5, 1.5) cover the range of plausible returns and increase the probability of finding a valid solution. If none of the guesses converge to a stable root within 300 iterations, the function returns null.
Under 365 Days: Absolute Return
Annualizing a return over a very short period produces misleading numbers. A 5% gain in 2 weeks annualizes to over 200%. For positions held less than 365 days, XIRR switches to simple absolute return:
export function computeReturn(cashFlows: CashFlow[]): number | null {
const sorted = [...cashFlows].sort((a, b) => a.date.getTime() - b.date.getTime())
const spanDays = (sorted[sorted.length - 1].date.getTime() - sorted[0].date.getTime()) / 86400000
const terminalValue = sorted[sorted.length - 1].amount
const otherFlows = sorted.slice(0, sorted.length - 1)
if (spanDays < 365) {
const totalBuys = otherFlows.filter(cf => cf.amount < 0).reduce((s, cf) => s + Math.abs(cf.amount), 0)
const totalSells = otherFlows.filter(cf => cf.amount > 0).reduce((s, cf) => s + cf.amount, 0)
const netInvested = totalBuys - totalSells
if (netInvested <= 0) return null
return (terminalValue - netInvested) / netInvested
}
return xirr(cashFlows)
}
computeReturn is the wrapper called everywhere. Under 365 days it returns (current_value - net_invested) / net_invested. At 365 days and beyond it delegates to xirr. The 365-day cutoff is applied to the span from the first cash flow to the terminal date, which after trimLotsToCurrentPosition is the first buy of the current position.
Cash Flow Construction
For a per-holding XIRR, cash flows are:
- Each buy lot:
-quantity * price_per_unit(negative - money going out) - Each sell lot:
+quantity * price_per_unit(positive - money coming in) - The current holding value as of snapshot date: positive terminal value
const cashFlows: CashFlow[] = trimmedLots.map(l => {
const qty = parseFloat(l.quantity)
const priceMajor = parseFloat(l.price_per_unit) / divisor
const amount = qty * priceMajor
return {
amount: l.transaction_type === 'buy' ? -amount : +amount,
date: new Date(l.transaction_date),
}
})
cashFlows.push({ amount: currentValueMajor, date: new Date(snapshotDate) })
The terminal value is always the last entry. trimLotsToCurrentPosition ensures only the current position’s lots are included.
Portfolio-Level XIRR with FX Conversion
Per-holding XIRR measures individual asset performance in the asset’s native currency. Portfolio-level XIRR measures total portfolio performance in the user’s base currency. This requires converting every cash flow to base currency at the prevailing rate.
At snapshot time, computePortfolioXirr collects all lots across all fixed-cost-basis holdings, converts each to base currency using the current FX rate, and passes the full cash flow series to computeReturn:
for (const lot of effectiveLots) {
const qty = parseFloat(lot.quantity)
const priceMajor = parseFloat(lot.price_per_unit) / assetDivisor
const amountInAsset = qty * priceMajor
const rate = await getFxRate(lot.currency)
if (rate === null) continue
cashFlows.push({
amount: lot.transaction_type === 'buy' ? -(amountInAsset * rate) : +(amountInAsset * rate),
date: new Date(lot.transaction_date)
})
}
cashFlows.push({ amount: totalBaseValueMajor, date: new Date(snapshotDate) })
FX rates used here are the current rates, not historical rates at the time of each transaction. This is a deliberate simplification - using current FX rates avoids requiring historical FX data and keeps the computation tractable. For a portfolio tracked in INR with primarily INR-denominated assets, the simplification has minimal impact.
The resulting portfolio XIRR is stored in portfolio_snapshots.portfolio_xirr at snapshot time.
Value-Weighted Category XIRR
The portfolio page shows XIRR per individual asset alongside the average XIRR for that asset’s category. This gives context: is this equity performing above or below the rest of the equity holdings?
Category XIRR is not a simple average. It is value-weighted: each asset’s XIRR is weighted by its value in the portfolio. An asset worth 10 lakh has ten times the influence on the category XIRR as an asset worth 1 lakh:
SELECT snapshot_date,
SUM(e.value_minor::numeric * e.xirr) / NULLIF(SUM(e.value_minor::numeric), 0) AS category_xirr
FROM portfolio_snapshot_entries e
WHERE e.asset_category = ${assetCategory}
AND e.xirr IS NOT NULL
GROUP BY snapshot_date
ORDER BY snapshot_date ASC
NULLIF(SUM(...), 0) prevents division by zero when all entries in a category have null XIRR. The category XIRR series is returned alongside the asset’s own history so the frontend can plot both on the same chart.
Next: Friday Snapshots: Weekly Portfolio State with Historical Backfill