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

On this page

  • Imports + tiny helpers
  • Domain types + sign convention
    • ParsedInputs
    • Flow
  • Day count
    • year_fraction
  • Modified Dietz
    • modified_dietz
  • Money-weighted return (XIRR)
    • money_weighted_return
  • Time-weighted return (post-flow valuations)
    • time_weighted_return
  • Unitization series (NAV/share)
    • unitization_series
  • Report an issue

Other Formats

  • CommonMark

Core

Author: cs224

Last updated: 2025-12-15

Python implementation: CPython
Python version       : 3.12.9
IPython version      : 9.8.0

numpy     : 2.3.5
pandas    : 2.3.3
scipy     : 1.16.3
nbdev     : 2.4.6
matplotlib: 3.10.8

Imports + tiny helpers

We’ll standardize dates (datetime/date/pandas Timestamp) to date.

We’ll use ACT/365F for year fractions.

assert _to_date(pd.Timestamp("2025-01-01")) == date(2025,1,1)

Domain types + sign convention

Investor view: deposits negative, withdrawals positive.

Portfolio view: opposite sign.

Flow(when, amount) is “external flow” whose sign depends on view.


ParsedInputs

 ParsedInputs (df:pandas.core.frame.DataFrame, start_date:datetime.date,
               end_date:datetime.date, start_value:float, end_value:float)

Flow

 Flow (when:datetime.date, amount:float)
f1 = Flow(_to_date(pd.Timestamp("2025-01-01")), float(10.5))
f2 = Flow(_to_date(pd.Timestamp("2025-02-01")), float(-10.1))
assert _ensure_sorted([f2, f1]) == [f1, f2]

Day count

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


year_fraction

 year_fraction (d0:datetime.date, d1:datetime.date)
assert year_fraction(date(2025,1,1), date(2026,1,1)) == 1.0
assert year_fraction(date(2025,1,1), date(2025,12,31)) == 364/365

Modified Dietz

Assumptions: * portfolio-view flows * cashflows on the start date are not allowed under the post-flow convention (omit them from the inputs)

formula: \[ R_{MD}=\frac{V_T - V_0 - \sum CF_i}{V_0 + \sum w_i CF_i},\quad w_i=\frac{T-t_i}{T-0} \]


modified_dietz

 modified_dietz (start:datetime.date, start_value:float,
                 flows:List[__main__.Flow], end:datetime.date,
                 end_value:float)

Modified Dietz with portfolio-view flows; start-date cashflows should be omitted under the post-flow convention.

md = modified_dietz(_to_date(pd.Timestamp("2025-01-01")), 100.0, [], _to_date(pd.Timestamp("2025-12-31")), 110.0)
assert md == approx(0.1, **APPROX)

Money-weighted return (XIRR)

Solve \(NPV(r)=\sum \frac{CF_i}{(1+r)^{\Delta t_i}}=0\)

If there are multiple sign changes the solution might not be unique; we warn but return one solution.

f1 = Flow(_to_date(pd.Timestamp("2025-01-01")), 10.0)
f2 = Flow(_to_date(pd.Timestamp("2025-12-31")), 20.0)
cfs = [f1, f2]
assert _npv(cfs, 0.0) == sum([f.amount for f in cfs])

money_weighted_return

 money_weighted_return (cashflows:Sequence[Tuple[datetime.date,float]])

XIRR on dated cashflows (negative investments, positive distributions).

cashflows = [(_to_date(pd.Timestamp("2025-01-01")), - 100.0)] + [] + [(_to_date(pd.Timestamp("2025-12-31")), 110.0)]
mwr = money_weighted_return(cashflows)
assert mwr == approx(0.1002880629803653, **APPROX)
mwr_period = (1.0 + mwr) ** year_fraction(pd.Timestamp("2025-01-01"), pd.Timestamp("2025-12-31")) - 1.0
assert mwr_period == approx(0.1, **APPROX)

Time-weighted return (post-flow valuations)

Assumptions: * valuations are post-flow at each valuation date * requires valuation on each cashflow date (strict mode) * chain-link:

\[ r_i=\frac{V_i - CF_i}{V_{i-1}}-1,\quad TWR=\prod_i(1+r_i)-1 \]

where (CF_i) is portfolio-view flow occurring on date (i).


time_weighted_return

 time_weighted_return (valuations:Sequence[Tuple[datetime.date,float]],
                       flows:Iterable[__main__.Flow])

Chain-linked return assuming valuations are post-flow (end-of-day).

portfolio_flows = []
valuations = [(_to_date(pd.Timestamp("2025-01-01")), 100.0), (_to_date(pd.Timestamp("2025-12-31")), 110.0)]
twr = time_weighted_return(valuations, portfolio_flows)
assert twr == approx(0.1, **APPROX)
twr_ann = (1.0 + twr) ** (1.0 / year_fraction(pd.Timestamp("2025-01-01"), pd.Timestamp("2025-12-31"))) - 1.0
assert twr_ann == approx(0.1002880629803653, **APPROX)

Unitization series (NAV/share)

Assumptions: * investor-view flows

compute shares so that flows are “flow-neutral” in NAV/share

invariants like valuation == shares * nav_per_share


unitization_series

 unitization_series (valuations:Sequence[Tuple[datetime.date,float]],
                     flows:Iterable[__main__.Flow])

Unitization series using investor-view flows; valuations are post-flow.

  • Report an issue