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

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.

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:

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:

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