portfolio_performance_metrics
  1. Portfolio Performance Metrics with External Cashflows
  • Portfolio Performance Metrics with External Cashflows
  • Core
  • IO
  • Metrics
  • CLI
  • Tests (nbdev notebook)

On this page

  • Installation & setup
    • Using uv (recommended during development)
  • Data model and sign conventions
  • Return measures
    • Year fraction
    • Time-Weighted Return (TWR)
    • Money-Weighted Return (MWR / XIRR)
    • Modified Dietz
    • Unitization (NAV per share)
  • Worked example: sample cashflows
  • CLI usage
  • Detailed notebooks
  • Report an issue

Other Formats

  • CommonMark

Portfolio Performance Metrics with External Cashflows

Installation & setup

You can use this project either as a library inside notebooks, or as a command-line tool.

Using uv (recommended during development)

From the project root:

uv run nbdev_export      # export code from notebooks
uv run nbdev_test        # run notebook tests
uv run pytest            # run full pytest suite

To run the CLI against a CSV/Excel file:

uv run portfolio-performance-metrics path/to/cashflows.csv

or equivalently via module execution:

uv run python -m portfolio_performance_metrics path/to/cashflows.csv

Data model and sign conventions

The core input is a table with three columns:

  • date: calendar date (daily frequency, but any irregular spacing is allowed),
  • cashflow: investor-view cashflow,
  • valuation: post-flow portfolio valuation at end-of-day.

We use the following sign convention:

  • Deposits into the portfolio (from the investor) are negative cashflows.
  • Withdrawals from the portfolio (to the investor) are positive cashflows.
  • A NaN cashflow means “no external flow recorded” on that date.

We distinguish two “views” of flows:

  • Investor view: deposits negative, withdrawals positive.
  • Portfolio view: deposits positive (increase assets), withdrawals negative (decrease assets).

Internally, some formulas (TWR, Dietz) use portfolio-view flows, while others (XIRR, unitization) use investor-view flows. The conversion is simply:

\[ \text{CF}_{\text{portfolio}} = -\,\text{CF}_{\text{investor}}. \]

Return measures

Year fraction

We measure time in year fractions based on ACT/365F:

\[ \operatorname{year\_fraction}(d_0, d_1) = \frac{(d_1 - d_0)_{\text{days}}}{365}. \]

This is used for annualizing and for time-based discounting in XIRR-like calculations.

Time-Weighted Return (TWR)

TWR isolates the effect of market performance, stripping out the timing and size of external cashflows.

We assume valuations are post-flow at each valuation date, and we require a valuation on each cashflow date (in strict mode).

For two consecutive valuations \(V_{i-1}\) and \(V_i\) and portfolio-view cashflow \(CF_i\) occurring on the date of \(V_i\), the subperiod return is:

\[ r_i = \frac{V_i - CF_i}{V_{i-1}} - 1. \]

The total time-weighted return over the full horizon is:

\[ \text{TWR} = \prod_{i}(1 + r_i) - 1. \]

Money-Weighted Return (MWR / XIRR)

Money-weighted return is the internal rate of return that makes the present value of investor-view cashflows and valuations equal to zero.

Given cashflows \((t_i, CF_i)\) (investor view, including initial and final valuations as flows), we solve for $ r $ in:

\[ NPV(r) = \sum_i \frac{CF_i}{(1 + r)^{\Delta t_i}} = 0, \]

where

\[ \Delta t_i = \operatorname{year\_fraction}(t_0, t_i). \]

The solution $ r $ is the annualized money-weighted return.

Modified Dietz

Modified Dietz approximates the effect of cashflow timing using a weighted average approach.

Between start date $ t_0 $ and end date $ t_T $, with:

  • $ V_0 $: start valuation,
  • $ V_T $: end valuation,
  • $ CF_i $: portfolio-view cashflows at date $ t_i $,

the Modified Dietz return is:

\[ R_{\text{MD}} = \frac{V_T - V_0 - \sum_i CF_i}{V_0 + \sum_i w_i CF_i}, \]

with weights

\[ w_i = \frac{t_T - t_i}{t_T - t_0}. \]

Cashflows on the start date are not allowed under the post-flow convention; include them in the initial valuation instead of passing them as flows.

Unitization (NAV per share)

Unitization tracks a notional share count and NAV per share such that performance can be compared across investors and time.

Conceptually:

  1. Initialize with 1.0 share at the first valuation date.
  2. On each date:
    • Grow the portfolio value from previous date to current date.
    • Apply the investor-view cashflow as a change in shares at the pre-flow price.
    • Compute NAV per share as current valuation divided by current share count.

This produces a time series of:

  • shares (notional share count),
  • nav_per_share (unit price),
  • valuation (shares × nav_per_share),
  • flow (investor-view external flows).

By construction, the TWR corresponds to the change in NAV per share:

\[ \text{TWR} = \frac{\text{NAV}_{\text{final}}}{\text{NAV}_{\text{initial}}} - 1. \]

Worked example: sample cashflows

Let’s run the full pipeline on a small sample cashflow file and inspect the results.

We assume the CSV / Excel file has at least these columns:

  • date
  • cashflow
  • valuation
# If you have a sample file in tests/fixtures, you can point to it directly:
sample_path = "../tests/fixtures/sample_cashflows.csv"

load_result = load_cashflows(sample_path)
load_result.df.head()
date cashflow valuation
0 2025-01-01 0 100000
1 2025-03-01 -10000 112000
2 2025-06-01 5000 118000
3 2025-09-01 -8000 125000
4 2025-12-31 0 137500
metrics = compute_metrics(load_result, lenient_missing_valuations=False)

summary = metrics.summary
nav = metrics.nav

summary
/home/cs/workspaces/portfolio-performance-metrics/portfolio_performance_metrics/core.py:83: RuntimeWarning: IRR may be non-unique because there are multiple sign changes in cashflows.
  warnings.warn("IRR may be non-unique because there are multiple sign changes in cashflows.", RuntimeWarning)
metric period_return annualized
0 TWR 0.221754 0.222427
1 MWR_XIRR 0.227029 0.227718
2 Modified_Dietz 0.226616 0.227304
nav.head()
date valuation shares nav_per_share flow
0 2025-01-01 100000.0 1.000000 100000.000000 0.0
1 2025-03-01 112000.0 1.098039 102000.000000 -10000.0
2 2025-06-01 118000.0 1.053403 112017.857143 5000.0
3 2025-09-01 125000.0 1.125431 111068.553269 -8000.0
4 2025-12-31 137500.0 1.125431 122175.408596 0.0

The summary table shows:

  • the period return for each metric,
  • the annualized return where applicable.

The nav table shows the unitization series:

  • date: valuation date,
  • valuation: post-flow portfolio value,
  • shares: notional share count,
  • nav_per_share: unit price (NAV per share),
  • flow: investor-view external flow on that date.
plt.plot(nav["date"], nav["nav_per_share"])
plt.xlabel("Date")
plt.ylabel("NAV per share")
plt.title("Unitization series")
plt.xticks(rotation=45);

CLI usage

The same functionality is available via a command-line interface.

From the project root:

uv run portfolio-performance-metrics tests/fixtures/sample_cashflows.csv

This will:

  • print a performance summary (TWR, MWR/XIRR, Modified Dietz),
  • print the unitization series (NAV per share over time).

To also write an Excel file:

uv run portfolio-performance-metrics tests/fixtures/sample_cashflows.csv --output output.xlsx

You can then open output.xlsx to inspect the results in a spreadsheet.

Detailed notebooks

The rest of the notebooks document the implementation in a more fine-grained, literate style:

  • 00_core.ipynb: core domain types and math (year_fraction, Dietz, XIRR, TWR, unitization).
  • 01_io.ipynb: input loading and cleaning logic (load_cashflows).
  • 02_metrics.ipynb: orchestration, lenient imputation, and compute_metrics.
  • 03_cli.ipynb: command-line interface and argument parsing.

Each of these notebooks follows the pattern:

  1. Explain the next small piece of functionality.
  2. Implement it in code.
  3. Add tests as executable cells right below.
  • Report an issue