portlab 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.
- portlab-0.1.0/CITATION.cff +11 -0
- portlab-0.1.0/LICENSE +21 -0
- portlab-0.1.0/MANIFEST.in +2 -0
- portlab-0.1.0/PKG-INFO +104 -0
- portlab-0.1.0/README.md +79 -0
- portlab-0.1.0/VALIDATION.md +118 -0
- portlab-0.1.0/pyproject.toml +37 -0
- portlab-0.1.0/setup.cfg +4 -0
- portlab-0.1.0/src/portlab/__init__.py +24 -0
- portlab-0.1.0/src/portlab/attribution.py +111 -0
- portlab-0.1.0/src/portlab/backtest/__init__.py +11 -0
- portlab-0.1.0/src/portlab/backtest/portfolio.py +393 -0
- portlab-0.1.0/src/portlab/backtest/rolling.py +97 -0
- portlab-0.1.0/src/portlab/backtest/stress.py +74 -0
- portlab-0.1.0/src/portlab/config.py +24 -0
- portlab-0.1.0/src/portlab/covariance.py +66 -0
- portlab-0.1.0/src/portlab/metrics.py +455 -0
- portlab-0.1.0/src/portlab/optimize/__init__.py +27 -0
- portlab-0.1.0/src/portlab/optimize/auglag.py +170 -0
- portlab-0.1.0/src/portlab/optimize/black_litterman.py +52 -0
- portlab-0.1.0/src/portlab/optimize/constrained.py +45 -0
- portlab-0.1.0/src/portlab/optimize/constraint_attribution.py +93 -0
- portlab-0.1.0/src/portlab/optimize/core.py +88 -0
- portlab-0.1.0/src/portlab/optimize/cvar.py +195 -0
- portlab-0.1.0/src/portlab/optimize/fista.py +223 -0
- portlab-0.1.0/src/portlab/optimize/gurobi_backend.py +186 -0
- portlab-0.1.0/src/portlab/optimize/hrp.py +64 -0
- portlab-0.1.0/src/portlab/optimize/meanvar.py +285 -0
- portlab-0.1.0/src/portlab/optimize/objectives.py +160 -0
- portlab-0.1.0/src/portlab/optimize/osqp_backend.py +114 -0
- portlab-0.1.0/src/portlab/optimize/resampled.py +46 -0
- portlab-0.1.0/src/portlab/optimize/riskparity.py +37 -0
- portlab-0.1.0/src/portlab/optimize/robust.py +110 -0
- portlab-0.1.0/src/portlab/py.typed +0 -0
- portlab-0.1.0/src/portlab/rebalance.py +54 -0
- portlab-0.1.0/src/portlab/returns.py +70 -0
- portlab-0.1.0/src/portlab/risk/__init__.py +36 -0
- portlab-0.1.0/src/portlab/risk/backtest.py +356 -0
- portlab-0.1.0/src/portlab/risk/regime.py +114 -0
- portlab-0.1.0/src/portlab/risk/tail.py +233 -0
- portlab-0.1.0/src/portlab/risk/var.py +394 -0
- portlab-0.1.0/src/portlab.egg-info/PKG-INFO +104 -0
- portlab-0.1.0/src/portlab.egg-info/SOURCES.txt +56 -0
- portlab-0.1.0/src/portlab.egg-info/dependency_links.txt +1 -0
- portlab-0.1.0/src/portlab.egg-info/requires.txt +11 -0
- portlab-0.1.0/src/portlab.egg-info/top_level.txt +1 -0
- portlab-0.1.0/tests/conftest.py +60 -0
- portlab-0.1.0/tests/test_attribution.py +95 -0
- portlab-0.1.0/tests/test_audit_fixes.py +71 -0
- portlab-0.1.0/tests/test_backtest.py +97 -0
- portlab-0.1.0/tests/test_constraint_attribution.py +102 -0
- portlab-0.1.0/tests/test_cvar_opt.py +74 -0
- portlab-0.1.0/tests/test_metrics.py +65 -0
- portlab-0.1.0/tests/test_optimize.py +121 -0
- portlab-0.1.0/tests/test_regime.py +120 -0
- portlab-0.1.0/tests/test_risk_backtest.py +360 -0
- portlab-0.1.0/tests/test_robust.py +117 -0
- portlab-0.1.0/tests/test_var_extensions.py +213 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
cff-version: 1.2.0
|
|
2
|
+
message: "If you use portlab in research, please cite it as below."
|
|
3
|
+
title: "portlab: Portfolio risk and optimization library"
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
date-released: 2026-10-12
|
|
6
|
+
license: MIT
|
|
7
|
+
url: "https://github.com/Realosunboy6/portlab"
|
|
8
|
+
authors:
|
|
9
|
+
- family-names: "Oyeyinka"
|
|
10
|
+
given-names: "Ibrahim"
|
|
11
|
+
affiliation: "Northern Illinois University"
|
portlab-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ibrahim Oyeyinka
|
|
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.
|
portlab-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: portlab
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Portfolio risk and optimization library — VaR/ES estimation, VaR backtesting, tail diagnostics, and portfolio optimization with a documented validation record.
|
|
5
|
+
Author: Ibrahim Oyeyinka
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Realosunboy6/portlab
|
|
8
|
+
Keywords: portfolio,risk,var,backtesting,optimization,finance
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: numpy>=1.24
|
|
16
|
+
Requires-Dist: pandas>=2.0
|
|
17
|
+
Requires-Dist: scipy>=1.10
|
|
18
|
+
Requires-Dist: scikit-learn>=1.3
|
|
19
|
+
Provides-Extra: opt
|
|
20
|
+
Requires-Dist: cvxpy>=1.4; extra == "opt"
|
|
21
|
+
Provides-Extra: test
|
|
22
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
23
|
+
Requires-Dist: arch; extra == "test"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# portlab
|
|
27
|
+
|
|
28
|
+
A portfolio risk and optimization library for Python, built around one
|
|
29
|
+
principle: **a risk number you cannot backtest is an opinion.** Every
|
|
30
|
+
estimator here is cross-checked against an independent implementation
|
|
31
|
+
and ships with the tests that pin its conventions down — see
|
|
32
|
+
[VALIDATION.md](VALIDATION.md) for the verification record, including
|
|
33
|
+
the defects that process found and fixed.
|
|
34
|
+
|
|
35
|
+
## What's inside
|
|
36
|
+
|
|
37
|
+
| Area | Modules | Highlights |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| Metrics | `portlab.metrics`, `portlab.returns` | CAGR, vol, Sharpe/Sortino/Calmar, drawdowns, rolling stats |
|
|
40
|
+
| VaR / ES | `portlab.risk.var` | Historical, parametric (normal / Student-t / Cornish–Fisher), EWMA, GARCH(1,1)-filtered VaR and Expected Shortfall, with explicit mean conventions (`mean="zero" \| "constant"`) |
|
|
41
|
+
| VaR backtesting | `portlab.risk.backtest` | Exception series with a nullable-boolean contract (unscored days are `<NA>`, never silently passed), Kupiec POF, Christoffersen independence (gap-aware: transitions never join across data holes), joint conditional-coverage test, Basel traffic light |
|
|
42
|
+
| Tail diagnostics | `portlab.risk.tail` | Jarque–Bera, Hill tail index, GPD extreme VaR/TVaR |
|
|
43
|
+
| Regime | `portlab.risk.regime` | HMM-style regime detection, EWMA vol, regime-conditional VaR |
|
|
44
|
+
| Optimization | `portlab.optimize` | Max-Sharpe / GMV / target-return frontiers, min-CVaR and min-CDaR (LP), Black–Litterman, HRP, risk parity, resampled efficiency, robust (box-uncertainty) weights, constraint attribution |
|
|
45
|
+
| Covariance | `portlab.covariance` | Sample, Ledoit–Wolf shrinkage, semicovariance |
|
|
46
|
+
| Backtesting | `portlab.backtest` | Portfolio backtests with rebalancing, rolling backtests, historical-episode stress |
|
|
47
|
+
| Attribution | `portlab.attribution` | Brinson allocation/selection/interaction |
|
|
48
|
+
|
|
49
|
+
## Conventions (read this before trusting a number)
|
|
50
|
+
|
|
51
|
+
- Inputs are **simple returns** (positive = gain). VaR/ES are reported
|
|
52
|
+
as **positive numbers = losses**. An exception is `returns < -VaR`.
|
|
53
|
+
- **`alpha` means two things, by module family** — in `portlab.risk.*`
|
|
54
|
+
it is the *tail probability* (`0.05` = 95% VaR); in
|
|
55
|
+
`portlab.metrics.*` and the CVaR optimizer it is the *confidence
|
|
56
|
+
level* (`0.95`). Every docstring states which.
|
|
57
|
+
- Rolling estimators use **forecast alignment**: the value stamped at
|
|
58
|
+
day *t* is built only from data strictly before *t*. Backtests score
|
|
59
|
+
exactly the stamped days — no look-ahead, no warmup padding.
|
|
60
|
+
- `exceptions()` returns a nullable-boolean Series. Days that cannot
|
|
61
|
+
be scored (VaR warmup, missing returns, gaps) are `<NA>`; Kupiec
|
|
62
|
+
counts only scored days and Christoffersen counts only transitions
|
|
63
|
+
between genuinely adjacent scored days.
|
|
64
|
+
|
|
65
|
+
## Install
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
pip install git+https://github.com/Realosunboy6/portlab.git
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Core dependencies: numpy, pandas, scipy, scikit-learn. The CVaR/CDaR
|
|
72
|
+
LP solvers use scipy; optional solver backends (cvxpy, OSQP, Gurobi)
|
|
73
|
+
are imported lazily when requested.
|
|
74
|
+
|
|
75
|
+
## Quick start
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
import pandas as pd
|
|
79
|
+
from portlab.risk import var, backtest
|
|
80
|
+
|
|
81
|
+
rets = pd.Series(...) # daily simple returns
|
|
82
|
+
v = var.historical_var(rets, alpha=0.05, window=250)
|
|
83
|
+
exc = backtest.exceptions(rets, v) # nullable boolean Series
|
|
84
|
+
print(backtest.kupiec_pof(exc.dropna().astype(bool), alpha=0.05))
|
|
85
|
+
print(backtest.christoffersen_ind(exc)) # gap-aware; do not dropna
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Scope
|
|
89
|
+
|
|
90
|
+
This is the public core of a larger private toolkit. Not included:
|
|
91
|
+
live portfolio monitoring, broker integrations, tax-lot optimization,
|
|
92
|
+
and forecasting/indicator tooling. The algorithms here are standard
|
|
93
|
+
textbook methods (Kupiec 1995; Christoffersen 1998; Rockafellar &
|
|
94
|
+
Uryasev 2000; Black & Litterman 1992) — the contribution is the
|
|
95
|
+
implementation discipline and the validation suite.
|
|
96
|
+
|
|
97
|
+
## License
|
|
98
|
+
|
|
99
|
+
MIT — see [LICENSE](LICENSE).
|
|
100
|
+
|
|
101
|
+
## Author
|
|
102
|
+
|
|
103
|
+
Ibrahim Oyeyinka — PhD candidate, Industrial & Systems Engineering,
|
|
104
|
+
Northern Illinois University.
|
portlab-0.1.0/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# portlab
|
|
2
|
+
|
|
3
|
+
A portfolio risk and optimization library for Python, built around one
|
|
4
|
+
principle: **a risk number you cannot backtest is an opinion.** Every
|
|
5
|
+
estimator here is cross-checked against an independent implementation
|
|
6
|
+
and ships with the tests that pin its conventions down — see
|
|
7
|
+
[VALIDATION.md](VALIDATION.md) for the verification record, including
|
|
8
|
+
the defects that process found and fixed.
|
|
9
|
+
|
|
10
|
+
## What's inside
|
|
11
|
+
|
|
12
|
+
| Area | Modules | Highlights |
|
|
13
|
+
|---|---|---|
|
|
14
|
+
| Metrics | `portlab.metrics`, `portlab.returns` | CAGR, vol, Sharpe/Sortino/Calmar, drawdowns, rolling stats |
|
|
15
|
+
| VaR / ES | `portlab.risk.var` | Historical, parametric (normal / Student-t / Cornish–Fisher), EWMA, GARCH(1,1)-filtered VaR and Expected Shortfall, with explicit mean conventions (`mean="zero" \| "constant"`) |
|
|
16
|
+
| VaR backtesting | `portlab.risk.backtest` | Exception series with a nullable-boolean contract (unscored days are `<NA>`, never silently passed), Kupiec POF, Christoffersen independence (gap-aware: transitions never join across data holes), joint conditional-coverage test, Basel traffic light |
|
|
17
|
+
| Tail diagnostics | `portlab.risk.tail` | Jarque–Bera, Hill tail index, GPD extreme VaR/TVaR |
|
|
18
|
+
| Regime | `portlab.risk.regime` | HMM-style regime detection, EWMA vol, regime-conditional VaR |
|
|
19
|
+
| Optimization | `portlab.optimize` | Max-Sharpe / GMV / target-return frontiers, min-CVaR and min-CDaR (LP), Black–Litterman, HRP, risk parity, resampled efficiency, robust (box-uncertainty) weights, constraint attribution |
|
|
20
|
+
| Covariance | `portlab.covariance` | Sample, Ledoit–Wolf shrinkage, semicovariance |
|
|
21
|
+
| Backtesting | `portlab.backtest` | Portfolio backtests with rebalancing, rolling backtests, historical-episode stress |
|
|
22
|
+
| Attribution | `portlab.attribution` | Brinson allocation/selection/interaction |
|
|
23
|
+
|
|
24
|
+
## Conventions (read this before trusting a number)
|
|
25
|
+
|
|
26
|
+
- Inputs are **simple returns** (positive = gain). VaR/ES are reported
|
|
27
|
+
as **positive numbers = losses**. An exception is `returns < -VaR`.
|
|
28
|
+
- **`alpha` means two things, by module family** — in `portlab.risk.*`
|
|
29
|
+
it is the *tail probability* (`0.05` = 95% VaR); in
|
|
30
|
+
`portlab.metrics.*` and the CVaR optimizer it is the *confidence
|
|
31
|
+
level* (`0.95`). Every docstring states which.
|
|
32
|
+
- Rolling estimators use **forecast alignment**: the value stamped at
|
|
33
|
+
day *t* is built only from data strictly before *t*. Backtests score
|
|
34
|
+
exactly the stamped days — no look-ahead, no warmup padding.
|
|
35
|
+
- `exceptions()` returns a nullable-boolean Series. Days that cannot
|
|
36
|
+
be scored (VaR warmup, missing returns, gaps) are `<NA>`; Kupiec
|
|
37
|
+
counts only scored days and Christoffersen counts only transitions
|
|
38
|
+
between genuinely adjacent scored days.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install git+https://github.com/Realosunboy6/portlab.git
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Core dependencies: numpy, pandas, scipy, scikit-learn. The CVaR/CDaR
|
|
47
|
+
LP solvers use scipy; optional solver backends (cvxpy, OSQP, Gurobi)
|
|
48
|
+
are imported lazily when requested.
|
|
49
|
+
|
|
50
|
+
## Quick start
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
import pandas as pd
|
|
54
|
+
from portlab.risk import var, backtest
|
|
55
|
+
|
|
56
|
+
rets = pd.Series(...) # daily simple returns
|
|
57
|
+
v = var.historical_var(rets, alpha=0.05, window=250)
|
|
58
|
+
exc = backtest.exceptions(rets, v) # nullable boolean Series
|
|
59
|
+
print(backtest.kupiec_pof(exc.dropna().astype(bool), alpha=0.05))
|
|
60
|
+
print(backtest.christoffersen_ind(exc)) # gap-aware; do not dropna
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Scope
|
|
64
|
+
|
|
65
|
+
This is the public core of a larger private toolkit. Not included:
|
|
66
|
+
live portfolio monitoring, broker integrations, tax-lot optimization,
|
|
67
|
+
and forecasting/indicator tooling. The algorithms here are standard
|
|
68
|
+
textbook methods (Kupiec 1995; Christoffersen 1998; Rockafellar &
|
|
69
|
+
Uryasev 2000; Black & Litterman 1992) — the contribution is the
|
|
70
|
+
implementation discipline and the validation suite.
|
|
71
|
+
|
|
72
|
+
## License
|
|
73
|
+
|
|
74
|
+
MIT — see [LICENSE](LICENSE).
|
|
75
|
+
|
|
76
|
+
## Author
|
|
77
|
+
|
|
78
|
+
Ibrahim Oyeyinka — PhD candidate, Industrial & Systems Engineering,
|
|
79
|
+
Northern Illinois University.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Validation record
|
|
2
|
+
|
|
3
|
+
How the numbers in this library were checked, what the checking found,
|
|
4
|
+
and what remains known-but-unfixed. All results below are from an
|
|
5
|
+
audit run in October 2026 on real market data (12 tickers, 1,954
|
|
6
|
+
trading days, 2019-01-02 → 2026-10-09), followed by five rounds of
|
|
7
|
+
independent adversarial verification of the VaR backtest stack.
|
|
8
|
+
|
|
9
|
+
## Method
|
|
10
|
+
|
|
11
|
+
- **Reference implementations:** PyPortfolioOpt and Riskfolio-Lib
|
|
12
|
+
(optimization, CVaR), `arch` (GARCH), statsmodels (ARIMA, factor
|
|
13
|
+
regressions), scikit-learn (Ledoit–Wolf), hmmlearn (regimes).
|
|
14
|
+
- **Independent reimplementation:** the backtest statistics were
|
|
15
|
+
re-derived from the primary sources (Kupiec 1995; Christoffersen
|
|
16
|
+
1998) and compared numerically, including an exhaustive sweep and
|
|
17
|
+
randomized differential testing (below).
|
|
18
|
+
- **Tests in this repo are synthetic by design** (seeded, no network).
|
|
19
|
+
The real-data anchors in this document are the regression targets
|
|
20
|
+
the synthetic suite's conventions were validated against.
|
|
21
|
+
|
|
22
|
+
## Defects found by verification — and fixed
|
|
23
|
+
|
|
24
|
+
1. **Kupiec warmup padding.** Exception counting once treated
|
|
25
|
+
NaN-warmup days (before the first VaR exists) as non-exceptions,
|
|
26
|
+
inflating *n* and biasing the test toward rejection: SPY LR 2.104
|
|
27
|
+
instead of 0.018. Measured exactly, the false-rejection rate at a
|
|
28
|
+
nominal 5% level was **26.9% padded vs 5.2% fixed**.
|
|
29
|
+
2. **One-observation look-ahead in rolling VaR.** The rolling helper
|
|
30
|
+
stamped each window's VaR on the window's last day, putting day
|
|
31
|
+
*t*'s own return inside its estimation window. Rolling estimators
|
|
32
|
+
now stamp forecasts: the value at *t* uses only data before *t*.
|
|
33
|
+
3. **Wrong restricted MLE in Christoffersen independence.** The
|
|
34
|
+
restricted π was computed as the exception rate over days instead
|
|
35
|
+
of over transition pairs, (n01 + n11) / total_pairs. On SPY this
|
|
36
|
+
moved LR_ind from 10.589804 to the correct **10.589773** — small
|
|
37
|
+
there, wrong in general.
|
|
38
|
+
4. **Silent passes on empty input.** Kupiec/Christoffersen once
|
|
39
|
+
returned LR = 0, p = 1.0 ("perfect pass") for a backtest with zero
|
|
40
|
+
scored days. They now raise (Kupiec) or return NaN with an
|
|
41
|
+
explanatory note (independence/joint, when exceptions exist but
|
|
42
|
+
their transitions are unidentified).
|
|
43
|
+
5. **Transitions joined across data holes.** Christoffersen counting
|
|
44
|
+
treated scored days separated by unscored gaps as adjacent. The
|
|
45
|
+
exception series is now nullable-boolean end-to-end; transitions
|
|
46
|
+
are counted only between genuinely adjacent scored pairs.
|
|
47
|
+
|
|
48
|
+
## Differential-testing evidence
|
|
49
|
+
|
|
50
|
+
- **Exhaustive sweep:** all 29,523 sequences over {0, 1, NA} of length
|
|
51
|
+
≤ 9 were run through both this implementation and an independent
|
|
52
|
+
one: **0 mismatches** in transition counts or in the
|
|
53
|
+
defined/undefined classification; maximum LR difference 1.6e-11.
|
|
54
|
+
The undefined-independence condition removes exactly the 3,008
|
|
55
|
+
zero-information sequences (all had LR exactly 0 under the old
|
|
56
|
+
formula).
|
|
57
|
+
- **Randomized check:** the pair-based restricted MLE matches
|
|
58
|
+
independent numerical maximization of the restricted likelihood to
|
|
59
|
+
9.1e-10 over 2,000 gapped random series.
|
|
60
|
+
- **Edge values:** Kupiec LR at n = 100: x = 0 → 10.2587;
|
|
61
|
+
x = 100 → 599.1465 (reference values, matched to 4 dp).
|
|
62
|
+
|
|
63
|
+
## Real-data anchors (SPY, rolling-250 historical VaR, α = 0.05)
|
|
64
|
+
|
|
65
|
+
| Quantity | Value |
|
|
66
|
+
|---|---|
|
|
67
|
+
| Scored days | 1,703 |
|
|
68
|
+
| Exceptions | 86 |
|
|
69
|
+
| Kupiec LR (p) | 0.0089 (passes) |
|
|
70
|
+
| Christoffersen counts (n00, n01, n10, n11) | 1542, 74, 74, 12 |
|
|
71
|
+
| Independence LR (p) | 10.589773 (0.00114 — **fails**) |
|
|
72
|
+
| Joint LR, df 2 | 10.5987 |
|
|
73
|
+
|
|
74
|
+
The failure is reported, not tuned away: SPY's exceptions cluster in
|
|
75
|
+
Feb–Apr 2020, so the model passes coverage and fails independence.
|
|
76
|
+
"Passes the backtest" claims for this configuration should cite
|
|
77
|
+
conditional coverage, not Kupiec alone.
|
|
78
|
+
|
|
79
|
+
## Parity results (audit)
|
|
80
|
+
|
|
81
|
+
- Max-Sharpe weights vs PyPortfolioOpt: agreement to 5.7e-5.
|
|
82
|
+
- GARCH(1,1) one-step σ forecasts vs `arch` (zero-mean, variance-
|
|
83
|
+
targeted): within the test tolerance across the audit set; a
|
|
84
|
+
residual VaR gap vs `arch`'s constant-mean default decomposed as
|
|
85
|
+
≈79% the mean term in the reference VaR, ≈15% a one-day dating
|
|
86
|
+
offset in the comparison harness, ≈6% fit-mean specification,
|
|
87
|
+
≈0% variance targeting/initialization. Same-window estimators
|
|
88
|
+
differ by 0.0002 pp — no estimation defect. This is why
|
|
89
|
+
`garch_var` and `parametric_var` now expose an explicit
|
|
90
|
+
`mean="zero" | "constant"` argument instead of disagreeing silently.
|
|
91
|
+
|
|
92
|
+
## Known limitations
|
|
93
|
+
|
|
94
|
+
- **GARCH with normal innovations under-covers.** On SPY (rolling
|
|
95
|
+
250-day windows, 1,703 forecasts), exception rates were ≈6.0%
|
|
96
|
+
(zero-mean) and ≈6.4% (mean-relative) against a 5% target. Prefer
|
|
97
|
+
`dist="t"`.
|
|
98
|
+
- **250-day historical VaR is slow to adapt** — see the independence
|
|
99
|
+
failure above; it is a property of the estimator, surfaced by the
|
|
100
|
+
backtest rather than hidden by it.
|
|
101
|
+
- Public tests use seeded synthetic data. Figures quoted here come
|
|
102
|
+
from the October 2026 real-data audit; the harness is reproducible
|
|
103
|
+
from the anchors and conventions documented above.
|
|
104
|
+
|
|
105
|
+
## Extraction notes (what is not in this repo, and why)
|
|
106
|
+
|
|
107
|
+
This package is a curated snapshot of a larger private toolkit,
|
|
108
|
+
published with fresh history under MIT. Held back, with their tests:
|
|
109
|
+
live monitoring (`risk.monitor`, `risk.rebalance` — the daily watcher
|
|
110
|
+
over a real portfolio), tax-lot optimization, multi-period
|
|
111
|
+
optimization (`mpo`), factor/indicator/forecast tooling, tear-sheet
|
|
112
|
+
reporting (`report`), and market data access. Two tests are skipped
|
|
113
|
+
in this repo because they exercise held-back modules (`mpo`,
|
|
114
|
+
`report`); the skips and their reasons are declared in
|
|
115
|
+
`tests/conftest.py`, and the
|
|
116
|
+
VaR/backtest tests were extracted verbatim from the private suite's
|
|
117
|
+
risk test file (the monitor/rebalance tests stayed private with
|
|
118
|
+
their modules).
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "portlab"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Portfolio risk and optimization library — VaR/ES estimation, VaR backtesting, tail diagnostics, and portfolio optimization with a documented validation record."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "Ibrahim Oyeyinka" }]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
dependencies = [
|
|
14
|
+
"numpy>=1.24",
|
|
15
|
+
"pandas>=2.0",
|
|
16
|
+
"scipy>=1.10",
|
|
17
|
+
"scikit-learn>=1.3",
|
|
18
|
+
]
|
|
19
|
+
keywords = ["portfolio", "risk", "var", "backtesting", "optimization", "finance"]
|
|
20
|
+
classifiers = [
|
|
21
|
+
"License :: OSI Approved :: MIT License",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Topic :: Office/Business :: Financial :: Investment",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
opt = ["cvxpy>=1.4"]
|
|
28
|
+
test = ["pytest>=7", "arch"]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/Realosunboy6/portlab"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
where = ["src"]
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
testpaths = ["tests"]
|
portlab-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""portlab — portfolio risk and optimization library.
|
|
2
|
+
|
|
3
|
+
Public core: performance/risk metrics, VaR/ES estimation with VaR
|
|
4
|
+
backtesting (Kupiec, Christoffersen), tail diagnostics, regime
|
|
5
|
+
detection, portfolio optimization (mean-variance, CVaR, Black-
|
|
6
|
+
Litterman, HRP, risk parity, robust), portfolio backtesting, and
|
|
7
|
+
Brinson attribution. See VALIDATION.md for the verification record.
|
|
8
|
+
|
|
9
|
+
Conventions: inputs are *simple returns* (positive = gain); VaR/ES
|
|
10
|
+
are reported as *positive numbers = losses*. In ``portlab.risk``,
|
|
11
|
+
``alpha`` is the tail probability (0.05 = 95% VaR); in
|
|
12
|
+
``portlab.metrics`` and the CVaR optimizer, ``alpha`` is the
|
|
13
|
+
confidence level (0.95). Each module's docstring states which.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
|
|
18
|
+
from . import (attribution, backtest, covariance, metrics, optimize,
|
|
19
|
+
rebalance, returns, risk)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"attribution", "backtest", "covariance", "metrics", "optimize",
|
|
23
|
+
"rebalance", "returns", "risk", "__version__",
|
|
24
|
+
]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Performance attribution: Brinson-Hood-Beebower (1986).
|
|
2
|
+
|
|
3
|
+
Decomposes a portfolio's excess return over a benchmark into *why* it
|
|
4
|
+
happened — allocation bets vs. stock selection — per segment (sector,
|
|
5
|
+
asset class, region, ...). For segment *i* with portfolio weight ``w_p``,
|
|
6
|
+
benchmark weight ``w_b``, portfolio return ``r_p`` and benchmark return
|
|
7
|
+
``r_b``::
|
|
8
|
+
|
|
9
|
+
allocation_i = (w_p,i - w_b,i) * r_b,i
|
|
10
|
+
selection_i = w_b,i * (r_p,i - r_b,i)
|
|
11
|
+
interaction_i = (w_p,i - w_b,i) * (r_p,i - r_b,i)
|
|
12
|
+
|
|
13
|
+
The identity ``sum(allocation + selection + interaction) == R_p - R_b`` holds
|
|
14
|
+
algebraically whenever both weight vectors sum to 1; :func:`brinson` checks
|
|
15
|
+
it and raises if it fails to close (a non-closing identity means an input
|
|
16
|
+
bug, never a rounding choice).
|
|
17
|
+
|
|
18
|
+
``method="fachler"`` switches allocation to the Brinson-Fachler variant,
|
|
19
|
+
``(w_p,i - w_b,i) * (r_b,i - R_b)``, which scores an overweight only when the
|
|
20
|
+
segment beat the overall benchmark. The identity is unaffected.
|
|
21
|
+
|
|
22
|
+
Reference: Brinson, Hood & Beebower (1986), "Determinants of Portfolio
|
|
23
|
+
Performance," *Financial Analysts Journal* 42(4), 39-44.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import pandas as pd
|
|
29
|
+
|
|
30
|
+
__all__ = ["brinson"]
|
|
31
|
+
|
|
32
|
+
_METHODS = ("bhb", "fachler")
|
|
33
|
+
_IDENTITY_TOL = 1e-9
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _as_frame(segments) -> pd.DataFrame:
|
|
37
|
+
if isinstance(segments, pd.DataFrame):
|
|
38
|
+
df = segments.copy()
|
|
39
|
+
else:
|
|
40
|
+
df = pd.DataFrame(list(segments))
|
|
41
|
+
need = {"segment", "w_p", "w_b", "r_p", "r_b"}
|
|
42
|
+
missing = need - set(df.columns)
|
|
43
|
+
if missing:
|
|
44
|
+
raise ValueError(f"segments need columns {sorted(need)}; missing {sorted(missing)}")
|
|
45
|
+
return df
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def brinson(segments, method: str = "bhb") -> dict:
|
|
49
|
+
"""Single-period Brinson attribution.
|
|
50
|
+
|
|
51
|
+
``segments``: DataFrame (or list of dicts) with columns
|
|
52
|
+
segment/w_p/w_b/r_p/r_b. Weights must each sum to 1 (within 1e-8);
|
|
53
|
+
all values must be finite.
|
|
54
|
+
|
|
55
|
+
Returns dict with ``table`` (per-segment allocation/selection/
|
|
56
|
+
interaction/total), ``portfolio_return``, ``benchmark_return``,
|
|
57
|
+
``excess_return``, and the three effect totals.
|
|
58
|
+
"""
|
|
59
|
+
method = str(method).lower()
|
|
60
|
+
if method not in _METHODS:
|
|
61
|
+
raise ValueError(f"method must be one of {_METHODS}, got {method!r}")
|
|
62
|
+
df = _as_frame(segments)
|
|
63
|
+
if df.empty:
|
|
64
|
+
raise ValueError("brinson() needs at least one segment")
|
|
65
|
+
for col in ("w_p", "w_b", "r_p", "r_b"):
|
|
66
|
+
vals = pd.to_numeric(df[col], errors="raise").to_numpy(dtype=float)
|
|
67
|
+
if not all(pd.notna(vals)) or not all(abs(v) != float("inf") for v in vals):
|
|
68
|
+
raise ValueError(f"column {col!r} must be finite")
|
|
69
|
+
df[col] = vals
|
|
70
|
+
for col, name in (("w_p", "portfolio"), ("w_b", "benchmark")):
|
|
71
|
+
s = float(df[col].sum())
|
|
72
|
+
if abs(s - 1.0) > 1e-8:
|
|
73
|
+
raise ValueError(f"{name} weights sum to {s}, not 1")
|
|
74
|
+
|
|
75
|
+
w_p = df["w_p"].to_numpy()
|
|
76
|
+
w_b = df["w_b"].to_numpy()
|
|
77
|
+
r_p = df["r_p"].to_numpy()
|
|
78
|
+
r_b = df["r_b"].to_numpy()
|
|
79
|
+
|
|
80
|
+
r_p_total = float((w_p * r_p).sum())
|
|
81
|
+
r_b_total = float((w_b * r_b).sum())
|
|
82
|
+
excess = r_p_total - r_b_total
|
|
83
|
+
|
|
84
|
+
active_w = w_p - w_b
|
|
85
|
+
active_r = r_p - r_b
|
|
86
|
+
if method == "fachler":
|
|
87
|
+
allocation = active_w * (r_b - r_b_total)
|
|
88
|
+
else:
|
|
89
|
+
allocation = active_w * r_b
|
|
90
|
+
selection = w_b * active_r
|
|
91
|
+
interaction = active_w * active_r
|
|
92
|
+
|
|
93
|
+
a, s, i = float(allocation.sum()), float(selection.sum()), float(interaction.sum())
|
|
94
|
+
if abs((a + s + i) - excess) > _IDENTITY_TOL:
|
|
95
|
+
raise ValueError(
|
|
96
|
+
f"attribution identity failed to close: {a + s + i} != {excess} "
|
|
97
|
+
"(check the weight/return inputs)")
|
|
98
|
+
|
|
99
|
+
table = pd.DataFrame({
|
|
100
|
+
"segment": df["segment"].astype(str).to_numpy(),
|
|
101
|
+
"allocation": allocation,
|
|
102
|
+
"selection": selection,
|
|
103
|
+
"interaction": interaction,
|
|
104
|
+
})
|
|
105
|
+
table["total"] = table[["allocation", "selection", "interaction"]].sum(axis=1)
|
|
106
|
+
return {"table": table.set_index("segment"),
|
|
107
|
+
"portfolio_return": r_p_total,
|
|
108
|
+
"benchmark_return": r_b_total,
|
|
109
|
+
"excess_return": excess,
|
|
110
|
+
"allocation": a, "selection": s, "interaction": i,
|
|
111
|
+
"method": method}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from .portfolio import (BacktestResult, backtest_dynamic,
|
|
2
|
+
backtest_portfolio, compare_portfolios, glide_path)
|
|
3
|
+
from .rolling import RollingBacktest, equal_weight
|
|
4
|
+
from .stress import (HISTORICAL_EPISODES, episode_returns, shock_scenario,
|
|
5
|
+
worst_windows)
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"BacktestResult", "backtest_dynamic", "backtest_portfolio", "compare_portfolios", "glide_path",
|
|
9
|
+
"RollingBacktest", "equal_weight", "HISTORICAL_EPISODES",
|
|
10
|
+
"episode_returns", "shock_scenario", "worst_windows",
|
|
11
|
+
]
|