privateassets 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ ## 0.0.1 (2026-06)
4
+
5
+ Initial release of the private-asset analytics package.
6
+
7
+ - `matf`: multi-factor, money-weighted PME. Tradable multi-factor deflator,
8
+ KS-PME / Direct Alpha / Long-Nickels, panel-MLE unsmoothing, rolling EWMA
9
+ covariance, sign-coherent shrinkage betas, and the MATF Direct Alpha pipeline.
10
+ - 14 illustration scripts under `matf/illustrations/` that regenerate the
11
+ precursor figures via `python -m privateassets.matf <id>`.
12
+ - Built on `qis` and `factorlasso`. No dependency on `optimalportfolios`
13
+ (sibling, not child).
14
+ - Flat package layout at the repository root, matching the `optimalportfolios`
15
+ house style.
@@ -0,0 +1,17 @@
1
+ # Data — licensed, not committed
2
+
3
+ **No data files are stored in this repository.** Every input is obtained under
4
+ licence and placed in `data/` locally; `data/` is git-ignored in full. The public
5
+ package must never contain vendor or LP data, nor any derived file from which it
6
+ could be reconstructed.
7
+
8
+ | Source | Used by | Granularity | Publication status |
9
+ |---|---|---|---|
10
+ | **MSCI Private Capital — CFO dataset** | JFQA paper, SAA/CMA | Cohort (strategy × vintage), 1990+ | Aggregated derived results publishable **with MSCI attribution + standard pre-publication review** |
11
+ | **Preqin (BlackRock) — fund-level cash flows** | FAJ paper, selection | Named fund-level | Publication terms **pending** (Boris Hamm / BlackRock) — confirm before use |
12
+ | **Oaktree / Crown LP cash flows** | Precursor draft only | Fund-level | **Internal only — never publishable** |
13
+ | MATF factor levels, rate series | all | — | LGT-internal; not for public release |
14
+
15
+ Rule of thumb: code is public, data is not. Before committing anything, confirm
16
+ it contains no vendor records and no intermediate file from which they could be
17
+ recovered.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Artur Sepp
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,11 @@
1
+ include README.md
2
+ include DATA_README.md
3
+ include LICENSE.txt
4
+ include CHANGELOG.md
5
+ recursive-include privateassets *.py
6
+ prune data
7
+ prune outputs
8
+ prune projects
9
+ prune paper_code
10
+ global-exclude *.py[cod]
11
+ global-exclude __pycache__
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.4
2
+ Name: privateassets
3
+ Version: 0.1.0
4
+ Summary: Quantitative analytics for private-asset returns: multi-factor money-weighted PME (MATF), unsmoothing, and risk-adjusted performance estimation.
5
+ Author: Artur Sepp
6
+ Project-URL: Homepage, https://github.com/ArturSepp/privateassets
7
+ Keywords: private equity,private credit,private assets,PME,direct alpha,risk-adjusted returns,unsmoothing
8
+ Requires-Python: >=3.11
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE.txt
11
+ Requires-Dist: numpy
12
+ Requires-Dist: pandas
13
+ Requires-Dist: scipy
14
+ Requires-Dist: matplotlib
15
+ Requires-Dist: cvxpy
16
+ Requires-Dist: openpyxl
17
+ Requires-Dist: qis
18
+ Requires-Dist: factorlasso
19
+ Dynamic: license-file
20
+
21
+ # privateassets
22
+
23
+ Quantitative analytics for **private-asset returns** — private equity, private
24
+ credit, and other private-market strategies.
25
+
26
+ Core method: **MATF** — a multi-factor, money-weighted PME that estimates
27
+ risk-adjusted alpha and systematic factor exposures (β) directly from private-asset
28
+ cash flows, generalising Direct Alpha / KS-PME / GPME from a single benchmark to a
29
+ multi-factor deflator. Additional analytics (unsmoothing, volatility/beta
30
+ estimation) currently live within `matf` and may be promoted to top-level
31
+ submodules as they generalise.
32
+
33
+ Built on [`qis`](https://pypi.org/project/qis/) and
34
+ [`factorlasso`](https://pypi.org/project/factorlasso/); it does **not** depend on
35
+ `optimalportfolios` (sibling, not child).
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ pip install privateassets # once published
41
+ # or, from source:
42
+ pip install -e .
43
+ ```
44
+
45
+ ```python
46
+ import privateassets
47
+ from privateassets.matf import ... # estimator API
48
+ ```
49
+
50
+ ## Layout
51
+
52
+ ```
53
+ privateassets/ # MATF estimator engine (deflator, PME, panel-MLE
54
+ │ ├── __init__.py # unsmoothing, rolling covar, shrinkage, pipeline)
55
+ │ └── matf/
56
+ │ └── illustrations/ # 14 figure scripts (python -m privateassets.matf <id>)
57
+ scripts/ # reproduction runners
58
+ paper_code/ # publication tracks: jfqa_matf_pme, faj_application (unwritten)
59
+ data/ # git-ignored, private: licensed inputs (see DATA_README.md)
60
+ outputs/ # git-ignored: generated artifacts
61
+ projects/ # git-ignored, private: mandate engagements (not part of the package)
62
+ ```
63
+
64
+ ## Papers
65
+
66
+ `paper_code/` holds the clean, public publication tracks. The JFQA paper
67
+ (estimator + theory, MSCI cohort data) and the FAJ paper (fund-level application,
68
+ Preqin data) are not yet written. The precursor Oaktree manuscript runs on
69
+ proprietary LP data and is kept in the private `projects/` tree, out of this
70
+ public package.
71
+
72
+ ## Data
73
+
74
+ No data is stored in this repository. All vendor / LP data is obtained under
75
+ licence and placed in `data/` (git-ignored). See `DATA_README.md` for the
76
+ per-source licensing rules before adding or committing anything.
@@ -0,0 +1,56 @@
1
+ # privateassets
2
+
3
+ Quantitative analytics for **private-asset returns** — private equity, private
4
+ credit, and other private-market strategies.
5
+
6
+ Core method: **MATF** — a multi-factor, money-weighted PME that estimates
7
+ risk-adjusted alpha and systematic factor exposures (β) directly from private-asset
8
+ cash flows, generalising Direct Alpha / KS-PME / GPME from a single benchmark to a
9
+ multi-factor deflator. Additional analytics (unsmoothing, volatility/beta
10
+ estimation) currently live within `matf` and may be promoted to top-level
11
+ submodules as they generalise.
12
+
13
+ Built on [`qis`](https://pypi.org/project/qis/) and
14
+ [`factorlasso`](https://pypi.org/project/factorlasso/); it does **not** depend on
15
+ `optimalportfolios` (sibling, not child).
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pip install privateassets # once published
21
+ # or, from source:
22
+ pip install -e .
23
+ ```
24
+
25
+ ```python
26
+ import privateassets
27
+ from privateassets.matf import ... # estimator API
28
+ ```
29
+
30
+ ## Layout
31
+
32
+ ```
33
+ privateassets/ # MATF estimator engine (deflator, PME, panel-MLE
34
+ │ ├── __init__.py # unsmoothing, rolling covar, shrinkage, pipeline)
35
+ │ └── matf/
36
+ │ └── illustrations/ # 14 figure scripts (python -m privateassets.matf <id>)
37
+ scripts/ # reproduction runners
38
+ paper_code/ # publication tracks: jfqa_matf_pme, faj_application (unwritten)
39
+ data/ # git-ignored, private: licensed inputs (see DATA_README.md)
40
+ outputs/ # git-ignored: generated artifacts
41
+ projects/ # git-ignored, private: mandate engagements (not part of the package)
42
+ ```
43
+
44
+ ## Papers
45
+
46
+ `paper_code/` holds the clean, public publication tracks. The JFQA paper
47
+ (estimator + theory, MSCI cohort data) and the FAJ paper (fund-level application,
48
+ Preqin data) are not yet written. The precursor Oaktree manuscript runs on
49
+ proprietary LP data and is kept in the private `projects/` tree, out of this
50
+ public package.
51
+
52
+ ## Data
53
+
54
+ No data is stored in this repository. All vendor / LP data is obtained under
55
+ licence and placed in `data/` (git-ignored). See `DATA_README.md` for the
56
+ per-source licensing rules before adding or committing anything.
@@ -0,0 +1,11 @@
1
+ """privateassets — quantitative analytics for private-asset returns.
2
+
3
+ Core method: MATF — multi-factor, money-weighted PME (risk-adjusted alpha and
4
+ factor exposures from private-asset cash flows). Additional analytics
5
+ (unsmoothing, volatility/beta estimation) currently live under `matf` and may
6
+ be promoted to top-level submodules as they generalise.
7
+
8
+ Built on `qis` and `factorlasso`.
9
+ """
10
+
11
+ __version__ = "0.0.1"
@@ -0,0 +1,47 @@
1
+ """
2
+ privateassets.matf — multi-factor, money-weighted PME.
3
+
4
+ Estimates risk-adjusted alpha and systematic factor exposures (beta) directly
5
+ from private-asset cash flows. Generalises Direct Alpha, KS-PME and GPME from a
6
+ single benchmark to a tradable multi-factor deflator, with sign-coherent
7
+ shrinkage betas (factorlasso / HCGL), panel-MLE AR(1) unsmoothing, and
8
+ block-bootstrap inference.
9
+
10
+ Modules
11
+ -------
12
+ _pme.py KS-PME, Direct Alpha, Long-Nickels, and XIRR
13
+ _pipeline.py core estimator: shrinkage betas, deflator, Direct Alpha ladder
14
+ _deflator.py rolling-Sigma multi-factor deflator
15
+ _unsmooth.py fixed-theta AR(1) Getmansky-Lo-Makarov unsmoothing
16
+ _panel_mle.py panel MLE for the AR(1) unsmoothing coefficient
17
+ _rolling_covar.py qis-canonical rolling EWMA factor covariance
18
+
19
+ Data and outputs
20
+ ----------------
21
+ Run from the repository root. Inputs are read from data/ and results written to
22
+ outputs/, both resolved against the current working directory (override with
23
+ PRIVATEASSETS_ROOT). Input data is licensed and is never shipped with the
24
+ package. See DATA_README.md.
25
+
26
+ Built on qis and factorlasso. No dependency on optimalportfolios.
27
+ """
28
+ from pathlib import Path
29
+ import os
30
+
31
+ # Repo root: where data/ and outputs/ live. Default is the current working
32
+ # directory (run the CLI from the repo root); override with PRIVATEASSETS_ROOT.
33
+ # Never resolved relative to the installed package, so a pip-installed copy
34
+ # does not read from or write into site-packages.
35
+ PROJECT_ROOT = Path(os.environ.get("PRIVATEASSETS_ROOT", os.getcwd())).resolve()
36
+ DATA_DIR = PROJECT_ROOT / 'data'
37
+ OUTPUT_DIR = PROJECT_ROOT / 'outputs'
38
+
39
+ # Ensure outputs dir exists at import time
40
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
41
+
42
+ # Convenience handles to the three data files
43
+ DATA_XLSX = DATA_DIR / 'data.xlsx'
44
+ FACTORS_CSV = DATA_DIR / 'factors.csv'
45
+ RF_CSV = DATA_DIR / 'rf_rate.csv'
46
+
47
+ __version__ = "0.0.1"
@@ -0,0 +1,187 @@
1
+ """
2
+ Rolling-Sigma multi-factor deflator.
3
+
4
+ Builds the multi-factor PME deflator
5
+ R^b_h = exp{ rf*h + beta'r_h + (1/2)(beta'diag(Sigma_h) - beta'Sigma_h beta) }
6
+ with a time-varying factor covariance Sigma_h. At each quarter-end the
7
+ covariance is an EWMA estimate on a trailing window of monthly factor returns
8
+ (span 36 months), so the deflator at a cash flow date uses only information
9
+ available by that date.
10
+
11
+ Specification:
12
+ - Monthly factor returns: end-of-month level to log excess return
13
+ - EWMA span: 36 months (about a 3-year half-life)
14
+ - Quarterly covariance: monthly Sigma times 3
15
+ - Burn-in: 36 months before the first cash flow date
16
+
17
+ Author: A. Sepp / the desk
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from typing import Dict
22
+
23
+ import numpy as np
24
+ import pandas as pd
25
+ from factorlasso import compute_ewm_covar
26
+
27
+ from privateassets.matf._pipeline import (
28
+ cf_with_terminal_for_vintage,
29
+ vintage_da_fast,
30
+ )
31
+
32
+
33
+ def factor_monthly_log_returns(levels_daily: pd.DataFrame) -> pd.DataFrame:
34
+ """End-of-month log returns from daily levels."""
35
+ me = levels_daily.resample('ME').last()
36
+ return np.log(me / me.shift(1)).dropna(how='all')
37
+
38
+
39
+ def rolling_ewma_quarterly_covar(
40
+ levels_daily: pd.DataFrame,
41
+ quarter_ends: pd.DatetimeIndex,
42
+ span_months: int = 36,
43
+ burnin_months: int = 36,
44
+ ) -> Dict[pd.Timestamp, np.ndarray]:
45
+ """
46
+ Build a dict mapping each quarter-end to an EWMA covariance matrix
47
+ estimated on monthly factor returns with span = span_months.
48
+
49
+ The returned Σ are SCALED TO A QUARTERLY HORIZON: monthly Σ × 3
50
+ (the standard variance-time-scaling under iid). This is consistent
51
+ with how the deflator uses Σ_h (h = 1 quarter for adjacent cash flow
52
+ dates within the same quarter).
53
+ """
54
+ monthly_log = factor_monthly_log_returns(levels_daily)
55
+
56
+ sigmas = {}
57
+ for q in quarter_ends:
58
+ # Monthly returns up to and including this quarter end
59
+ m_end = q.to_period('M').to_timestamp(how='end').normalize()
60
+ window = monthly_log.loc[:m_end].dropna(how='any')
61
+ if len(window) < burnin_months:
62
+ continue
63
+ # EWMA covariance on monthly excess log returns, demean within the window
64
+ Xv = window.values
65
+ Xv_dm = Xv - Xv.mean(axis=0)
66
+ sigma_monthly = compute_ewm_covar(a=Xv_dm, span=span_months)
67
+ sigma_quarterly = sigma_monthly * 3.0 # variance-time scaling
68
+ sigmas[q] = sigma_quarterly
69
+ return sigmas
70
+
71
+
72
+ def matf_deflator_rolling(
73
+ cf_dates: list,
74
+ t0: pd.Timestamp,
75
+ cum_log_factor_q: np.ndarray, # (T, M) cumulative log factor returns at QE
76
+ cum_log_rf_q: np.ndarray, # (T,) cumulative log(1+rf) at QE
77
+ quarters_q: pd.DatetimeIndex,
78
+ quarters_ns: np.ndarray,
79
+ beta_vec: np.ndarray,
80
+ sigma_h_dict: Dict[pd.Timestamp, np.ndarray],
81
+ sigma_h_default: np.ndarray,
82
+ ) -> np.ndarray:
83
+ """
84
+ Deflator with quarter-specific Σ_h pulled from sigma_h_dict.
85
+ For each cash flow date d, find the most recent quarter-end ≤ d,
86
+ look up Σ_h for that quarter (or use sigma_h_default if missing
87
+ due to burn-in), then evaluate Eq 3.
88
+ """
89
+ cf_ns = np.array([pd.Timestamp(t).value for t in cf_dates], dtype=np.int64)
90
+ t0_ns = pd.Timestamp(t0).value
91
+ n_q = len(quarters_ns)
92
+
93
+ idx_t = np.clip(np.searchsorted(quarters_ns, cf_ns, side='right') - 1,
94
+ 0, n_q - 1)
95
+ idx_t0 = int(np.clip(np.searchsorted(quarters_ns, t0_ns, side='right') - 1,
96
+ 0, n_q - 1))
97
+
98
+ rh = cum_log_factor_q[idx_t] - cum_log_factor_q[idx_t0] # (n_cf, M)
99
+ rfh = cum_log_rf_q[idx_t] - cum_log_rf_q[idx_t0] # (n_cf,)
100
+
101
+ h_years = np.array([max((pd.Timestamp(t) - pd.Timestamp(t0)).days, 1) / 365.25
102
+ for t in cf_dates])
103
+ h_q = h_years * 4.0
104
+
105
+ out = np.empty(len(cf_dates), dtype=float)
106
+ for j, t in enumerate(cf_dates):
107
+ q_end = quarters_q[idx_t[j]]
108
+ sigma = sigma_h_dict.get(q_end, sigma_h_default)
109
+ diag_h = np.diag(sigma)
110
+ quad = float(beta_vec @ sigma @ beta_vec)
111
+ rb_j = (rfh[j]
112
+ + rh[j] @ beta_vec
113
+ + 0.5 * h_q[j] * (beta_vec @ diag_h)
114
+ - 0.5 * h_q[j] * quad)
115
+ out[j] = np.exp(rb_j)
116
+ return out
117
+
118
+
119
+ def per_vintage_da_with_rolling_covar(
120
+ cf: pd.DataFrame,
121
+ navs: pd.DataFrame,
122
+ asof: pd.Timestamp,
123
+ cum_log_factor_q: np.ndarray,
124
+ cum_log_rf_q: np.ndarray,
125
+ quarters_q: pd.DatetimeIndex,
126
+ quarters_ns: np.ndarray,
127
+ beta_arr: np.ndarray,
128
+ sigma_h_dict: Dict[pd.Timestamp, np.ndarray],
129
+ sigma_h_default: np.ndarray,
130
+ ) -> pd.DataFrame:
131
+ rows = []
132
+ for v in sorted(cf['vintage_label'].unique()):
133
+ cf_g = cf[cf['vintage_label'] == v]
134
+ nav_g = navs[navs['vintage_label'] == v]
135
+ cf_v, rvpi_nav, _, dates = cf_with_terminal_for_vintage(cf_g, nav_g, asof)
136
+ defl = matf_deflator_rolling(
137
+ dates, dates[0], cum_log_factor_q, cum_log_rf_q,
138
+ quarters_q, quarters_ns, beta_arr, sigma_h_dict, sigma_h_default)
139
+ da = vintage_da_fast(cf_v, rvpi_nav, dates, defl)
140
+ contrib = -cf_v.loc[cf_v['amount'] < 0, 'amount'].sum()
141
+ distrib = cf_v.loc[cf_v['amount'] > 0, 'amount'].sum()
142
+ dpi = distrib / contrib if contrib > 0 else float('nan')
143
+ rows.append({'vintage_label': v, 'contribution': contrib, 'DPI': dpi,
144
+ 'DA': da})
145
+ return pd.DataFrame(rows)
146
+
147
+
148
+ def per_vintage_da_with_constant_covar(
149
+ cf: pd.DataFrame,
150
+ navs: pd.DataFrame,
151
+ asof: pd.Timestamp,
152
+ cum_log_factor_q: np.ndarray,
153
+ cum_log_rf_q: np.ndarray,
154
+ quarters_q: pd.DatetimeIndex,
155
+ quarters_ns: np.ndarray,
156
+ beta_arr: np.ndarray,
157
+ sigma_h_const: np.ndarray,
158
+ ) -> pd.DataFrame:
159
+ rows = []
160
+ diag_h_q = np.diag(sigma_h_const)
161
+ quad = float(beta_arr @ sigma_h_const @ beta_arr)
162
+ for v in sorted(cf['vintage_label'].unique()):
163
+ cf_g = cf[cf['vintage_label'] == v]
164
+ nav_g = navs[navs['vintage_label'] == v]
165
+ cf_v, rvpi_nav, _, dates = cf_with_terminal_for_vintage(cf_g, nav_g, asof)
166
+ cf_ns = np.array([pd.Timestamp(t).value for t in dates], dtype=np.int64)
167
+ n_q = len(quarters_ns)
168
+ idx_t = np.clip(np.searchsorted(quarters_ns, cf_ns, side='right') - 1,
169
+ 0, n_q - 1)
170
+ idx_t0 = idx_t[0]
171
+ rh = cum_log_factor_q[idx_t] - cum_log_factor_q[idx_t0]
172
+ rfh = cum_log_rf_q[idx_t] - cum_log_rf_q[idx_t0]
173
+ h_years = np.array([
174
+ max((pd.Timestamp(t) - pd.Timestamp(dates[0])).days, 1) / 365.25
175
+ for t in dates])
176
+ h_q = h_years * 4.0
177
+ rb = (rfh + rh @ beta_arr
178
+ + 0.5 * h_q * (beta_arr @ diag_h_q)
179
+ - 0.5 * h_q * quad)
180
+ defl = np.exp(rb)
181
+ da = vintage_da_fast(cf_v, rvpi_nav, dates, defl)
182
+ contrib = -cf_v.loc[cf_v['amount'] < 0, 'amount'].sum()
183
+ distrib = cf_v.loc[cf_v['amount'] > 0, 'amount'].sum()
184
+ dpi = distrib / contrib if contrib > 0 else float('nan')
185
+ rows.append({'vintage_label': v, 'contribution': contrib, 'DPI': dpi,
186
+ 'DA': da})
187
+ return pd.DataFrame(rows)
@@ -0,0 +1,126 @@
1
+ """
2
+ Panel MLE for common AR(1) coefficient across vintages.
3
+
4
+ Model: each vintage i has quarterly NAV-implied returns r_{i,t} that
5
+ follow:
6
+ r_{i,t} = θ · r_{i,t-1} + ε_{i,t}, ε_{i,t} ~ N(0, σ_i²)
7
+
8
+ with COMMON AR coefficient θ shared across vintages, but idiosyncratic
9
+ residual variances σ_i².
10
+
11
+ Profile-out variances and reduce to a 1-D optimization in θ. With
12
+ σ_i² profiled at MLE σ̂_i²(θ) = mean_t (r_{i,t} - θ r_{i,t-1})², the
13
+ profile log-likelihood for θ is:
14
+
15
+ L_p(θ) = -(1/2) Σ_i n_i log σ̂_i²(θ) + const
16
+
17
+ Maximize over θ ∈ (-1, 1).
18
+
19
+ Compare to:
20
+ - Production pooled AR(1): θ_pooled = 0.142
21
+ - Per-vintage average: avg θ_i = 0.136
22
+
23
+ Author: A. Sepp / the desk
24
+ """
25
+ from __future__ import annotations
26
+ from typing import Dict
27
+ import numpy as np
28
+ from scipy.optimize import minimize_scalar
29
+
30
+
31
+ def panel_ar1_neg_log_likelihood(
32
+ theta: float,
33
+ series_list: list, # list of (label, np.ndarray) per vintage, demeaned
34
+ ) -> float:
35
+ """
36
+ Profile-out negative log-likelihood for panel AR(1) with common θ
37
+ and per-vintage idiosyncratic σ_i². Series are pre-demeaned.
38
+
39
+ Returns: -L_p(θ) where L_p is the profile log-likelihood.
40
+ """
41
+ nll = 0.0
42
+ for item in series_list:
43
+ r = item[1] if isinstance(item, tuple) else item
44
+ if len(r) < 3:
45
+ continue
46
+ eps = r[1:] - theta * r[:-1]
47
+ n_i = len(eps)
48
+ sigma2_hat = np.mean(eps ** 2)
49
+ if sigma2_hat <= 0:
50
+ return np.inf
51
+ nll += 0.5 * n_i * np.log(sigma2_hat)
52
+ return nll
53
+
54
+
55
+ def panel_ar1_grad(theta: float, series_list: list) -> float:
56
+ """Numerical derivative of NLL wrt theta (used for diagnostics)."""
57
+ eps = 1e-6
58
+ return (panel_ar1_neg_log_likelihood(theta + eps, series_list)
59
+ - panel_ar1_neg_log_likelihood(theta - eps, series_list)) / (2 * eps)
60
+
61
+
62
+ def fisher_info_panel_ar1(theta_hat: float, series_list: list) -> float:
63
+ """
64
+ Approximate Fisher information for theta from the panel.
65
+ For AR(1) with σ_i² known, the per-obs Fisher info is
66
+ Var(r_{i,t-1}) / σ_i². With σ_i² profiled at MLE this becomes
67
+ n_i · Var(r_{i,t-1}) / sum((r_{i,t} - θ r_{i,t-1})²).
68
+ """
69
+ fisher = 0.0
70
+ for item in series_list:
71
+ # series_list is list of (label, array)
72
+ r = item[1] if isinstance(item, tuple) else item
73
+ if len(r) < 3:
74
+ continue
75
+ eps = r[1:] - theta_hat * r[:-1]
76
+ sigma2_hat = np.mean(eps ** 2)
77
+ if sigma2_hat <= 0:
78
+ continue
79
+ var_lag = np.var(r[:-1])
80
+ n_i = len(eps)
81
+ fisher += n_i * var_lag / sigma2_hat
82
+ return fisher
83
+
84
+
85
+ def fit_panel_ar1(series_list: list) -> Dict:
86
+ """
87
+ Fit common-θ panel AR(1) by profile MLE, return θ_hat and
88
+ asymptotic standard error.
89
+ """
90
+ res = minimize_scalar(
91
+ panel_ar1_neg_log_likelihood,
92
+ args=(series_list,),
93
+ bounds=(-0.95, 0.95),
94
+ method='bounded',
95
+ options={'xatol': 1e-6},
96
+ )
97
+ theta_hat = float(res.x)
98
+ fisher = fisher_info_panel_ar1(theta_hat, series_list)
99
+ se = float(1.0 / np.sqrt(fisher)) if fisher > 0 else float('nan')
100
+
101
+ # Also compute per-vintage MLE for comparison
102
+ per_vintage = {}
103
+ for v, r in series_list:
104
+ if len(r) < 5:
105
+ per_vintage[v] = None
106
+ continue
107
+ # Per-vintage AR(1) MLE = OLS of r_t on r_{t-1}
108
+ y = r[1:]; x = r[:-1]
109
+ if np.var(x) < 1e-10:
110
+ per_vintage[v] = None
111
+ continue
112
+ theta_i = float(np.cov(y, x)[0, 1] / np.var(x))
113
+ per_vintage[v] = theta_i
114
+
115
+ # n_total
116
+ n_total = sum(max(0, len(r) - 1) for _, r in series_list)
117
+
118
+ return {
119
+ 'theta_hat': theta_hat,
120
+ 'se': se,
121
+ 'neg_log_lik': float(res.fun),
122
+ 'fisher_info': fisher,
123
+ 'per_vintage_theta': per_vintage,
124
+ 'n_total': n_total,
125
+ 'n_vintages': len([1 for _, r in series_list if len(r) >= 5]),
126
+ }