pybrinson 1.0.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.
- pybrinson-1.0.0/.gitignore +41 -0
- pybrinson-1.0.0/LICENSE +21 -0
- pybrinson-1.0.0/PKG-INFO +173 -0
- pybrinson-1.0.0/README.md +152 -0
- pybrinson-1.0.0/pyproject.toml +57 -0
- pybrinson-1.0.0/src/pybrinson/__init__.py +55 -0
- pybrinson-1.0.0/src/pybrinson/_format.py +94 -0
- pybrinson-1.0.0/src/pybrinson/_math.py +79 -0
- pybrinson-1.0.0/src/pybrinson/_types.py +94 -0
- pybrinson-1.0.0/src/pybrinson/errors.py +18 -0
- pybrinson-1.0.0/src/pybrinson/linking/__init__.py +16 -0
- pybrinson-1.0.0/src/pybrinson/linking/carino.py +163 -0
- pybrinson-1.0.0/src/pybrinson/linking/geometric.py +154 -0
- pybrinson-1.0.0/src/pybrinson/linking/grap.py +123 -0
- pybrinson-1.0.0/src/pybrinson/py.typed +0 -0
- pybrinson-1.0.0/src/pybrinson/single_period/__init__.py +6 -0
- pybrinson-1.0.0/src/pybrinson/single_period/bhb.py +166 -0
- pybrinson-1.0.0/src/pybrinson/single_period/fachler.py +158 -0
- pybrinson-1.0.0/tests/__init__.py +0 -0
- pybrinson-1.0.0/tests/linking/__init__.py +0 -0
- pybrinson-1.0.0/tests/linking/test_carino.py +153 -0
- pybrinson-1.0.0/tests/linking/test_geometric.py +111 -0
- pybrinson-1.0.0/tests/linking/test_grap.py +93 -0
- pybrinson-1.0.0/tests/single_period/__init__.py +0 -0
- pybrinson-1.0.0/tests/single_period/test_bhb.py +139 -0
- pybrinson-1.0.0/tests/single_period/test_fachler.py +117 -0
- pybrinson-1.0.0/tests/test_format.py +55 -0
- pybrinson-1.0.0/tests/test_smoke.py +6 -0
- pybrinson-1.0.0/tests/test_validation.py +51 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
.Python
|
|
7
|
+
build/
|
|
8
|
+
dist/
|
|
9
|
+
wheels/
|
|
10
|
+
*.egg-info/
|
|
11
|
+
*.egg
|
|
12
|
+
.pytest_cache/
|
|
13
|
+
.mypy_cache/
|
|
14
|
+
.ruff_cache/
|
|
15
|
+
.coverage
|
|
16
|
+
.coverage.*
|
|
17
|
+
htmlcov/
|
|
18
|
+
.tox/
|
|
19
|
+
.nox/
|
|
20
|
+
|
|
21
|
+
# uv
|
|
22
|
+
.venv/
|
|
23
|
+
uv.lock.bak
|
|
24
|
+
|
|
25
|
+
# IDE
|
|
26
|
+
.idea/
|
|
27
|
+
.vscode/
|
|
28
|
+
*.swp
|
|
29
|
+
*.swo
|
|
30
|
+
|
|
31
|
+
# OS
|
|
32
|
+
.DS_Store
|
|
33
|
+
Thumbs.db
|
|
34
|
+
|
|
35
|
+
# Local env / secrets
|
|
36
|
+
.env
|
|
37
|
+
.env.*
|
|
38
|
+
!.env.example
|
|
39
|
+
*.pem
|
|
40
|
+
*.key
|
|
41
|
+
secrets/
|
pybrinson-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pybrinson contributors
|
|
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.
|
pybrinson-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pybrinson
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Portfolio return attribution in Python: Brinson-Hood-Beebower, Brinson-Fachler, and multi-period linking.
|
|
5
|
+
Project-URL: Homepage, https://github.com/gghez/pybrinson
|
|
6
|
+
Project-URL: Repository, https://github.com/gghez/pybrinson
|
|
7
|
+
Project-URL: Issues, https://github.com/gghez/pybrinson/issues
|
|
8
|
+
Author: pybrinson contributors
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: asset-management,attribution,brinson,finance,performance,portfolio
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.14
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# pybrinson
|
|
23
|
+
|
|
24
|
+
[](https://github.com/gghez/pybrinson/actions/workflows/ci.yml)
|
|
25
|
+
[](LICENSE)
|
|
26
|
+
|
|
27
|
+
Portfolio return attribution in Python — with sources you can audit.
|
|
28
|
+
|
|
29
|
+
`pybrinson` decomposes a portfolio's excess return versus a benchmark into
|
|
30
|
+
**allocation**, **selection**, and **interaction** effects, across any
|
|
31
|
+
user-defined classification (sector, country, asset class). Every formula
|
|
32
|
+
ships with its mathematical statement, an academic citation, and a
|
|
33
|
+
clickable URL — readers can verify the math without leaving the file.
|
|
34
|
+
|
|
35
|
+
It targets the gap left by the existing Python finance stack: R has the
|
|
36
|
+
`pa` package on CRAN and MATLAB ships `brinsonAttribution`, but no
|
|
37
|
+
maintained PyPI package implements the Brinson family of models.
|
|
38
|
+
|
|
39
|
+
## Status
|
|
40
|
+
|
|
41
|
+
v1.0 — single-period BHB and Brinson-Fachler plus Cariño / GRAP /
|
|
42
|
+
geometric multi-period linking. Single-level classification only;
|
|
43
|
+
nested hierarchies are scheduled for v1.1.
|
|
44
|
+
|
|
45
|
+
## Methods supported
|
|
46
|
+
|
|
47
|
+
| | Method | Reference |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| Single-period | Brinson-Hood-Beebower (3-effect) | Brinson, Hood & Beebower (1986) |
|
|
50
|
+
| Single-period | Brinson-Fachler (3-effect) | Brinson & Fachler (1985) |
|
|
51
|
+
| Multi-period linking | Cariño log-smoothing | Cariño (1999) |
|
|
52
|
+
| Multi-period linking | GRAP factors | GRAP (1997) |
|
|
53
|
+
| Multi-period linking | Geometric (Bacon) | Bacon (2008), chap. 6 |
|
|
54
|
+
|
|
55
|
+
Deferred to v1.1+: Menchero / Frongello linking, multi-level (nested)
|
|
56
|
+
classification, currency attribution.
|
|
57
|
+
|
|
58
|
+
## Install
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install pybrinson
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`pybrinson` has **zero runtime dependencies** — just the Python standard
|
|
65
|
+
library. Requires Python 3.14+.
|
|
66
|
+
|
|
67
|
+
## Quickstart
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from pybrinson import Segment, bhb
|
|
71
|
+
|
|
72
|
+
segments = [
|
|
73
|
+
Segment("UK Equity", portfolio_weight=0.40, benchmark_weight=0.40,
|
|
74
|
+
portfolio_return=0.20, benchmark_return=0.10),
|
|
75
|
+
Segment("Japan Equity", portfolio_weight=0.30, benchmark_weight=0.20,
|
|
76
|
+
portfolio_return=-0.05, benchmark_return=-0.04),
|
|
77
|
+
Segment("US Equity", portfolio_weight=0.30, benchmark_weight=0.40,
|
|
78
|
+
portfolio_return=0.06, benchmark_return=0.08),
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
result = bhb(segments, period="2024-Q1")
|
|
82
|
+
print(result)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
```text
|
|
86
|
+
BHB attribution — period 2024-Q1
|
|
87
|
+
R_p = 8.3000% R_b = 6.4000% excess = 1.9000%
|
|
88
|
+
|
|
89
|
+
Segment Allocation Selection Interaction Total
|
|
90
|
+
------------ ---------- --------- ----------- --------
|
|
91
|
+
UK Equity 0.0000% 4.0000% 0.0000% 4.0000%
|
|
92
|
+
Japan Equity -0.4000% -0.2000% -0.1000% -0.7000%
|
|
93
|
+
US Equity -0.8000% -0.8000% 0.2000% -1.4000%
|
|
94
|
+
Total -1.2000% 3.0000% 0.1000% 1.9000%
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The identity ``allocation + selection + interaction == excess_return``
|
|
98
|
+
holds within ``1e-9`` by construction; pybrinson **raises**
|
|
99
|
+
`AttributionError` rather than storing a silent residual when it fails.
|
|
100
|
+
|
|
101
|
+
### Multi-period linking
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from pybrinson import bhb, link_carino, Segment
|
|
105
|
+
|
|
106
|
+
period_attrs = [
|
|
107
|
+
bhb([Segment("Equities", 0.6, 0.5, 0.10, 0.05),
|
|
108
|
+
Segment("Bonds", 0.4, 0.5, 0.05, 0.10)], period="P1"),
|
|
109
|
+
bhb([Segment("Equities", 0.5, 0.5, 0.20, 0.10),
|
|
110
|
+
Segment("Bonds", 0.5, 0.5, 0.05, 0.10)], period="P2"),
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
print(link_carino(period_attrs))
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
See `examples/` for runnable scripts covering BHB, Brinson-Fachler, and
|
|
117
|
+
Cariño / GRAP / geometric linking.
|
|
118
|
+
|
|
119
|
+
## Design principles
|
|
120
|
+
|
|
121
|
+
- **Pure Python first.** Zero runtime dependencies. Realistic attribution
|
|
122
|
+
inputs are small (≤1k segments × ≤1k periods); a NumPy dependency
|
|
123
|
+
would not pay for itself.
|
|
124
|
+
- **Specification-driven, externally verified.** Every function carries
|
|
125
|
+
its formula, an academic citation, and a clickable URL. The math is
|
|
126
|
+
cross-checked against published worked examples.
|
|
127
|
+
- **No silent residuals.** Identity failures raise. Bad inputs raise.
|
|
128
|
+
pybrinson never imputes, rescales or swallows residuals.
|
|
129
|
+
- **Typed and tested.** Public API is fully type-annotated; ships
|
|
130
|
+
`py.typed`; tests cover both pinned worked examples and randomised
|
|
131
|
+
identity checks.
|
|
132
|
+
|
|
133
|
+
## Positioning vs `ppar` / `fincore`
|
|
134
|
+
|
|
135
|
+
| | `ppar` | `fincore` | `pybrinson` |
|
|
136
|
+
|---|---|---|---|
|
|
137
|
+
| BHB | no | yes | yes |
|
|
138
|
+
| Brinson-Fachler 3-effect | 2-effect only | no | yes |
|
|
139
|
+
| Cariño linking | yes | no | yes |
|
|
140
|
+
| GRAP linking | no | no | **yes** |
|
|
141
|
+
| Geometric linking | no | no | **yes** |
|
|
142
|
+
| Inline source citations | no | no | **mandatory** |
|
|
143
|
+
| Identity failure handling | n/a | silent residual | **raises** |
|
|
144
|
+
| Runtime dependencies | 9 | 2 | **0** |
|
|
145
|
+
|
|
146
|
+
See `docs/implementation-v1.md` for the audited findings against pinned
|
|
147
|
+
upstream commits.
|
|
148
|
+
|
|
149
|
+
## Development
|
|
150
|
+
|
|
151
|
+
This project uses [uv](https://docs.astral.sh/uv/) and targets Python 3.14.
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
uv sync # install deps, fetch Python 3.14 if needed
|
|
155
|
+
uv run pytest # full test suite
|
|
156
|
+
uv run pytest -k bhb # subset
|
|
157
|
+
uv build # sdist + wheel into dist/
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## References
|
|
161
|
+
|
|
162
|
+
Primary papers cited in the source:
|
|
163
|
+
|
|
164
|
+
- Brinson, G. P., Hood, L. R., & Beebower, G. L. (1986). "Determinants of Portfolio Performance." *Financial Analysts Journal*, 42(4). [DOI](https://doi.org/10.2469/faj.v42.n4.39)
|
|
165
|
+
- Brinson, G. P., & Fachler, N. (1985). "Measuring Non-U.S. Equity Portfolio Performance." *Journal of Portfolio Management*, 11(3). [DOI](https://doi.org/10.3905/jpm.1985.409005)
|
|
166
|
+
- Cariño, D. R. (1999). "Combining Attribution Effects Over Time." *Journal of Performance Measurement*, 3(4).
|
|
167
|
+
- Groupe de Recherche en Attribution de Performance (1997). *Synthèse des modèles d'attribution de performance*.
|
|
168
|
+
- Bacon, C. R. (2008). *Practical Portfolio Performance Measurement and Attribution*, 2nd ed., Wiley. [Wiley page](https://www.wiley.com/en-us/Practical+Portfolio+Performance+Measurement+and+Attribution%2C+2nd+Edition-p-9780470059289)
|
|
169
|
+
- Bacon, C. R. (2019). *Performance Attribution: History and Progress*. CFA Institute Research Foundation. [Free PDF](https://www.cfainstitute.org/-/media/documents/book/rf-publication/2019/rf-v2019-n4-1-pdf.pdf)
|
|
170
|
+
|
|
171
|
+
## License
|
|
172
|
+
|
|
173
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# pybrinson
|
|
2
|
+
|
|
3
|
+
[](https://github.com/gghez/pybrinson/actions/workflows/ci.yml)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
Portfolio return attribution in Python — with sources you can audit.
|
|
7
|
+
|
|
8
|
+
`pybrinson` decomposes a portfolio's excess return versus a benchmark into
|
|
9
|
+
**allocation**, **selection**, and **interaction** effects, across any
|
|
10
|
+
user-defined classification (sector, country, asset class). Every formula
|
|
11
|
+
ships with its mathematical statement, an academic citation, and a
|
|
12
|
+
clickable URL — readers can verify the math without leaving the file.
|
|
13
|
+
|
|
14
|
+
It targets the gap left by the existing Python finance stack: R has the
|
|
15
|
+
`pa` package on CRAN and MATLAB ships `brinsonAttribution`, but no
|
|
16
|
+
maintained PyPI package implements the Brinson family of models.
|
|
17
|
+
|
|
18
|
+
## Status
|
|
19
|
+
|
|
20
|
+
v1.0 — single-period BHB and Brinson-Fachler plus Cariño / GRAP /
|
|
21
|
+
geometric multi-period linking. Single-level classification only;
|
|
22
|
+
nested hierarchies are scheduled for v1.1.
|
|
23
|
+
|
|
24
|
+
## Methods supported
|
|
25
|
+
|
|
26
|
+
| | Method | Reference |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| Single-period | Brinson-Hood-Beebower (3-effect) | Brinson, Hood & Beebower (1986) |
|
|
29
|
+
| Single-period | Brinson-Fachler (3-effect) | Brinson & Fachler (1985) |
|
|
30
|
+
| Multi-period linking | Cariño log-smoothing | Cariño (1999) |
|
|
31
|
+
| Multi-period linking | GRAP factors | GRAP (1997) |
|
|
32
|
+
| Multi-period linking | Geometric (Bacon) | Bacon (2008), chap. 6 |
|
|
33
|
+
|
|
34
|
+
Deferred to v1.1+: Menchero / Frongello linking, multi-level (nested)
|
|
35
|
+
classification, currency attribution.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install pybrinson
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`pybrinson` has **zero runtime dependencies** — just the Python standard
|
|
44
|
+
library. Requires Python 3.14+.
|
|
45
|
+
|
|
46
|
+
## Quickstart
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from pybrinson import Segment, bhb
|
|
50
|
+
|
|
51
|
+
segments = [
|
|
52
|
+
Segment("UK Equity", portfolio_weight=0.40, benchmark_weight=0.40,
|
|
53
|
+
portfolio_return=0.20, benchmark_return=0.10),
|
|
54
|
+
Segment("Japan Equity", portfolio_weight=0.30, benchmark_weight=0.20,
|
|
55
|
+
portfolio_return=-0.05, benchmark_return=-0.04),
|
|
56
|
+
Segment("US Equity", portfolio_weight=0.30, benchmark_weight=0.40,
|
|
57
|
+
portfolio_return=0.06, benchmark_return=0.08),
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
result = bhb(segments, period="2024-Q1")
|
|
61
|
+
print(result)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```text
|
|
65
|
+
BHB attribution — period 2024-Q1
|
|
66
|
+
R_p = 8.3000% R_b = 6.4000% excess = 1.9000%
|
|
67
|
+
|
|
68
|
+
Segment Allocation Selection Interaction Total
|
|
69
|
+
------------ ---------- --------- ----------- --------
|
|
70
|
+
UK Equity 0.0000% 4.0000% 0.0000% 4.0000%
|
|
71
|
+
Japan Equity -0.4000% -0.2000% -0.1000% -0.7000%
|
|
72
|
+
US Equity -0.8000% -0.8000% 0.2000% -1.4000%
|
|
73
|
+
Total -1.2000% 3.0000% 0.1000% 1.9000%
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The identity ``allocation + selection + interaction == excess_return``
|
|
77
|
+
holds within ``1e-9`` by construction; pybrinson **raises**
|
|
78
|
+
`AttributionError` rather than storing a silent residual when it fails.
|
|
79
|
+
|
|
80
|
+
### Multi-period linking
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from pybrinson import bhb, link_carino, Segment
|
|
84
|
+
|
|
85
|
+
period_attrs = [
|
|
86
|
+
bhb([Segment("Equities", 0.6, 0.5, 0.10, 0.05),
|
|
87
|
+
Segment("Bonds", 0.4, 0.5, 0.05, 0.10)], period="P1"),
|
|
88
|
+
bhb([Segment("Equities", 0.5, 0.5, 0.20, 0.10),
|
|
89
|
+
Segment("Bonds", 0.5, 0.5, 0.05, 0.10)], period="P2"),
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
print(link_carino(period_attrs))
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
See `examples/` for runnable scripts covering BHB, Brinson-Fachler, and
|
|
96
|
+
Cariño / GRAP / geometric linking.
|
|
97
|
+
|
|
98
|
+
## Design principles
|
|
99
|
+
|
|
100
|
+
- **Pure Python first.** Zero runtime dependencies. Realistic attribution
|
|
101
|
+
inputs are small (≤1k segments × ≤1k periods); a NumPy dependency
|
|
102
|
+
would not pay for itself.
|
|
103
|
+
- **Specification-driven, externally verified.** Every function carries
|
|
104
|
+
its formula, an academic citation, and a clickable URL. The math is
|
|
105
|
+
cross-checked against published worked examples.
|
|
106
|
+
- **No silent residuals.** Identity failures raise. Bad inputs raise.
|
|
107
|
+
pybrinson never imputes, rescales or swallows residuals.
|
|
108
|
+
- **Typed and tested.** Public API is fully type-annotated; ships
|
|
109
|
+
`py.typed`; tests cover both pinned worked examples and randomised
|
|
110
|
+
identity checks.
|
|
111
|
+
|
|
112
|
+
## Positioning vs `ppar` / `fincore`
|
|
113
|
+
|
|
114
|
+
| | `ppar` | `fincore` | `pybrinson` |
|
|
115
|
+
|---|---|---|---|
|
|
116
|
+
| BHB | no | yes | yes |
|
|
117
|
+
| Brinson-Fachler 3-effect | 2-effect only | no | yes |
|
|
118
|
+
| Cariño linking | yes | no | yes |
|
|
119
|
+
| GRAP linking | no | no | **yes** |
|
|
120
|
+
| Geometric linking | no | no | **yes** |
|
|
121
|
+
| Inline source citations | no | no | **mandatory** |
|
|
122
|
+
| Identity failure handling | n/a | silent residual | **raises** |
|
|
123
|
+
| Runtime dependencies | 9 | 2 | **0** |
|
|
124
|
+
|
|
125
|
+
See `docs/implementation-v1.md` for the audited findings against pinned
|
|
126
|
+
upstream commits.
|
|
127
|
+
|
|
128
|
+
## Development
|
|
129
|
+
|
|
130
|
+
This project uses [uv](https://docs.astral.sh/uv/) and targets Python 3.14.
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
uv sync # install deps, fetch Python 3.14 if needed
|
|
134
|
+
uv run pytest # full test suite
|
|
135
|
+
uv run pytest -k bhb # subset
|
|
136
|
+
uv build # sdist + wheel into dist/
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## References
|
|
140
|
+
|
|
141
|
+
Primary papers cited in the source:
|
|
142
|
+
|
|
143
|
+
- Brinson, G. P., Hood, L. R., & Beebower, G. L. (1986). "Determinants of Portfolio Performance." *Financial Analysts Journal*, 42(4). [DOI](https://doi.org/10.2469/faj.v42.n4.39)
|
|
144
|
+
- Brinson, G. P., & Fachler, N. (1985). "Measuring Non-U.S. Equity Portfolio Performance." *Journal of Portfolio Management*, 11(3). [DOI](https://doi.org/10.3905/jpm.1985.409005)
|
|
145
|
+
- Cariño, D. R. (1999). "Combining Attribution Effects Over Time." *Journal of Performance Measurement*, 3(4).
|
|
146
|
+
- Groupe de Recherche en Attribution de Performance (1997). *Synthèse des modèles d'attribution de performance*.
|
|
147
|
+
- Bacon, C. R. (2008). *Practical Portfolio Performance Measurement and Attribution*, 2nd ed., Wiley. [Wiley page](https://www.wiley.com/en-us/Practical+Portfolio+Performance+Measurement+and+Attribution%2C+2nd+Edition-p-9780470059289)
|
|
148
|
+
- Bacon, C. R. (2019). *Performance Attribution: History and Progress*. CFA Institute Research Foundation. [Free PDF](https://www.cfainstitute.org/-/media/documents/book/rf-publication/2019/rf-v2019-n4-1-pdf.pdf)
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pybrinson"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Portfolio return attribution in Python: Brinson-Hood-Beebower, Brinson-Fachler, and multi-period linking."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.14"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [{ name = "pybrinson contributors" }]
|
|
10
|
+
keywords = [
|
|
11
|
+
"finance",
|
|
12
|
+
"portfolio",
|
|
13
|
+
"attribution",
|
|
14
|
+
"brinson",
|
|
15
|
+
"performance",
|
|
16
|
+
"asset-management",
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 5 - Production/Stable",
|
|
20
|
+
"Intended Audience :: Financial and Insurance Industry",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3.14",
|
|
24
|
+
"Topic :: Office/Business :: Financial :: Investment",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
dependencies = []
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://github.com/gghez/pybrinson"
|
|
31
|
+
Repository = "https://github.com/gghez/pybrinson"
|
|
32
|
+
Issues = "https://github.com/gghez/pybrinson/issues"
|
|
33
|
+
|
|
34
|
+
[dependency-groups]
|
|
35
|
+
dev = [
|
|
36
|
+
"pytest>=8.3",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[build-system]
|
|
40
|
+
requires = ["hatchling"]
|
|
41
|
+
build-backend = "hatchling.build"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.wheel]
|
|
44
|
+
packages = ["src/pybrinson"]
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.sdist]
|
|
47
|
+
include = [
|
|
48
|
+
"src/pybrinson",
|
|
49
|
+
"tests",
|
|
50
|
+
"README.md",
|
|
51
|
+
"LICENSE",
|
|
52
|
+
"pyproject.toml",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
[tool.pytest.ini_options]
|
|
56
|
+
testpaths = ["tests"]
|
|
57
|
+
addopts = "-ra --strict-markers --strict-config"
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Portfolio return attribution: Brinson-Hood-Beebower, Brinson-Fachler, multi-period linking.
|
|
2
|
+
|
|
3
|
+
Public API
|
|
4
|
+
----------
|
|
5
|
+
Single-period attribution:
|
|
6
|
+
|
|
7
|
+
- :func:`bhb` — Brinson-Hood-Beebower (3-effect)
|
|
8
|
+
- :func:`fachler` — Brinson-Fachler (3-effect)
|
|
9
|
+
|
|
10
|
+
Multi-period linking:
|
|
11
|
+
|
|
12
|
+
- :func:`link_carino` — Cariño (1999) log-smoothing
|
|
13
|
+
- :func:`link_grap` — GRAP (1997) factor linking
|
|
14
|
+
- :func:`link_geometric` — Bacon-style geometric linking
|
|
15
|
+
|
|
16
|
+
Data model:
|
|
17
|
+
|
|
18
|
+
- :class:`Segment` — input row
|
|
19
|
+
- :class:`SegmentAttribution`, :class:`PeriodAttribution`,
|
|
20
|
+
:class:`LinkedAttribution` — result types
|
|
21
|
+
|
|
22
|
+
Errors:
|
|
23
|
+
|
|
24
|
+
- :class:`AttributionError` — raised on bad input or identity failure
|
|
25
|
+
(subclass of :class:`ValueError`)
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from ._format import linked_to_table, period_to_table # noqa: F401 (side effect: __repr__)
|
|
29
|
+
from ._types import (
|
|
30
|
+
LinkedAttribution,
|
|
31
|
+
PeriodAttribution,
|
|
32
|
+
Segment,
|
|
33
|
+
SegmentAttribution,
|
|
34
|
+
)
|
|
35
|
+
from .errors import AttributionError
|
|
36
|
+
from .linking import link_carino, link_geometric, link_grap
|
|
37
|
+
from .single_period import bhb, fachler
|
|
38
|
+
|
|
39
|
+
__version__ = "1.0.0"
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"AttributionError",
|
|
43
|
+
"LinkedAttribution",
|
|
44
|
+
"PeriodAttribution",
|
|
45
|
+
"Segment",
|
|
46
|
+
"SegmentAttribution",
|
|
47
|
+
"__version__",
|
|
48
|
+
"bhb",
|
|
49
|
+
"fachler",
|
|
50
|
+
"link_carino",
|
|
51
|
+
"link_geometric",
|
|
52
|
+
"link_grap",
|
|
53
|
+
"linked_to_table",
|
|
54
|
+
"period_to_table",
|
|
55
|
+
]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Plain-text rendering of attribution results.
|
|
2
|
+
|
|
3
|
+
Pure stdlib formatting. Returns fixed-width tables with explicit columns
|
|
4
|
+
for allocation, selection, interaction and per-segment / total rows.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from ._types import LinkedAttribution, PeriodAttribution
|
|
10
|
+
|
|
11
|
+
_HEAD = ("Segment", "Allocation", "Selection", "Interaction", "Total")
|
|
12
|
+
_PCT_DECIMALS = 4
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _fmt_pct(x: float) -> str:
|
|
16
|
+
return f"{x * 100:.{_PCT_DECIMALS}f}%"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _row(name: str, alloc: float, sel: float, inter: float) -> tuple[str, ...]:
|
|
20
|
+
return (
|
|
21
|
+
name,
|
|
22
|
+
_fmt_pct(alloc),
|
|
23
|
+
_fmt_pct(sel),
|
|
24
|
+
_fmt_pct(inter),
|
|
25
|
+
_fmt_pct(alloc + sel + inter),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _render(rows: list[tuple[str, ...]], header_lines: list[str]) -> str:
|
|
30
|
+
widths = [
|
|
31
|
+
max(len(row[col]) for row in (rows + [_HEAD]))
|
|
32
|
+
for col in range(len(_HEAD))
|
|
33
|
+
]
|
|
34
|
+
sep = " "
|
|
35
|
+
|
|
36
|
+
def _line(row: tuple[str, ...]) -> str:
|
|
37
|
+
return sep.join(
|
|
38
|
+
cell.ljust(widths[i]) if i == 0 else cell.rjust(widths[i])
|
|
39
|
+
for i, cell in enumerate(row)
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
out: list[str] = list(header_lines)
|
|
43
|
+
out.append(_line(_HEAD))
|
|
44
|
+
out.append(sep.join("-" * w for w in widths))
|
|
45
|
+
for row in rows:
|
|
46
|
+
out.append(_line(row))
|
|
47
|
+
return "\n".join(out)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def period_to_table(result: PeriodAttribution) -> str:
|
|
51
|
+
"""Render a single-period attribution result as a fixed-width table."""
|
|
52
|
+
label = result.period or "(unlabelled)"
|
|
53
|
+
header_lines = [
|
|
54
|
+
f"{result.method} attribution — period {label}",
|
|
55
|
+
f" R_p = {_fmt_pct(result.portfolio_return)} "
|
|
56
|
+
f"R_b = {_fmt_pct(result.benchmark_return)} "
|
|
57
|
+
f"excess = {_fmt_pct(result.excess_return)}",
|
|
58
|
+
"",
|
|
59
|
+
]
|
|
60
|
+
rows = [
|
|
61
|
+
_row(s.name, s.allocation, s.selection, s.interaction)
|
|
62
|
+
for s in result.by_segment
|
|
63
|
+
]
|
|
64
|
+
rows.append(
|
|
65
|
+
_row("Total", result.allocation, result.selection, result.interaction)
|
|
66
|
+
)
|
|
67
|
+
return _render(rows, header_lines)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def linked_to_table(result: LinkedAttribution) -> str:
|
|
71
|
+
"""Render a multi-period linked attribution result."""
|
|
72
|
+
header_lines = [
|
|
73
|
+
f"{result.method}-linked attribution — {len(result.periods)} periods",
|
|
74
|
+
f" R_p = {_fmt_pct(result.portfolio_return)} "
|
|
75
|
+
f"R_b = {_fmt_pct(result.benchmark_return)} "
|
|
76
|
+
f"excess = {_fmt_pct(result.excess_return)}",
|
|
77
|
+
"",
|
|
78
|
+
]
|
|
79
|
+
rows = [_row("Total", result.allocation, result.selection, result.interaction)]
|
|
80
|
+
return _render(rows, header_lines)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# Attach __repr__ overrides on the dataclasses. Done here (not in
|
|
84
|
+
# _types.py) to keep _types.py free of formatting concerns.
|
|
85
|
+
def _period_repr(self: PeriodAttribution) -> str:
|
|
86
|
+
return period_to_table(self)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _linked_repr(self: LinkedAttribution) -> str:
|
|
90
|
+
return linked_to_table(self)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
PeriodAttribution.__repr__ = _period_repr # type: ignore[method-assign]
|
|
94
|
+
LinkedAttribution.__repr__ = _linked_repr # type: ignore[method-assign]
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Internal numeric helpers: validation, aggregation, identity check.
|
|
2
|
+
|
|
3
|
+
Tolerances
|
|
4
|
+
----------
|
|
5
|
+
``WEIGHT_SUM_TOL = 1e-8`` — used to check ``|Σw − 1|``.
|
|
6
|
+
``RESIDUAL_TOL = 1e-9`` — used to check the per-period identity
|
|
7
|
+
``Σ(A + S + I) ≈ R_p − R_b`` returned by BHB / Brinson-Fachler.
|
|
8
|
+
|
|
9
|
+
These are deliberately tight: pybrinson never silently rescales weights
|
|
10
|
+
or absorbs a residual, because a wrong fill rule (zero-fill, drop, …)
|
|
11
|
+
would propagate into the identity without the reader noticing — exactly
|
|
12
|
+
the bug that fincore's ``residual`` field hides
|
|
13
|
+
(``fincore/attribution/brinson.py`` line 97-98).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import math
|
|
19
|
+
from collections.abc import Sequence
|
|
20
|
+
|
|
21
|
+
from .errors import AttributionError
|
|
22
|
+
|
|
23
|
+
WEIGHT_SUM_TOL: float = 1e-8
|
|
24
|
+
RESIDUAL_TOL: float = 1e-9
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def reject_non_finite(values: Sequence[float], *, name: str) -> None:
|
|
28
|
+
"""Raise :class:`AttributionError` if any value is ``NaN`` or ``±inf``."""
|
|
29
|
+
for v in values:
|
|
30
|
+
if not math.isfinite(v):
|
|
31
|
+
raise AttributionError(
|
|
32
|
+
f"{name} contains a non-finite value ({v!r}). "
|
|
33
|
+
"pybrinson does not silently impute or drop missing data — "
|
|
34
|
+
"the caller must clean inputs before calling."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def validate_weights(weights: Sequence[float], *, name: str) -> None:
|
|
39
|
+
"""Raise unless ``weights`` are finite and sum to ``1`` within tolerance."""
|
|
40
|
+
reject_non_finite(weights, name=name)
|
|
41
|
+
total = math.fsum(weights)
|
|
42
|
+
if abs(total - 1.0) > WEIGHT_SUM_TOL:
|
|
43
|
+
raise AttributionError(
|
|
44
|
+
f"{name} weights must sum to 1 within {WEIGHT_SUM_TOL:g}; "
|
|
45
|
+
f"got {total!r}. pybrinson never silently rescales — fix the input."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def aggregate_return(weights: Sequence[float], returns: Sequence[float]) -> float:
|
|
50
|
+
r"""Compute the weighted return :math:`\sum_i w_i r_i`.
|
|
51
|
+
|
|
52
|
+
Uses :func:`math.fsum` for an exact, order-independent sum so that the
|
|
53
|
+
identity check downstream is not polluted by accumulated rounding.
|
|
54
|
+
"""
|
|
55
|
+
if len(weights) != len(returns):
|
|
56
|
+
raise AttributionError(
|
|
57
|
+
f"weights/returns length mismatch: {len(weights)} vs {len(returns)}"
|
|
58
|
+
)
|
|
59
|
+
return math.fsum(w * r for w, r in zip(weights, returns, strict=True))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def check_identity(
|
|
63
|
+
allocation: float,
|
|
64
|
+
selection: float,
|
|
65
|
+
interaction: float,
|
|
66
|
+
excess: float,
|
|
67
|
+
*,
|
|
68
|
+
method: str,
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Raise if ``A + S + I`` does not match ``R_p − R_b`` within tolerance."""
|
|
71
|
+
residual = (allocation + selection + interaction) - excess
|
|
72
|
+
if abs(residual) > RESIDUAL_TOL:
|
|
73
|
+
raise AttributionError(
|
|
74
|
+
f"{method} identity violated: "
|
|
75
|
+
f"A + S + I = {allocation + selection + interaction!r}, "
|
|
76
|
+
f"excess = {excess!r}, residual = {residual!r}. "
|
|
77
|
+
"This is either a numerical pathology or a bug — pybrinson "
|
|
78
|
+
"refuses to store a silent residual."
|
|
79
|
+
)
|