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