privateassets
Multi-factor, money-weighted PME for private-asset cash flows.
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.
The package 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.
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.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file privateassets-0.6.1.tar.gz.
File metadata
- Download URL: privateassets-0.6.1.tar.gz
- Upload date:
- Size: 76.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/6.2.0 CPython/3.12.10
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e6618579e21d4c1718b9e8b3ade94b1bae0bfe9f72bba099808100710744f39d
|
|
| MD5 |
f9174c75209d1fbf525dd24fa5445287
|
|
| BLAKE2b-256 |
7f19609c690e2554fae4223aca40bf8d567d432c0c9158d3e4be23a3d9e66546
|
File details
Details for the file privateassets-0.6.1-py3-none-any.whl.
File metadata
- Download URL: privateassets-0.6.1-py3-none-any.whl
- Upload date:
- Size: 75.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/6.2.0 CPython/3.12.10
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
351a4e7f143866a06c06cabf24246b90aaefc083dcd6dca5c40eb5a375843912
|
|
| MD5 |
53f2d1e259a2b0d4374c8eaaa461be45
|
|
| BLAKE2b-256 |
23ff9ae4f2ea293b430d2c931fd117ae6ec55fc54eba3829fadfdc3890c4441c
|