Core workflows¶
Author: Artur Sepp / First recorded: 2026-09-08.
Implemented in privateassets. Software citation: CITATION.cff.
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.
A pooled-cohort extract reports one strategy x vintage path per row group rather than one fund, with paid-in, distributions and the residual valuation as a percent of committed capital and one copy per IRR percentile band. Read it, keep the full pool, drop the cohorts that are still mostly appraised, and the cohorts become the funds of a tidy panel:
from privateassets.matf import (build_cohorts, cohort_summary, cohorts_to_cash_flows,
filter_mature, load_cohort_extract)
extract = load_cohort_extract('cohort_extract.csv') # percent columns, scaled to fractions
cohorts = build_cohorts(extract, groups=['1:Alpha Funds']) # illustrative synthetic group
mature = filter_mature(cohorts, max_residual_ratio=0.10) # residual valuation under 10% of paid-in
print(cohort_summary(mature)) # DPI, TVPI, residual ratio per cohort
cf, navs = cohorts_to_cash_flows(mature) # every stage above now runs unchanged
result = estimate_matf_alpha(cf=cf, navs=navs, factor_levels=factor_levels, rf_rate=rf_quarterly)
Amounts are a percent of committed capital by default, so every cohort carries
the same commitment and the pooled series is equal-commitment weighted. Pass
the currency columns with scale=1.0 to weight by capital instead. Column
names are arguments, and nothing about a vendor’s taxonomy is baked in.
Factor loadings come from the shrinkage estimator. Shrinkage improves recovery on the package’s short synthetic panels, including correlated factors:
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.
Loadings with an expert prior¶
ExpertPrior exposes the production capital-market-assumptions prior convention
through the optional factorlasso dependency:
Regress the asset on one caller-selected factor with an intercept and the fit’s loss weights. Its OLS slope centres that factor’s penalty; other centres are zero.
Impose the sign-oriented magnitude floor \(\max(0, |b_{OLS}| - n_{std} SE_{HAC})\), using a Bartlett HAC standard error. The production settings are
bound_n_std=1.0, one lag for quarterly inputs, and three lags for monthly inputs. A negative slope imposes an upper bound at minus the floor.Apply the caller’s sign declarations and retain
reg_lambda=1e-5. At this penalty strength the centre has little weight and a positive floor can bind.
This workflow fragment assumes aligned quarterly asset returns and excess factor returns with the named columns. Signs are caller policy, not package defaults:
from privateassets.matf import ExpertPrior, SignConstraint, fit_factor_betas
signs = {
'Equity': SignConstraint.POS, 'Rates': SignConstraint.POS,
'Credit': SignConstraint.POS, 'Carry': SignConstraint.POS,
'Private Equity': SignConstraint.POS, 'Rates Vol': SignConstraint.NEG,
'Inflation': SignConstraint.FREE, 'Commodities': SignConstraint.FREE,
'Fx': SignConstraint.FREE,
}
prior = ExpertPrior('Private Equity', bound_n_std=1.0, hac_lags=1)
fit = fit_factor_betas(quarterly_returns, quarterly_factor_returns,
sign_constraints=signs, expert_prior=prior,
prior_beta=None, span=None)
print(fit.prior)
Expert-prior fits require a complete common observation grid with unique ascending
indices and regularly spaced dates. Leading and trailing unavailable history is
trimmed; internal missing rows or values raise ValueError. This prevents a
missing quarter or month from becoming an adjacent observation in the HAC
calculation. The same check applies to estimator and bootstrap calls. Fits without
an expert prior retain their existing complete-case handling.
FREE maps to NaN in factorlasso, ZERO to zero. FREE supplies no explicit sign;
the default automatic sign layer can still restrict the loading. A conflicting
explicit sign takes precedence over the expert floor. Finite prior_beta
entries override centres; overriding the selected factor leaves that prior soft
and suppresses its floor. Unavailable standard errors are NaN and unavailable
observation counts are None. PriorDiagnostics.centre always refers to the
raw OLS slope, including when an override changes the effective penalty centre.
The floor is a univariate uncertainty statement, not identification of the
multifactor loading or a confidence bound for it. Fitting remains in-sample.
Pass the same prior to estimate_matf_alpha with fit_kwargs={'span': None}
and num_bootstrap to include per-draw re-estimation of the centre and floor.
The bootstrap defaults to equal weights; using an explicit common span keeps its
point fit aligned with the original fit. This interval is conditional on the
supplied return series and factor choice. It does not include unsmoothing or
factor-selection uncertainty.
FactorBetas.n_obs retains the legacy aligned-row count minus warmup_period;
its descriptive covariance and means use that truncated window. Under equal
weights factorlasso uses all aligned rows. fit.prior.n_obs reports the actual
expert-regression count when available, so those two counts can differ.
Pricing with the multi-factor deflator¶
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.