
Performance Reporting
- 406 installs
- 161 repo stars
- Updated July 18, 2026
- joellewis/finance_skills
Compute and present returns, attribution, and benchmark comparisons for portfolios and investment mandates.
About
Covers portfolio performance reporting: return calculations, benchmark comparisons, attribution breakdowns, and investor-ready report structures for wealth and asset management products.
- TWR and MWR
- Attribution slices
- Benchmark selection
- Report templates
- Period rollups
Performance Reporting by the numbers
- 406 all-time installs (skills.sh)
- +17 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #245 of 1,106 Finance & Trading skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/joellewis/finance_skills --skill performance-reportingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 406 |
|---|---|
| repo stars | ★ 161 |
| Last updated | July 18, 2026 |
| Repository | joellewis/finance_skills ↗ |
What it does
Compute and present returns, attribution, and benchmark comparisons for portfolios and investment mandates.
Files
Performance Reporting — Reporting & Communication
Core Concepts
Return Reporting
Accurate and consistent return calculation is the foundation of all performance reporting.
Period returns: Report standard time periods — MTD (month-to-date), QTD (quarter-to-date), YTD (year-to-date), 1Y, 3Y, 5Y, 10Y, and since inception. Always state the exact inception date.
Cumulative vs annualized: Annualize returns only for periods greater than 1 year. Annualizing a 3-month return is misleading because it implies the rate is sustainable for a full year. For periods under 1 year, report cumulative (total) returns only.
- Annualized return formula:
(1 + cumulative_return)^(1/years) - 1 - For multi-year periods, always present both cumulative and annualized figures so the reader can see total wealth growth and the rate of compounding.
Gross vs net of fees: Always specify whether returns are gross or net of management fees, advisory fees, and transaction costs. Net-of-fee returns are what the investor actually experiences and should be the primary presentation. If showing gross returns, also show the fee drag.
GIPS (Global Investment Performance Standards): For institutional reporting, follow GIPS requirements — composite construction, full disclosure, verified calculations, and standardized presentation. Even for non-GIPS reports, the principles of fair representation and full disclosure apply.
Time-weighted vs money-weighted returns:
- Time-weighted return (TWR) removes the impact of cash flows — use for evaluating the investment manager's skill.
- Money-weighted return (MWR / IRR) reflects the investor's actual experience including timing of contributions and withdrawals — use for evaluating the investor's outcome.
Calculation Engines
scripts/performance_reporting.py implements the return calculations behind these reports:
- Modified Dietz (`ModifiedDietz`): approximates TWR by weighting each external cash flow by the fraction of the period it was invested: R = (V_end - V_start - sum(CF)) / (V_start + sum(w_i * CF_i)), with w_i = (D - d_i)/D. A GIPS-acceptable approximation when daily valuations are unavailable.
- True TWR (`TimeWeightedReturn`): chain-links sub-period returns, prod(1 + r_t) - 1, with an annualization helper that refuses periods under 1 year.
- IRR / MWR (`MoneyWeightedReturn`): solves NPV(rate) = 0 numerically using Brent's root-finding method (
scipy.optimize.brentq) over a bracketing interval, returning the annual money-weighted return. - GIPS composites (`CompositeReturn`): asset-weighted composite return using beginning-of-period values as weights, plus equal-weighted return and the asset-weighted internal dispersion GIPS requires for composites with 6+ portfolios.
- Standard periods (`PeriodReturns`): MTD/QTD/YTD-style trailing windows (1M through 10Y) and inception-to-date from a daily return series, annualizing only periods of 1 year or more.
Benchmark Comparison
A return number in isolation is meaningless. Context requires a benchmark.
Appropriate benchmark selection: The benchmark must match the portfolio's investment style, geography, capitalization, and asset class mix. A US large-cap equity portfolio should be compared to the S&P 500 or Russell 1000, not the MSCI Emerging Markets Index.
- For multi-asset portfolios, use a blended benchmark (e.g., 60% S&P 500 / 40% Bloomberg Aggregate).
- The benchmark should be investable — the investor could have held it as a passive alternative.
- Document the benchmark rationale and keep it consistent over time to avoid cherry-picking.
Active return (alpha): Portfolio return minus benchmark return. Positive alpha indicates outperformance; negative alpha indicates underperformance.
Tracking error and information ratio: For definitions and computation, see performance-metrics. In reports, present these alongside active return so the reader can judge how consistently outperformance was achieved.
Risk Dashboard
Complement return reporting with risk metrics to give a complete picture. For definitions and computation of these metrics (volatility, VaR, drawdown, etc.), see historical-risk.
Current snapshot metrics:
- Annualized volatility
- Maximum drawdown and current drawdown
- Value at Risk (VaR) at 95% and 99% confidence levels
- Beta relative to the benchmark
Rolling metrics: Show how risk evolves over time, not just a point-in-time estimate.
- 12-month rolling Sharpe ratio
- 12-month rolling volatility
- 36-month rolling beta
- Rolling drawdown chart
Risk exposure breakdown:
- Sector concentration and weights vs benchmark
- Factor exposures (value, growth, momentum, quality, size)
- Geographic allocation
- Duration and credit quality (for fixed income)
Attribution Summary
Explain why the portfolio outperformed or underperformed.
Brinson attribution (allocation, selection, interaction) and factor decomposition: For methodology and formulas, see performance-attribution. In a report, summarize each effect in one plain-language sentence (e.g., "sector weighting added 0.2%, stock selection added 0.4%").
Top/bottom contributors (holdings-level):
- List the 5-10 holdings that contributed most positively and most negatively to portfolio returns.
- Show both the return of the holding and its contribution to total portfolio return (weight x return).
- Provide brief commentary on why each top/bottom contributor performed as it did.
Goal Progress Tracking
For goal-based investors, frame performance in terms of progress toward their specific objectives.
On-track assessment: Is the portfolio on track, behind, or ahead relative to the financial plan?
Probability of success: Use Monte Carlo simulation to estimate the probability of reaching the goal given current assets, savings rate, time horizon, and expected return/risk assumptions. Express as a percentage (e.g., "82% probability of funding retirement at age 65").
Projected vs required return: Compare the return needed to reach the goal with the expected return of the current portfolio. If the required return exceeds what is reasonable, flag this as a planning gap.
Milestone tracking: Express progress as percentage of goal funded. For example: "Retirement goal: $2,000,000. Current portfolio: $850,000. 42.5% funded with 15 years remaining."
Visualization Best Practices
Charts communicate faster than tables. Choose the right chart for the message.
Growth of $10,000 chart: Shows cumulative wealth growth of portfolio vs benchmark over time. Intuitive for all audiences. Use log scale for long time periods to avoid visual distortion from compounding.
Rolling return chart: Shows trailing 12-month or 36-month returns over time. Reveals consistency and regime changes. More informative than a single annualized number.
Drawdown chart: Shows peak-to-trough declines over time. Viscerally communicates risk in a way that volatility numbers cannot.
Asset allocation pie/bar chart: Current allocation vs target/benchmark. Use a grouped bar chart to show both side by side.
Risk-return scatter plot: Plot portfolio and benchmark (and possibly peer group) on an annualized return vs annualized volatility plane. Positions in the upper-left (high return, low risk) are desirable.
Report Frequency and Structure
- Monthly brief: 1-page summary — headline return, benchmark comparison, major attribution drivers, any notable events.
- Quarterly detailed: 3-5 pages — full return table, attribution, risk dashboard, goal progress, market commentary, and outlook.
- Annual comprehensive: 8-15 pages — everything in the quarterly report plus year-in-review, tax reporting summary, planning updates, and IPS review.
Plain Language Communication
The most important reporting skill is translating numbers into meaning.
- Do not just state "the portfolio returned 8.1% YTD." Add context: "The portfolio returned 8.1% YTD, outperforming its benchmark by 0.6 percentage points, driven primarily by strong stock selection in the technology sector."
- Explain whether performance is good or bad relative to expectations and the plan.
- Use analogies and comparisons the audience understands.
- Define technical terms on first use or include a glossary.
- Lead with the conclusion, then provide supporting detail for those who want to dig deeper.
Worked Examples
Example 1: Quarterly Performance Report Summary
Given: A balanced portfolio (60% equity / 40% fixed income) returned 3.2% in Q3 (benchmark: 2.8%). YTD the portfolio returned 8.1% vs 7.5% for the benchmark. The portfolio Sharpe ratio is 0.85 over the trailing 12 months. Equity selection in technology (+0.3%) and an underweight in energy (-0.1%) were the main attribution drivers.
Analysis:
Headline: The portfolio outperformed its benchmark by 0.4 percentage points in Q3 and 0.6 percentage points YTD, driven by strong stock selection in technology.
Return summary table:
| Period | Portfolio | Benchmark | Active Return |
|---|---|---|---|
| Q3 | +3.2% | +2.8% | +0.4% |
| YTD | +8.1% | +7.5% | +0.6% |
Attribution highlights:
- Technology stock selection contributed +0.3% — the largest single driver of outperformance.
- Underweight energy allocation detracted -0.1% as energy prices rallied in the quarter.
- Net active return of +0.4% demonstrates disciplined bottom-up security selection.
Risk context:
- Trailing 12-month Sharpe ratio of 0.85 indicates the portfolio is generating meaningful risk-adjusted excess return.
- Portfolio volatility remains in line with the benchmark, so outperformance is not coming from taking additional risk.
Plain-language summary for the client: "Your portfolio gained 3.2% this quarter, beating the benchmark by about half a percent. Year-to-date, you are ahead of the benchmark by a similar margin. The main driver was our technology stock picks, which outperformed the broader tech sector. We remain on track relative to your long-term financial plan."
Example 2: Goal Progress — Retirement Funding
Given: A client has a retirement goal of $2,000,000 in today's dollars. Current portfolio value is $850,000. Time horizon is 15 years. Current annual contribution is $30,000 (increasing 3% per year). Portfolio expected return is 7% nominal, expected volatility is 12%. Inflation assumption is 2.5%.
Analysis:
Current status:
- Goal: $2,000,000 (in today's dollars)
- Current assets: $850,000
- Funded ratio: 42.5%
- Time remaining: 15 years
Projection (deterministic):
- Future value of current assets at 4.5% real return over 15 years: $850,000 x (1.045)^15 = approximately $1,636,000
- Future value of contributions ($30,000/yr escalating 3%/yr) at 4.5% real: approximately $620,000
- Projected total (real): approximately $2,256,000
- Deterministic assessment: On track — projected to exceed goal by ~$256,000
Projection (Monte Carlo, 10,000 simulations):
- Median outcome: $2,180,000
- 25th percentile: $1,650,000
- 10th percentile: $1,320,000
- Probability of reaching $2,000,000 goal: 68%
Interpretation: While the deterministic projection shows the client is on track, the Monte Carlo analysis reveals a 68% probability of success — reasonable but not highly confident. The gap between the deterministic and probabilistic views is driven by sequence-of-returns risk and volatility drag.
Recommendations to improve probability of success:
- Increase annual contributions by $5,000 (raises probability to ~78%).
- Consider modest reduction in spending goal or flexible retirement date.
- Maintain current allocation — reducing risk at this stage would lower expected return and reduce success probability.
Client-facing summary: "You have $850,000 saved toward your $2,000,000 retirement goal, which is 42.5% of the way there with 15 years to go. Based on our projections, you have roughly a 68% chance of reaching your goal with your current savings plan. This is a reasonable position, but we can improve your odds by increasing your annual contribution or building in some flexibility on your retirement date."
Common Pitfalls
- Cherry-picking favorable time periods to present performance in the best light. Always show standard periods and since-inception returns.
- Not showing risk alongside returns. A 15% return with 30% volatility is a very different story than 15% with 10% volatility.
- Using inappropriate benchmarks to flatter performance. Comparing a growth equity fund to a value index during growth-favoring markets is dishonest.
- Too much jargon for non-technical audiences. Sharpe ratios and tracking error mean nothing to most clients without explanation.
- Not providing context for numbers. Is 8% good or bad? It depends on the benchmark, the risk taken, the market environment, and the goal.
- Showing short-period returns annualized. A 5% return in one month is not "60% annualized" — this is misleading and should never be presented.
- Presenting only time-weighted returns when the client's cash flow timing significantly impacted their actual experience. Show money-weighted returns alongside TWR when there are large or ill-timed flows.
- Survivorship bias in composite reporting: excluding terminated accounts or poor-performing strategies from historical track records.
- Ignoring taxes: for taxable investors, after-tax returns are what actually matters.
Cross-References
- statistics-fundamentals (core plugin, Layer 0): return distributions, confidence intervals for projections
- time-value-of-money (core plugin, Layer 0): future value projections, annualization math
- performance-metrics (wealth-management plugin, Layer 1a): Sharpe, Sortino, Information Ratio calculation details
- historical-risk (wealth-management plugin, Layer 1a): volatility, drawdown, VaR computation for risk dashboards
- asset-allocation (wealth-management plugin, Layer 4): benchmark construction for multi-asset portfolios
- quantitative-valuation (wealth-management plugin, Layer 3): forward-looking return assumptions for goal projections
- finance-psychology (wealth-management plugin, Layer 7): framing effects in how performance is presented to clients
- client-review-prep (advisory-practice plugin, Layer 10): performance data is assembled into the client review meeting package
Running the script
Run with uv run scripts/performance_reporting.py (the PEP 723 header resolves numpy/scipy automatically) or with python3 scripts/performance_reporting.py after pip install numpy scipy. A bare run prints five demos: a Modified Dietz return, chain-linked TWR, an IRR solved via Brent's method, a GIPS composite summary, and a standard-period return table. Use --verify to assert the demo outputs match expected values (exit code 0 on PASS) and --help for an overview of the classes. The file is primarily meant to be imported as a module (e.g., from performance_reporting import ModifiedDietz, MoneyWeightedReturn).
# /// script
# dependencies = ["numpy", "scipy"]
# requires-python = ">=3.11"
# ///
"""
Performance Reporting
======================
Compute portfolio performance metrics for reporting: time-weighted returns
(modified Dietz), money-weighted returns (IRR), GIPS-compliant composite
construction, and standard period returns (MTD, QTD, YTD, inception-to-date).
Part of Layer 8 (Reporting & Communication) in the finance skills framework.
"""
import argparse
import math
import sys
import numpy as np
from scipy.optimize import brentq
class ModifiedDietz:
"""Compute time-weighted returns using the Modified Dietz method.
The Modified Dietz method approximates the true time-weighted return
by weighting cash flows by the fraction of the period they were invested.
It is widely used as a GIPS-acceptable approximation when daily valuations
are not available.
Parameters
----------
beginning_value : float
Portfolio market value at the start of the period.
ending_value : float
Portfolio market value at the end of the period.
cash_flows : np.ndarray
External cash flows during the period (positive = contribution,
negative = withdrawal).
cash_flow_days : np.ndarray
Day within the period each cash flow occurred (0 = start, D = end).
total_days : int
Total number of days in the measurement period.
"""
def __init__(
self,
beginning_value: float,
ending_value: float,
cash_flows: np.ndarray,
cash_flow_days: np.ndarray,
total_days: int,
):
self.v_start = beginning_value
self.v_end = ending_value
self.cf = np.asarray(cash_flows, dtype=np.float64)
self.cf_days = np.asarray(cash_flow_days, dtype=np.float64)
self.total_days = total_days
if len(self.cf) != len(self.cf_days):
raise ValueError(
"cash_flows and cash_flow_days must have the same length."
)
def weights(self) -> np.ndarray:
"""Compute the time-weighting factor for each cash flow.
Returns
-------
np.ndarray
w_i = (D - d_i) / D, the fraction of the period each flow
was invested.
"""
return (self.total_days - self.cf_days) / self.total_days
def weighted_cash_flows(self) -> float:
"""Compute the sum of time-weighted cash flows.
Returns
-------
float
sum(w_i * CF_i)
"""
w = self.weights()
return float(np.dot(w, self.cf))
def compute_return(self) -> float:
"""Compute the Modified Dietz return.
Returns
-------
float
R = (V_end - V_start - sum(CF)) / (V_start + sum(w_i * CF_i))
"""
total_cf = float(np.sum(self.cf))
denominator = self.v_start + self.weighted_cash_flows()
if abs(denominator) < 1e-12:
return 0.0
return (self.v_end - self.v_start - total_cf) / denominator
class TimeWeightedReturn:
"""Compute true time-weighted return by chain-linking sub-period returns.
When portfolio valuations are available at each cash flow date, the exact
TWR can be calculated by compounding sub-period returns. This removes
the impact of external cash flows and measures pure investment performance.
Parameters
----------
sub_period_returns : np.ndarray
Array of sub-period returns (decimals). Each sub-period ends at
a valuation date (typically when an external cash flow occurs).
"""
def __init__(self, sub_period_returns: np.ndarray):
self.sub_returns = np.asarray(sub_period_returns, dtype=np.float64)
def cumulative_return(self) -> float:
"""Compute the cumulative time-weighted return.
Returns
-------
float
TWR = prod(1 + r_t) - 1
"""
return float(np.prod(1.0 + self.sub_returns) - 1.0)
@staticmethod
def annualize(cumulative_return: float, years: float) -> float:
"""Annualize a cumulative return.
Only valid for periods greater than 1 year. For periods under
1 year, report cumulative returns only (annualizing is misleading).
Parameters
----------
cumulative_return : float
Total cumulative return over the period.
years : float
Number of years in the period. Must be > 0.
Returns
-------
float
Annualized return = (1 + cumulative_return)^(1/years) - 1
"""
if years <= 0:
raise ValueError("years must be positive.")
return (1.0 + cumulative_return) ** (1.0 / years) - 1.0
class MoneyWeightedReturn:
"""Compute money-weighted return (internal rate of return / IRR).
The MWR reflects the investor's actual experience, including the
timing and size of cash flows. Use for evaluating the investor's
outcome (vs TWR for manager skill evaluation).
Parameters
----------
cash_flows : np.ndarray
Array of cash flows. Convention: negative = outflow from investor
(contribution), positive = inflow to investor (withdrawal or
ending value). The first element is typically -beginning_value
and the last is +ending_value.
times : np.ndarray
Time (in years) of each cash flow. The first is typically 0.0.
"""
def __init__(
self,
cash_flows: np.ndarray,
times: np.ndarray,
):
self.cf = np.asarray(cash_flows, dtype=np.float64)
self.times = np.asarray(times, dtype=np.float64)
if len(self.cf) != len(self.times):
raise ValueError("cash_flows and times must have the same length.")
def _npv(self, rate: float) -> float:
"""Compute the net present value at a given discount rate.
Parameters
----------
rate : float
Annual discount rate.
Returns
-------
float
NPV = sum(CF_i / (1 + rate)^t_i)
"""
return float(np.sum(self.cf / (1.0 + rate) ** self.times))
def compute_irr(
self,
lower_bound: float = -0.99,
upper_bound: float = 10.0,
) -> float:
"""Compute the internal rate of return via Brent's root-finding method.
Parameters
----------
lower_bound : float, optional
Lower bound for the IRR search. Default is -0.99 (99% loss).
upper_bound : float, optional
Upper bound for the IRR search. Default is 10.0 (1000% return).
Returns
-------
float
The annual IRR such that NPV(IRR) = 0.
Raises
------
ValueError
If the root-finding algorithm does not converge.
"""
try:
irr = brentq(self._npv, lower_bound, upper_bound, xtol=1e-12)
except ValueError:
raise ValueError(
f"IRR not found in [{lower_bound}, {upper_bound}]. "
"Check cash flow signs and magnitudes."
)
return float(irr)
class CompositeReturn:
"""GIPS-compliant composite construction and return calculation.
A composite groups portfolios with similar investment mandates.
GIPS requires asset-weighted composite returns using beginning-of-period
values (or beginning values plus weighted cash flows for Modified Dietz).
Parameters
----------
portfolio_returns : np.ndarray
Array of returns for each portfolio in the composite.
portfolio_values : np.ndarray
Beginning-of-period market values for each portfolio (used as
weights for asset-weighted composite return).
"""
def __init__(
self,
portfolio_returns: np.ndarray,
portfolio_values: np.ndarray,
):
self.returns = np.asarray(portfolio_returns, dtype=np.float64)
self.values = np.asarray(portfolio_values, dtype=np.float64)
if len(self.returns) != len(self.values):
raise ValueError(
"portfolio_returns and portfolio_values must have the same length."
)
def asset_weighted_return(self) -> float:
"""Compute the asset-weighted composite return.
Returns
-------
float
R_composite = sum(w_i * R_i) where w_i = V_i / sum(V_i)
"""
total_value = np.sum(self.values)
if total_value == 0:
return 0.0
weights = self.values / total_value
return float(np.dot(weights, self.returns))
def equal_weighted_return(self) -> float:
"""Compute the equal-weighted composite return.
Returns
-------
float
Simple average of all portfolio returns.
"""
if len(self.returns) == 0:
return 0.0
return float(np.mean(self.returns))
def internal_dispersion(self) -> float:
"""Compute the asset-weighted internal dispersion of the composite.
Internal dispersion measures the spread of individual portfolio
returns around the composite return. GIPS requires disclosure of
a measure of internal dispersion for composites with >= 6 portfolios.
Returns
-------
float
Asset-weighted standard deviation of portfolio returns around
the composite return.
"""
if len(self.returns) < 2:
return 0.0
total_value = np.sum(self.values)
if total_value == 0:
return 0.0
weights = self.values / total_value
composite_ret = self.asset_weighted_return()
variance = float(np.dot(weights, (self.returns - composite_ret) ** 2))
return np.sqrt(variance)
def summary(self) -> dict:
"""Compute composite statistics.
Returns
-------
dict
Contains 'asset_weighted_return', 'equal_weighted_return',
'internal_dispersion', 'n_portfolios', 'total_assets',
'high_return', 'low_return'.
"""
return {
"asset_weighted_return": self.asset_weighted_return(),
"equal_weighted_return": self.equal_weighted_return(),
"internal_dispersion": self.internal_dispersion(),
"n_portfolios": len(self.returns),
"total_assets": float(np.sum(self.values)),
"high_return": float(np.max(self.returns)) if len(self.returns) > 0 else 0.0,
"low_return": float(np.min(self.returns)) if len(self.returns) > 0 else 0.0,
}
class PeriodReturns:
"""Compute standard-period returns from a daily return series.
Provides MTD, QTD, YTD, trailing-period, and inception-to-date returns
with proper annualization conventions (annualize only periods > 1 year).
Parameters
----------
daily_returns : np.ndarray
Array of daily simple returns (decimals).
periods_per_year : int, optional
Number of trading days per year. Default is 252.
"""
def __init__(
self,
daily_returns: np.ndarray,
periods_per_year: int = 252,
):
self.daily_returns = np.asarray(daily_returns, dtype=np.float64)
self.periods_per_year = periods_per_year
def cumulative_return(self, returns: np.ndarray | None = None) -> float:
"""Compute the cumulative return for a return series.
Parameters
----------
returns : np.ndarray or None, optional
Return series to compound. If None, uses the full daily series.
Returns
-------
float
prod(1 + r_t) - 1
"""
r = np.asarray(
returns if returns is not None else self.daily_returns,
dtype=np.float64,
)
return float(np.prod(1.0 + r) - 1.0)
def annualized_return(self, returns: np.ndarray | None = None) -> float | None:
"""Compute the annualized return for a return series.
Returns None if the period is less than 1 year (annualizing
short periods is misleading).
Parameters
----------
returns : np.ndarray or None, optional
Return series. If None, uses the full daily series.
Returns
-------
float or None
(1 + cumulative)^(periods_per_year / n) - 1, or None if
the series spans less than 1 year.
"""
r = np.asarray(
returns if returns is not None else self.daily_returns,
dtype=np.float64,
)
n = len(r)
if n < self.periods_per_year:
return None
cumulative = self.cumulative_return(r)
years = n / self.periods_per_year
return (1.0 + cumulative) ** (1.0 / years) - 1.0
def trailing_return(self, n_days: int) -> float:
"""Compute the trailing cumulative return over the last n_days.
Parameters
----------
n_days : int
Number of trailing days.
Returns
-------
float
Cumulative return over the last n_days of the series.
Raises
------
ValueError
If n_days exceeds the available data length.
"""
if n_days > len(self.daily_returns):
raise ValueError(
f"Requested {n_days} trailing days but only "
f"{len(self.daily_returns)} days available."
)
subset = self.daily_returns[-n_days:]
return self.cumulative_return(subset)
def inception_to_date(self) -> dict:
"""Compute inception-to-date return statistics.
Returns
-------
dict
Contains 'cumulative_return', 'annualized_return' (None if
< 1 year), 'n_days', and 'years'.
"""
n = len(self.daily_returns)
years = n / self.periods_per_year
cumulative = self.cumulative_return()
annualized = self.annualized_return()
return {
"cumulative_return": cumulative,
"annualized_return": annualized,
"n_days": n,
"years": years,
}
def standard_periods(self) -> dict:
"""Compute returns for standard reporting periods.
Computes trailing 1-month (~21 days), 3-month (~63 days),
YTD (~252 days based on available data), 1-year, 3-year,
5-year, and inception-to-date.
Returns
-------
dict
Dictionary of period labels to return values. Periods longer
than available data are reported as None. Annualized returns
are included for periods >= 1 year.
"""
n = len(self.daily_returns)
result = {}
# Standard trailing periods (approximate trading days)
period_defs = [
("1M", 21),
("3M", 63),
("6M", 126),
("1Y", 252),
("3Y", 756),
("5Y", 1260),
("10Y", 2520),
]
for label, days in period_defs:
if days > n:
result[label] = {"cumulative": None, "annualized": None}
else:
subset = self.daily_returns[-days:]
cum_ret = self.cumulative_return(subset)
ann_ret = self.annualized_return(subset)
result[label] = {"cumulative": cum_ret, "annualized": ann_ret}
# Inception-to-date
itd = self.inception_to_date()
result["ITD"] = {
"cumulative": itd["cumulative_return"],
"annualized": itd["annualized_return"],
}
return result
def run_demo() -> None:
"""Run the demonstration suite (default when executed with no arguments)."""
# ----------------------------------------------------------------
# Demo 1: Modified Dietz return
# ----------------------------------------------------------------
print("=" * 60)
print("Demo 1: Modified Dietz Return")
print("=" * 60)
md = ModifiedDietz(
beginning_value=1_000_000.0,
ending_value=1_080_000.0,
cash_flows=np.array([50_000.0, -20_000.0]),
cash_flow_days=np.array([10.0, 25.0]),
total_days=30,
)
print(f"\nBeginning value: ${md.v_start:,.2f}")
print(f"Ending value: ${md.v_end:,.2f}")
print(f"Cash flows: {md.cf}")
print(f"Flow days: {md.cf_days}")
print(f"Period days: {md.total_days}")
print(f"\nTime weights: {md.weights()}")
print(f"Weighted CFs: ${md.weighted_cash_flows():,.2f}")
print(f"Modified Dietz: {md.compute_return():.6f} "
f"({md.compute_return()*100:.4f}%)")
# ----------------------------------------------------------------
# Demo 2: Time-weighted return (chain-linking)
# ----------------------------------------------------------------
print("\n" + "=" * 60)
print("Demo 2: Time-Weighted Return (Chain-Linking)")
print("=" * 60)
# 4 quarterly sub-period returns
sub_returns = np.array([0.032, -0.015, 0.048, 0.022])
twr = TimeWeightedReturn(sub_returns)
cum_ret = twr.cumulative_return()
ann_ret = TimeWeightedReturn.annualize(cum_ret, years=1.0)
print(f"\nQuarterly returns: {sub_returns}")
print(f"Cumulative TWR: {cum_ret:.6f} ({cum_ret*100:.4f}%)")
print(f"Annualized (1yr): {ann_ret:.6f} ({ann_ret*100:.4f}%)")
# 3-year example
np.random.seed(42)
monthly_returns = np.random.normal(0.007, 0.035, 36)
twr_3y = TimeWeightedReturn(monthly_returns)
cum_3y = twr_3y.cumulative_return()
ann_3y = TimeWeightedReturn.annualize(cum_3y, years=3.0)
print(f"\n3-year monthly series ({len(monthly_returns)} months):")
print(f" Cumulative TWR: {cum_3y:.6f} ({cum_3y*100:.2f}%)")
print(f" Annualized TWR: {ann_3y:.6f} ({ann_3y*100:.2f}%)")
# ----------------------------------------------------------------
# Demo 3: Money-weighted return (IRR)
# ----------------------------------------------------------------
print("\n" + "=" * 60)
print("Demo 3: Money-Weighted Return (IRR)")
print("=" * 60)
# Investor puts in $100k at t=0, adds $50k at t=0.5y, withdraws at
# t=1y with $165k ending value
mwr = MoneyWeightedReturn(
cash_flows=np.array([-100_000.0, -50_000.0, 165_000.0]),
times=np.array([0.0, 0.5, 1.0]),
)
irr = mwr.compute_irr()
print(f"\nCash flows: {mwr.cf}")
print(f"Times (yr): {mwr.times}")
print(f"IRR: {irr:.6f} ({irr*100:.4f}%)")
# Verification: NPV at the IRR should be ~0
npv_check = mwr._npv(irr)
print(f"NPV at IRR: ${npv_check:.6f} (should be ~0)")
# ----------------------------------------------------------------
# Demo 4: GIPS composite construction
# ----------------------------------------------------------------
print("\n" + "=" * 60)
print("Demo 4: GIPS Composite Construction")
print("=" * 60)
comp = CompositeReturn(
portfolio_returns=np.array([0.082, 0.075, 0.091, 0.068, 0.078,
0.085, 0.072, 0.088]),
portfolio_values=np.array([5_000_000, 3_200_000, 8_100_000,
1_500_000, 2_800_000, 4_300_000,
2_100_000, 6_500_000]),
)
comp_result = comp.summary()
print(f"\nComposite: {comp_result['n_portfolios']} portfolios, "
f"${comp_result['total_assets']:,.0f} total assets")
print(f"Asset-weighted return: {comp_result['asset_weighted_return']*100:.4f}%")
print(f"Equal-weighted return: {comp_result['equal_weighted_return']*100:.4f}%")
print(f"Internal dispersion: {comp_result['internal_dispersion']*100:.4f}%")
print(f"Highest portfolio: {comp_result['high_return']*100:.4f}%")
print(f"Lowest portfolio: {comp_result['low_return']*100:.4f}%")
# ----------------------------------------------------------------
# Demo 5: Standard period returns
# ----------------------------------------------------------------
print("\n" + "=" * 60)
print("Demo 5: Standard Period Returns")
print("=" * 60)
np.random.seed(123)
# 3 years of daily returns
n_days = 756
daily_rets = np.random.normal(0.0003, 0.012, n_days)
pr = PeriodReturns(daily_returns=daily_rets, periods_per_year=252)
periods = pr.standard_periods()
print(f"\nReturn series: {n_days} trading days "
f"({n_days/252:.1f} years)")
print(f"\n{'Period':<8} {'Cumulative':>12} {'Annualized':>12}")
print("-" * 34)
for label, vals in periods.items():
cum_str = (f"{vals['cumulative']*100:.2f}%"
if vals["cumulative"] is not None else "N/A")
ann_str = (f"{vals['annualized']*100:.2f}%"
if vals["annualized"] is not None else "N/A")
print(f"{label:<8} {cum_str:>12} {ann_str:>12}")
itd = pr.inception_to_date()
print(f"\nInception-to-date detail:")
print(f" Days: {itd['n_days']}")
print(f" Years: {itd['years']:.2f}")
print(f" Cumulative: {itd['cumulative_return']*100:.2f}%")
if itd["annualized_return"] is not None:
print(f" Annualized: {itd['annualized_return']*100:.2f}%")
print("\n" + "=" * 60)
print("Demo complete.")
print("=" * 60)
def run_verify() -> int:
"""Re-run the demo computations and assert their key outputs.
Returns
-------
int
0 if all checks pass, 1 otherwise.
"""
failures = 0
def check(name: str, actual: float, expected: float,
rel_tol: float = 1e-6, abs_tol: float = 1e-9) -> None:
nonlocal failures
ok = math.isclose(actual, expected, rel_tol=rel_tol, abs_tol=abs_tol)
status = "PASS" if ok else "FAIL"
print(f"[{status}] {name}: actual={actual:.10g} expected={expected:.10g}")
if not ok:
failures += 1
# Demo 1: Modified Dietz
md = ModifiedDietz(
beginning_value=1_000_000.0,
ending_value=1_080_000.0,
cash_flows=np.array([50_000.0, -20_000.0]),
cash_flow_days=np.array([10.0, 25.0]),
total_days=30,
)
check("Modified Dietz weighted cash flows", md.weighted_cash_flows(), 30_000.0)
check("Modified Dietz return", md.compute_return(), 0.0485436893)
# Demo 2: chain-linked TWR
twr = TimeWeightedReturn(np.array([0.032, -0.015, 0.048, 0.022]))
cum_ret = twr.cumulative_return()
check("Cumulative TWR", cum_ret, 0.0887498451)
check("Annualized TWR (1yr)", TimeWeightedReturn.annualize(cum_ret, 1.0),
0.0887498451)
np.random.seed(42)
monthly_returns = np.random.normal(0.007, 0.035, 36)
twr_3y = TimeWeightedReturn(monthly_returns)
cum_3y = twr_3y.cumulative_return()
check("3-year cumulative TWR (seeded)", cum_3y, 0.0285067234)
check("3-year annualized TWR (seeded)",
TimeWeightedReturn.annualize(cum_3y, 3.0), 0.0094133519)
# Demo 3: IRR via Brent's method
mwr = MoneyWeightedReturn(
cash_flows=np.array([-100_000.0, -50_000.0, 165_000.0]),
times=np.array([0.0, 0.5, 1.0]),
)
irr = mwr.compute_irr()
check("IRR", irr, 0.1206873836)
check("NPV at IRR", mwr._npv(irr), 0.0, abs_tol=1e-4)
# Demo 4: GIPS composite
comp = CompositeReturn(
portfolio_returns=np.array([0.082, 0.075, 0.091, 0.068, 0.078,
0.085, 0.072, 0.088]),
portfolio_values=np.array([5_000_000, 3_200_000, 8_100_000,
1_500_000, 2_800_000, 4_300_000,
2_100_000, 6_500_000]),
)
check("Composite asset-weighted return", comp.asset_weighted_return(),
0.0834686567)
check("Composite equal-weighted return", comp.equal_weighted_return(),
0.079875)
check("Composite internal dispersion", comp.internal_dispersion(),
0.0068286973)
# Demo 5: standard period returns (seeded)
np.random.seed(123)
daily_rets = np.random.normal(0.0003, 0.012, 756)
pr = PeriodReturns(daily_returns=daily_rets, periods_per_year=252)
itd = pr.inception_to_date()
check("ITD cumulative return (seeded)", itd["cumulative_return"],
0.2634802003)
check("ITD annualized return (seeded)", itd["annualized_return"],
0.0810758028)
if failures:
print(f"\nFAIL: {failures} check(s) did not match expected values.")
return 1
print("\nPASS: all checks matched expected values.")
return 0
def main() -> None:
parser = argparse.ArgumentParser(
description=(
"Performance reporting calculations: Modified Dietz time-weighted "
"returns (ModifiedDietz), chain-linked TWR (TimeWeightedReturn), "
"IRR via Brent's method (MoneyWeightedReturn), GIPS composite "
"returns (CompositeReturn), and standard period returns "
"(PeriodReturns)."
),
epilog=(
"Run with no arguments to print the demo suite. "
"Import as a module: "
"from performance_reporting import ModifiedDietz, "
"TimeWeightedReturn, MoneyWeightedReturn, CompositeReturn, "
"PeriodReturns"
),
)
parser.add_argument(
"--verify",
action="store_true",
help="run the demo computations and assert key outputs; "
"exits nonzero on mismatch",
)
args = parser.parse_args()
if args.verify:
sys.exit(run_verify())
run_demo()
if __name__ == "__main__":
main()