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:

  1. 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.

  2. 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.

  3. 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.