privateassets
July 29, 2026 · View on GitHub
Multi-factor, money-weighted PME for private-asset cash flows — risk-adjusted alpha and systematic factor exposures estimated directly from fund reporting, from NAVs and cash flows to alpha in one call
Why privateassets
A single-benchmark PME divides fund cash flows by the return of one index. That charges the fund for one exposure and credits everything else to skill. The MATF deflator divides them by the return of a tradable multi-factor portfolio, so a distressed-credit fund is measured against the credit and equity basket it actually loaded on rather than against equities alone.
privateassets generalises Direct Alpha, KS-PME and GPME from one benchmark to a multi-factor deflator, and ships the classical measures alongside so the two can be compared on the same cash flows.
Key differentiators
Fund reporting to alpha in one call. estimate_matf_alpha takes cash flows,
NAVs and factor levels and returns capital-weighted and per-vintage alpha,
factor loadings, bootstrap intervals and full provenance. It returns every
intermediate it computed, and writes nothing. Each stage — NAV-implied returns,
unsmoothing, factor betas, deflators — is also usable on its own.
Point-in-time by construction. The covariance inside the deflator uses only
returns observed by each quarter end: deleting every observation after a vintage
closes leaves its alpha unchanged to 1e-12, which
test_no_look_ahead_end_to_end asserts.
One panel, one reporting frequency. Nothing is forward-filled or interpolated; a panel whose vintages report at different frequencies raises, naming the offenders. Interpolating them onto a common grid is an assumption about an unobserved path, and this package does not make it for you.
The incumbents ship alongside. kn24_benchmark_deflator and
kn16_gpme_deflator price the same cash flows against one market index, so the
multi-factor result is reported next to what it replaces — and passing a single
benchmark's reciprocal index ratio as the deflator reproduces classical Direct
Alpha to root-finder tolerance, pinned by a test.
Built on the stack, not duplicating it. Unsmoothing, covariance estimation
and block resampling delegate to qis;
sign-constrained shrinkage betas to
factorlasso through the optional
[factors] extra.
When to use it — and when not
Use privateassets to estimate risk-adjusted alpha and systematic factor
exposures of private-equity and private-credit funds from their cash flows and
NAVs, to unsmooth appraisal-based NAV returns with bias-corrected AR(1)
estimates, and to report multi-factor and classical PME side by side on the
same panel.
Do not reach for it for portfolio construction — that is its sibling
optimalportfolios. The
reporting and factsheet layer is not in this release, no data ships with the
package, and the factor loadings are in-sample by construction — one beta over
the whole panel — so it is a measurement tool, not a live risk system. The
caveats travel with every number in provenance.
Install
pip install privateassets
Sign-constrained shrinkage betas need the factors extra:
pip install "privateassets[factors]"
Use
Classical single-benchmark measures take a tidy cash-flow frame and a benchmark index level series:
import pandas as pd
from privateassets.matf import ks_pme, direct_alpha, compute_vintage_stats
stats = compute_vintage_stats(cf=cash_flows, navs=navs)
pme = ks_pme(cf_dates=cf['date'], cf_amounts=cf['amount'],
rvpi_nav=nav, rvpi_date=nav_date, bench_idx=benchmark_levels)
alpha = direct_alpha(cf_dates=cf['date'], cf_amounts=cf['amount'],
rvpi_nav=nav, rvpi_date=nav_date, bench_idx=benchmark_levels)
The whole estimator is one call. It returns every intermediate it computed, and writes nothing:
from privateassets.matf import estimate_matf_alpha
result = estimate_matf_alpha(cf=cash_flows, navs=navs,
factor_levels=factor_levels, # excess index levels
rf_rate=rf_quarterly,
num_bootstrap=1000, seed=1)
print(result.cap_weighted_alpha) # annualised, capital-weighted
print(result.vintage_alpha) # per vintage
print(result.betas.beta) # loadings
print(result.beta_bootstrap.lower) # resampled interval
print(result.provenance) # versions, seed, specification
The covariance inside the deflator is point-in-time: deleting every observation
after a vintage closes leaves its alpha unchanged to 1e-12, which
test_no_look_ahead_end_to_end asserts. The loadings are in-sample by
construction — one beta over the whole panel — and provenance says so, because
that caveat has to travel with the number.
Pass beta= to price against a loading vector you already have instead of
fitting one.
The stages are also available individually.
A fund reports marks and cash flows, not returns. Reconstruct the return series first:
from privateassets.matf import (infer_reporting_frequency, nav_implied_returns,
pool_vintage_returns, split_by_reporting_frequency)
print(infer_reporting_frequency(navs)) # months between marks, per vintage
returns, capital = nav_implied_returns(cf=cash_flows, navs=navs, freq='QE')
quarterly_returns = pool_vintage_returns(returns, capital)
One panel, one reporting frequency. Every return spans exactly one period, and nothing is forward-filled or interpolated. A panel whose vintages report at different frequencies raises, naming the offenders:
groups = split_by_reporting_frequency(cf, navs)
returns, capital = nav_implied_returns(*groups[6], freq='2QE') # the semi-annual reporters
Estimating the groups separately is honest. Interpolating them onto a common grid is an assumption about an unobserved path, and this package does not make it for you.
Factor loadings come from the shrinkage estimator. The panel is short and the factors are collinear, so an unconstrained least-squares beta is not usable:
from privateassets.matf import SignConstraint, fit_factor_betas
fit = fit_factor_betas(asset_returns=quarterly_returns,
factor_returns=quarterly_factor_returns,
sign_constraints={'Equity': SignConstraint.POS,
'Credit': SignConstraint.POS},
span=None) # equal weights for in-sample identification
beta = fit.beta.values
Needs the [factors] extra. The shrinkage target defaults to zero — a non-zero
prior is an economic view, so it is yours to pass, not the library's to assume.
The multi-factor measure then replaces the benchmark index with a deflator path and solves the same root-finding step:
import numpy as np
from privateassets.matf import (cf_with_terminal_for_vintage, factor_log_levels_panel,
matf_deflator, rolling_factor_covar,
vintage_direct_alpha)
quarter_ends = pd.DatetimeIndex(factor_levels.resample('QE').last().index)
quarterly = np.log(factor_levels.resample('QE').last()).diff().fillna(0.0)
cum_log_factor = factor_log_levels_panel(quarterly).values
cum_log_rf = np.log1p(rf_quarterly).cumsum().values
sigma_by_quarter = rolling_factor_covar(factor_levels)
cf_v, rvpi_nav, rvpi_date, dates = cf_with_terminal_for_vintage(cf_g, nav_g)
deflators = matf_deflator(cf_dates=dates, t0=dates[0],
cum_log_factor=cum_log_factor, cum_log_rf=cum_log_rf,
quarter_ends=quarter_ends, beta=beta,
sigma_by_quarter=sigma_by_quarter,
sigma_default=sigma_burnin)
alpha = vintage_direct_alpha(cf_v, rvpi_nav, dates, deflators)
Passing a single benchmark's reciprocal index ratio as the deflator returns the
classical Direct Alpha, to within root-finder tolerance. That reduction is
pinned by test_vintage_direct_alpha_matches_direct_alpha_on_a_benchmark_deflator.
Unsmoothing
Appraisal NAVs are reported with a lag and anchored to the previous mark, so
reported returns are a moving average of true ones. Estimate the AR(1)
coefficient across a panel of funds here, then apply it with qis:
import qis
from privateassets.matf import fit_panel_ar1
result = fit_panel_ar1(demeaned_series_by_fund)
unsmoothed = qis.unsmooth_returns_glm(returns, ar_order=1, theta=result['theta_hat'])
The inversion itself is qis.unsmooth_returns_glm. This package does not carry a
second copy of it.
The estimate is biased down by two separate mechanisms, and only one is correctable.
Demeaning a short AR(1) biases the coefficient by about -(1 + 3θ)/n. Pass
bias_correction=BiasCorrection.BOOTSTRAP to remove it — a parametric
simulation from the fitted model, which cuts mean absolute error by roughly an
order of magnitude and handles heterogeneous series lengths that the analytic
KENDALL formula only approximates.
What remains is measurement error. Modified Dietz returns are noisiest while capital is still being called, and error in a regressor attenuates its coefficient. On the end-to-end synthetic panel with a true θ of 0.30, the raw estimate is 0.161, correcting the demeaning bias gives 0.196, and the remaining 0.104 is measurement error that no small-sample correction reaches. It is the larger of the two.
Corrections are off by default and theta_raw is always reported.
Comparing against the single-factor incumbents
kn24_benchmark_deflator and kn16_gpme_deflator price the same cash flows
against one market index, so the multi-factor result can be reported next to
what it replaces. Both take the equity factor as an excess log return and add
the risk-free leg back where the economics needs a total return.
from privateassets.matf import kn16_gpme_deflator, kn16_sdf_params
delta, gamma, sigma2 = kn16_sdf_params(equity_excess_log_returns, rf_quarterly)
kn_deflators = kn16_gpme_deflator(cf_dates=dates, t0=dates[0],
cum_log_equity_excess=cum_log_equity,
cum_log_rf=cum_log_rf, quarter_ends=quarter_ends,
delta=delta, gamma=gamma)
kn_alpha = vintage_direct_alpha(cf_v, rvpi_nav, dates, kn_deflators)
Inference
Loadings are resampled in blocks through qis, with the asset and its factors
resampled together:
from privateassets.matf import bootstrap_factor_betas
boot = bootstrap_factor_betas(asset_returns, factor_returns,
num_samples=1000, block_size=12, seed=1)
print(boot.lower, boot.upper, boot.qis_version)
share_at_zero reports how often a sign constraint binds. It is not a p-value:
under a binding constraint the mass sits on the boundary, so the quantity tracks
the constraint, not the evidence.
Conventions
- Factor inputs are excess log returns. The risk-free rate enters the deflator once, through its own term.
- Covariance is point-in-time: the matrix at a quarter end uses only returns
observed by that date.
test_rolling_covariance_is_point_in_timeenforces it. - Deflator matrices are in quarterly units and scale by the horizon in quarters.
- Day counts are ACT/365.25 throughout.
- A cash-flow date before the factor panel starts returns NaN. It is not pinned to the first quarter end.
- There is one covariance path,
rolling_factor_covar, which delegates toqis.estimate_rolling_ewma_covarat a 60-month span. The 36-month full-window-mean variant that shipped in 0.1.0 is gone. - One panel, one reporting frequency. Every return spans exactly one period, and nothing is forward-filled or interpolated.
Dependencies
Built on qis for unsmoothing, covariance
estimation and resampling, and optionally on
factorlasso for sign-constrained
shrinkage betas. It does not depend on optimalportfolios, which is a sibling.
Licence note. This package is MIT. factorlasso is GPL-3, so a redistributed
work combining the two takes on GPL-3 obligations. Installing the factors extra
is what creates that combination. The core PME and deflator paths do not import
it.
Data
No data ships with this repository, and none may be added. Every input is
licensed and read from a path you supply. See DATA_README.md.
Tests
pip install -e ".[dev]"
pytest
177 tests, no network and no data files. privateassets/tests/synthetic_data.py
draws a seeded panel carrying the defects real panels carry: irregular cash-flow
dates, a J-curve, unrealised residual NAVs, and a factor panel that starts after
the first fund does.
Tests needing the [factors] extra skip rather than fail, so a core install
stays green.
Seven of the tests are enforcement rather than behaviour — they fail the suite
if the package imports with a filesystem side effect, documents an argument it
does not take, ships a proprietary identifier, imports a competing analytics
stack, imports factorlasso at module scope, lets the release triple disagree,
or declares a qis floor the suite never ran on.
Status
0.6.1 runs from fund reporting to alpha in one call, and each stage is usable
on its own. The reporting and factsheet layer is not in this release. See
CHANGELOG.md.
Two caveats travel with every number and are recorded in provenance: the
loadings are in-sample, and the smoothing coefficient is attenuated by
measurement noise in the J-curve period even after bias correction.
Citation
See CITATION.cff.