Return methodologies
Which number answers which question.
| Question | Use | Command |
|---|---|---|
| What annual rate did my money earn, given when I put it in? | XIRR (money-weighted) | folioperf xirr |
| How good were the investments, ignoring my timing? | TWR (time-weighted) | folioperf twr |
| Did I beat the index with the same cash flows? | Direct-equivalent / PME | folioperf vs-benchmark |
| Which holdings produced the return? | Carino-linked contribution | folioperf attribution |
| Was it allocation or selection? | Brinson-Fachler | folioperf attribution with targets.csv |
| How bad did it get? | Drawdowns, volatility | folioperf drawdowns, twr |
Conventions
- Flows are signed from the investor's side: money in is negative, money out is positive. Terminal value is a positive flow on the end date.
- Transactions settle at the close of their date. Day
dvalueV_dincludes them. - Switches are a sell plus a buy of equal value. They cancel at portfolio level and appear as flows when you look at one fund.
- Reinvested dividends (a
dividendrow with units) add units and cost but no flow. Cash dividends (no units) are inflows to the investor. feerows (brokerage, stamp duty) are investor cash costs outside the portfolio. They appear incostsand the XIRR cash flows are not adjusted, so XIRR is quoted before them. Mutual-fund expense ratios are already inside NAV.- Windows (
--from/--to): the opening value at the close of the day before--frombecomes the first outflow.
XIRR
Solves sum(A_i * (1+r)^(-(d_i - d_0)/365)) = 0.
Worked example (Excel's documentation case): flows -10,000 (2008-01-01), +2,750 (2008-03-01), +4,250 (2008-10-30), +3,250 (2009-02-15), +2,750 (2009-04-01) give 37.3362535%, which is the figure in tests/test_xirr.py.
Solver: Newton from 10% (with a step that never crosses -100%); if it fails, a log-spaced grid from -99.99% to about 1,000x is scanned for sign changes of the NPV and Brent's method runs on the bracket nearest 10%. The same grid counts roots. More than one sign change gives a "multiple IRR" warning. This happens when flows change sign more than once, for example a withdrawal followed by a fresh deposit with a loss in between. If all flows have the same sign, no IRR exists and the command reports an error instead of a number.
TWR
Because flows land at the close, the return of day d is r_d = (V_d - F_d) / V_(d-1) - 1, where F_d is the net contribution that day. Linking prod(1 + r_d) - 1 is exact sub-period linking with a break at every flow. Days with no capital at the previous close are skipped, so the chain starts the day after the first purchase.
Worked example: values 0, 1000, 1100, 2210, 2431 with contributions 0, 1000, 0, 1000, 0. Returns are 10%, 10%, 10% so TWR is 33.1% whatever the size of the second contribution. XIRR on the same flows is dominated by the larger money at work later.
Annualised: (1 + TWR)^(365/days) - 1.
Modified Dietz approximates the same thing from the two end values: gain / (V_0 + sum(F_i * w_i)) with w_i = (days after the flow) / (period days). It is accurate for short periods (the attribution uses months) and drifts over long ones, which is why summary shows it only as a cross-check.
Risk statistics
Computed from weekday daily returns, 252 per year. Holidays are forward-filled so they show up as zero-return days.
- Volatility:
stdev(r) * sqrt(252). - Sharpe:
mean(r - rf) / stdev(r) * sqrt(252). - Sortino:
mean(r - rf) / sqrt(mean(min(0, r - rf)^2)) * sqrt(252). - Beta:
cov(r, b) / var(b). Alpha (Jensen):(mean(r - rf) - beta * mean(b - rf)) * 252. - Up/down capture: mean portfolio return on days the benchmark was up (or down) divided by the mean benchmark return on those days.
- Risk-free:
riskfree.csv(annual percent, forward-filled) or--rf.
The default benchmark for risk statistics and rolling comparison is a blend: each day, the benchmark returns of your holdings weighted by the previous close's holdings. Pass --benchmark ID to use a single series.
Rolling returns
For each date, the annualised return of the time-weighted index over the trailing N years. The summary gives min, quartiles, max, share of windows below zero and the share where the portfolio beat the benchmark over the same window. Because the index is time-weighted, rolling returns do not depend on how much you invested when.
Drawdowns
On the TWR index, an episode starts at a peak, reaches a trough, and ends when the index first regains the peak (the recovery date). An episode still below its peak has no recovery date.
Benchmark comparison (direct equivalent, PME)
Long-Nickels: replay every flow into the benchmark. An outflow of Rs 1,000 buys 1000 / level units; an inflow sells units. The benchmark's terminal value is units * final level, and the XIRR of the original flows with this terminal value is the benchmark XIRR. Excess = XIRR_portfolio - XIRR_benchmark.
Kaplan-Schoar PME: (distributions compounded at the index + NAV) / (contributions compounded at the index). Above 1 means the portfolio beat the benchmark.
Worked example: Rs 1,000 invested when the index is 100 and index at the end is 150. Benchmark value is Rs 1,500. A portfolio worth Rs 1,800 has KS-PME 1.8/1.5 = 1.2.
By default each flow is routed to its own instrument's benchmark (instruments.csv), so the portfolio line is a composite. If withdrawals exceed what the benchmark would hold, its unit balance goes negative and a warning is printed.
Attribution
Per calendar month and holding (or asset class) g:
- gain
G_g = V_end - V_start - flows - average capital
D_g = V_start + sum(F_i * w_i)(Modified Dietz denominator) - return
r_g = G_g / D_g, weightw_g = D_g / sum(D), contributionc_g = w_g * r_g = G_g / sum(D)
Contributions add to the month's portfolio return R_t exactly. Months are linked with Carino smoothing: k_t = ln(1 + R_t) / R_t, K = ln(1 + R) / R for the compounded total, and the linked contribution is sum_t c_gt * k_t / K. Since sum_t R_t k_t = ln(1 + R) = K R, linked contributions sum to the compounded total, which the tests assert to 1e-12.
Brinson-Fachler
Targets come from targets.csv (asset class, weight, optional benchmark). Each month the target mix is rebalanced and each class earns its benchmark's return. With portfolio weight/return wp, rp, target wb, rb, and totals Rp, Rb:
- Allocation
(wp - wb)(rb - Rb) - Selection
wb (rp - rb) - Interaction
(wp - wb)(rp - rb)
These sum to Rp - Rb because the weights each sum to 1. Months are linked using the Carino coefficient for a difference, (ln(1+Rp_t) - ln(1+Rb_t)) / (Rp_t - Rb_t) scaled by the same coefficient for the whole period, so the linked effects sum to the active return Rp - Rb. A class held but absent from the targets, or a class without a benchmark series, is benchmarked against its own return, so it contributes allocation only.
Costs
- Expense-ratio drag:
sum_d V_d * ER / 365. NAVs are already net of it; this is the rupees you paid. - Direct vs regular:
direct_equivalentininstruments.csvnames a NAV series for the direct plan. The regular plan's flows are replayed into it and the terminal values compared.