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.
Files changed (58) hide show
  1. portlab-0.1.0/CITATION.cff +11 -0
  2. portlab-0.1.0/LICENSE +21 -0
  3. portlab-0.1.0/MANIFEST.in +2 -0
  4. portlab-0.1.0/PKG-INFO +104 -0
  5. portlab-0.1.0/README.md +79 -0
  6. portlab-0.1.0/VALIDATION.md +118 -0
  7. portlab-0.1.0/pyproject.toml +37 -0
  8. portlab-0.1.0/setup.cfg +4 -0
  9. portlab-0.1.0/src/portlab/__init__.py +24 -0
  10. portlab-0.1.0/src/portlab/attribution.py +111 -0
  11. portlab-0.1.0/src/portlab/backtest/__init__.py +11 -0
  12. portlab-0.1.0/src/portlab/backtest/portfolio.py +393 -0
  13. portlab-0.1.0/src/portlab/backtest/rolling.py +97 -0
  14. portlab-0.1.0/src/portlab/backtest/stress.py +74 -0
  15. portlab-0.1.0/src/portlab/config.py +24 -0
  16. portlab-0.1.0/src/portlab/covariance.py +66 -0
  17. portlab-0.1.0/src/portlab/metrics.py +455 -0
  18. portlab-0.1.0/src/portlab/optimize/__init__.py +27 -0
  19. portlab-0.1.0/src/portlab/optimize/auglag.py +170 -0
  20. portlab-0.1.0/src/portlab/optimize/black_litterman.py +52 -0
  21. portlab-0.1.0/src/portlab/optimize/constrained.py +45 -0
  22. portlab-0.1.0/src/portlab/optimize/constraint_attribution.py +93 -0
  23. portlab-0.1.0/src/portlab/optimize/core.py +88 -0
  24. portlab-0.1.0/src/portlab/optimize/cvar.py +195 -0
  25. portlab-0.1.0/src/portlab/optimize/fista.py +223 -0
  26. portlab-0.1.0/src/portlab/optimize/gurobi_backend.py +186 -0
  27. portlab-0.1.0/src/portlab/optimize/hrp.py +64 -0
  28. portlab-0.1.0/src/portlab/optimize/meanvar.py +285 -0
  29. portlab-0.1.0/src/portlab/optimize/objectives.py +160 -0
  30. portlab-0.1.0/src/portlab/optimize/osqp_backend.py +114 -0
  31. portlab-0.1.0/src/portlab/optimize/resampled.py +46 -0
  32. portlab-0.1.0/src/portlab/optimize/riskparity.py +37 -0
  33. portlab-0.1.0/src/portlab/optimize/robust.py +110 -0
  34. portlab-0.1.0/src/portlab/py.typed +0 -0
  35. portlab-0.1.0/src/portlab/rebalance.py +54 -0
  36. portlab-0.1.0/src/portlab/returns.py +70 -0
  37. portlab-0.1.0/src/portlab/risk/__init__.py +36 -0
  38. portlab-0.1.0/src/portlab/risk/backtest.py +356 -0
  39. portlab-0.1.0/src/portlab/risk/regime.py +114 -0
  40. portlab-0.1.0/src/portlab/risk/tail.py +233 -0
  41. portlab-0.1.0/src/portlab/risk/var.py +394 -0
  42. portlab-0.1.0/src/portlab.egg-info/PKG-INFO +104 -0
  43. portlab-0.1.0/src/portlab.egg-info/SOURCES.txt +56 -0
  44. portlab-0.1.0/src/portlab.egg-info/dependency_links.txt +1 -0
  45. portlab-0.1.0/src/portlab.egg-info/requires.txt +11 -0
  46. portlab-0.1.0/src/portlab.egg-info/top_level.txt +1 -0
  47. portlab-0.1.0/tests/conftest.py +60 -0
  48. portlab-0.1.0/tests/test_attribution.py +95 -0
  49. portlab-0.1.0/tests/test_audit_fixes.py +71 -0
  50. portlab-0.1.0/tests/test_backtest.py +97 -0
  51. portlab-0.1.0/tests/test_constraint_attribution.py +102 -0
  52. portlab-0.1.0/tests/test_cvar_opt.py +74 -0
  53. portlab-0.1.0/tests/test_metrics.py +65 -0
  54. portlab-0.1.0/tests/test_optimize.py +121 -0
  55. portlab-0.1.0/tests/test_regime.py +120 -0
  56. portlab-0.1.0/tests/test_risk_backtest.py +360 -0
  57. portlab-0.1.0/tests/test_robust.py +117 -0
  58. 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.
@@ -0,0 +1,2 @@
1
+ include LICENSE README.md VALIDATION.md CITATION.cff
2
+ recursive-include tests *.py
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.
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ ]